# Estimates & proposals

An estimate is the priced bid for a job: areas of the site, line items in each area (built from your [catalog](catalog.md)), and the totals TruTec's pricing engine works out. Through the API you can read every estimate and build or change one the same way an estimator does on the estimating sheet.

The customer-facing document an estimate becomes is a **proposal**. Creating, editing, sending and tracking proposals is on [Proposals](proposals.md).

## How it works

**Every number comes from TruTec's pricing engine.** You send quantities, catalog services and rates; TruTec prices. Each write re-runs the engine and returns the estimate's fresh totals, so what you read always matches the estimating sheet. Reads never re-price: they return the numbers as last calculated. `POST /v1/estimates/{id}/recalculate` re-prices on demand.

**Writes behave exactly like the app.** They act as the user who created the API key, with that user's estimating role (see [Authentication](authentication.md)):

| Role | Can |
|---|---|
| Owner or estimating admin | Everything, including overriding the profit-floor approval when issuing. |
| Estimator | Build, price, issue, and mark estimates won or lost. |
| Sales | Create estimates and add or change unit-priced lines. |
| Viewer | Read only. Writes return `403` with the reason. |

The app's rules apply too: a sent estimate keeps its frozen numbers, a won estimate needs a change order for new scope, a quantity you type replaces the takeoff's number and is flagged as manual, and every change lands in the estimate's revision log under that user.

**Nothing reaches the customer** from these endpoints. Issuing a version freezes numbers but sends nothing; only `POST /v1/proposals/{id}/send` emails a customer.

**Typical flow:**

1. `POST /v1/estimates` with a `job_id` (optionally a `template_id`).
2. `POST /v1/estimates/{id}/line-items` for each catalog service and quantity.
3. `PATCH /v1/estimates/{id}` to set the markup, tax or price list. Adjust lines with `PATCH .../line-items/{line_item_id}`.
4. `POST /v1/estimates/{id}/issue` to freeze version 1.
5. `POST /v1/estimates/{id}/proposal`, then `POST /v1/proposals/{id}/send` ([Proposals](proposals.md)).
6. `POST /v1/estimates/{id}/mark-won` when the customer accepts.

## Estimates and versions

When an estimator issues an estimate, TruTec freezes a numbered **version** of it: version 1 for the first one sent, version 2 for the next revision, and so on. The live estimate can keep changing afterwards; issued versions never change.

- `GET /v1/estimates/{id}?version=N` returns version N exactly as issued. `GET /v1/estimates/{id}/versions` lists every version with its totals and issue date.
- Each line item's `allocated_price` is the price the proposal shows for that line, after job-level money (mobilization, overhead, contingency, bond, rounding) is spread across the lines.
- The takeoff map is at `GET /v1/jobs/{id}/exports/proposal_map` ([Jobs](jobs.md)).
- Webhooks fire when an estimate is issued, revised, won or lost, and when a proposal is sent, viewed, accepted or declined ([Webhooks & events](webhooks-and-events.md)).

## Response format

`GET /v1/estimates` and `/revisions` return `{"data": [...], "pagination": {"total", "limit", "offset"}}`. `GET /v1/estimates/{id}` returns the estimate object directly, **not** wrapped in `data`; every write returns it wrapped in `data`. Errors return JSON with an `error` string, and a `code` where one helps (`issue_blocked`, `approval_required`).

Estimate timestamps are UTC in ISO 8601 with a `Z` suffix, to the second, for example `2026-10-08T21:14:09Z`. Money is in dollars, rounded to cents. Rates are fractions (`0.25` = 25%).

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

## The estimate object

| Field | Type | Description |
|---|---|---|
| `id` | integer | TruTec id. |
| `doc_number` | integer or null | Customer-facing document number. The estimate, its proposal and its invoices share it. |
| `name` | string | Estimate name. |
| `status` | string | `draft` (being built), `estimate` (issued to the customer), `won`, `lost` or `change_order`. |
| `job_id` | integer or null | The job this estimate belongs to. |
| `parent_estimate_id` | integer or null | For a change-order estimate, the estimate it changes. |
| `is_change_order` | boolean | True for change-order estimates. |
| `current_version` | integer or null | Latest issued version number. Null if never issued. |
| `version` | integer or null | Null for the live estimate. The version number when you asked for `?version=N`. |
| `issued_at`, `issued_by_user_id` | string, integer | When and by whom this version was issued. Only present with `?version=N`. |
| `priced` | boolean | False if the estimate has never been priced. `totals` is then null. |
| `awarded_at`, `archived_at`, `created_at`, `updated_at` | string or null | UTC timestamps. |
| `totals` | object or null | See below. |
| `areas` | array | Areas and their line items. Only on single-estimate responses. |
| `pricing` | object | The estimate's own pricing inputs. Only on single-estimate responses; see below. |

