# Jobs

A job is one piece of work for a customer at a site: a lead in the pipeline, an active bid, or won work being scheduled and built. Estimates, takeoffs, photos, change orders, costs and invoices all hang off a job.

Jobs link to a customer (`client_account_id`) and a site (`location_id`). See **Customers & sites**.

## Syncing with your own system

TruTec ids are integers that never change. To keep TruTec and your system in step, store your own id on each TruTec record and pull changes on a schedule.

**Store your id in `external_ref`.** Customers, sites and jobs each have an `external_ref` field. It is unique per workspace. A second record cannot take a ref another record already holds; you get a `409` naming the holder.

**Write with upserts.** These calls are safe to repeat:

| Record | Create-or-match call | Matches on |
|---|---|---|
| Customer | `POST /v1/client-accounts` | `external_ref`, then name |
| Site | `POST /v1/locations` | `external_ref`, then address |
| Job | `POST /v1/jobs` with `external_ref` | `external_ref` |

A match returns `200` with `"created": false`. A new record returns `201` with `"created": true`.

**Look up by your id.** `GET /v1/client-accounts`, `GET /v1/locations` and `GET /v1/jobs` all accept `?external_ref=`. A job can also link to its customer by your id: send `client_account_external_ref` instead of `client_account_id`.

**Recommended order:** customer, then site (with `client_account_id`), then job (with `client_account_id` or `client_account_external_ref`, and the site's `address`).

**Pull changes with `updated_after`.** These list endpoints accept an ISO 8601 `updated_after` timestamp:

| Endpoint | Returns |
|---|---|
| `GET /v1/jobs` | Jobs whose record changed after the timestamp |
| `GET /v1/estimates` | Estimates that changed after the timestamp |
| `GET /v1/sites/completed` | Sites with new or changed completed work |

With `updated_after`, results are sorted by `updated_at`, oldest first. To poll:

1. Call with `updated_after` set to your saved cursor (start with a date far in the past).
2. Process the page. Save the `updated_at` of the last row as your new cursor.
3. Repeat until a page comes back with fewer rows than `limit`.

Upsert what you receive by `id`. You may see a row more than once, so make your writes idempotent.

```bash
curl "https://trutec.ai/api/v1/jobs?updated_after=2026-10-11T00:00:00Z&limit=100" \
  -H "X-API-Key: sk_your_api_key_here"
```

!!! note
    Customers, sites, users, change orders and job-costing lists do not take `updated_after`; photos do through `GET /v1/photos?updated_after=...` ([Photos & files](photos-and-files.md)). For those, use the bulk export (`GET /v1/exports/{dataset}.jsonl?updated_after=...`), which covers customers, sites, jobs, estimates, change orders, invoices, costs, work orders and time entries, or subscribe to webhooks.

A job's `updated_at` moves when the job record itself changes. A new estimate, photo or cost entry on the job does not move it; track those through their own endpoints or webhooks.

The API has no endpoint for deleting jobs. Archived customers and sites drop out of list results, so run a full pull now and then to reconcile.

## Response format

List endpoints return:

```json
{
  "data": [ ... ],
  "pagination": { "total": 1342, "limit": 50, "offset": 0 }
}
```

`GET /v1/change-orders` is the exception; see below. Errors return JSON with an `error` string.

Job and user timestamps are UTC in ISO 8601 without a timezone suffix, for example `2026-10-12T14:03:22.418211`. Dates (`start_date` and similar) are `YYYY-MM-DD`.

!!! note
    Partner keys that act on behalf of several workspaces must also name the target workspace with `firm_id` (query string, or JSON body on writes). Workspace keys never need it.

## The job object

| Field | Type | Description |
|---|---|---|
| `id` | integer | TruTec id. Never changes. |
| `name` | string | Job name. |
| `company` | string or null | Customer name as shown on the job. |
| `contact_name`, `contact_email`, `contact_phone` | string or null | Customer contact for this job. |
| `bill_to_name`, `bill_to_email` | string or null | "Bill to (if different)": the billing entity when it is not the job's customer. Invoices for the job go to this entity in QuickBooks Online. |
| `address` | string or null | Job site address. |
| `description`, `notes` | string or null | Free text. |
| `job_type` | string or null | Job types as one comma-separated string. |
| `job_types` | array of strings | The same values as a list. |
| `status` | string | Pipeline stage key: `lead`, `active`, `won` or `lost`, or one of the workspace's own stages (`GET /v1/job-stages`). |
| `status_label` | string | Display label for the status. |
| `source` | string or null | Where the job came from. Jobs created through the API default to `api`. |
| `priority` | string | `low`, `normal`, `high` or `urgent`. |
| `estimated_value` | number or null | Expected value in dollars. |
| `probability` | integer or null | Win probability, 0–100. |
| `start_date`, `end_date`, `next_follow_up` | string or null | Dates as `YYYY-MM-DD`. |
| `measurement_count` | integer | Number of takeoffs on the job. |
| `firm_id` | integer | Workspace id. |
| `team_id` | integer or null | Team within the workspace, if any. |
| `client_account_id` | integer or null | The job's customer. |
| `client_account_external_ref` | string or null | That customer's `external_ref`. |
| `location_id` | integer or null | The job's site. Set by TruTec from the address. Read-only. |
| `location_external_ref` | string or null | That site's `external_ref`. |
| `external_ref` | string or null | Your id for the job. Unique per workspace. |
| `custom_fields` | object | The workspace's custom job fields. Set them with `PATCH /v1/jobs/{id}/custom-fields` (see [Custom fields](custom-fields.md)). |
| `quickbooks_sync_error` | string or null | Why the job's latest invoice did not reach QuickBooks Online, if it failed. Null otherwise. |
| `assigned_to_user_id` | integer | The user who owns the job. |
| `assigned_to` | object | That user (see **List users**). |
| `assignees` | array | Other team members on the job. Only present with `include_assignees=true`. |
| `created_at`, `updated_at` | string | UTC timestamps. |

## Jobs

### List jobs

`GET /v1/jobs`

**Permission:** Read jobs

Returns the workspace's jobs, newest first. With `updated_after`, results are sorted by `updated_at`, oldest first.

| Name | Type | Required | Description |
|---|---|---|---|
| `updated_after` | string | No | ISO 8601 timestamp. Only jobs changed after it. Without a timezone it is read as UTC. |
| `external_ref` | string | No | Only the job with this `external_ref` (0 or 1 rows). |
| `status` | string | No | Only jobs in this stage, for example `won`. |
| `assigned_to_user_id` | integer | No | Only jobs owned by this user. |
| `q` | string | No | Search name, company, address and contact name (case-insensitive). |
| `include_assignees` | boolean | No | `true` to add the `assignees` list to each job. |
| `limit` | integer | No | Page size. Default 50, maximum 100. |
| `offset` | integer | No | Rows to skip. Default 0. |

```bash
curl "https://trutec.ai/api/v1/jobs?status=won&limit=2" \
  -H "X-API-Key: sk_your_api_key_here"
```

```json
{
  "data": [
    {
      "id": 70412,
      "name": "Northgate Plaza Sealcoat",
      "company": "Northgate Property Group",
      "contact_name": "Pat Sample",
      "contact_email": "pat@northgate.example",
      "contact_phone": "512-555-0199",
      "bill_to_name": null,
      "bill_to_email": null,
      "address": "1200 Commerce Dr, Austin, TX 78701",
      "description": "Sealcoat and restripe the front lot.",
      "notes": null,
      "job_type": "sealcoating,striping",
      "job_types": ["sealcoating", "striping"],
      "status": "won",
      "status_label": "Won",
      "source": "api",
      "priority": "normal",
      "estimated_value": 48500.0,
      "probability": 100,
      "start_date": "2026-11-02",
      "end_date": null,
      "next_follow_up": null,
      "measurement_count": 1,
      "firm_id": 1201,
      "team_id": null,
      "client_account_id": 4812,
      "client_account_external_ref": "crm-acct-20931",
      "location_id": 9307,
      "location_external_ref": "crm-site-5512",
      "external_ref": "crm-deal-88120",
      "custom_fields": {},
      "quickbooks_sync_error": null,
      "assigned_to_user_id": 311,
      "assigned_to": {
        "id": 311,
        "name": "Chris Example",
        "username": "chris",
        "email": "chris@yourcompany.example",
        "role": "member",
        "job_title": "Estimator",
        "phone_number": "512-555-0110",
        "headshot_url": null,
        "is_firm_owner": false,
        "created_at": "2026-03-02T16:20:11.902344"
      },
      "created_at": "2026-09-30T17:44:02.118003",
      "updated_at": "2026-10-09T13:12:47.660215"
    }
  ],
  "pagination": { "total": 87, "limit": 2, "offset": 0 }
}
```

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

### Get a job

`GET /v1/jobs/{id}`

**Permission:** Read jobs

| Name | Type | Required | Description |
|---|---|---|---|
| `id` | integer (path) | Yes | Job id. |
| `include_assignees` | boolean | No | `true` to add the `assignees` list. |

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

Returns `{"data": {job}}`. With `include_assignees=true` the job also has an `assignees` array of user objects.

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

### Create a job

`POST /v1/jobs`

**Permission:** Write jobs

Creates a job in the pipeline. With `external_ref`, this call is an **upsert**: if a job in the workspace already has that ref, TruTec updates it with the fields you sent (same rules as **Update a job**) and returns `200` with `"created": false`. Otherwise it creates the job and returns `201` with `"created": true`.

| Name | Type | Required | Description |
|---|---|---|---|
| `name` | string | Yes, when creating | Job name. Max 200 characters. |
| `external_ref` | string | No | Your id for the job. Max 255 characters. Makes the call an upsert. |
| `address` | string | No | Job site address. TruTec links the job to the site at this address, creating one if needed. |
| `company` | string | No | Customer name. Links to the workspace's customer with that name, or creates one. |
| `client_account_id` | integer | No | Link an existing customer by TruTec id. Wins over `company`. |
| `client_account_external_ref` | string | No | Link an existing customer by your id. Wins over `company`. |
| `contact_name` | string | No | Max 200 characters. |
| `contact_email` | string | No | Max 120 characters. |
| `contact_phone` | string | No | |
| `bill_to_name` | string | No | Billing entity, when not the customer. Max 200 characters. |
| `bill_to_email` | string | No | Max 255 characters. |
| `job_type` | string or array | No | One or more values from **List job types**. A comma-separated string or a list. |
| `status` | string | No | `lead` (default), `active`, `won` or `lost`. |
| `priority` | string | No | `low`, `normal` (default), `high` or `urgent`. |
| `source` | string | No | Defaults to `api`. Max 100 characters. |
| `description`, `notes` | string | No | Free text. |
| `estimated_value` | number | No | Dollars. |
| `probability` | integer | No | 0–100. |
| `start_date`, `end_date`, `next_follow_up` | string | No | `YYYY-MM-DD`. |
| `assigned_to_user_id` | integer | No | Owner. Defaults to the user who created the API key, then the workspace owner. |
| `allow_duplicate_name` | boolean | No | `true` to create a job even though one with the same name exists. |

Longer text values are cut to the maximum length.

**Duplicate names.** Without `external_ref`, a job whose name matches an existing job (case and spacing ignored) is refused with `409`, so a retry never creates a twin. Send `allow_duplicate_name: true` for repeat work under the same name. With `external_ref`, the ref is the identity and the name check does not apply.

**Workspace intake rules.** A workspace can require fields on every new job (address, company, job type, source or contact email). Missing ones return `422`. A linked `client_account_id` or `client_account_external_ref` counts as the company.

```bash
curl -X POST "https://trutec.ai/api/v1/jobs" \
  -H "X-API-Key: sk_your_api_key_here" \
  -H "Content-Type: application/json" \
  -d '{
    "external_ref": "crm-deal-88120",
    "name": "Northgate Plaza Sealcoat",
    "address": "1200 Commerce Dr, Austin, TX 78701",
    "client_account_external_ref": "crm-acct-20931",
    "contact_name": "Pat Sample",
    "contact_email": "pat@northgate.example",
    "job_type": ["sealcoating", "striping"],
    "estimated_value": 48500,
    "assigned_to_user_id": 311
  }'
```

```json
{
  "data": {
    "id": 70412,
    "name": "Northgate Plaza Sealcoat",
    "company": "Northgate Property Group",
    "address": "1200 Commerce Dr, Austin, TX 78701",
    "job_types": ["sealcoating", "striping"],
    "status": "lead",
    "client_account_id": 4812,
    "client_account_external_ref": "crm-acct-20931",
    "location_id": 9307,
    "location_external_ref": "crm-site-5512",
    "external_ref": "crm-deal-88120",
    "assigned_to_user_id": 311,
    "created_at": "2026-10-12T14:06:31.220145",
    "updated_at": "2026-10-12T14:06:31.220145"
  },
  "created": true
}
```

(Response trimmed. `data` is the full job object.)

Creating or updating a job sends the `job_created`, `job_updated` or `job_status_changed` webhook, the same as an edit in the app.

**Errors:**

- `400` `name is required`
- `400` `client_account_id not found in this workspace` / `client_account_external_ref "…" not found in this workspace`
- `400` for a bad value: `status must be one of: lead, active, won, lost`, `priority must be one of: high, low, normal, urgent`, `start_date must be in YYYY-MM-DD format`, `probability must be between 0 and 100`, `estimated_value must be a number`
- `404` `assigned_to_user_id does not match a user in this firm`
- `409` duplicate name. The body includes the existing job so you can link to it:

```json
{
  "error": "Job \"Northgate Plaza Sealcoat\" already exists. Add another takeoff to the existing job, or send allow_duplicate_name: true to create another job with this name.",
  "duplicate_job_id": 70412,
  "duplicate_job": { "id": 70412, "name": "Northgate Plaza Sealcoat" }
}
```

- `409` `{"error": "external_ref \"crm-deal-88120\" is already used by another job in this workspace", "code": "external_ref_conflict", "existing_job_id": 70412}` when two requests race on the same ref. Retry and the second call updates the job.
- `422` `{"error": "This workspace requires: Address, Company", "missing_fields": ["address", "company"]}`
- `422` with `"code": "bill_to_company_only"` when the workspace sets billing on the customer only. Link the job to the customer that should be billed instead of sending `bill_to_name` or `bill_to_email`.

### Update a job

`PATCH /v1/jobs/{id}`

**Permission:** Write jobs

Changes only the fields you send. Accepts every field from **Create a job** except `allow_duplicate_name`, plus:

| Name | Type | Required | Description |
|---|---|---|---|
| `id` | integer (path) | Yes | Job id. |
| `close_reason` | string | No | Why the job was won or lost. Max 100 characters. |
| `close_notes` | string | No | Free text. |

Rules:

- `name` cannot be blank. `assigned_to_user_id` cannot be `null`.
- Send a blank `external_ref` to clear it.
- **Changing the customer.** Send `client_account_id` or `client_account_external_ref`. Send `client_account_id: null` to unlink the customer. On a job that already has a customer, `company` text only changes the name shown on the job; it never switches or unlinks the customer. On a job with no customer yet, `company` text links or creates one.
- Changing the billing entity (a different `bill_to_name`, or a different customer on a job with no bill-to) makes the next QuickBooks Online sync look up the billing customer again.

```bash
curl -X PATCH "https://trutec.ai/api/v1/jobs/70412" \
  -H "X-API-Key: sk_your_api_key_here" \
  -H "Content-Type: application/json" \
  -d '{"status": "won", "close_reason": "Best price", "start_date": "2026-11-02"}'
```

Returns `{"data": {job}}`.

**Errors:**

- `404` `Job not found`
- `400` `name cannot be empty`, `assigned_to_user_id cannot be null`, and the same value errors as create
- `409` `external_ref_conflict` when another job holds the ref (body includes `existing_job_id`)
- `403` with `"locked": true` when the workspace locks won jobs and this move is not allowed (for example, moving a won job back to `lead`). Nothing in the request is saved.
- `422` `bill_to_company_only`, as on create

## Pipeline

A workspace's sales pipeline is a list of stages (columns on the jobs board). Workspaces rename them and add their own; each stage has a `stage_type` that says what it means for reports: `open`, `won`, `lost` or `excluded` (no-bid, cancelled).

`PATCH /v1/jobs/{id}` accepts the four built-in statuses. To move a job to any stage the workspace uses, with the same rules as dragging it on the board, use **Move a job to a stage**.

### List pipeline stages

`GET /v1/job-stages`

**Permission:** Read jobs

The stages in board order. Lost and excluded stages list the `outcome_questions` a move into them must answer.

```bash
curl "https://trutec.ai/api/v1/job-stages" \
  -H "X-API-Key: sk_your_api_key_here"
```

```json
{
  "data": [
    {"key": "lead", "label": "New lead", "position": 0, "stage_type": "open", "is_warranty": false, "color": "#6366f1", "default_probability": 10, "outcome_questions": []},
    {"key": "bid_sent", "label": "Bid sent", "position": 1, "stage_type": "open", "is_warranty": false, "color": "#3b82f6", "default_probability": 40, "outcome_questions": []},
    {"key": "won", "label": "Won", "position": 2, "stage_type": "won", "is_warranty": false, "color": "#10b981", "default_probability": 100, "outcome_questions": []},
    {
      "key": "lost", "label": "Lost", "position": 3, "stage_type": "lost", "is_warranty": false, "color": "#ef4444", "default_probability": 0,
      "outcome_questions": [
        {"key": "reason", "label": "Why did we lose this one?", "field_type": "select", "options": ["Price", "Schedule", "Relationship", "Response time", "Cancelled", "No reason given", "Work not done"], "required": true},
        {"key": "competitor", "label": "Which vendor did they go with?", "field_type": "text", "options": [], "required": false},
        {"key": "percent_high", "label": "How much were we high (%)?", "field_type": "percent", "options": [], "required": false}
      ]
    }
  ]
}
```

### Move a job to a stage

`POST /v1/jobs/{id}/move`

**Permission:** Write jobs

Moves the job the way dragging it on the board does:

- the won-job lock applies (when the workspace turned it on, only permitted people can move a job out of a won stage); the key's creator must be allowed;
- a move into a lost or excluded stage must answer that stage's questions (why it was lost, who won it);
- a move into a completed stage completes the job's open work orders, after you confirm;
- the stage's follow-up automations and sales sequences run, teammates on the job get the usual email, and webhooks fire.

The activity log records "Status changed from ... to ... via API".

| Name | Type | Required | Description |
|---|---|---|---|
| `status` | string | Yes | Stage key from `GET /v1/job-stages`. |
| `outcome_answers` | object | For lost / excluded stages | Answers by question key, e.g. `{"reason": "Price", "percent_high": 12}`. |
| `reason`, `competitor` | string | No | Shortcuts for `outcome_answers.reason` and `outcome_answers.competitor`. |
| `confirm_schedule_completion` | boolean | No | Confirm completing the job's open work orders. |
| `confirm_sub_pricing_override` | boolean | No | Owners: move past the subcontractor pricing hold, when the workspace uses it. |

```bash
curl -X POST "https://trutec.ai/api/v1/jobs/70412/move" \
  -H "X-API-Key: sk_your_api_key_here" \
  -H "Content-Type: application/json" \
  -d '{"status": "lost", "reason": "Price", "competitor": "Acme Sealcoat"}'
```

```json
{
  "data": {"id": 70412, "name": "Northgate Plaza Sealcoat", "status": "lost", "status_label": "Lost", "...": "..."},
  "moved": true,
  "previous_status": "bid_sent",
  "schedule_completion": null
}
```

`data` is the full job object. Moving a job to the stage it is already in returns `"moved": false` and changes nothing.

**Errors:**

- `400` `invalid_status` with the list of `statuses`
- `403` `status_locked`: the won-job lock refused the move
- `409` `confirmation_required`: the move would complete `active_work_order_count` open work orders; send `confirm_schedule_completion: true`
- `409` `sub_pricing_override_required`: subcontractor pricing is still held; an owner can resend with `confirm_sub_pricing_override: true`
- `422` `outcome_answers_required`: the stage asks questions; the body lists them in `questions`. Nothing is saved.

## Job team

Every job has one **sales owner** (`assigned_to_user_id`, changed with `PATCH /v1/jobs/{id}`) and can have more teammates on it. Teammates whose access is limited to their assigned jobs can then see it.

### List the job team

`GET /v1/jobs/{id}/assignees`

**Permission:** Read jobs

```json
{
  "data": [
    {"user_id": 131, "name": "Riley Ford", "email": "riley@northgate.example", "is_sales_owner": false, "assigned_by_user_id": 118, "assigned_at": "2026-10-12T14:10:00Z"}
  ],
  "sales_owner": {"user_id": 118, "name": "Dana Whitfield", "email": "dana@northgate.example"}
}
```

### Add a teammate to a job

`POST /v1/jobs/{id}/assignees`

**Permission:** Write jobs

Body: `{"user_id": 131}`. They get the usual "you were assigned" email and the job's activity log records it. Returns `201` with the row.

**Errors:** `400` `user_id is required`; `404` for a user outside the workspace; `409` `already_assigned`.

### Remove a teammate from a job

`DELETE /v1/jobs/{id}/assignees/{user_id}`

**Permission:** Write jobs

Returns `204`. **Errors:** `404` when the user is not on the job; `409` `sales_owner_required`: give the job a new owner first.

## People, notes, follow-ups and custom fields

- People on a job (`GET/POST /v1/jobs/{id}/contacts`): [Contacts](contacts.md)
- Activity log, notes and logged calls (`/v1/jobs/{id}/activity`, `/v1/jobs/{id}/notes`) and follow-ups (`/v1/tasks?job_id=`): [Notes & tasks](notes-and-tasks.md)
- Custom field values (`PATCH /v1/jobs/{id}/custom-fields`): [Custom fields](custom-fields.md)
- New requests from your website: [Leads](leads.md)

## Job types and users

### List job types

`GET /v1/job-types`

**Permission:** Any active key

Returns the job types configured for the workspace (the platform defaults when none are configured). Send the `value` strings as `job_type` when you create or update jobs.

```bash
curl "https://trutec.ai/api/v1/job-types" \
  -H "X-API-Key: sk_your_api_key_here"
```

```json
{
  "data": [
    { "value": "sealcoating", "label": "Sealcoating" },
    { "value": "striping", "label": "Striping" },
    { "value": "paving", "label": "Paving (New Pavement)" },
    { "value": "mill_overlay", "label": "Mill & Overlay" }
  ]
}
```

This list is not paginated.

### List users

`GET /v1/users`

**Permission:** Any active key

Returns the workspace's team members. Use the `id` values as `assigned_to_user_id` on jobs.

| Name | Type | Required | Description |
|---|---|---|---|
| `role` | string | No | Only users with this role, for example `owner` or `member`. |
| `q` | string | No | Search username, email and job title (case-insensitive). |
| `limit` | integer | No | Page size. Default 50, maximum 200. |
| `offset` | integer | No | Rows to skip. Default 0. |

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

```json
{
  "data": [
    {
      "id": 311,
      "name": "Chris Example",
      "username": "chris",
      "email": "chris@yourcompany.example",
      "role": "member",
      "job_title": "Estimator",
      "phone_number": "512-555-0110",
      "headshot_url": null,
      "is_firm_owner": false,
      "created_at": "2026-03-02T16:20:11.902344"
    }
  ],
  "pagination": { "total": 1, "limit": 50, "offset": 0 }
}
```

## Photos

### List job photos

`GET /v1/jobs/{id}/photos`

**Permission:** Read jobs

Returns the site photos and videos on a job, in the order the office arranged them.

| Name | Type | Required | Description |
|---|---|---|---|
| `id` | integer (path) | Yes | Job id. |
| `stage` | string | No | `before`, `during`, `after` or `general`. Other values are ignored. |
| `visibility` | string | No | `external` (customer-visible) or `internal` (team only). |
| `include_in_report` | boolean | No | Only photos that are (or are not) marked for the customer report. |
| `limit` | integer | No | Page size. Default 50, maximum 100. |
| `offset` | integer | No | Rows to skip. Default 0. |

```bash
curl "https://trutec.ai/api/v1/jobs/70412/photos?stage=before" \
  -H "X-API-Key: sk_your_api_key_here"
```

```json
{
  "data": [
    {
      "id": 55120,
      "job_id": 70412,
      "url": "https://files.example/photos/55120.jpg",
      "thumbnail_url": "https://files.example/photos/55120_thumb.jpg",
      "annotated_url": null,
      "caption": "Alligator cracking at the east entrance",
      "description": "Alligator cracking at the east entrance",
      "title": null,
      "stage": "before",
      "include_in_report": true,
      "visibility": "external",
      "is_internal": false,
      "taken_at": "2026-10-01T15:40:12",
      "taken_by": {
        "id": 311,
        "name": "Chris Example",
        "username": "chris",
        "email": "chris@yourcompany.example"
      },
      "lat": 30.2711,
      "lng": -97.7437,
      "compass_heading": 182.0,
      "original_filename": "IMG_4471.jpg",
      "is_video": false,
      "annotations": {},
      "tags": [],
      "created_at": "2026-10-01T15:41:03.882190",
      "updated_at": "2026-10-01T15:41:03.882190"
    }
  ],
  "pagination": { "total": 14, "limit": 50, "offset": 0 }
}
```

`url` is the original file. `annotated_url` is a full-size copy with the office's markup drawn on, when one exists. `caption` and `description` hold the same text. `tags` lists the photo's tags (`id`, `name`, `color` and more).

**Errors:** `400` `visibility must be external or internal`. `404` `Job not found`.

## Proposal map

### Download the proposal map

`GET /v1/jobs/{id}/exports/proposal_map`

**Permission:** Read jobs and Read measurements

Returns the job's takeoff map as a file: aerial imagery with the measured areas drawn on, your workspace logo, and takeoff notes. This is the same map the proposal uses.

Without `measurement_id`, TruTec picks the most recently updated takeoff on the job that has a saved proposal-map layout, or else the most recent takeoff with drawn areas.

| Name | Type | Required | Description |
|---|---|---|---|
| `id` | integer (path) | Yes | Job id. |
| `format` | string | No | `pdf` (default) or `png`. |
| `measurement_id` | integer | No | Which takeoff to render, when the job has more than one. |

```bash
curl "https://trutec.ai/api/v1/jobs/70412/exports/proposal_map?format=pdf" \
  -H "X-API-Key: sk_your_api_key_here" \
  -o proposal-map.pdf
```

The response is the file (`application/pdf` or `image/png`), sent as an attachment named after the job, for example `Northgate Plaza Sealcoat - Proposal Map.pdf`. Two headers tell you what was rendered:

| Header | Description |
|---|---|
| `X-TruTec-Job-Id` | The job id. |
| `X-TruTec-Measurement-Id` | The takeoff used. |

**Errors:**

- `400` `format must be either pdf or png` / `measurement_id must be an integer`
- `403` `API key lacks measurement read permission`
- `404` `Job not found` / `Measurement not found for this job` / `No proposal map export is available for this job`
- `422` `Unable to render proposal map export for this job`

## Change orders

### List change orders

`GET /v1/change-orders`

**Permission:** Read jobs

Returns change orders across the workspace, or for one job, most recently updated first. Each row is ready for accounting: the contract change, the approval, and the lines grouped by cost code.

| Name | Type | Required | Description |
|---|---|---|---|
| `job_id` | integer | No | Only this job's change orders. |
| `status` | string | No | `draft`, `sent`, `approved`, `declined` or `void`. Default: all. |
| `limit` | integer | No | Page size. Default 50, maximum 200. |
| `offset` | integer | No | Rows to skip. Default 0. |

This endpoint uses a different envelope from the other lists: `change_orders`, `total`, `limit` and `offset` at the top level.

```bash
curl "https://trutec.ai/api/v1/change-orders?job_id=70412&status=approved" \
  -H "X-API-Key: sk_your_api_key_here"
```

```json
{
  "change_orders": [
    {
      "id": 318,
      "co_number": 2,
      "kind": "full",
      "kind_label": "Full detail pricing",
      "pricing_mode": "revision",
      "title": "Add ADA stall at east entrance",
      "description": "Add one van-accessible stall with signage.",
      "status": "approved",
      "stage": null,
      "approved_at": "2026-10-03T18:12:44.210933",
      "approval_source": "customer_link",
      "signature_name": "Pat Sample",
      "job_id": 70412,
      "job_number": 1186,
      "job_name": "Northgate Plaza Sealcoat",
      "customer": "Northgate Property Group",
      "customer_email": "pat@northgate.example",
      "amount_delta": 2450.0,
      "tax_delta": 0.0,
      "cost_delta": 1610.0,
      "days_delta": 1,
      "revised_contract": 50950.0,
      "ep_estimate_id": 9921,
      "est_proposal_id": null,
      "exported_at": null,
      "export_ref": null,
      "lines": [
        {
          "cost_code": "02-STRIPE",
          "name": "ADA stall",
          "qty": 1.0,
          "unit": "EA",
          "price": 2450.0,
          "cost": 1610.0,
          "lines": 3
        }
      ]
    }
  ],
  "total": 1,
  "limit": 50,
  "offset": 0
}
```

| Field | Description |
|---|---|
| `co_number` | Change order number within the job. |
| `kind` | `quick` (a typed price), `tm` (time and materials) or `full` (priced from services like an estimate). |
| `pricing_mode` | `revision` (the whole job re-priced), `additive` (only the new work priced) or null for quick change orders. |
| `status` | `draft`, `sent`, `approved`, `declined` or `void`. `stage` is the workspace's own label for it, if set. |
| `approved_at` | When it was approved. Null unless `status` is `approved`. |
| `approval_source` | How approval came in, for example `customer_link`, `proposal_signature` or `office`. |
| `amount_delta`, `tax_delta`, `cost_delta` | Change to contract price, sales tax and cost, in dollars. |
| `days_delta` | Change to the schedule, in days. |
| `revised_contract` | Original award plus every approved change order up to and including this one. Null for quick change orders. |
| `ep_estimate_id` | The change-order estimate behind a `tm` or `full` change order. Read it with `GET /v1/estimates/{id}`. |
| `lines` | Lines by cost code. For `revision`, each line is the difference from the original award. For `additive`, every line of the change order. For `quick`, one line with the typed price. Lines without a cost code are grouped under `UNCODED-…` codes. |
| `exported_at`, `export_ref` | When and how the approved change order reached your accounting system, if it has. |

**Errors:** `400` `status must be one of draft, sent, approved, declined, void`.

## Clone a job

`POST /v1/jobs/{job_id}/clone`

**Permission:** Write jobs. Returns `201`.

Starts a new job from an existing one, for a re-bid next season or repeat work, exactly like **Clone** in TruTec. The new job copies the customer, contact, address, job types, source, value, priority, description, notes and bid details (customer and property type, bond, liquidated damages, prevailing wage), but not the bid due date, and it lands on the same property. The source job's takeoffs and site assessments are copied onto it too, unless you turn that off. Copies never use a credit, and takeoffs TruTec is still reworking stay behind (`takeoffs_held_back`).

It acts as the person who created the API key and follows the workspace's job rules (required fields and questions, who owns new jobs). When a rule stops it, the `422` says what is missing. Workspaces whose jobs come in from another system don't take new jobs here (`403`). Nothing is emailed.

| Field | Type | Description |
|---|---|---|
| `name` | string | Default: the source name plus " (Copy)". |
| `status` | string | Pipeline stage key. Default: `lead` (or the first stage). |
| `bid_due_date` | string | `YYYY-MM-DD`. |
| `assigned_to_user_id` | integer | Who owns the new job. |
| `copy_takeoffs`, `copy_assessments` | boolean | Default `true`. |
| `allow_duplicate_name` | boolean | A job with the same name is a `409` with `duplicate_job_id` unless this is `true`. |

```bash
curl -X POST "https://trutec.ai/api/v1/jobs/70412/clone" \
  -H "X-API-Key: sk_your_api_key_here" \
  -H "Content-Type: application/json" \
  -d '{"name": "Northgate Plaza reseal 2027", "bid_due_date": "2027-03-01"}'
```

```json
{
  "data": { "id": 70988, "name": "Northgate Plaza reseal 2027", "status": "lead", "location_id": 2210 },
  "created": true,
  "cloned_from_job_id": 70412,
  "copied": { "takeoffs": 2, "site_assessments": 1, "takeoffs_held_back": 0 },
  "message": "Job created — brought over 2 takeoffs and 1 assessment from \"Northgate Plaza reseal\"."
}
```

`data` is the full job, as `GET /v1/jobs/{job_id}` returns it. `warnings` appears if the copies could not be made (the job is still created).
