Resources
Photos
Photos and videos captured against a project: inspection sets, progress shots, damage documentation, and anything a customer uploaded through the portal.
/v1/photosList photos
Newest first. Requires projects.view on the member who created the API key.
Query parameters
| Name | Type | Description |
|---|---|---|
| project_id | uuid | Only photos on this project. |
| created_after | date | Photos created on or after this YYYY-MM-DD date. |
| created_before | date | Photos created on or before this YYYY-MM-DD date. Inclusive of the whole named day. |
| media_type | enum | image or video. |
| content_hash | string | Hex SHA-256 of the file bytes. Use it to ask whether a specific file is already here. |
| include_trashed | boolean | Set true to include photos in the trash. Excluded by default. |
| limit | integer | Rows per page, 1 to 100. Defaults to 25. |
| offset | integer | Rows to skip. Combine with has_more to page. |
Only photos whose file exists are returned
A photo row can exist before its bytes do. The mobile app reserves a row and hands the transfer to a background upload, so there is a window where the record is real and the file is not. This endpoint excludes those, and photo.uploaded waits for the same moment, so anything you receive here or by webhook is always fetchable.
/v1/photos/{id}Retrieve a photo
Returns one photo with freshly signed links, plus trashed_at. Unlike the list, this does return a trashed photo, so an id from an earlier webhook tells you what became of it rather than 404ing as though it never existed.
The photo object
| Field | Type | Description |
|---|---|---|
| id | uuid | The photo id. |
| project_id | uuid | The project it belongs to. |
| name | string | Display name, falling back to the original filename. |
| description | string | Caption a member added, if any. |
| media_type | enum | image or video. |
| width / height | integer | Pixel dimensions of the original, when known. |
| size_bytes | integer | Size of the original file. |
| content_hash | string | SHA-256 of the bytes. Null for photos uploaded before the column existed. |
| from_customer | boolean | True when the end customer uploaded it through the portal rather than a workspace member. |
| visible_in_customer_portal | boolean | Whether the photo is shared into the customer-facing gallery. |
| uploaded_by | uuid | The workspace member who uploaded it. Null for portal uploads. |
| urls.original | string | Signed link to the full-resolution file. |
| urls.display | string | Signed link to the web-sized rendition. Null until it has been generated. |
| urls.thumbnail | string | Signed link to the thumbnail. Null until it has been generated. |
| urls.expires_at | string | When the three links above stop working. |
Links expire
Every link is signed and valid for 24 hours, and needs no authentication to fetch. Treat one like a password: anyone holding it can read the file until it expires. Store the photo id rather than the URL, and re-fetch when you need the bytes.
Webhook payloads are built at delivery time, so each retry carries a fresh link rather than an expired one from the first attempt.
Mirroring photos elsewhere
Unlike expenses, photos need no reconciliation write-back. Both systems hold the same file, so content_hash lets you compare the two sides directly: list what is here, list what is there, and copy the difference. Nothing to keep in sync and nothing to reset, and it self-corrects when somebody uploads by hand at the far end.
See webhooks to be told the moment a photo lands instead of polling, and pagination and rate limits before writing a sweep.