`totals`:

| Field | Description |
|---|---|
| `price` | Customer total, including tax. |
| `subtotal` | `price` minus `tax`. |
| `tax` | Sales tax. |
| `cost` | Total cost, including job-level costs. |
| `margin` | Profit in dollars. |
| `margin_pct` | Margin as a fraction of price (`0.2814` means 28.14%). |
| `optional_total` | Price of optional add-ons. Not included in `price`. |

Use `totals` for the bid. Line costs do not include job-level costs such as mobilization, so line items will not sum to `totals.cost`.

`pricing` (what `PATCH /v1/estimates/{id}` sets; `null` means the workspace default applies):

| Field | Description |
|---|---|
| `markup_method` | `markup` (on cost) or `margin` (of price). |
| `markup_rate` | The estimate-wide rate, a fraction (`0.25` = 25%). |
| `contingency_rate` | Contingency added before markup, or `null` when off. |
| `tax_rate` | Sales tax rate, or `null` for the workspace's. |
| `minimum_charge` | Smallest total this estimate sells for, in dollars. |
| `price_list_id` | The price column it prices from ([Catalog](catalog.md)). |

## The line item object

Each area has `id`, `name`, `quantity_source` (`manual`, `takeoff` or `assessment`), `takeoff_id` (the takeoff its quantities come from, if any) and `services` (its line items). Inactive areas and lines are left out, and so are lines the estimator excluded from the bid.

| Field | Type | Description |
|---|---|---|
| `id` | integer | Line id. |
| `name` | string | Line name as written on the estimate. |
| `service_type` | string or null | The catalog item this line was built from. |
| `category` | string or null | That catalog item's group. |
| `assembly_id` | integer or null | Catalog item id. |
| `estimating_mode` | string | `detailed` or `unit_costing`. |
| `quantity`, `unit` | number, string | Quantity and unit, for example `48250` and `SF`. |
| `unit_cost`, `unit_price` | number or null | Cost and price per unit. |
| `price`, `cost`, `margin`, `margin_pct` | number or null | Line totals. `margin_pct` is a fraction of price. |
| `allocated_price` | number or null | The price the proposal shows for this line, after job-level money is spread. |
| `is_optional` | boolean | An optional add-on. Not in `totals.price`. |
| `hide_on_proposal` | boolean | Priced into the bid but not listed on the proposal. |
| `parent_service_id` | integer or null | Parent line, for sub-lines. |
| `cost_code` | string or null | Your cost code for the line. |
| `is_bundle_master` | boolean | This line's price and cost are the roll-up of its sub-lines. |
| `bundle_child_service_ids` | array | The sub-lines rolled into this line. |
| `bundle_parent_service_id` | integer or null | The bundle line this line rolls into. |
| `area_id` | integer | The area the line is in. |
| `quantity_source` | string | `inherited` (from the takeoff, assessment or catalog), `manual` (typed) or `derived` (follows another line). |
| `pricing_method`, `pricing_rate` | string, number or null | The line's own markup or margin, when it has one. |
| `price_override` | number or null | A customer price pinned on this line; the difference is spread across the other lines. |
| `proposal_group` | string or null | Lines sharing a group print as one proposal line. |
| `proposal_text` | string or null | This line's proposal wording. `null` uses the catalog service's. |
| `what_to_expect` | string or null | HTML printed under the line on the proposal ("What to expect"): the line's own, else the catalog wording frozen when it was added. |
| `internal_scope` | string or null | HTML scope for this line that goes with quote requests to subcontractors. `null` uses the catalog service's internal description. |
| `notes` | string or null | The estimator's note on the line. |

!!! warning
    A bundle line already includes its sub-lines. When you add up lines, sum either the bundle lines or their sub-lines, not both.


## The cost line object

A line built in detail is costed from cost lines: the crews, equipment, materials, trucking, subcontractors, misc items and overhead its recipe brought in. `GET /v1/estimates/{id}/line-items/{line_item_id}` returns them under `cost_lines`.

