Estimates & proposals
Read estimates as structured data, and build them from your own systems — create, add and price lines, issue versions, mark them won or lost, and start change orders.
View as MarkdownAn estimate is the priced bid for a job: areas of the site, line items in each area (built from your catalog), 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.
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):
| 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:
POST /v1/estimateswith ajob_id(optionally atemplate_id).POST /v1/estimates/{id}/line-itemsfor each catalog service and quantity.PATCH /v1/estimates/{id}to set the markup, tax or price list. Adjust lines withPATCH .../line-items/{line_item_id}.POST /v1/estimates/{id}/issueto freeze version 1.POST /v1/estimates/{id}/proposal, thenPOST /v1/proposals/{id}/send(Proposals).POST /v1/estimates/{id}/mark-wonwhen 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=Nreturns version N exactly as issued.GET /v1/estimates/{id}/versionslists every version with its totals and issue date.- Each line item's
allocated_priceis 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). - Webhooks fire when an estimate is issued, revised, won or lost, and when a proposal is sent, viewed, accepted or declined (Webhooks & events).
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). |
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. |
curl "https://trutec.ai/api/v1/estimates?job_id=70412" \
-H "X-API-Key: sk_your_api_key_here"
{
"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. |
curl "https://trutec.ai/api/v1/estimates/9840?version=2" \
-H "X-API-Key: sk_your_api_key_here"
{
"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.
curl "https://trutec.ai/api/v1/estimates/9840/versions" \
-H "X-API-Key: sk_your_api_key_here"
{
"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. |
curl "https://trutec.ai/api/v1/estimates/9840/revisions?limit=2" \
-H "X-API-Key: sk_your_api_key_here"
{
"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 " |
takeoff_id |
integer | No | One of the job's takeoffs (measurement ids from GET /v1/measurements). Adds an area whose quantities come from it. |
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"}'
{
"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. |
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 " |
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. |
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"}'
{
"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.
curl "https://trutec.ai/api/v1/estimates/9841/line-items/140260" \
-H "X-API-Key: sk_your_api_key_here"
{
"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. |
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}'
{
"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). An empty string sends none. |
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. |
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. |
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.
{
"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); 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. |
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"}'
{
"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.