# Job costing

These 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](#record-costs)); to create and send invoices, see [Invoices & payments](invoices-and-payments.md), and for crew time, [Timesheets](timesheets.md). For every job's revenue and direct cost in a month, see [Month-end job cost](owner-reports.md#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. |

```bash
curl "https://trutec.ai/api/v1/jobs/70412/costs?category=material" \
  -H "X-API-Key: sk_your_api_key_here"
```

```json
{
  "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. |

```bash
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](timesheets.md) 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. |

```bash
curl "https://trutec.ai/api/v1/jobs/70412/labor" \
  -H "X-API-Key: sk_your_api_key_here"
```

```json
{
  "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. |

```bash
curl "https://trutec.ai/api/v1/jobs/70412/invoices" \
  -H "X-API-Key: sk_your_api_key_here"
```

```json
{
  "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.

```bash
curl "https://trutec.ai/api/v1/jobs/70412/allocation" \
  -H "X-API-Key: sk_your_api_key_here"
```

```json
{
  "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. |

```bash
curl "https://trutec.ai/api/v1/sites/completed?updated_after=2026-10-01T00:00:00Z" \
  -H "X-API-Key: sk_your_api_key_here"
```

```json
{
  "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`.