| Field | Type | Description |
|---|---|---|
| `id` | integer | Cost line id. |
| `line_item_id` | integer | The line it belongs to. |
| `type` | string | `crew`, `equipment`, `material`, `trucking`, `subcontractor`, `misc`, `overhead` or `adjustment`. |
| `catalog_item_id` | integer or null | The catalog cost item it was copied from. |
| `name` | string | |
| `quantity`, `unit` | number, string | What the engine worked out, for example `50` `GAL`. |
| `unit_cost` | number or null | This estimate's unit cost (editing it never changes the catalog). |
| `cost`, `price` | number or null | In dollars. |
| `quantity_formula` | string | How quantity follows the line's quantity, for example `qty * 0.05`. |
| `manual_quantity` | number or null | A typed quantity that replaces the formula. |
| `waste_factor` | number or null | This line's waste override, a fraction. |
| `pricing_method`, `pricing_rate` | string, number or null | A markup pinned on this cost line (`null` follows the line). |
| `billing` | string | `billed`, or `internal_only` (costed but not billed). |
| `is_alternate` | boolean | A comparison option, costed but left out of every total. |
| `suppressed` | boolean | Costed but left out of every total. |
| `hauls` | array | Trucking only: what the truck hauls — `asphalt`, `millings`, `spoil`. |
| `automatic_driving_time` | boolean | Trucking only: the line updates its own driving time from the plant, job and dump site. |
| `route` | object or null | Trucking only: the trip its dump site chose — `kind` `plant_round_trip` (the dump site is the plant), `three_leg` (plant → job → dump site → plant, with `legs_hr`) or `dump_round_trip` (job ↔ dump site), plus the plant, job and dump-site addresses. `null` = a plain plant round trip. |
| `dump_material` | string or null | Misc only: `millings` or `spoil` when the line is a dump fee. |
| `dump_site` | object or null | Misc dump fees only: `vendor_id` and `name` of the dump site picked on the line. |


## Read estimates

### List estimates

`GET /v1/estimates`

**Permission:** Read jobs

Returns summary rows (no line items), most recently updated first. With `updated_after`, results are sorted by `updated_at`, oldest first, so you can page forward. Archived estimates are left out unless you ask for them.

| Name | Type | Required | Description |
|---|---|---|---|
| `job_id` | integer | No | Only this job's estimates. |
| `status` | string | No | `draft`, `estimate`, `won`, `lost` or `change_order`. |
| `updated_after` | string | No | ISO 8601 timestamp. Only estimates changed after it. |
| `include_archived` | boolean | No | `true` to include archived estimates. |
| `limit` | integer | No | Page size. Default 50, maximum 100. |
| `offset` | integer | No | Rows to skip. Default 0. |

```bash
curl "https://trutec.ai/api/v1/estimates?job_id=70412" \
  -H "X-API-Key: sk_your_api_key_here"
```

```json
{
  "data": [
    {
      "id": 9840,
      "doc_number": 1412,
      "name": "Northgate Plaza Sealcoat",
      "status": "won",
      "job_id": 70412,
      "parent_estimate_id": null,
      "is_change_order": false,
      "current_version": 2,
      "awarded_at": "2026-10-09T13:12:47Z",
      "archived_at": null,
      "created_at": "2026-10-01T16:02:55Z",
      "updated_at": "2026-10-09T13:12:47Z",
      "totals": {
        "price": 48500.0,
        "subtotal": 48500.0,
        "tax": 0.0,
        "cost": 34852.1,
        "margin": 13647.9,
        "margin_pct": 0.281400,
        "optional_total": 1850.0
      },
      "version": null,
      "priced": true
    }
  ],
  "pagination": { "total": 1, "limit": 50, "offset": 0 }
}
```

**Errors:** `400` `status must be one of draft, estimate, won, lost, change_order`. `400` `updated_after must be an ISO 8601 timestamp`. `403` `API key lacks job read permission`.

### Get an estimate

`GET /v1/estimates/{id}`

**Permission:** Read jobs

Returns one estimate with totals, areas and line items. Add `version=N` to get issued version N, frozen as the customer received it.

| Name | Type | Required | Description |
|---|---|---|---|
| `id` | integer (path) | Yes | Estimate id. |
| `version` | integer | No | Issued version number. Omit for the live estimate. |

```bash
curl "https://trutec.ai/api/v1/estimates/9840?version=2" \
  -H "X-API-Key: sk_your_api_key_here"
```

