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) | |
|---|---|---|
| Auth | API token (Authorization: Bearer pp_live_…) | Admin session JWT |
| For | Integrations, scripts, the Lightroom plugin | The admin UI |
| Photo endpoints | Upload, replace, list-with-marks | Everything 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_xxxxxxxxScopes 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_idis optional and must belong to this event or be global; anything else returns400.
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:
| Param | Default | Notes |
|---|---|---|
page | 1 | |
limit | 50 | Max 100. |
marked_only | false | Only photos carrying a rating or colour label from mark_source. |
mark_source | either | client, 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. |
logic | AND | AND 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_filenameis the camera-original name, preserved even after a photo has been replaced by an edited version. Match on this, not onoriginal_filename— the latter is overwritten by a replace.color_labelsholds the per-colour tallies across all guests;dominant_color_labelis most-labelled-wins with ties broken green first.color_labelandratingare the merged values for the requestedmark_source— one colour and one rating, ready to write into a catalog. Withmark_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.
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.
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:
fileSizeagainst Max File Size / Max Video Size →400 File too large. Maximum size is N MB per file.- 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.
| Param | Notes |
|---|---|
category_id | A numeric category id, or individual / collage / uncategorized. |
type | Legacy type filter, kept for backwards compatibility. |
search | Substring match on filename. |
has_likes, has_favorites, has_comments | true to require each. |
min_rating | Guest star average. |
color_label | A single client colour. |
logic | AND (default) or OR across the feedback filters. |
sort / order | date (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
Originals and thumbnails are 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):
| Path | Auth | Notes |
|---|---|---|
/api/gallery/{slug}/photo/{id} | gallery or admin token | Full-size photo, or the protected/watermarked variant when the event calls for it. |
/api/gallery/{slug}/thumbnail/{id} | gallery or admin token | Thumbnail. |
/api/gallery/{slug}/download/{id} | gallery or admin token | Download 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 fromevent.archived.
See Webhooks for the payload shape.