Job costing
Read and record a job's actual costs, and read its crew labor, invoices and crew and equipment allocation, plus a feed of every site with completed work.
View as MarkdownThese endpoints cover what happened on a job after it was won: the costs recorded against it, crew clock time, invoices, and which crews, people and equipment were on each work order. A separate feed lists every site where your workspace has finished work.
Reads need the Read jobs permission. You can also add, change, review and delete cost entries (see Record costs); to create and send invoices, see Invoices & payments, and for crew time, Timesheets. For every job's revenue and direct cost in a month, see Month-end job cost.
Response format#
Most of these endpoints return {"data": [...], "pagination": {"total", "limit", "offset"}}. Labor and allocation return every row for the job with no pagination. Errors return JSON with an error string. A job that is not in your workspace returns 404 Job not found.
Timestamps are UTC in ISO 8601 with a Z suffix, for example 2026-10-14T21:30:00Z. Plain dates are YYYY-MM-DD.
Note
Partner keys that act on behalf of several workspaces must also pass firm_id in the query string.
Costs#
Get job costs#
GET /v1/jobs/{id}/costs
Permission: Read jobs
Returns the job's cost entries (oldest first) and a summary. The summary always covers every entry on the job, even when you filter or page the rows.
| Name | Type | Required | Description |
|---|---|---|---|
id |
integer (path) | Yes | Job id. |
category |
string | No | labor, material, equipment, trucking, subcontractor, overhead or other. |
review_status |
string | No | pending, approved or rejected. |
limit |
integer | No | Page size. Default 100, maximum 500. |
offset |
integer | No | Rows to skip. Default 0. |
curl "https://trutec.ai/api/v1/jobs/70412/costs?category=material" \
-H "X-API-Key: sk_your_api_key_here"
{
"job_id": 70412,
"job_external_ref": "crm-deal-88120",
"data": [
{
"id": 88310,
"job_id": 70412,
"work_order_id": 4120,
"estimate_service_id": null,
"category": "material",
"cost_code": "01-SEAL",
"source": "invoice",
"description": "Sealer concentrate, 1,100 gal",
"qty": 1100.0,
"unit": "GAL",
"unit_cost": 4.85,
"total_cost": 5335.0,
"amount": 5335.0,
"incurred_on": "2026-11-03",
"is_committed": false,
"variance_reason": null,
"review_status": "approved",
"reviewed_at": "2026-11-04T15:02:11Z",
"reconciled_at": null,
"has_attachment": true,
"created_at": "2026-11-03T22:40:19Z"
}
],
"pagination": { "total": 1, "limit": 100, "offset": 0 },
"summary": {
"total_amount": 31820.44,
"entry_count": 14,
"by_category": {
"labor": { "count": 6, "amount": 9840.0 },
"material": { "count": 1, "amount": 5335.0 },
"equipment": { "count": 3, "amount": 6120.0 },
"trucking": { "count": 2, "amount": 2900.0 },
"subcontractor": { "count": 1, "amount": 7200.0 },
"overhead": { "count": 0, "amount": 0.0 },
"other": { "count": 1, "amount": 425.44 }
},
"by_review_status": {
"pending": { "count": 4, "amount": 6210.44 },
"approved": { "count": 10, "amount": 25610.0 },
"rejected": { "count": 0, "amount": 0.0 }
},
"original_contract": 48500.0,
"revised_contract": 50950.0
}
}
| Field | Description |
|---|---|
amount |
The entry's cost: total_cost when set, otherwise qty × unit_cost, otherwise 0. |
source |
How the entry was recorded, for example manual (office), field (crew app), invoice (uploaded invoice or ticket) or clock (from crew time entries). |
is_committed |
Whether the entry is marked as committed. |
review_status, reviewed_at, reconciled_at |
Office review and reconciliation state. |
has_attachment |
Whether a receipt or ticket image is attached. The file itself is not available through the API. |
original_contract, revised_contract |
The job's contract value at award, and after approved change orders. Null when TruTec cannot compute it. |
Record costs#
Cost writes run the same rules as the Job costing page in TruTec. They act as the person who created the API key, who must be able to see pricing. Job costing must be turned on for the workspace; otherwise writes return 403 with the reason. Budget alerts go out when a new cost pushes a job past its budget, as in TruTec.
Add a cost#
POST /v1/jobs/{id}/costs
Permission: Write jobs
Records an actual cost (an invoice, a ticket, a rental) as source: "manual", pending office review.
| Name | Type | Required | Description |
|---|---|---|---|
description |
string | Yes | Up to 255 characters. |
category |
string | No | labor, material, equipment, trucking, subcontractor, overhead or other (default). |
cost_code |
string | No | Defaults to the workspace's code for the category. |
qty, unit, unit_cost |
No | Quantity, unit (for example TON) and cost per unit. |
|
total_cost |
number | No | The total in dollars. When left out, amount is qty × unit_cost. |
incurred_on |
string | No | YYYY-MM-DD. |
work_order_id |
integer | No | Tie the cost to one visit of the job. |
estimate_service_id |
integer | No | Tie it to an estimate line, for budget-vs-actual by service. |
variance_reason |
string | No | Why it differs from the budget. |
is_committed |
boolean | No | A committed (ordered, not yet billed) cost. |
curl -X POST "https://trutec.ai/api/v1/jobs/70412/costs" \
-H "X-API-Key: sk_your_api_key_here" \
-H "Content-Type: application/json" \
-d '{
"category": "material",
"description": "Sealer concentrate, 1,100 gal",
"qty": 1100,
"unit": "GAL",
"unit_cost": 4.85,
"incurred_on": "2026-11-03",
"work_order_id": 4120
}'
Returns 201 with the cost entry, in the same shape as Get job costs. A work_order_id or estimate_service_id from another job returns 400.
Update a cost#
PATCH /v1/jobs/{id}/costs/{cost_id}
Permission: Write jobs
Changes the fields you send: description, category, cost_code, qty, unit_cost, total_cost, work_order_id, estimate_service_id, variance_reason, is_committed, review_status (pending, approved or rejected) and reconciled (boolean). unit and incurred_on can't be changed after the entry is recorded (400).
Review a cost#
POST /v1/jobs/{id}/costs/{cost_id}/review
Permission: Write jobs
| Name | Type | Required | Description |
|---|---|---|---|
status |
string | Yes | approved, rejected or pending. |
reconciled |
boolean | No | Mark it matched to the bill. Rejecting clears it. |
resolution |
object | No | For a crew-app record that needs a price: {"kind": "price", "unit_cost": 18.5, "reason": "..."}, {"kind": "link", "entry_id": 88310, "reason": "..."} (a duplicate of another cost) or {"kind": "delivery_only", "reason": "..."}. |
The reviewer is recorded as the key's creator. A crew-app record with no verified price can't be approved until it is resolved (400, needs_resolution: true).
Delete a cost#
DELETE /v1/jobs/{id}/costs/{cost_id}
Permission: Write jobs and Delete records
Returns 204. Labor costs that come from crew time entries (source: "clock") are rebuilt from the timesheet; change the time entries instead.
Labor#
Get job labor#
GET /v1/jobs/{id}/labor
Permission: Read jobs
Returns every crew clock-in and clock-out on the job's work orders, oldest first, with totals. Voided entries are left out unless you ask for them. Not paginated.
| Name | Type | Required | Description |
|---|---|---|---|
id |
integer (path) | Yes | Job id. |
include_voided |
boolean | No | true to include voided entries. |
curl "https://trutec.ai/api/v1/jobs/70412/labor" \
-H "X-API-Key: sk_your_api_key_here"
{
"data": [
{
"id": 602118,
"job_id": 70412,
"work_order_id": 4120,
"user_id": 344,
"category": "work",
"paid": true,
"clock_in": "2026-11-03T13:00:00Z",
"clock_out": "2026-11-03T22:30:00Z",
"hours": 9.0,
"unpaid_lunch_minutes": 30,
"wage_rate": 28.5,
"wage_rate_kind": "standard",
"is_prevailing_wage": false,
"approved_at": "2026-11-04T14:10:02Z",
"voided_at": null
}
],
"summary": {
"hours_by_category": { "work": 42.5, "travel": 6.0 },
"total_hours": 48.5,
"open_entries": 0,
"labor_cost_at_wage_rate": 1374.25
}
}
| Field | Description |
|---|---|
category |
work, travel, break or shop. Breaks are unpaid (paid: false). |
hours |
Clock time minus the unpaid lunch. Null while the person is still clocked in. |
wage_rate |
Hourly cost rate captured at clock-in. |
wage_rate_kind |
Where the rate came from: standard (the person's default rate), prevailing (the job's prevailing-wage rate), ep_person (the catalog rate for that person), crew_derived (crew day rate ÷ headcount ÷ hours) or actual_pay (the person's actual pay rate). |
open_entries |
Entries with no clock-out yet. |
total_hours |
Sum of hours_by_category, breaks included. |
labor_cost_at_wage_rate |
Paid hours × wage_rate, summed. Straight time only: no overtime or labor burden. |
Invoices#
List job invoices#
GET /v1/jobs/{id}/invoices
Permission: Read jobs
Returns the job's invoices, oldest first. Payment links and customer share links are never included.
| Name | Type | Required | Description |
|---|---|---|---|
id |
integer (path) | Yes | Job id. |
status |
string | No | open, paid, void or uncollectible. |
limit |
integer | No | Page size. Default 100, maximum 500. |
offset |
integer | No | Rows to skip. Default 0. |
curl "https://trutec.ai/api/v1/jobs/70412/invoices" \
-H "X-API-Key: sk_your_api_key_here"
{
"job_id": 70412,
"job_external_ref": "crm-deal-88120",
"data": [
{
"id": 7781,
"job_id": 70412,
"doc_number": 1412,
"status": "paid",
"billing_type": "deposit",
"payment_method": "check",
"description": "Deposit, 30%",
"currency": "usd",
"amount_cents": 1455000,
"amount": 14550.0,
"subtotal_cents": 1455000,
"subtotal": 14550.0,
"discount_cents": 0,
"discount": 0.0,
"tax_cents": 0,
"tax": 0.0,
"due_date": "2026-10-24T00:00:00Z",
"issued_at": "2026-10-10T16:20:41Z",
"paid_at": "2026-10-18T19:02:13Z",
"updated_at": "2026-10-18T19:02:13Z",
"quickbooks": {
"has_invoice": true,
"has_payment": true,
"invoice_synced_at": "2026-10-10T16:21:05Z",
"sync_error": null
}
}
],
"pagination": { "total": 1, "limit": 100, "offset": 0 }
}
Amounts come in both cents (amount_cents) and dollars (amount). billing_type is one of full, deposit, milestone, custom, line_items or recurring. payment_method is, for example, stripe (paid online) or check. The quickbooks object shows whether the invoice and its payment reached QuickBooks Online, and the error if a sync failed.
Allocation#
Get job allocation#
GET /v1/jobs/{id}/allocation
Permission: Read jobs
Returns each of the job's work orders, by scheduled date, with the crew, people and equipment on it. Not paginated.
curl "https://trutec.ai/api/v1/jobs/70412/allocation" \
-H "X-API-Key: sk_your_api_key_here"
{
"data": [
{
"work_order_id": 4120,
"job_id": 70412,
"title": "Sealcoat front lot",
"status": "completed",
"scheduled_date": "2026-11-03",
"completed_at": "2026-11-03T23:05:40Z",
"crew": { "id": 18, "name": "Seal Crew 1" },
"people": [
{ "user_id": 344, "name": "Crew Lead", "source": "base" },
{ "user_id": null, "name": "Laborer 2", "source": "manual" }
],
"equipment": [
{ "id": 902, "name": "Seal rig 2", "ownership_type": "owned", "group": "Sealcoating" }
]
}
]
}
status is unscheduled, scheduled, in_progress, completed or cancelled. Crew members without a TruTec login have a name and a null user_id. A person's source says how they were placed: base (their own crew), utility (the utility crew), manual (picked by the office) or connected (copied from a linked work order).
Completed sites#
List completed sites#
GET /v1/sites/completed
Permission: Read jobs
Returns every site (not archived) where the workspace has completed work, with the customer, coordinates and the last completion date. Use it to mark properties as active customers in your CRM.
A site counts when at least one of its jobs sits in a pipeline stage the workspace treats as finished, the same rule the crew schedule uses.
By default rows are sorted by last_completed_at, newest first. With updated_after, they are sorted by updated_at, oldest first, so you can page forward and save the last row's updated_at as your next cursor.
| Name | Type | Required | Description |
|---|---|---|---|
client_account_id |
integer | No | Only sites under this customer. |
external_ref |
string | No | Only sites under the customer with this external_ref. This is the customer's ref, not the site's. |
completed_after |
string | No | ISO 8601. Only sites whose last completion is after it. |
updated_after |
string | No | ISO 8601. Only sites whose record, completed jobs or last completion changed after it. |
limit |
integer | No | Page size. Default 100, maximum 500. |
offset |
integer | No | Rows to skip. Default 0. |
curl "https://trutec.ai/api/v1/sites/completed?updated_after=2026-10-01T00:00:00Z" \
-H "X-API-Key: sk_your_api_key_here"
{
"data": [
{
"location_id": 9307,
"external_ref": "crm-site-5512",
"name": "Northgate Plaza",
"address": "1200 Commerce Dr, Austin, TX 78701",
"latitude": 30.2711,
"longitude": -97.7437,
"client_account": {
"id": 4812,
"name": "Northgate Property Group",
"external_ref": "crm-acct-20931"
},
"last_completed_at": "2026-11-03T23:05:40Z",
"completed_at_source": "work_order",
"completed_job_count": 3,
"last_completed_job_id": 70412,
"updated_at": "2026-11-03T23:05:40Z"
}
],
"pagination": { "total": 1, "limit": 100, "offset": 0 }
}
completed_at_source tells you how precise last_completed_at is:
| Value | Meaning |
|---|---|
work_order |
When the job's last work order was marked complete. Most precise. |
end_date |
The job's end date (midnight UTC). |
updated_at |
When the job record last changed. Least precise. |
Errors: 400 client_account_id must be an integer. 400 completed_after must be an ISO 8601 timestamp / updated_after must be an ISO 8601 timestamp.