```json
{
  "id": 9840,
  "doc_number": 1412,
  "name": "Northgate Plaza Sealcoat",
  "status": "estimate",
  "job_id": 70412,
  "parent_estimate_id": null,
  "is_change_order": false,
  "current_version": 2,
  "awarded_at": "2026-10-09T13:12:47Z",
  "archived_at": null,
  "created_at": "2026-10-01T16:02:55Z",
  "updated_at": "2026-10-09T13:12:47Z",
  "version": 2,
  "issued_at": "2026-10-06T19:30:02Z",
  "issued_by_user_id": 311,
  "priced": true,
  "totals": {
    "price": 48500.0, "subtotal": 48500.0, "tax": 0.0,
    "cost": 34852.1, "margin": 13647.9, "margin_pct": 0.2814,
    "optional_total": 1850.0
  },
  "areas": [
    {
      "id": 22018,
      "name": "Front lot",
      "services": [
        {
          "id": 140233,
          "name": "Sealcoat, 2 coats",
          "service_type": "Sealcoat",
          "category": "Sealcoating",
          "assembly_id": 5120,
          "estimating_mode": "detailed",
          "quantity": 48250.0,
          "unit": "SF",
          "unit_cost": 0.4512,
          "unit_price": 0.6275,
          "price": 30276.88,
          "cost": 21770.4,
          "margin": 8506.48,
          "margin_pct": 0.280956,
          "allocated_price": 31940.12,
          "is_optional": false,
          "hide_on_proposal": false,
          "parent_service_id": null,
          "cost_code": "01-SEAL",
          "is_bundle_master": false,
          "bundle_child_service_ids": [],
          "bundle_parent_service_id": null
        }
      ]
    }
  ]
}
```

(Response trimmed to one line item.)

**Errors:** `400` `version must be an integer`. `404` `Estimate not found`. `404` `Version 3 not found for this estimate`.

### List issued versions

`GET /v1/estimates/{id}/versions`

**Permission:** Read jobs

Every issued version, oldest first, with its totals. Not paginated.

```bash
curl "https://trutec.ai/api/v1/estimates/9840/versions" \
  -H "X-API-Key: sk_your_api_key_here"
```

```json
{
  "data": [
    {
      "number": 1,
      "issued_at": "2026-10-02T15:10:44Z",
      "issued_by_user_id": 311,
      "totals": { "price": 51200.0, "subtotal": 51200.0, "tax": 0.0, "cost": 36010.55, "margin": 15189.45, "margin_pct": 0.296669, "optional_total": 1850.0 }
    },
    {
      "number": 2,
      "issued_at": "2026-10-06T19:30:02Z",
      "issued_by_user_id": 311,
      "totals": { "price": 48500.0, "subtotal": 48500.0, "tax": 0.0, "cost": 34852.1, "margin": 13647.9, "margin_pct": 0.2814, "optional_total": 1850.0 }
    }
  ],
  "estimate_id": 9840
}
```

**Errors:** `404` `Estimate not found`.

### List revisions

`GET /v1/estimates/{id}/revisions`

**Permission:** Read jobs

The estimate's edit log, newest first: who changed what, and when. `summary` is a short plain-English description.

| Name | Type | Required | Description |
|---|---|---|---|
| `id` | integer (path) | Yes | Estimate id. |
| `limit` | integer | No | Page size. Default 50, maximum 100. |
| `offset` | integer | No | Rows to skip. Default 0. |

```bash
curl "https://trutec.ai/api/v1/estimates/9840/revisions?limit=2" \
  -H "X-API-Key: sk_your_api_key_here"
```

```json
{
  "data": [
    {
      "id": 771204,
      "action": "lifecycle.issue",
      "actor_user_id": 311,
      "occurred_at": "2026-10-06T19:30:02Z",
      "summary": "Issued version 2 ($48,500.00)",
      "undone": false
    },
    {
      "id": 771188,
      "action": "service.update",
      "actor_user_id": 311,
      "occurred_at": "2026-10-06T19:22:40Z",
      "summary": "service update: qty_value",
      "undone": false
    }
  ],
  "pagination": { "total": 64, "limit": 2, "offset": 0 }
}
```

`undone` is true when the estimator undid that edit.

**Errors:** `404` `Estimate not found`.

## Create and change estimates

### Create an estimate

`POST /v1/estimates`

**Permission:** Write jobs (role: owner, admin, estimator or sales)

Starts a draft estimate on a job. Without a template it is the workspace's standard sheet: one area, plus any catalog rows marked "on every new estimate" and, when the job has a site assessment, its services. With `template_id` it copies that template's areas, lines and pricing.

| Name | Type | Required | Description |
|---|---|---|---|
| `job_id` | integer | Yes | The job. |
| `template_id` | integer | No | From `GET /v1/estimate-templates`. |
| `name` | string | No | Defaults to "<job name> — Estimate". |
| `takeoff_id` | integer | No | One of the job's takeoffs (measurement ids from `GET /v1/measurements`). Adds an area whose quantities come from it. |

```bash
curl -X POST "https://trutec.ai/api/v1/estimates" \
  -H "X-API-Key: sk_your_api_key_here" \
  -H "Content-Type: application/json" \
  -d '{"job_id": 70412, "name": "Front lot reseal"}'
```

