Resources

Photos

Photos and videos captured against a project: inspection sets, progress shots, damage documentation, and anything a customer uploaded through the portal.

GET/v1/photos

List photos

Newest first. Requires projects.view on the member who created the API key.

Query parameters

NameTypeDescription
project_iduuidOnly photos on this project.
created_afterdatePhotos created on or after this YYYY-MM-DD date.
created_beforedatePhotos created on or before this YYYY-MM-DD date. Inclusive of the whole named day.
media_typeenumimage or video.
content_hashstringHex SHA-256 of the file bytes. Use it to ask whether a specific file is already here.
include_trashedbooleanSet true to include photos in the trash. Excluded by default.
limitintegerRows per page, 1 to 100. Defaults to 25.
offsetintegerRows 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.

GET/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.

POST/v1/photos

Upload 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.

POST/v1/photos/uploads

Upload 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.

  1. POST /v1/photos/uploads with a project_id and up to 100 files. Each comes back upload_required with a photo_id and an upload link, or duplicate with the photo already on the project.
  2. PUT each file's bytes to its upload.url with the upload.headers given. No API key: the link is the credential. Each link takes one upload and is valid for two hours.
  3. POST /v1/photos/uploads/complete with up to 100 photo_ids. Each comes back created or duplicate with its photo, pending if its bytes have not arrived, failed if Coastline refused the file, or not_found. Safe to repeat. Processing takes about a second a photo, so batches of 25 keep each call short.

File fields

NameTypeDescription
namestringRequired. The filename; the photo is named after it, without the extension.
size_bytesintegerRequired. The file size, so an oversize file is refused before it is sent.
content_typestringThe file's MIME type. Inferred from the name when omitted.
content_hashstringHex SHA-256 of the file. Send it and a file already on the project comes back as a duplicate before you upload it.
descriptionstringA caption.
captured_attimestampWhen 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_hash on the result is the SHA-256 of the file you sent.
  • Refuses an archived project with 409 and a project outside your workspace with 404.

The photo object

FieldTypeDescription
iduuidThe photo id.
project_iduuidThe project it belongs to.
namestringDisplay name, falling back to the original filename.
descriptionstringCaption a member added, if any.
media_typeenumimage or video.
width / heightintegerPixel dimensions of the original, when known.
size_bytesintegerSize of the original file.
content_hashstringSHA-256 of the bytes. Null for photos uploaded before the column existed.
from_customerbooleanTrue when the end customer uploaded it through the portal rather than a workspace member.
visible_in_customer_portalbooleanWhether the photo is shared into the customer-facing gallery.
uploaded_byuuidThe workspace member who uploaded it. Null for portal uploads.
urls.originalstringSigned link to the full-resolution file.
urls.displaystringSigned link to the web-sized rendition. Null until it has been generated.
urls.thumbnailstringSigned link to the thumbnail. Null until it has been generated.
urls.expires_atstringWhen 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.