# Photos & files

Every job has a photo gallery and a Files card. Photos carry GPS, the camera direction, a capture time, a stage (before, during, after) and tags, and feed photo reports and proposals. Files are documents: plans, permits, contracts, executed proposals. Customers (companies) can hold files too.

Uploads through the API behave like uploads in the app: phone photos are turned upright, thumbnails are made, EXIF GPS and capture time are read, photo uploads count toward the plan's photo allowance, and both show in the job timeline. They are recorded as the person who created the API key.

!!! note
    Partner keys add `firm_id` to name the workspace: in the query string, or as a form or JSON field on writes. See [Partner API](partner-api.md).

## Photos

The photo object is the one `GET /v1/jobs/{job_id}/photos` returns (see [Jobs](jobs.md#list-job-photos)): `id`, `job_id`, `url`, `thumbnail_url`, `caption`, `title`, `stage`, `visibility` (`external` or `internal`), `include_in_report`, `taken_at`, `taken_by`, `lat`, `lng`, `compass_heading`, `tags`, `created_at`, `updated_at`. Photo timestamps are UTC without a timezone suffix.

`internal` photos are team only. They never appear in customer reports, so `include_in_report` is always `false` for them.

### Upload a photo

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

**Permission:** Write jobs

Send the image as multipart form field `photo`, or send JSON with a `url` for TruTec to download. One photo per request.

| Name | Type | Required | Description |
|---|---|---|---|
| `photo` | file | One of `photo` / `url` | JPEG, PNG, WebP, GIF or HEIC (or a phone video). Multipart only. |
| `url` | string | One of `photo` / `url` | JSON only. A public `https://` link to the image file itself, up to 15 MB. Links to private networks are refused. |
| `filename` | string | No | With `url`: the name to store. The extension follows the image type. |
| `caption` | string | No | Description. |
| `title` | string | No | Short label, up to 100 characters. |
| `stage` | string | No | `before`, `during`, `after` or `general` (default). |
| `visibility` | string | No | `external` or `internal`. Default: the workspace's photo setting. |
| `include_in_report` | boolean | No | Default: the workspace's photo setting. Ignored for internal photos. |
| `latitude`, `longitude` | number | No | Where it was taken. GPS in the file's EXIF wins. Send both or neither. |
| `compass_heading` | number | No | Camera direction, degrees clockwise from north. |
| `taken_at` | string | No | ISO 8601 capture time. Default: the file's EXIF time, else now. |
| `tag_ids` | array of integers | No | Photo tag ids from [List photo tags](#list-photo-tags). In multipart, repeat the field or send `19,22`. |
| `client_uid` | string | No | Your id for this shot, up to 64 characters. Sending the same `client_uid` again returns the photo already uploaded (`200`, `"created": false`) instead of a duplicate. Use it to make retries safe. |

```bash
curl -X POST https://trutec.ai/api/v1/jobs/4812/photos \
  -H "X-API-Key: sk_your_api_key_here" \
  -F "photo=@IMG_2201.jpg" \
  -F "caption=Edge cracking by the north entrance" \
  -F "stage=before" \
  -F "tag_ids=19" \
  -F "client_uid=crew-tablet-7-0412"
```

```bash
curl -X POST https://trutec.ai/api/v1/jobs/4812/photos \
  -H "X-API-Key: sk_your_api_key_here" \
  -H "Content-Type: application/json" \
  -d '{"url": "https://cdn.example.com/site/IMG_2201.jpg", "stage": "before", "caption": "Edge cracking by the north entrance"}'
```

Returns `201`:

```json
{
  "data": {
    "id": 88231,
    "job_id": 4812,
    "url": "https://media.example.com/photos/job_4812/a81c2f.jpg",
    "thumbnail_url": "https://media.example.com/photos/job_4812/a81c2f_thumb.jpg",
    "caption": "Edge cracking by the north entrance",
    "title": null,
    "stage": "before",
    "include_in_report": true,
    "visibility": "external",
    "taken_at": "2026-10-12T14:58:02",
    "taken_by": {"id": 311, "name": "Dana Ortiz", "username": "dana", "email": "dana@northgatepaving.example"},
    "lat": 30.40121,
    "lng": -97.72088,
    "compass_heading": 42.0,
    "tags": [{"id": 19, "name": "Cracking", "color": "#ef4444"}],
    "created_at": "2026-10-12T15:04:11.204511",
    "updated_at": "2026-10-12T15:04:11.204517"
  },
  "created": true
}
```

Captions may be filled in automatically a few seconds later if the workspace has automatic photo descriptions turned on.

| Status | `error` |
|---|---|
| 400 | `No photo provided`, `One photo per request`, `Send a file or a url, not both`, `Invalid photo` (unsupported file type), a field message such as `stage must be one of: ...`, or `Unknown fields` |
| 403 | `photo_limit_reached` with `photos_used`, `photos_limit` and `photos_remaining`, when the plan's photo allowance is used up |
| 404 | `Job not found`, `Photo tag not found` |
| 422 | `url is not an image`, `Could not fetch url`, `Image too large` |
| 503 | `Photo storage is temporarily unavailable` with `"retryable": true`. Retry with the same `client_uid`. |

### List photos

`GET /v1/photos`

**Permission:** Read jobs

Every job photo in the workspace. Use it with `updated_after` to keep a copy of all photos in sync; for one job, `GET /v1/jobs/{job_id}/photos` returns the gallery order.

| Name | Type | Required | Description |
|---|---|---|---|
| `job_id` | integer | No | Only this job. |
| `stage` | string | No | `before`, `during`, `after` or `general`. |
| `visibility` | string | No | `external` or `internal`. |
| `updated_after` | string | No | ISO 8601. Photos changed after it, oldest first. Without it, newest first. |
| `limit` | integer | No | Default 50, maximum 200. |
| `offset` | integer | No | Default 0. |

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

Returns `{"data": [photo, ...], "pagination": {"total", "limit", "offset"}}`.

### Get a photo

`GET /v1/photos/{photo_id}`

**Permission:** Read jobs

Returns `{"data": photo}`. A photo in another workspace is `404 Photo not found`.

### Update a photo

`PATCH /v1/photos/{photo_id}`

**Permission:** Write jobs

Send only the fields that change: `caption`, `title`, `stage`, `visibility`, `include_in_report`, `tag_ids` (replaces the tags), `latitude`, `longitude`, `compass_heading` (wrapped into 0–359; `null` clears it) and `sort_order`. Setting `visibility` to `internal` also clears `include_in_report`. A location or direction you set here is kept even if the file's own GPS says otherwise.

```bash
curl -X PATCH https://trutec.ai/api/v1/photos/88231 \
  -H "X-API-Key: sk_your_api_key_here" \
  -H "Content-Type: application/json" \
  -d '{"caption": "Raveling at the dumpster pad", "stage": "after", "tag_ids": [19, 22]}'
```

Returns `200` with `{"data": photo}`. Errors: `400` for a bad value or unknown field, `404 Photo tag not found` for a tag from another workspace.

### Delete a photo

`DELETE /v1/photos/{photo_id}`

**Permission:** Write jobs and Delete records

Deletes the photo and its stored files, unless the job's site assessment still shows the same file, and logs it on the job. Returns `204`. This cannot be undone.

### List photo tags

`GET /v1/photo-tags`

**Permission:** Read jobs

```json
{
  "data": [
    {"id": 19, "name": "Cracking", "color": "#ef4444", "description": "Alligator, block, edge and transverse cracking."},
    {"id": 22, "name": "Drainage", "color": "#10b981", "description": null}
  ]
}
```

## Files

### The file object

| Field | Type | Description |
|---|---|---|
| `id` | integer | File id. |
| `filename` | string | The name it was uploaded with. |
| `content_type` | string or null | MIME type. |
| `size_bytes` | integer or null | Size. |
| `description` | string or null | |
| `document_type` | string or null | Set for files TruTec made, for example `executed_proposal` or `executed_agreement`. |
| `source` | string | `job`, `customer`, `contact`, `library` (Company Library), `proposal` or `work_order`. |
| `audience` | string | `office`, or `field` when crews see it in the field app and dispatch packets. |
| `customer_visible` | boolean | Shown on the customer's portal. |
| `job_id`, `client_account_id`, `contact_id`, `work_order_id` | integer or null | What the file belongs to. |
| `folder` | object or null | Company Library folder `{id, name}`. |
| `uploaded_by` | object | `{id, name}`. |
| `download_url` | string | API path that redirects to the file. Needs your API key. |
| `signed_url`, `signed_url_expires_at` | string | Only on [Get a file](#get-a-file): a direct link that works without a key for one hour. |
| `attachment` | object | Only on Company Library files in a job's list: `{id, job_id, attached_by_rule, created_at}`. |
| `created_at` | string | UTC, ISO 8601 with `Z`. |

### List a job's files

`GET /v1/jobs/{job_id}/files`

**Permission:** Read jobs

The job's own files, newest first, then Company Library files linked to the job (insurance certificates, spec sheets), the same list as the job page's Files card.

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

```json
{
  "data": [
    {
      "id": 5531,
      "filename": "civil-set-rev3.pdf",
      "content_type": "application/pdf",
      "size_bytes": 4821933,
      "description": "Civil set, revision 3",
      "document_type": null,
      "source": "job",
      "audience": "field",
      "customer_visible": false,
      "job_id": 4812,
      "client_account_id": null,
      "contact_id": null,
      "work_order_id": null,
      "folder": null,
      "uploaded_by": {"id": 311, "name": "Dana Ortiz"},
      "download_url": "/api/v1/files/5531/download",
      "created_at": "2026-10-12T15:04:11Z"
    }
  ],
  "pagination": {"total": 1, "limit": 1, "offset": 0}
}
```

### List files

`GET /v1/files`

**Permission:** Read jobs

Every file in the workspace, newest first.

| Name | Type | Required | Description |
|---|---|---|---|
| `job_id` | integer | No | Files on this job (not linked library files). |
| `client_account_id` | integer | No | Files on this customer. |
| `contact_id` | integer | No | Files on this contact. |
| `source` | string | No | `job`, `customer`, `contact`, `library`, `proposal` or `work_order`. |
| `q` | string | No | Search file name and description. |
| `created_after` | string | No | ISO 8601. Files added after it, oldest first. Files have no `updated_at`; use this to pick up new files. |
| `limit`, `offset` | integer | No | Default 50 (max 200) and 0. |

### Upload a file to a job

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

**Permission:** Write jobs

Multipart upload. One file per request, up to 200 MB: PDF, Word, Excel, PowerPoint, text, CSV, RTF, images, ZIP, RAR or 7z.

| Name | Type | Required | Description |
|---|---|---|---|
| `file` | file | Yes | The document. |
| `description` | string | No | |
| `audience` | string | No | `office` (default) or `field` to share it with crews. |
| `customer_visible` | boolean | No | Show it on the customer portal. The job must be linked to a customer whose portal is turned on. |

```bash
curl -X POST https://trutec.ai/api/v1/jobs/4812/files \
  -H "X-API-Key: sk_your_api_key_here" \
  -F "file=@civil-set-rev3.pdf" \
  -F "description=Civil set, revision 3" \
  -F "audience=field"
```

Returns `201` with `{"data": file}`. The upload is logged on the job and copied to the workspace's connected cloud storage and CRM, as uploads in the app are.

| Status | `error` |
|---|---|
| 400 | `No file provided`, `Upload with multipart/form-data`, `Invalid file` (type or size), `audience must be office or field` |
| 404 | `Job not found` |
| 409 | `Customer portal not ready`, with a `message` saying what to turn on and `portal_status` (`job_not_linked`, `account_archived` or `portal_disabled`) |
| 503 | `File storage is temporarily unavailable`. Retry. |

### Upload a file to a customer

`POST /v1/client-accounts/{account_id}/files`

**Permission:** Write jobs

The same multipart upload onto a customer record: W-9s, certificates of insurance, master agreements. Takes `file` and `description`. Returns `201` with `"source": "customer"`.

```bash
curl -X POST https://trutec.ai/api/v1/client-accounts/915/files \
  -H "X-API-Key: sk_your_api_key_here" \
  -F "file=@w9-2026.pdf" -F "description=W-9"
```

### Get a file

`GET /v1/files/{file_id}`

**Permission:** Read jobs

Returns the file with `signed_url`, a direct link you can hand to a browser or another system, valid for one hour.

```json
{
  "data": {
    "id": 5531,
    "filename": "civil-set-rev3.pdf",
    "source": "job",
    "job_id": 4812,
    "download_url": "/api/v1/files/5531/download",
    "signed_url": "https://media.example.com/documents/job_4812/civil-set-rev3.pdf?expires=1760285051&signature=...",
    "signed_url_expires_at": "2026-10-12T16:04:11Z",
    "created_at": "2026-10-12T15:04:11Z"
  }
}
```

### Download a file

`GET /v1/files/{file_id}/download`

**Permission:** Read jobs

Redirects (`302`) to a short-lived link to the file. Tell your client to follow redirects:

```bash
curl -L -o civil-set-rev3.pdf https://trutec.ai/api/v1/files/5531/download \
  -H "X-API-Key: sk_your_api_key_here"
```

### Update a file

`PATCH /v1/files/{file_id}`

**Permission:** Write jobs

Change `description`, and for job files `audience` and `customer_visible`. Sending `audience` or `customer_visible` for a file that is not on a job returns `400`. Turning on `customer_visible` follows the same portal rule as uploads (`409`).

```bash
curl -X PATCH https://trutec.ai/api/v1/files/5531 \
  -H "X-API-Key: sk_your_api_key_here" \
  -H "Content-Type: application/json" \
  -d '{"audience": "field"}'
```

### Delete a file

`DELETE /v1/files/{file_id}`

**Permission:** Write jobs and Delete records

Deletes the file and its stored copy and returns `204`. A Company Library file is also removed from every job it was linked to. Job file deletes are logged on the job. This cannot be undone.

## Photo tags

Add, change and remove the workspace's photo tags, as the photo gallery does. List them with [List photo tags](#list-photo-tags).

### Create a photo tag

`POST /v1/photo-tags`

**Permission:** Write jobs

| Name | Type | Required | Description |
|---|---|---|---|
| `name` | string | Yes | Up to 100 characters. |
| `color` | string | No | Hex color like `#ef4444`. Default gray. |
| `description` | string | No | What the tag means. The photo AI uses it to know what to look for. |

```bash
curl -X POST https://trutec.ai/api/v1/photo-tags \
  -H "X-API-Key: sk_your_api_key_here" \
  -H "Content-Type: application/json" \
  -d '{"name": "Raveling", "color": "#f97316", "description": "Loose aggregate and a rough, worn surface."}'
```

Returns `201` with `{"data": {"id": 31, "name": "Raveling", "color": "#f97316", "description": "..."}}`. A tag with the same name returns `409` `duplicate_name` with its id in `conflicting_id`.

### Update a photo tag

`PATCH /v1/photo-tags/{tag_id}`

**Permission:** Write jobs

Send any of `name`, `color` (`null` sets the default gray) and `description` (`null` or `""` clears it). Photos keep the tag. Returns `200` with `{"data": tag}`; `409` `duplicate_name` when another tag has the new name.

### Delete a photo tag

`DELETE /v1/photo-tags/{tag_id}`

**Permission:** Write jobs and Delete records

Deletes the tag and takes it off every photo that has it; the photos stay. Returns `204`. This cannot be undone.
