Skip to Content

Photos API

Photos are reachable through two different APIs, and picking the wrong one is the most common reason a request comes back 401.

Public API (/api/v1)Admin API (/api/admin)
AuthAPI token (Authorization: Bearer pp_live_…)Admin session JWT
ForIntegrations, scripts, the Lightroom pluginThe admin UI
Photo endpointsUpload, replace, list-with-marks, download originalsEverything else on this page

The admin API is JWT-only — adminAuth verifies a signed session token, so an API token sent to an /api/admin/… route is rejected. There is currently no API-token equivalent for delete, hide, chunked upload or dimension repair; those are admin-UI operations.

For the OpenAPI spec of the public API see /api/openapi.json on your instance.

Authentication

Public API

Authorization: Bearer pp_live_xxxxxxxx

Scopes are hierarchical: admin implies write implies read. On top of the scope, the token inherits its owner’s RBAC permissions — so a read needs both the read scope and the photos.view permission. Create tokens in Settings → API Tokens. See Authentication.

Admin API

Authorization: Bearer <admin JWT from POST /api/auth/admin/login>

The JWT expires after 24 hours. Permissions are named per route below (photos.upload, photos.edit, photos.delete, photos.view, photos.download), and every event-scoped route additionally checks that the caller owns the event.

Public API endpoints

Upload a photo

One image per request, in a multipart field named photo (singular).

curl -X POST "$BASE_URL/api/v1/events/$EVENT_ID/photos" \ -H "Authorization: Bearer $TOKEN" \ -F "photo=@/path/to/IMG_1234.JPG" \ -F "category_id=12"

Needs the write scope and the photos.upload permission.

Replacing an existing photo: pass replaces_photo_id to overwrite a photo’s file while keeping its identity — the ID, the client’s ratings and colour labels, its comments and its position in the gallery all survive, and the share link stays valid. The ID must belong to the event in the URL.

curl -X POST "$BASE_URL/api/v1/events/$EVENT_ID/photos" \ -H "Authorization: Bearer $TOKEN" \ -F "photo=@/path/to/Smith_Wedding_11234.jpg" \ -F "replaces_photo_id=1234"

