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, download originals or resized renditions, preview images | 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. - The file’s bytes must match its declared type: a file whose signature does
not match (for example HTML sent as
image/jpeg) answers400 File content does not match declared type. An image type the signature table does not know (TIFF, AVIF, …) must instead be an image Sharp can read. The admin multipart and chunked uploads apply the same check. - Max file size is the Max File Size (MB) setting in General settings (default 50 MB).
category_idis optional and must belong to this event or be global; anything else returns400. On the beta channel from v3.164.0-beta.0, where galleries have nested folders, passing a folder’s id places the photo in that folder (itsfolder_id) instead of setting a filter category.- The v1 upload never refuses a duplicate. On the beta channel it stores the file’s content hash, so a later admin upload with duplicate detection on recognises it (see Duplicate detection).
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.
Each row also carries like_count, favorite_count, comment_count,
category, width, height and uploaded_at. On the beta channel from
v3.142.0-beta.0 it adds what a client needs to decide how to fetch the photo:
| Field | Notes |
|---|---|
size_bytes | Recorded size of the stored original. null means unknown, not zero. |
media_type | image or video. Videos are never resized. |
mime_type | The stored type. |
processing_status | complete unless the background worker is still on the photo. The preview and rendition routes answer 503 (still processing) or 422 (failed) otherwise. |
moderation_status | Set while a team member’s upload waits for review, or after it was rejected; such a photo is not shown to guests. From v3.167.0-beta.0. |
folder | The photo’s folder name (beta channel from v3.164.0-beta.0). Since folders exist, category names only the filter category: for a photo that was in a folder-style category before the upgrade, category is now null and the name moved to folder. |
Photos in the trash are not listed.
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: attachmentwith 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, andX-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.
Resized renditions (beta channel from v3.142.0-beta.0): add
?resolution=WxH, for example ?resolution=2048x2048, to get the photo
resized into that box instead — aspect ratio kept, never enlarged,
re-encoded in the source format, never watermarked. These are the same ids
the gallery’s download menu uses; resolution=original (the default) serves
the stored bytes, and any other value answers 400.
- Only JPEG, PNG, WebP and GIF are resized. Videos, RAW (for example DNG) and HEIC/HEIF are always served at original size.
- A photo already inside the box is returned byte for byte.
- The box is a maximum, not a guarantee: when the image cannot be re-encoded safely (HEIC detected from the bytes, or a file Sharp cannot decode) the stored bytes go out unchanged. Check what you received if size matters.
HEADcarries noContent-Lengthfor a rendition: its length is only known once it has been rendered.
Get a preview image
Beta channel from v3.142.0-beta.0. The web-sized JPEG preview the admin grid shows, for building a picker without pulling originals:
curl -o preview.jpg "$BASE_URL/api/v1/events/$EVENT_ID/photos/$PHOTO_ID/preview?w=1280" \
-H "Authorization: Bearer $TOKEN"Requires the read scope and photos.view (no photos.download). It is not a
download: no Content-Disposition, no download audit entry, no notification.
wpicks a tier:640,1280or1920. Any other value is ignored and the default preview is served, so read the image’s own width.- The preview is JPEG, or WebP when the source is animated or has an alpha
channel — read
Content-Type. - Generated on first request and cached afterwards; sent with
Cache-Control: private, max-age=3600. - Videos have no preview:
404 PREVIEW_UNAVAILABLE. - A photo still being processed answers
503 PHOTO_PROCESSINGwithRetry-After; one whose processing failed answers422 PHOTO_PROCESSING_FAILED. Poll rather than treating either as fatal. - An archived event answers
409.
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, soIMG.jpgandimg.JPGdon’t overwrite each other when extracted on macOS or Windows. idslimits the archive to those photos:?ids=1234,1235,1236, at most 500, and every id must belong to the event.resolution=WxH(beta channel from v3.142.0-beta.0) packs resized renditions under the same rules as the single download, and names the archive after the box. Renditions are rendered one at a time as the archive streams, so this is slower than originals, and the size cap below is measured against the originals. A photo that fails to render is skipped and listed inMISSING_FILES.txtwhile the archive still ends200— read the manifest.- 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.txtentry inside the archive (MISSING_FILES_1.txtif one of your photos is itself calledMISSING_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), aGETasks the storage for the size. Beyond the cap you get400 ZIP_TOO_LARGE; split the request withids. The cap is also enforced on the bytes actually streamed: an archive that grows past 20 GiB anyway is aborted mid-transfer (see below). HEADis 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 onGET, aHEADcan answer200where theGETthen refuses withZIP_TOO_LARGE.- At most 2 archives per API token are built at once. Another
GETmeanwhile gets429 ZIP_CONCURRENCY; wait for one to finish.HEADdoes 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
| Status | code | When |
|---|---|---|
400 | INVALID_PHOTO_IDS | ids is malformed, or an id does not belong to this event (the offending ids are not named). |
400 | TOO_MANY_PHOTO_IDS | More than 500 ids. |
400 | ZIP_TOO_LARGE | Over 5000 photos or 20 GiB; the body carries photo_count and total_bytes. |
401 | NO_TOKEN, INVALID_TOKEN, TOKEN_REVOKED, … | Missing, unknown, revoked or expired token. |
403 | INSUFFICIENT_SCOPE / FORBIDDEN | Token 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. |
404 | PHOTO_FILE_MISSING | The photo exists but its file is gone from storage (single download only). |
404 | NO_PHOTOS | The ZIP selection matched no photos. |
409 | EVENT_ARCHIVED | The event is archived: its originals only exist inside the archive. Restore the event first. |
429 | — | Rate limit exceeded. |
429 | ZIP_CONCURRENCY | This 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.downloadgets 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
- HTTP Request —
GET {{BASE_URL}}/api/v1/events/{{eventId}}/photos?limit=100, Authentication Generic → Header Auth with nameAuthorizationand valueBearer pp_live_…. Page withpage=2,3, … whilepagination.pagessays there are more. - Split Out on
photos. - HTTP Request —
GET {{BASE_URL}}/api/v1/events/{{eventId}}/photos/{{$json.id}}/download, same credential, Options → Response → Response Format: File, put output in fielddata. n8n takes the filename and MIME type from the response headers. - Hand the binary field
datato 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.
Upload (multipart)
curl -X POST "$BASE_URL/api/admin/photos/$EVENT_ID/upload" \
-H "Authorization: Bearer $ADMIN_JWT" \
-F "photos=@IMG_1.JPG" -F "photos=@IMG_2.JPG" \
-F "detect_duplicates=true"Permission: photos.upload. Files go in the repeated field photos, up to
Max files per upload. Optional text fields: category_id, and on the beta
channel folder_id (a folder of this event) and detect_duplicates.
Duplicate detection
Beta channel from v3.171.0-beta.0. With detect_duplicates=true on the
multipart upload or on the chunked complete call, a file whose bytes are
identical to a photo already in the same gallery is not stored:
- The multipart upload lists it under
duplicates([{ "index": 0, "filename": "IMG_1.JPG", "duplicate_of": "IMG_1.JPG" }]) with aduplicateCount, and stores the rest. - The chunked
completeanswers409 { "code": "DUPLICATE_PHOTO", "duplicate_of": "<name>" }.
Only byte-identical files match, and only within one gallery. A photo in the trash does not count, so the same file can be uploaded again. Without the flag nothing is refused. The admin UI’s uploader sets the flag and offers “upload anyway” for the files it reports.
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, "detect_duplicates": true }'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.
complete checks the merged file’s content against its type
(400 File content does not match declared type), and takes the same optional
category_id, folder_id and detect_duplicates fields as the multipart
upload.
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:
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. |
media_type | photo or video. Beta channel. |
folder_id | A folder id, root (photos in no folder) or pending (waiting for a folder request). Beta channel. |
moderation | Photos waiting for review or rejected. Beta channel. |
sort | date (upload time, default), name, size, and on the beta channel capture_date and rating. |
order | desc (default) or asc. |
On the beta channel name sorts in natural order on the original filename
(IMG_2 before IMG_10), and capture_date sorts by the photo’s capture time.
On stable, name sorts by the stored filename, which follows upload order.
Photos in the trash are never listed.
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.
On the beta channel from v3.172.0-beta.0 a delete moves the photo to the
gallery’s trash instead: the row and files stay, guests and every
other route stop seeing it, and it can be restored until its retention runs
out. Disk space is only freed when the photo is purged. With Trash retention
(days) set to 0 in General settings, or
on stable, a delete removes the file, its derivatives and the database row at
once.
{ "message": "Photo moved to trash", "trashed": 1, "deleted": 0, "retention_days": 30 }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.
On the beta channel the photos go to the trash under the same rule as a single
delete; the response reports trashed, deleted, failed and
retention_days.
Trash
Beta channel from v3.172.0-beta.0. Every trash route needs photos.delete and
is scoped to the event.
# List the gallery's trash
curl "$BASE_URL/api/admin/photos/$EVENT_ID/photos/trash" -H "Authorization: Bearer $ADMIN_JWT"
# Restore
curl -X POST "$BASE_URL/api/admin/photos/$EVENT_ID/photos/trash/restore" \
-H "Authorization: Bearer $ADMIN_JWT" -H "Content-Type: application/json" \
-d '{ "ids": [1234, 1235] }'
# Delete for good
curl -X POST "$BASE_URL/api/admin/photos/$EVENT_ID/photos/trash/purge" \
-H "Authorization: Bearer $ADMIN_JWT" -H "Content-Type: application/json" \
-d '{ "ids": [1234] }'- The list answers
{ items, retention_days }, newest first. Each item hasid,filename,media_type,size_bytes,deleted_at,purge_after,deleted_by_name,held(it was waiting for review and comes back held) andthumbnail_url(GET …/photos/trash/:photoId/thumbnail, the only route that serves a trashed photo’s thumbnail). idsis 1–500 positive integers; anything else answers400.- Restore answers
{ restored, photo_ids, failed },200when anything came back and409when nothing did. A photo that would push the gallery past its photo cap fails withPHOTO_CAP_REACHED; one no longer in the trash withNOT_IN_TRASH. - Purge answers
{ purged, failed }. - Retention is Trash retention (days) in General settings: default 30, range 0–365. An hourly job purges what is past its retention. Archiving a gallery empties its trash.
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):
| Path | Auth | Notes |
|---|---|---|
/api/gallery/{slug}/photo/{id} | gallery or admin token | Full-size photo; watermarked when the event’s watermark applies, and at protection level maximum (beta from v3.175.0-beta.0) a copy capped at 1920 × 1080, quality 60, without EXIF. |
/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). With the trash, it fires when the photo is moved to the trash and not again when the trash is purged or emptied. Not fired per-photo when an event is archived — receivers infer fromevent.archived.
See Webhooks for the payload shape.