# Catalog

The catalog is what TruTec estimates are built from. It has two layers:

- **Catalog services** (`/v1/catalog/services`): the services you sell, such as "Sealcoat, 2 coats" or "2\" mill and overlay". Each has a unit (SF, SY, LF, LM for linear miles, EA, TON, DAY…), a way of pricing, proposal wording, optional qualifiers (for example *Depth: 2" / 3"*) and a **recipe**: the crews, equipment and materials one unit of the service takes. Adding a catalog service to an estimate copies its recipe onto the estimate line ([Estimates & proposals](estimates-and-proposals.md)).
- **Cost items** (`/v1/catalog/{item_type}`): the priced building blocks recipes use, one list per type.

| `item_type` | What it holds | `unit` is | `unit_cost` is |
|---|---|---|---|
| `materials` | Sealer, asphalt, paint, aggregate | The unit it's bought in (`TON`, `GAL`, …) | Base price per unit |
| `crews` | Crews and the people on them | `hour` or `day` | The crew's rate when it's priced as one total rate |
| `equipment` | Owned or rented machines | `hour`, `day` or `fixed` | Base rate |
| `trucking` | Haul trucks | `hour`, `day`, `ton` or `load` | Base rate |
| `subcontractors` | Subcontracted services | How the sub charges | Base price |
| `misc-items` | Anything else (permits, cones, dump fees) | Its unit | Cost per unit |
| `overhead-items` | Overhead and indirect costs | How it's charged (`per_day`, `fixed`, `percent_direct`, …) | The amount |

**Some workspaces change their catalog only in TruTec** (those on a separate estimating edition). Catalog writes there return `409` with that message.