```json
{
  "data": {
    "id": 9841,
    "doc_number": 1413,
    "name": "Front lot reseal",
    "status": "draft",
    "job_id": 70412,
    "parent_estimate_id": null,
    "is_change_order": false,
    "current_version": null,
    "awarded_at": null,
    "archived_at": null,
    "created_at": "2026-10-12T14:56:38Z",
    "updated_at": "2026-10-12T14:56:39Z",
    "totals": { "price": 0.0, "subtotal": 0.0, "tax": 0.0, "cost": 0.0, "margin": 0.0, "margin_pct": 0.0, "optional_total": 0.0 },
    "version": null,
    "priced": true,
    "pricing": { "markup_method": "margin", "markup_rate": 0.0, "contingency_rate": null, "tax_rate": null, "minimum_charge": null, "price_list_id": null },
    "areas": [
      { "id": 22101, "name": "Area 1", "quantity_source": "manual", "takeoff_id": null, "services": [] }
    ]
  }
}
```

**Errors:** `400` `job_id is required (an integer job id)`. `400` `Unknown fields: …`. `403` `API key lacks job write permission`, or the key's user can't create estimates. `404` `Job not found`, `Estimate template not found`.

### Update an estimate

`PATCH /v1/estimates/{id}`

**Permission:** Write jobs (role: owner, admin or estimator for pricing; sales may rename)

Rename the estimate or change its pricing inputs. Only the fields you send change; the estimate re-prices.

| Name | Type | Description |
|---|---|---|
| `name` | string | Up to 200 characters. Also renames the proposal. |
| `markup_method` | string | `markup` or `margin`. |
| `markup_rate` | number | Fraction. A margin must be below `1`. |
| `contingency_rate` | number or null | Fraction; `null` or `0` turns contingency off. |
| `tax_rate` | number or null | Fraction; `null` uses the workspace's rate. |
| `minimum_charge` | number or null | Dollars; `null` uses the workspace's. |
| `price_list_id` | integer or null | A price list from `GET /v1/catalog/price-lists`; `null` for the default. Re-reads every catalog-priced line from that column. |

```bash
curl -X PATCH "https://trutec.ai/api/v1/estimates/9841" \
  -H "X-API-Key: sk_your_api_key_here" \
  -H "Content-Type: application/json" \
  -d '{"markup_method": "margin", "markup_rate": 0.28, "tax_rate": 0.0825}'
```

Returns `200` with the estimate.

**Errors:** `400` `markup_method must be markup or margin`, `A margin rate must be below 1 (100%)`. `404` `Estimate not found`, `Price list not found`. `409` when the estimate is locked (for example sent, and the change would move its price).

### Archive an estimate

`DELETE /v1/estimates/{id}`

**Permission:** Write jobs and Delete records (role: owner, admin or estimator)

Archives, never deletes: the estimate leaves active lists (and `GET /v1/estimates` unless `include_archived=true`) and keeps every number. Returns `204`.

### Restore an estimate

`POST /v1/estimates/{id}/restore`

**Permission:** Write jobs (role: owner, admin or estimator)

Brings an archived estimate back. Returns `200` with the estimate.

### Duplicate an estimate

`POST /v1/estimates/{id}/duplicate`

**Permission:** Write jobs (role: owner, admin, estimator or sales)

Copies an estimate as a new draft with its own document number: areas, lines, cost lines and pricing, exactly as the app's "Save as template" captures them. Takeoff links are not copied (they belong to the original job).

| Name | Type | Description |
|---|---|---|
| `job_id` | integer | The job for the copy. Defaults to the original's job. |
| `name` | string | Defaults to "<original name> (copy)". |

Returns `201` with the new estimate.

### Re-price an estimate

`POST /v1/estimates/{id}/recalculate`

**Permission:** Write jobs