Constraints:

  • Images only — the endpoint rejects any non-image/* MIME type. Video goes through the admin chunked upload.
  • Max 100 MB per file.
  • category_id is optional and must belong to this event or be global; anything else returns 400.

Response (201):

{ "id": 1234, "filename": "1755892345_a1b2c3d4.jpg", "path": "wedding-smith/1755892345_a1b2c3d4.jpg", "thumbnail_path": "thumb_1755892345_a1b2c3d4.jpg", "size_bytes": 4523894, "category_id": 12 }

A replace answers 200 with a different shape:

{ "replaced": true, "photo": { "id": 1234, "filename": "...", "original_filename": "...", "source_filename": "IMG_1234.JPG", "previous_filename": "..." } }

Each upload fires a photo.uploaded webhook.

List photos for an event

Returns each photo with the proofing marks it carries — the client’s colour labels and star ratings, plus your own admin marks. This is what the Lightroom round-trip reads.

curl "$BASE_URL/api/v1/events/$EVENT_ID/photos?marked_only=true&mark_source=either" \ -H "Authorization: Bearer $TOKEN"

Requires the read scope and the photos.view permission, and is scoped to events the token’s owner may see.

Query parameters:

ParamDefaultNotes
page1
limit50Max 100.
marked_onlyfalseOnly photos carrying a rating or colour label from mark_source.
mark_sourceeitherclient, mine, or either. Drives marked_only and the merged color_label / rating fields.
color_labels(omit for all)Comma-separated client colours, e.g. green,yellow.
my_color_labels(omit for all)Same, against the token owner’s own marks.
min_rating(omit)0–5, against the guest star average.
my_min_rating(omit)1–5, against your own marks.
logicANDAND or OR across the filters above.

Response:

{ "photos": [ { "id": 1234, "filename": "wedding-smith_individual_1755892345.jpg", "original_filename": "IMG_1234.JPG", "source_filename": "IMG_1234.JPG", "average_rating": 4.5, "feedback_count": 2, "color_labels": { "green": 2, "red": 1 }, "dominant_color_label": "green", "my_rating": 5, "my_color_label": "green", "color_label": "green", "rating": 5 } ], "pagination": { "page": 1, "limit": 50, "total": 480, "filtered": 62, "pages": 2 } }

Three fields are worth understanding before you build against this:

  • source_filename is the camera-original name, preserved even after a photo has been replaced by an edited version. Match on this, not on original_filename — the latter is overwritten by a replace.
  • color_labels holds the per-colour tallies across all guests; dominant_color_label is most-labelled-wins with ties broken green first.
  • color_label and rating are the merged values for the requested mark_source — one colour and one rating, ready to write into a catalog. With mark_source=either, your own colour wins outright whenever you have set one — not only on a tie — and the rating takes the higher of the two.

my_rating and my_color_label are scoped to the token owner. Marks made by a different admin are deliberately not visible here.

Download an original

Streams one photo or video exactly as it was uploaded — the stored original, not the gallery’s download rendition. No resize to the gallery’s standard resolution, no watermark.

curl -OJ "$BASE_URL/api/v1/events/$EVENT_ID/photos/$PHOTO_ID/download" \ -H "Authorization: Bearer $TOKEN"

Requires the read scope and both the photos.view and photos.download permissions on the token owner’s role, and the owner must be allowed to see the event. The built-in editor and viewer roles have no photos.download, so their tokens can list photos but not download them.

The response carries:

  • Content-Type — the photo’s type (image/jpeg, image/png, video/mp4, …).
  • Content-Disposition: attachment with the original upload filename, both as an ASCII fallback and RFC 5987 encoded (filename*=UTF-8''…) so umlauts and spaces survive. It is always the original upload name, whatever the Use original filenames for downloads setting says — an integration wants the name your files carry, not the renamed storage name.
  • Content-Length, and X-Content-Type-Options: nosniff.

HEAD answers the same headers, Content-Length included, without reading the file — a cheap way to check a photo is there and how big it is.

Range requests are not supported; a partial download restarts from zero.

Download originals as a ZIP

curl -o originals.zip "$BASE_URL/api/v1/events/$EVENT_ID/photos/download" \ -H "Authorization: Bearer $TOKEN"

Same permissions as the single download. The archive is streamed as it is built, so there is no waiting before the first byte, and it has no Content-Length.

  • Entries are the stored originals, uncompressed (photos and videos are already compressed), always named by their original upload filename. : is replaced by _, and duplicate names get _1, _2 … suffixes — compared case-insensitively, so IMG.jpg and img.JPG don’t overwrite each other when extracted on macOS or Windows.
  • ids limits the archive to those photos: ?ids=1234,1235,1236, at most 500, and every id must belong to the event.
  • The list filters work here too (marked_only, mark_source, color_labels, my_color_labels, min_rating, my_min_rating, logic), so you can zip exactly what you just listed — for example ?marked_only=true&color_labels=green.
  • A photo whose file is missing from storage is skipped, and its id is listed in a MISSING_FILES.txt entry inside the archive (MISSING_FILES_1.txt if one of your photos is itself called MISSING_FILES.txt).
  • One request is capped at 5000 photos and 20 GiB, checked before anything is streamed. Sizes come from the recorded file sizes; for photos with no recorded size (or a recorded size of 0), a GET asks the storage for the size. Beyond the cap you get 400 ZIP_TOO_LARGE; split the request with ids. The cap is also enforced on the bytes actually streamed: an archive that grows past 20 GiB anyway is aborted mid-transfer (see below).
  • HEAD is a cheap probe: it runs the filter, id and count checks and the size check on recorded sizes only, then answers with the ZIP headers without loading rows, asking the storage or building the archive. Because unsized photos are only sized on GET, a HEAD can answer 200 where the GET then refuses with ZIP_TOO_LARGE.
  • At most 2 archives per API token are built at once. Another GET meanwhile gets 429 ZIP_CONCURRENCY; wait for one to finish. HEAD does not count.

Archives over 4 GiB are ZIP64. Tools that read the central directory (unzip 6, 7-Zip, macOS Archive Utility, Python’s zipfile) handle that; some streaming unzip tools and libraries don’t. If yours doesn’t, keep each archive under 4 GiB with ids batches.

If a file fails to read while the archive is streaming, or the streamed bytes pass the 20 GiB cap, PicPeak aborts the connection rather than ending the response, so a truncated archive never arrives looking complete. Treat an incomplete transfer (your client reports a connection reset or a premature end) as a failure and retry. A failure before streaming starts is a normal 500 JSON error.

Rate limit

Every API request counts against the general rate limit: by default 300 requests per 15 minutes per client IP, adjustable under Settings → Security → API rate limiting. Downloading an event photo by photo with the single endpoint spends one request per photo, so an event with a few hundred photos hits the limit (429). For bulk delivery use the ZIP endpoint — one request for the whole event, or one per ids batch of up to 500.

Download errors

StatuscodeWhen
400INVALID_PHOTO_IDSids is malformed, or an id does not belong to this event (the offending ids are not named).
400TOO_MANY_PHOTO_IDSMore than 500 ids.
400ZIP_TOO_LARGEOver 5000 photos or 20 GiB; the body carries photo_count and total_bytes.
401NO_TOKEN, INVALID_TOKEN, TOKEN_REVOKED, …Missing, unknown, revoked or expired token.
403INSUFFICIENT_SCOPE / FORBIDDENToken lacks the read scope, or its owner lacks photos.view or photos.download.
403—The event belongs to another admin (same answer as the other /api/v1 event routes).
404—Unknown event or photo. A photo of a different event gets the same 404 as an unknown id.
404PHOTO_FILE_MISSINGThe photo exists but its file is gone from storage (single download only).
404NO_PHOTOSThe ZIP selection matched no photos.
409EVENT_ARCHIVEDThe event is archived: its originals only exist inside the archive. Restore the event first.
429—Rate limit exceeded.
429ZIP_CONCURRENCYThis token already has 2 archives in progress (ZIP only).
500—Unexpected server error before the file or archive started streaming. After that, the connection is aborted instead.

What an API download does not do

API downloads are integration access, not guest downloads:

  • They do not count towards the gallery’s download statistics, per-photo download counters or any download limit, and they write no gallery access log entries.
  • Gallery settings that shape guest downloads — downloads disabled, the per-category download switch, the standard download resolution, the watermark — do not apply. Anyone holding a token with photos.download gets the originals.
  • Each single download leaves one entry in the admin activity log with the token id, event id and photo id. The notification bell does not list those; it shows one entry per token, event and hour with a running count (“API token “n8n” downloaded 42 originals from …”). A ZIP is one entry with its photo count.

n8n: send the originals on WhatsApp

  1. HTTP Request — GET {{BASE_URL}}/api/v1/events/{{eventId}}/photos?limit=100, Authentication Generic → Header Auth with name Authorization and value Bearer pp_live_…. Page with page=2, 3, … while pagination.pages says there are more.
  2. Split Out on photos.
  3. HTTP Request — GET {{BASE_URL}}/api/v1/events/{{eventId}}/photos/{{$json.id}}/download, same credential, Options → Response → Response Format: File, put output in field data. n8n takes the filename and MIME type from the response headers.
  4. Hand the binary field data to the WhatsApp Cloud API media upload.

This spends one API request per photo, so for events with more than a couple of hundred photos mind the rate limit — or fetch a single archive instead: point one HTTP Request node at /api/v1/events/{{eventId}}/photos/download with the same File response format, and unpack it with n8n’s Compression node.

Give the n8n token only the read scope. It can then list and download, but cannot create events or upload.

Admin API endpoints

Everything below needs an admin JWT, not an API token, and is mounted under /api/admin/photos.

Chunked upload (large files)

For individual files over the normal upload limit (typical for video), use the chunked flow. Every step is scoped to the event, and the permission is photos.upload (photos.view for status, photos.delete to abort):

# 1. Initialise — the extension of `filename` selects the file type curl -X POST "$BASE_URL/api/admin/photos/$EVENT_ID/chunked-upload/init" \ -H "Authorization: Bearer $ADMIN_JWT" \ -H "Content-Type: application/json" \ -d '{ "filename": "video.mp4", "fileSize": 1234567890, "totalChunks": 118 }' # → { "uploadId": "…", "chunkSize": 10485760, "expectedChunks": 118 } # 2. Send each chunk — raw bytes, 10 MB each, any order curl -X POST "$BASE_URL/api/admin/photos/$EVENT_ID/chunked-upload/$UPLOAD_ID/chunk/0" \ -H "Authorization: Bearer $ADMIN_JWT" \ -H "Content-Type: application/octet-stream" \ --data-binary @chunk0 # 3. Finish — merges the chunks and runs the normal photo pipeline curl -X POST "$BASE_URL/api/admin/photos/$EVENT_ID/chunked-upload/$UPLOAD_ID/complete" \ -H "Authorization: Bearer $ADMIN_JWT" \ -H "Content-Type: application/json" \ -d '{ "category_id": null }'

Progress and cleanup:

curl "$BASE_URL/api/admin/photos/$EVENT_ID/chunked-upload/$UPLOAD_ID/status" -H "Authorization: Bearer $ADMIN_JWT" curl -X DELETE "$BASE_URL/api/admin/photos/$EVENT_ID/chunked-upload/$UPLOAD_ID" -H "Authorization: Bearer $ADMIN_JWT"

Re-sending a chunk index replaces the earlier copy, so a failed chunk can be retried on its own.

In builds containing PicPeak PR #1809  the chunk layout is fixed by fileSize: totalChunks, when sent, must equal ceil(fileSize / chunkSize), every chunk but the last must be exactly chunkSize bytes and the last one the remainder (400 otherwise). An upload belongs to the admin and event that initialised it; another admin or another event gets 404 for its id. complete can be called once: a second call while the first is merging, or after it finished, answers 409.

File type rule

The file type is derived from the filename extension, and that extension must be on the Allowed File Types list — the same rule the multipart upload path enforces. The mimeType field older clients sent in the init body is ignored: PicPeak no longer stores or serves a client-declared MIME type (a file that decoded as an image but was declared text/html used to be served as HTML on the app origin).

Checks run in this order at init:

  1. fileSize against Max File Size / Max Video Size → 400 File too large. Maximum size is N MB per file.
  2. extension against Allowed File Types → 400 File type not allowed

The default Allowed File Types list is jpg,jpeg,png,webp. On that default a chunked upload of video.mp4 is refused with File type not allowed — add the video extensions you want (for example mp4,mov,webm) in General settings before uploading video through this API. See Video Support.

List photos in the admin grid

curl "$BASE_URL/api/admin/photos/$EVENT_ID/photos?min_rating=4&color_label=green" \ -H "Authorization: Bearer $ADMIN_JWT"

Permission: photos.view. This is the grid’s own endpoint, with the filters the admin UI exposes.

ParamNotes
category_idA numeric category id, or individual / collage / uncategorized.
typeLegacy type filter, kept for backwards compatibility.
searchSubstring match on filename.
has_likes, has_favorites, has_commentstrue to require each.
min_ratingGuest star average.
color_labelA single client colour.
logicAND (default) or OR across the feedback filters.
sort / orderdate (default), plus asc / desc.

For the mark-aware, API-token-friendly version, use the public list endpoint above instead.

Get single photo metadata

curl "$BASE_URL/api/admin/photos/$EVENT_ID/photo/$PHOTO_ID" \ -H "Authorization: Bearer $ADMIN_JWT"

Permission: photos.view. Note the singular photo in the path — the plural photos is the list route above.

Hide / unhide a photo

curl -X PATCH "$BASE_URL/api/admin/photos/$EVENT_ID/photos/$PHOTO_ID" \ -H "Authorization: Bearer $ADMIN_JWT" \ -H "Content-Type: application/json" \ -d '{ "visibility": "hidden" }'

Permission: photos.edit. Visibility is a string — visible or hidden, not a boolean. Any other value is ignored rather than rejected.

The same route sets the category via category_id, which accepts a numeric category id, individual / collage, or null to clear it. Setting it by hand also clears the “automatically categorised” flag, so a later undo automatic categories will not wipe your choice.

Hidden photos stay in the database and the admin grid but are not shown to guests.

Delete

curl -X DELETE "$BASE_URL/api/admin/photos/$EVENT_ID/photos/$PHOTO_ID" \ -H "Authorization: Bearer $ADMIN_JWT"

Permission: photos.delete. Removes the file, its derivatives and the database row.

Bulk delete

curl -X POST "$BASE_URL/api/admin/photos/$EVENT_ID/photos/bulk-delete" \ -H "Authorization: Bearer $ADMIN_JWT" \ -H "Content-Type: application/json" \ -d '{ "photoIds": [1234, 1235, 1236] }'

Permission: photos.delete. The body key is photoIds, camelCase. Ids outside the event in the URL are ignored, so this cannot reach another gallery’s photos.

Repair dimensions

curl -X POST "$BASE_URL/api/admin/photos/repair-dimensions" \ -H "Authorization: Bearer $ADMIN_JWT"

Permission: photos.edit. Instance-wide, not per event — it scans every photos row with a null width or height (skipping videos) and re-extracts from the file.

Returns immediately with { message, count } and continues in the background; 409 if a repair is already running. Poll it:

curl "$BASE_URL/api/admin/photos/repair-dimensions/status" \ -H "Authorization: Bearer $ADMIN_JWT"
{ "total": 4820, "withDimensions": 4795, "withoutDimensions": 25, "isRunning": true, "lastResult": null }

See System Status for when to run this.

Photo + thumbnail serving

For integrations, the original download endpoints above serve the stored files with an API token. Everything else is served only through the authenticated gallery API, which applies the per-photo rules (download permission, per-category download opt-out, watermarking, resolution cap, reveal windows, hidden photos, customer-assignment re-check):

PathAuthNotes
/api/gallery/{slug}/photo/{id}gallery or admin tokenFull-size photo, or the protected/watermarked variant when the event calls for it.
/api/gallery/{slug}/thumbnail/{id}gallery or admin tokenThumbnail.
/api/gallery/{slug}/download/{id}gallery or admin tokenDownload with Content-Disposition; honours the download settings.

The response Content-Type is always resolved from the file extension (images) or a validated video/* value (videos), never from a stored, client-supplied MIME string.

The legacy static mounts /photos/{slug}/{filename} and /thumbnails/{slug}/{filename} have been removed. They bypassed every per-photo rule above; the nginx locations that proxied them now return 404. Nothing in the shipped frontend or the email templates used them, but a custom integration that built those URLs must switch to the gallery API.

Video specifics

Video goes through the admin upload and chunked-upload endpoints. The public /api/v1 upload accepts images only and rejects any other MIME type.

  • Formats: MP4 (.mp4, .m4v), WebM, MOV, AVI — but only the extensions listed in Allowed File Types are accepted, and the default list contains none of them.
  • Per-file cap: Max Video Size (MB) in General settings, separate from the photo cap (cap at the proxy level too — see Video Support).
  • Files above the multipart limit go through the chunked flow above.
  • A thumbnail is auto-extracted from the 1-second mark via FFmpeg.

Webhook events

The Photos API fires:

  • photo.uploaded — once per successfully uploaded file (admin upload, API upload, guest upload, S3 prefix walker import, filewatcher auto-import)
  • photo.deleted — once per delete (single or bulk). Not fired per-photo when an event is archived — receivers infer from event.archived.

See Webhooks for the payload shape.

Last updated on