**Estimates keep their own copy.** An estimate line copies the catalog values when it's added. Changing the catalog never changes an estimate that's already built; new lines (and an estimator's explicit "update prices") pick up the new values.

**Archive, never delete.** `DELETE` archives a catalog row: it drops out of lists and pickers, and estimates that already use it keep pricing exactly as before. `include_archived=true` shows archived rows.

**Who can change the catalog.** Writes act as the user who created the API key (see [Authentication](authentication.md)), with that user's estimating role. Only workspace owners and estimating admins can change the catalog; anyone else gets `403` with the reason.

Rates are fractions: `0.25` means 25%. Money is in dollars. Timestamps are UTC ISO 8601 with a `Z`.

!!! note
    Partner keys that act on behalf of several workspaces must also pass `firm_id` (query string, or JSON body on writes).

## The catalog service object

| Field | Type | Description |
|---|---|---|
| `id` | integer | TruTec id. Use it as `catalog_service_id` when adding a line to an estimate. |
| `name`, `description` | string | Name, and the internal description crews see. |
| `group` | object or null | `{id, name}` of its catalog group. |
| `unit` | string | The service's unit, for example `SF`. |
| `pricing_mode` | string | `detailed` (priced from its recipe) or `unit_costing` (one price per unit). |
| `unit_cost` | number | Price per unit for `unit_costing` services. |
| `minimum_price` | number or null | The least this service is ever sold for on an estimate. |
| `default_pricing_method`, `default_pricing_rate` | string, number | `markup` or `margin`, and the rate new lines start with. |
| `cost_code` | string or null | Your job-cost code. |
| `color` | string or null | Hex color used on maps and takeoffs. |
| `on_every_estimate` | boolean | Lands on every new estimate with a blank quantity. |
| `subservice_ids` | array | Catalog services that come along when this one is added. |
| `qualifiers` | array | Questions the service asks, `{name, options, required}`. |
| `scopes` | array | The answer combinations it prices, `{id, name, answers}`. Only on `GET /v1/catalog/services/{id}`. |
| `proposal_text`, `pricing_text`, `additional_info` | string or null | Customer-facing wording. Only on the single-service read. |
| `recipe` | array | `{id, type, catalog_item_id, quantity_formula, notes, position, vendor_id, scope_id}`: what one unit takes. `scope_id` is the type (see `scopes`) a line belongs to, or null for lines every type uses. Only on the single-service read. Set it with [Set a service's recipe](#set-a-services-recipe). |
| `approval_state` | string | `approved`, or `draft` for rows waiting on an admin's review (drafts still price). |
| `is_active`, `created_at`, `updated_at` | | |

## The cost item object

| Field | Type | Description |
|---|---|---|
| `id` | integer | TruTec id. Use it as `catalog_item_id` when adding a cost line to an estimate. |
| `type` | string | The `item_type` it belongs to. |
| `name`, `description`, `cost_code` | string | |
| `group` | object or null | `{id, name}`. |
| `unit`, `unit_cost` | string, number | See the table above. |
| `default_pricing_method`, `default_pricing_rate` | string, number | Markup or margin new estimate lines start with. |
| `details` | object | The rest of the fields the catalog form edits for this type (for example `waste_factor` and `taxable` on materials, `ownership` on equipment, `default_load_tons` on trucking). |
| `members` | array | Crews only: `{id, role, count, rate, rate_basis}`. |
| `vendor_prices` | array | Materials only: `{id, vendor_id, vendor_name, price, unit, effective_date}`. Add one with [Add a vendor price](#add-a-vendor-price). |
| `approval_state`, `is_active`, `created_at`, `updated_at` | | |

## Catalog services

### List catalog services

`GET /v1/catalog/services`

**Permission:** Read jobs

Sorted by name. With `updated_after`, sorted by `updated_at`, oldest first, so you can page forward.

| Name | Type | Required | Description |
|---|---|---|---|
| `q` | string | No | Match part of the name (case-insensitive). |
| `group_id` | integer | No | Only this group. |
| `updated_after` | string | No | ISO 8601 timestamp. Only rows changed after it. |
| `include_archived` | boolean | No | `true` to include archived services. |
| `limit` | integer | No | Page size. Default 50, maximum 200. |
| `offset` | integer | No | Rows to skip. Default 0. |

```bash
curl "https://trutec.ai/api/v1/catalog/services?q=seal" \
  -H "X-API-Key: sk_your_api_key_here"
```

```json
{
  "data": [
    {
      "id": 5120,
      "name": "Sealcoat, 2 coats",
      "description": null,
      "group": { "id": 88, "name": "Sealcoating" },
      "unit": "SF",
      "pricing_mode": "unit_costing",
      "unit_cost": 0.25,
      "minimum_price": 750.0,
      "default_pricing_method": "markup",
      "default_pricing_rate": 0.0,
      "cost_code": "02-SEAL",
      "color": "#1f6feb",
      "on_every_estimate": false,
      "subservice_ids": [],
      "qualifiers": [],
      "approval_state": "approved",
      "is_active": true,
      "created_at": "2026-08-03T15:20:11Z",
      "updated_at": "2026-10-01T18:44:02Z"
    }
  ],
  "pagination": { "total": 1, "limit": 50, "offset": 0 }
}
```

**Errors:** `400` `updated_after must be an ISO 8601 timestamp`. `403` `API key lacks job read permission`.

### Get a catalog service

`GET /v1/catalog/services/{service_id}`

**Permission:** Read jobs

Adds `scopes`, `proposal_text`, `pricing_text`, `additional_info` and `recipe` to the object above.

```bash
curl "https://trutec.ai/api/v1/catalog/services/5121" \
  -H "X-API-Key: sk_your_api_key_here"
```

```json
{
  "data": {
    "id": 5121,
    "name": "Mill and overlay",
    "group": { "id": 89, "name": "Paving" },
    "unit": "SY",
    "pricing_mode": "detailed",
    "unit_cost": 0.0,
    "qualifiers": [{ "name": "Depth", "options": ["2\"", "3\""], "required": true }],
    "scopes": [
      { "id": "sc_1a2b", "name": "2\"", "answers": { "Depth": "2\"" } },
      { "id": "sc_3c4d", "name": "3\"", "answers": { "Depth": "3\"" } }
    ],
    "proposal_text": "Mill the existing surface and pave a new hot-mix overlay.",
    "pricing_text": null,
    "additional_info": null,
    "recipe": [
      { "id": 9001, "type": "crew", "catalog_item_id": 311, "quantity_formula": "qty", "notes": null, "position": 0 },
      { "id": 9002, "type": "material", "catalog_item_id": 742, "quantity_formula": "qty * 0.11", "notes": "HMA at 2 in", "position": 1 }
    ],
    "approval_state": "approved",
    "is_active": true,
    "created_at": "2026-08-03T15:20:11Z",
    "updated_at": "2026-10-01T18:44:02Z"
  }
}
```

(Response trimmed.)

**Errors:** `404` `Catalog service not found`.

### Create a catalog service

`POST /v1/catalog/services`

**Permission:** Write jobs, and the key's user must be a workspace owner or estimating admin

| Name | Type | Required | Description |
|---|---|---|---|
| `name` | string | Yes | Up to 200 characters. |
| `unit` | string | No | Default `SF`. |
| `pricing_mode` | string | No | `detailed` or `unit_costing`. |
| `unit_cost` | number | No | Price per unit for `unit_costing`. |
| `minimum_price` | number or null | No | Selling-price floor. |
| `group_id` | integer or null | No | A service group from `GET /v1/catalog/groups?type=services`. |
| `description`, `cost_code`, `color` | string | No | |
| `default_pricing_method` | string | No | `markup` or `margin`. |
| `default_pricing_rate` | number | No | Fraction, for example `0.3`. |
| `proposal_text`, `pricing_text`, `additional_info` | string | No | Customer-facing wording. |
| `on_every_estimate` | boolean | No | |
| `subservice_ids` | array of integers | No | Other active catalog services to bring along. |

Set the recipe with [Set a service's recipe](#set-a-services-recipe) and the questions it asks with [Set a service's qualifiers](#set-a-services-qualifiers).

```bash
curl -X POST "https://trutec.ai/api/v1/catalog/services" \
  -H "X-API-Key: sk_your_api_key_here" \
  -H "Content-Type: application/json" \
  -d '{"name": "Crack fill", "unit": "LF", "pricing_mode": "unit_costing", "unit_cost": 1.10, "group_id": 88}'
```

Returns `201` with the new service (the single-service shape). A `warnings` list is added when the setup saves but would price oddly.

**Errors:** `400` `name is required`. `400` `Unknown fields: …`. `403` when the key's user can't manage the catalog. `404` `Group not found for this kind of catalog row`. `409` when the workspace's catalog can't be changed through the API.

### Update a catalog service

`PATCH /v1/catalog/services/{service_id}`

**Permission:** Write jobs, as above

Same fields as create, all optional. Only the fields you send change.

```bash
curl -X PATCH "https://trutec.ai/api/v1/catalog/services/5120" \
  -H "X-API-Key: sk_your_api_key_here" \
  -H "Content-Type: application/json" \
  -d '{"unit_cost": 0.27}'
```

Returns `200` with the service.

### Archive a catalog service

`DELETE /v1/catalog/services/{service_id}`

**Permission:** Write jobs and Delete records, as above

Returns `204`.

## Cost items

### List cost items

`GET /v1/catalog/{item_type}`

**Permission:** Read jobs

`item_type` is one of `materials`, `crews`, `equipment`, `trucking`, `subcontractors`, `misc-items`, `overhead-items`. Takes the same filters as **List catalog services**.

```bash
curl "https://trutec.ai/api/v1/catalog/materials?q=tack" \
  -H "X-API-Key: sk_your_api_key_here"
```

```json
{
  "data": [
    {
      "id": 742,
      "type": "materials",
      "name": "Tack coat",
      "description": null,
      "group": { "id": 91, "name": "Liquids" },
      "cost_code": "03-MAT",
      "unit": "GAL",
      "unit_cost": 3.0,
      "default_pricing_method": "markup",
      "default_pricing_rate": 0.15,
      "details": { "taxable": true, "waste_factor": 0.05, "is_procured": true, "external_sku": "TK-100" },
      "vendor_prices": [
        { "id": 3310, "vendor_id": 41, "vendor_name": "Northside Asphalt Supply", "price": 2.85, "unit": "GAL", "effective_date": "2026-09-01" }
      ],
      "approval_state": "approved",
      "is_active": true,
      "created_at": "2026-08-03T15:20:11Z",
      "updated_at": "2026-09-30T13:02:40Z"
    }
  ],
  "pagination": { "total": 1, "limit": 50, "offset": 0 }
}
```

(`details` trimmed.)

**Errors:** `404` `Unknown catalog type. Use one of: …`.

### Get a cost item

`GET /v1/catalog/{item_type}/{item_id}`

**Permission:** Read jobs

**Errors:** `404` `Catalog item not found`.

### Create a cost item

`POST /v1/catalog/{item_type}`

**Permission:** Write jobs, and the key's user must be a workspace owner or estimating admin

| Name | Type | Required | Description |
|---|---|---|---|
| `name` | string | Yes | |
| `unit` | string | No | See the table at the top. |
| `unit_cost` | number | No | See the table at the top. |
| `description`, `cost_code` | string | No | |
| `group_id` | integer or null | No | A group of this type (`GET /v1/catalog/groups?type=materials`). |
| `default_pricing_method`, `default_pricing_rate` | string, number | No | |
| `details` | object | No | Any of the type's `details` fields. Unknown names are a `400` that lists the allowed ones. |
| `members` | array | No | Crews only. Replaces the crew's people: `[{role, rate, rate_basis, count}]`. |

```bash
curl -X POST "https://trutec.ai/api/v1/catalog/crews" \
  -H "X-API-Key: sk_your_api_key_here" \
  -H "Content-Type: application/json" \
  -d '{"name": "Seal crew", "unit": "hour", "details": {"pricing_mode": "sum_of_individuals"},
       "members": [{"role": "Foreman", "rate": 38, "rate_basis": "hour"},
                   {"role": "Laborer", "rate": 24, "count": 2}]}'
```

```json
{
  "data": {
    "id": 318,
    "type": "crews",
    "name": "Seal crew",
    "unit": "hour",
    "unit_cost": 0.0,
    "details": { "pricing_mode": "sum_of_individuals", "working_hours_per_day": null, "paid_full_day": false },
    "members": [
      { "id": 1201, "role": "Foreman", "count": 1, "rate": 38.0, "rate_basis": "hour" },
      { "id": 1202, "role": "Laborer", "count": 2, "rate": 24.0, "rate_basis": "hour" }
    ],
    "approval_state": "approved",
    "is_active": true,
    "created_at": "2026-10-12T15:27:17Z",
    "updated_at": "2026-10-12T15:27:17Z"
  }
}
```

(Response trimmed.)

**Errors:** `400` `name is required`, `Unknown details: …`, `details.waste_factor must be a number`, `Each member needs a role`. `403`, `404`, `409` as for services.

### Update a cost item

`PATCH /v1/catalog/{item_type}/{item_id}`

**Permission:** Write jobs, as above

Same fields as create; only what you send changes. Sending `members` replaces a crew's whole roster.

```bash
curl -X PATCH "https://trutec.ai/api/v1/catalog/materials/742" \
  -H "X-API-Key: sk_your_api_key_here" \
  -H "Content-Type: application/json" \
  -d '{"unit_cost": 3.15, "details": {"waste_factor": 0.07}}'
```

### Archive a cost item

`DELETE /v1/catalog/{item_type}/{item_id}`

**Permission:** Write jobs and Delete records, as above

Returns `204`.

## Groups, price lists and estimate templates

### List catalog groups

`GET /v1/catalog/groups`

**Permission:** Read jobs

Every group and subgroup (`parent_id`). `type` filters to one catalog: `services`, `materials`, `crews`, `equipment`, `trucking`, `subcontractors`, `misc-items` or `overhead-items`.

```json
{
  "data": [
    { "id": 88, "type": "services", "name": "Sealcoating", "parent_id": null, "position": 0 },
    { "id": 89, "type": "services", "name": "Paving", "parent_id": null, "position": 1 }
  ],
  "pagination": { "total": 2, "limit": 2, "offset": 0 }
}
```

### List price lists

`GET /v1/catalog/price-lists`

**Permission:** Read jobs

Price columns, for example one per county or customer tier. Each estimate prices from one; set it with `price_list_id` on `PATCH /v1/estimates/{id}`.

```json
{
  "data": [
    { "id": 12, "name": "Non-county", "description": null, "group_name": null, "is_default": true },
    { "id": 13, "name": "Travis County", "description": "County bid rates", "group_name": "Counties", "is_default": false }
  ],
  "pagination": { "total": 2, "limit": 2, "offset": 0 }
}
```

### List estimate templates

`GET /v1/estimate-templates`

**Permission:** Read jobs

Saved starting points (areas, services and pricing) for new estimates. Pass `template_id` to `POST /v1/estimates`.

```json
{
  "data": [
    { "id": 31, "name": "HOA reseal + restripe", "description": "Two coats, full restripe", "area_count": 1, "service_count": 4, "created_at": "2026-07-14T16:01:09Z", "updated_at": "2026-09-02T12:30:44Z" }
  ],
  "pagination": { "total": 1, "limit": 50, "offset": 0 }
}
```

### Create a catalog group

`POST /v1/catalog/groups`

**Permission:** Write jobs, and the key's user must be a workspace owner or estimating admin

| Name | Type | Required | Description |
|---|---|---|---|
| `name` | string | Yes | Up to 160 characters. |
| `type` | string | No | Which catalog: `services` (default; a service group or division), `materials`, `crews`, `equipment`, `trucking`, `subcontractors`, `misc-items` or `overhead-items`. |
| `parent_id` | integer | No | A group of the same type, to make a subgroup. |

```bash
curl -X POST "https://trutec.ai/api/v1/catalog/groups" \
  -H "X-API-Key: sk_your_api_key_here" \
  -H "Content-Type: application/json" \
  -d '{"name": "Striping"}'
```

```json
{ "data": { "id": 90, "type": "services", "name": "Striping", "parent_id": null, "position": 0 } }
```

Returns `201`. **Errors:** `400` `name is required`, `type must be one of …`. `404` `Parent group not found in this catalog`.

### Rename a service group

`PATCH /v1/catalog/groups/{group_id}`

**Permission:** Write jobs, as above

Body: `{"name": "Line striping"}`. Only service groups can be renamed; settings that name the group follow the rename. Returns the group and `changed` (`{"name": {"before", "after"}}`).

**Errors:** `400` when the name is taken or the group isn't a service group. `404` `Group not found`.

### Remove a service group

`DELETE /v1/catalog/groups/{group_id}`

**Permission:** Write jobs and Delete records, as above

Removes an **empty** service group. Returns `204`. A group that still holds services (active or archived), subgroups or anything else returns `409` with the reason; move what's in it first.

## Recipes and qualifiers

### Set a service's recipe

`PUT /v1/catalog/services/{service_id}/recipe`

**Permission:** Write jobs, and the key's user must be a workspace owner or estimating admin

The recipe is what **one unit** of the service takes. `lines` is the whole recipe:

- A line with the `id` of a current line keeps that line and everything else set on it in TruTec (material links, takeoff ties, rounding); the fields you send change.
- A line without an `id` is new and needs `type`, `catalog_item_id` and `quantity_formula`.
- Current lines you leave out are removed.

| Line field | Type | Description |
|---|---|---|
| `id` | integer | A current line's id, from `recipe` on [Get a catalog service](#get-a-catalog-service). |
| `type` | string | `material`, `crew`, `equipment`, `trucking`, `subcontractor`, `misc` or `overhead`. |
| `catalog_item_id` | integer | A cost item of that type in this workspace. |
| `quantity_formula` | string | How much per unit of the service, using `qty` (the service quantity): `"qty * 0.0025"` gallons of sealer per SF, `"qty / 4000"` crew hours. Up to 400 characters. |
| `notes` | string | Up to 300 characters. |
| `vendor_id` | integer or null | A vendor to price this line from. |
| `scope_id` | string or null | One of the service's types (`scopes[].id`) when only that type uses the line. |

```bash
curl -X PUT "https://trutec.ai/api/v1/catalog/services/5121/recipe" \
  -H "X-API-Key: sk_your_api_key_here" \
  -H "Content-Type: application/json" \
  -d '{"lines": [
        {"id": 9001, "quantity_formula": "qty / 3500"},
        {"type": "material", "catalog_item_id": 742, "quantity_formula": "qty * 0.05", "notes": "Tack before paving"}
      ]}'
```

Returns `200` with the service (single-service shape) and `changed.recipe` (`before` and `after` lists). A `warnings` list is added when the recipe saves but would price oddly.

**Errors:** `400` for a bad line (unknown type, missing `quantity_formula` on a new line, a `scope_id` the service doesn't have). `404` `Catalog item not found in …`, `Vendor not found`, or a line `id` that isn't in this recipe. `400` with TruTec's reason for a recipe it can't save (for example a quantity linked to a material you removed).

### Set a service's qualifiers

`PUT /v1/catalog/services/{service_id}/qualifiers`

**Permission:** Write jobs, as above

Qualifiers are the questions a service asks when it's added to an estimate, for example *Coats: 1 coat / 2 coats*. Each answer combination can be a **type** of the service (`scopes`) with its own price and recipe lines.

| Name | Type | Required | Description |
|---|---|---|---|
| `qualifiers` | array | Yes | The whole list of questions: `{name, options, required, default_option, show_when}`. Up to 6 questions, each with 1 to 8 answers of up to 40 characters. |
| `add_missing_types` | boolean | No | Default `true`: every answer combination that isn't a type yet is added (like the service editor's Generate). Send `false` for a question that doesn't change the price by type. |

To ask a question only for some answers of an earlier question, add `show_when`: `{"question": "Curb type", "options": ["Vertical", "Barrier"]}`. The earlier question must come before it in the list, and the options must be that question's own answers. A skipped question is left out of the type name ("Rollover · 12\"" instead of "Rollover · 6\" · 12\""), so only the real combinations become types.

Types that already exist keep their id, prices and recipe lines, and none are removed. A type whose name matches no answer combination after the change is listed in `changed.types_not_matching_any_answers`; rename or remove it in TruTec's service editor. A service can have up to 200 types.

```bash
curl -X PUT "https://trutec.ai/api/v1/catalog/services/5120/qualifiers" \
  -H "X-API-Key: sk_your_api_key_here" \
  -H "Content-Type: application/json" \
  -d '{"qualifiers": [{"name": "Coats", "options": ["1 coat", "2 coats"], "required": true}]}'
```

Returns `200` with the service and `changed`: `{qualifiers: {before, after}, types_added, types_not_matching_any_answers}`.

**Errors:** `400` for too many questions, answers or combinations, or a repeated answer. `409` when the service prices by individual answers and the change would rename, reorder or drop a question or answer; add questions or answers here, and make those changes in TruTec's service editor.

## Vendors and vendor prices

### The vendor object

| Field | Type | Description |
|---|---|---|
| `id` | integer | TruTec id. |
| `name` | string | |
| `kind` | string | `vendor`, `rental_house` or `subcontractor`. |
| `supplies` | array | Any of `material`, `equipment`, `trucking`. |
| `email`, `phone`, `address` | string or null | The company's details; `address` is where crews pick up. |
| `contacts` | array | `{name, title, email, phone, notes, is_primary}`. |
| `tax_rate` | number or null | Their sales tax as a fraction. For reference only; no price uses it. |
| `notes` | string or null | |
| `parent_vendor_id` | integer or null | The company a location belongs to. |
| `is_active`, `created_at`, `updated_at` | | |

### List vendors

`GET /v1/catalog/vendors`

**Permission:** Read jobs

Sorted by name. Takes `q`, `include_archived`, `limit` and `offset`. `GET /v1/catalog/vendors/{vendor_id}` returns one.

### Create a vendor

`POST /v1/catalog/vendors`

**Permission:** Write jobs, and the key's user must be a workspace owner or estimating admin

Takes `name` (required) and any of `kind`, `email`, `phone`, `address`, `notes`, `tax_rate`, `supplies`, `contacts`, `parent_vendor_id`. Returns `201` with the vendor.

```bash
curl -X POST "https://trutec.ai/api/v1/catalog/vendors" \
  -H "X-API-Key: sk_your_api_key_here" \
  -H "Content-Type: application/json" \
  -d '{"name": "Northside Asphalt Supply", "supplies": ["material"], "phone": "512-555-0100"}'
```

### Update a vendor

`PATCH /v1/catalog/vendors/{vendor_id}`

Same fields; only what you send changes. Returns the vendor and `changed`.

### Archive a vendor

`DELETE /v1/catalog/vendors/{vendor_id}`

**Permission:** Write jobs and Delete records

The vendor leaves every list and picker; its quoted prices and request-for-quote history stay on record. Returns `204`, or `409` while it still has active locations.

### Add a vendor price

`POST /v1/catalog/materials/{material_id}/vendor-prices`

**Permission:** Write jobs

| Name | Type | Required | Description |
|---|---|---|---|
| `vendor_id` | integer | Yes | A vendor in this workspace. |
| `price` | number | Yes | Dollars per unit, more than 0. |
| `unit` | string | No | Defaults to the material's unit. |
| `effective_date` | string | No | `YYYY-MM-DD`. |
| `replace_current` | boolean | No | Default `true`: this replaces the vendor's current price for the material. `false` keeps the older quote active beside it. |
| `delivery_terms`, `vendor_item_name`, `spec` | string | No | |

The material's own `unit_cost` doesn't change. Returns `201` with the new price, the material, and `changed.replaced` (the prices it replaced).

```bash
curl -X POST "https://trutec.ai/api/v1/catalog/materials/742/vendor-prices" \
  -H "X-API-Key: sk_your_api_key_here" \
  -H "Content-Type: application/json" \
  -d '{"vendor_id": 41, "price": 2.95, "effective_date": "2026-10-01"}'
```

### Stop using a vendor's prices

`DELETE /v1/catalog/materials/{material_id}/vendor-prices/{vendor_id}`

**Permission:** Write jobs and Delete records

Archives that vendor's prices for the material. Returns `204`.

## Bulk import and approval

### Import catalog rows

`POST /v1/catalog/import`

**Permission:** Write jobs, and the key's user must be a workspace owner or estimating admin

Creates or updates many rows at once with the same rules as TruTec's catalog import:

- Rows match existing ones by **exact name** and update them; an archived row with that name comes back.
- `group` names a group (or a list for a subgroup path); missing groups are created.
- A service's `components` (`[{type, name}]`, cost items by name) replace its recipe.
- **New rows land as drafts.** Drafts still price; estimates flag them until an owner approves them (in TruTec or with [Approve a draft](#approve-a-draft)).
- The whole batch saves or nothing does.

| Name | Type | Required | Description |
|---|---|---|---|
| `rows` | array | Yes | Up to 2,000 rows. |
| `dry_run` | boolean | No | `true` previews what would change and saves nothing. |

Each row:

| Field | Description |
|---|---|
| `type` | `services`, `materials`, `crews`, `equipment`, `trucking`, `subcontractors`, `misc-items`, `overhead-items` or `vendors`. |
| `name` | Required. |
| `unit`, `unit_cost` | As on the cost item object (services take `unit` only). |
| `description`, `cost_code`, `group` | Not on vendors. |
| `components` | Services only. |
| Crews: `pricing_mode`, `working_hours_per_day`. Equipment: `ownership`. Trucking: `default_load_tons`. Materials: `density_lb_per_cuft`. Vendors: `kind`, `supplies`, `email`, `phone`, `address`, `contacts`, `notes`. | |

```bash
curl -X POST "https://trutec.ai/api/v1/catalog/import" \
  -H "X-API-Key: sk_your_api_key_here" \
  -H "Content-Type: application/json" \
  -d '{"dry_run": true, "rows": [
        {"type": "materials", "name": "Sealer", "unit": "GAL", "unit_cost": 2.4, "group": "Liquids"},
        {"type": "services", "name": "Sealcoat 2 coats", "unit": "SF",
         "components": [{"type": "materials", "name": "Sealer"}]}
      ]}'
```

```json
{
  "data": {
    "dry_run": true,
    "applied": false,
    "summary": { "rows": { "materials": 1, "services": 1 }, "changes": { "materials": 1, "services": 1 } },
    "actions": [
      { "action": "create material", "name": "Sealer", "type": "materials" },
      { "action": "create service", "name": "Sealcoat 2 coats", "type": "services" }
    ],
    "warnings": [],
    "errors": []
  }
}
```

Returns `200`, or `422` with `errors` when a row can't be saved (nothing is saved then). `400` for a row with an unknown type or field.

### Approve a draft

`POST /v1/catalog/{item_type}/{item_id}/approve`

**Permission:** Write jobs, and the key's user must be a workspace owner or estimating admin

`item_type` is `services` or a cost item type. Returns the row with `approval_state: "approved"`.
