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.
/v1/photosUpload photos
A multipart/form-data request with a project_id field and up to 10 file fields, 4 MB in total. An optional description field captions every file. Requires projects.view. For anything larger, use the bulk upload below.
Every file is checked before any is stored, so a request carrying a file Coastline cannot take stores nothing and returns 400 naming it. Otherwise the response lists each file with a status of created or duplicate and its photo, or failed with an error.
/v1/photos/uploadsUpload in bulk
For large files or many of them. The bytes go straight to storage on signed links, so they never count against the rate limit: 100 photos cost two API requests.
POST /v1/photos/uploadswith aproject_idand up to 100files. Each comes backupload_requiredwith aphoto_idand anuploadlink, orduplicatewith the photo already on the project.- PUT each file's bytes to its
upload.urlwith theupload.headersgiven. No API key: the link is the credential. Each link takes one upload and is valid for two hours. POST /v1/photos/uploads/completewith up to 100photo_ids. Each comes backcreatedorduplicatewith itsphoto,pendingif its bytes have not arrived,failedif Coastline refused the file, ornot_found. Safe to repeat. Processing takes about a second a photo, so batches of 25 keep each call short.
File fields
| Name | Type | Description |
|---|---|---|
| name | string | Required. The filename; the photo is named after it, without the extension. |
| size_bytes | integer | Required. The file size, so an oversize file is refused before it is sent. |
| content_type | string | The file's MIME type. Inferred from the name when omitted. |
| content_hash | string | Hex SHA-256 of the file. Send it and a file already on the project comes back as a duplicate before you upload it. |
| description | string | A caption. |
| captured_at | timestamp | When the photo was taken. Overrides the file's EXIF. |
A reservation you never complete is finished automatically about 10 minutes after its bytes land.
What Coastline does with each file
- Accepts JPEG, PNG, WebP, GIF, HEIC, AVIF, MP4, MOV and WebM, judged by the bytes rather than the name. Photos up to 50 MB, videos up to 500 MB.
- Stores photos as JPEG capped at 16 megapixels, with display and thumbnail renditions, as the app does. Videos are stored as sent.
- Dates and places each photo from its EXIF capture time and GPS, unless you sent
captured_at. - Skips a file whose exact bytes are already on the project, returning that photo as a
duplicate.content_hashon the result is the SHA-256 of the file you sent. - Refuses an archived project with
409and a project outside your workspace with404.
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.