Runs the pricing engine now, as opening the estimating sheet does (catalog changes to frozen lines still need the estimator's "update prices"). Returns `200` with the estimate and its fresh `totals`.

## Areas

### Add an area

`POST /v1/estimates/{id}/areas`

**Permission:** Write jobs (role: owner, admin, estimator or sales)

| Name | Type | Required | Description |
|---|---|---|---|
| `name` | string | Yes | For example `Back lot`. |
| `takeoff_id` | integer | No | A takeoff on the estimate's job. Quantities for lines in this area come from it. |

```bash
curl -X POST "https://trutec.ai/api/v1/estimates/9841/areas" \
  -H "X-API-Key: sk_your_api_key_here" \
  -H "Content-Type: application/json" \
  -d '{"name": "Back lot"}'
```

```json
{
  "data": {
    "id": 22102,
    "estimate_id": 9841,
    "name": "Back lot",
    "quantity_source": "manual",
    "takeoff_id": null,
    "services": [],
    "estimate_totals": { "price": 3100.0, "subtotal": 3100.0, "tax": 0.0, "cost": 2480.0, "margin": 620.0, "margin_pct": 0.2, "optional_total": 0.0 }
  }
}
```

### Update an area

`PATCH /v1/estimates/{id}/areas/{area_id}`

**Permission:** Write jobs

`name`, and `takeoff_id` (an id to bind a takeoff, `null` to switch the area to typed quantities). Returns the area.

### Remove an area

`DELETE /v1/estimates/{id}/areas/{area_id}`

**Permission:** Write jobs (role: owner, admin or estimator)

Removes the area and its lines from the estimate. The revision log keeps them, and Undo in TruTec brings them back. Returns `204`.

## Line items

### Get a line item

`GET /v1/estimates/{id}/line-items/{line_item_id}`

**Permission:** Read jobs

The line item object plus its `cost_lines`.

```bash
curl "https://trutec.ai/api/v1/estimates/9841/line-items/140260" \
  -H "X-API-Key: sk_your_api_key_here"
```

```json
{
  "data": {
    "id": 140260,
    "name": "Mill and overlay",
    "service_type": "Mill and overlay",
    "category": "Paving",
    "assembly_id": 5121,
    "estimating_mode": "detailed",
    "area_id": 22101,
    "quantity": 1000.0,
    "unit": "SY",
    "quantity_source": "manual",
    "unit_cost": 21.4,
    "unit_price": 27.82,
    "cost": 21400.0,
    "price": 27820.0,
    "margin": 6420.0,
    "margin_pct": 0.230769,
    "allocated_price": 28150.4,
    "is_optional": false,
    "hide_on_proposal": false,
    "cost_lines": [
      {
        "id": 448812,
        "line_item_id": 140260,
        "type": "material",
        "catalog_item_id": 742,
        "name": "HMA surface mix",
        "quantity": 110.0,
        "unit": "TON",
        "unit_cost": 86.0,
        "cost": 9460.0,
        "price": 12298.0,
        "quantity_formula": "qty * 0.11",
        "manual_quantity": null,
        "waste_factor": null,
        "pricing_method": "markup",
        "pricing_rate": null,
        "billing": "billed",
        "is_alternate": false,
        "suppressed": false
      }
    ]
  }
}
```

(Response trimmed.)

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

### Add a line item

`POST /v1/estimates/{id}/line-items`

**Permission:** Write jobs (role: owner, admin, estimator or sales)

Adds a catalog service, the way the sheet's **Add service** does: its recipe of crews, equipment and materials is copied onto the line, its subservices come along (unless `include_subservices` is `false`), and the estimate re-prices. When the area is bound to a takeoff and you send no `quantity`, the line starts at the takeoff's measured quantity. Or add a custom line with `name`.

| Name | Type | Required | Description |
|---|---|---|---|
| `catalog_service_id` | integer | One of these | From `GET /v1/catalog/services`. |
| `name` | string | One of these | A custom line not in the catalog. |
| `quantity` | number | No | In the service's unit. |
| `unit` | string | No | Defaults to the catalog service's unit. |
| `area_id` | integer | No | Defaults to the estimate's first area. |
| `include_subservices` | boolean | No | Default `true`. |
| `unit_cost` | number | No | Custom lines only: price per unit. |
| `pricing_mode` | string | No | Custom lines only: `unit_costing` (default) or `detailed`. |

```bash
curl -X POST "https://trutec.ai/api/v1/estimates/9841/line-items" \
  -H "X-API-Key: sk_your_api_key_here" \
  -H "Content-Type: application/json" \
  -d '{"catalog_service_id": 5120, "quantity": 48250}'
```

```json
{
  "data": {
    "id": 140261,
    "name": "Sealcoat, 2 coats",
    "service_type": "Sealcoat, 2 coats",
    "category": "Sealcoating",
    "assembly_id": 5120,
    "estimating_mode": "unit_costing",
    "area_id": 22101,
    "quantity": 48250.0,
    "unit": "SF",
    "quantity_source": "inherited",
    "unit_cost": 0.25,
    "unit_price": 0.25,
    "price": 12062.5,
    "cost": 12062.5,
    "allocated_price": 12062.5,
    "is_optional": false,
    "hide_on_proposal": false
  },
  "created_line_items": [ { "id": 140261, "name": "Sealcoat, 2 coats" } ],
  "estimate_totals": { "price": 39882.5, "subtotal": 39882.5, "tax": 0.0, "cost": 33462.5, "margin": 6420.0, "margin_pct": 0.160972, "optional_total": 0.0 }
}
```

(Response trimmed.) `data` is the first new line; `created_line_items` lists every line the add created (subservices included).

**Errors:** `400` `Send catalog_service_id, or name for a custom line`, `quantity must be 0 or more`. `404` `Catalog service not found`, `Area not found`. `409` when the app refuses the add (for example the service is already on that parent).

### Update a line item

`PATCH /v1/estimates/{id}/line-items/{line_item_id}`

**Permission:** Write jobs (role: owner, admin, estimator or sales)

Only the fields you send change, then the estimate re-prices. A `quantity` you send is kept as typed: it replaces the takeoff's number and the line's `quantity_source` becomes `manual`.

| Name | Type | Description |
|---|---|---|
| `name` | string | |
| `quantity` | number | |
| `unit` | string | `SF` ↔ `SY` converts the quantity and unit price. |
| `unit_cost` | number | Price per unit (unit-priced lines). |
| `pricing_mode` | string | `detailed` or `unit_costing`. |
| `pricing_method`, `pricing_rate` | string, number or null | This line's own markup or margin (fraction); `null` rate follows the estimate. |
| `price_override` | number or null | Pin this line's customer price; `null` clears. |
| `minimum_price` | number or null | |
| `is_optional` | boolean | An add-on the customer can choose; left out of `totals.price`. |
| `hide_on_proposal` | boolean | Priced but not listed on the proposal. |
| `proposal_group` | string | Lines sharing a group print as one proposal line. |
| `proposal_text` | string | The line's proposal wording. |
| `what_to_expect` | string | HTML printed under the line on the proposal. An empty string shows nothing. |
| `internal_scope` | string | HTML scope sent with this line's quote requests to subcontractors ([Quote requests](rfqs-and-subcontractors.md)). An empty string sends none. |

```bash
curl -X PATCH "https://trutec.ai/api/v1/estimates/9841/line-items/140261" \
  -H "X-API-Key: sk_your_api_key_here" \
  -H "Content-Type: application/json" \
  -d '{"quantity": 51000, "is_optional": false}'
```

Returns `200` with `data` (the line) and `estimate_totals`.

**Errors:** `400` `Send at least one field to change`, `Unknown fields: …`. `404` `Line item not found`. `409` when the estimate is locked for that change.

### Remove a line item

`DELETE /v1/estimates/{id}/line-items/{line_item_id}`

**Permission:** Write jobs (role: owner, admin or estimator)

A service group takes its subservices with it. Returns `204`.

## Cost lines

### Add a cost line

`POST /v1/estimates/{id}/cost-lines`

**Permission:** Write jobs (role: owner, admin or estimator)

Adds a catalog cost item to one line's recipe, on this estimate only.

| Name | Type | Required | Description |
|---|---|---|---|
| `line_item_id` | integer | Yes | The line. |
| `type` | string | Yes | `crew`, `equipment`, `material`, `trucking`, `subcontractor`, `misc` or `overhead`. |
| `catalog_item_id` | integer | Yes | From `GET /v1/catalog/{item_type}`. |
| `quantity_formula` | string | No | Quantity per the line's quantity, for example `qty * 0.05`. Defaults to the catalog's. |

```bash
curl -X POST "https://trutec.ai/api/v1/estimates/9841/cost-lines" \
  -H "X-API-Key: sk_your_api_key_here" \
  -H "Content-Type: application/json" \
  -d '{"line_item_id": 140260, "type": "material", "catalog_item_id": 742, "quantity_formula": "qty * 0.05"}'
```

Returns `201` with `data` (the cost line) and `estimate_totals`.

**Errors:** `400` `type must be one of …`. `404` `Line item not found`, `Catalog item not found`.

### Update a cost line

`PATCH /v1/estimates/{id}/cost-lines/{cost_line_id}`

**Permission:** Write jobs (role: owner, admin or estimator)

| Name | Type | Description |
|---|---|---|
| `quantity` | number or null | A typed quantity; `null` goes back to the formula. |
| `quantity_formula` | string | |
| `unit_cost` | number | This estimate's unit cost. The catalog is never changed. |
| `waste_factor` | number or null | Fraction. |
| `pricing_method`, `pricing_rate` | string, number or null | Pin a markup on this cost line; a `null` rate follows the line again. |
| `billing` | string | `billed` or `internal_only`. |
| `is_alternate` | boolean | Cost it as a comparison option, outside every total. |
| `hauls` | array or null | Trucking only: what the truck hauls (`asphalt`, `millings`, `spoil`). With automatic driving time on, millings or spoil send it to the service's dump site. |
| `dump_material` | string or null | Misc only: mark the line as the dump fee for `millings` or `spoil`; `null` = an ordinary misc line. |
| `dump_site_vendor_id` | integer or null | Misc dump fees only: the dump site — a vendor with a price on this dump fee. The fee prices at that site, and the service's millings/spoil trucks re-route: a round trip when the dump site is the asphalt plant, otherwise plant → job → dump site → plant. `null` clears it. |

Returns `200` with `data` and `estimate_totals`.

### Remove a cost line

`DELETE /v1/estimates/{id}/cost-lines/{cost_line_id}`

**Permission:** Write jobs (role: owner, admin or estimator)

Returns `204`.

## Issue, win, lose, change orders

### Issue a version

`POST /v1/estimates/{id}/issue`

**Permission:** Write jobs (role: owner, admin or estimator)

Re-prices, then freezes the next numbered version (`draft` → `estimate`). Nothing is sent to the customer. TruTec's issue checks apply: unfinished recipe inputs, the workspace's profit floor and blocking review checks return `409` with `code: issue_blocked` and the reason. A key whose user is an owner or estimating admin can send `override_approval: true` to release the profit floor and review checks, as the app's admin approval does.

| Name | Type | Description |
|---|---|---|
| `override_approval` | boolean | Admins only. |

```bash
curl -X POST "https://trutec.ai/api/v1/estimates/9841/issue" \
  -H "X-API-Key: sk_your_api_key_here"
```

Returns `200` with `data` (the estimate, now `current_version` 1 and `status` `estimate`) and `issued_version`.

```json
{
  "error": "This bid's profit is below your company's approval floor — an admin has to approve it.",
  "code": "issue_blocked"
}
```

### Mark won

`POST /v1/estimates/{id}/mark-won`

**Permission:** Write jobs (role: owner, admin or estimator)

The estimate must be issued first (`409` otherwise). Freezes the award for job costing, moves the job to the workspace's won stage (when the user may move it; otherwise `job_status_notice` says what stayed put) and queues the crew work on the schedule. For a change-order estimate it approves the change order. Optional `reason`. Returns `200` with the estimate, plus `job_status_notice`, `crew_work_orders_created` and `change_order_id` when they apply.

### Mark lost

`POST /v1/estimates/{id}/mark-lost`

**Permission:** Write jobs (role: owner, admin or estimator)

| Name | Type | Description |
|---|---|---|
| `winning_price` | number | What the job went for, if known. |
| `winning_contractor` | string | Who won it. |
| `reason` | string | Why. |

Returns `200` with the estimate.

### Start a change order

`POST /v1/estimates/{id}/change-orders`

**Permission:** Write jobs (role: owner, admin or estimator)

Creates a change-order estimate linked to this one (`parent_estimate_id`) and its job change order. Edit it with the same line-item endpoints, prepare and send its proposal, and mark it won to approve it.

| Name | Type | Description |
|---|---|---|
| `title` | string | Names the change order. |
| `pricing_mode` | string | `revision` (default): a copy of the whole estimate to re-price. `additive`: price only the new work, on a blank area. |
| `request_key` | string (UUID) | `additive` only: repeat the call with the same key and you get the same change order back. |

Returns `201` with the change-order estimate and `change_order_id`.

**Errors:** `400` `pricing_mode must be revision or additive`, and the app's reason when a change order can't start yet.

## Estimate templates

`GET /v1/estimate-templates` lists them (see [Catalog](catalog.md)); `template_id` on `POST /v1/estimates` starts an estimate from one. These calls save and manage them, exactly as **Save as template** and the Templates page do.

**Permission:** Write jobs (role: owner, admin or estimator); archiving also needs Delete records.

| Call | What it does |
|---|---|
| `POST /v1/estimate-templates` | Save an estimate as a template: `estimate_id`, `name`, optional `description`. Its areas, lines, cost lines and pricing are copied, never the job or customer. Returns `201`. |
| `PATCH /v1/estimate-templates/{id}` | `name`, `description`, and/or `refresh_from_estimate_id` to re-save the template from an estimate. Estimates already made from it don't change. |
| `POST /v1/estimate-templates/{id}/duplicate` | Copy it (named "… (copy)" unless you send `name`). Returns `201`. |
| `DELETE /v1/estimate-templates/{id}` | Archive it. Returns `204`. |

```bash
curl -X POST "https://trutec.ai/api/v1/estimate-templates" \
  -H "X-API-Key: sk_your_api_key_here" \
  -H "Content-Type: application/json" \
  -d '{"estimate_id": 9841, "name": "Parking lot sealcoat + restripe"}'
```

```json
{
  "data": {
    "id": 214,
    "name": "Parking lot sealcoat + restripe",
    "description": null,
    "area_count": 1,
    "service_count": 3,
    "created_at": "2026-10-13T18:20:04Z",
    "updated_at": "2026-10-13T18:20:04Z"
  }
}
```

**Errors:** `400` `name is required`. `404` `Estimate not found`, `Estimate template not found`. `403` when the key's user's estimating role can't manage templates.
