Skip to content

HTTP API ​

The routes the interface uses. They're stable within a major version, but they exist for the UI, not as a public integration API.

All mutating requests need a same-origin Origin header (browsers send it). Errors are JSON: { "error": "<code>", "details"?: { field: message } }.

CodeStatusMeaning
bad_request400Invalid input, see details
unauthorized401Not signed in as admin, or wrong password
invalid_code401Wrong event access code
forbidden403Cross-site request, or the guest hasn't joined the event
uploads_closed403Uploads are off or past their closing time
quota_reached403The guest reached the per-guest limit
not_found404Doesn't exist, or exists but isn't yours
too_large413Body above MAX_UPLOAD_MB
invalid_image415Not a supported image
rate_limited429Slow down; see Retry-After

Guest routes ​

Authenticated by the gr_guest cookie.

RouteBodyResponse
POST /api/e/:slug/joinnone{ ok }. Events without an access code only.
POST /api/e/:slug/unlock{ code }{ ok }
POST /api/e/:slug/guest{ name }{ name }. Stored in the cookie.
POST /api/e/:slug/booth{ key }{ ok }: sets this device up as the event's booth; 403 for a wrong or replaced key
POST /api/e/:slug/photos?at=<spot id>multipart, field photo, optional challenge (a challenge id)201 { photo: PhotoDto }; an unknown challenge, or a place of another event, is ignored
DELETE /api/e/:slug/photos/:id{ ok }: the guest's own photo, files included; 404 otherwise
GET /api/e/:slug/photos?before=<ms>&scope=all{ photos: PhotoDto[], nextCursor }: the guest's own photos; with scope=all and a shared album, the others' approved ones too
GET /api/e/:slug/logo?v=<n>The event logo (PNG), public with the slug
GET /api/media/:id?v=thumb|display|original[&download=1]The image, if the viewer may see it

| POST /api/e/:slug/contact | { email } | { email }. Only on events that collect emails. | | DELETE /api/e/:slug/contact | | { ok }, the address is deleted |

| GET /api/e/:slug/messages | | { messages: MessageDto[] }, the guest's own | | POST /api/e/:slug/messages | multipart: text and/or audio, seconds | 201 { message } | | DELETE /api/e/:slug/messages/:id | | { ok }, the guest's own only | | GET /api/messages/:id/audio[?download=1] | | The voice note, if the viewer may hear it |

Authenticated by the gr_share cookie, set by unlock.

RouteBodyResponse
POST /api/g/:token/unlock{ password }{ ok }, or invalid_code
GET /api/g/:token/photos?challenge=&before=<ms>{ photos, nextCursor }, visible photos only; 403 until unlocked
GET /api/g/:token/messages?before={ messages, nextCursor }, when the hosts share the guestbook
GET /api/g/:token/archiveStreamed ZIP, if the link allows downloads
ts
interface PhotoDto {
  id: string;
  width: number;
  height: number;
  guestName: string | null;
  status: "visible" | "pending" | "hidden";
  mine: boolean;
  downloads?: number; // admins only
  favorite?: boolean;  // admins only
  challengeId: string | null;
  createdAt: number;  // epoch ms
}

Admin routes ​

Authenticated by the gr_admin cookie. Routes under /api/admin/events/:id answer 404 for events outside an organizer's scope; event creation and deletion, accounts and email settings are for owners (403 otherwise).

RouteBodyResponse
POST /api/admin/login{ username, password }{ ok }. Without username, the first owner (ADMIN_USERNAME).
POST /api/admin/login/code{ code } (authenticator or recovery code){ ok }; required when login answered { codeRequired: true }
GET /api/admin/me/two-factor{ enabled, recoveryCodesLeft }
POST /api/admin/me/two-factor{ action: "start" }, { action: "confirm", code }, { action: "recovery", code }{ secret, uri, qr } / { enabled, recoveryCodes } / { recoveryCodes }
DELETE /api/admin/me/two-factor{ password, code }{ enabled: false }
POST /api/admin/logout{ ok }
POST /api/admin/password{ current, next }{ ok }, for the signed-in account
GET /api/admin/me / PATCH{ name?, email? }{ account }
GET /api/admin/accountsOwners: { accounts }
POST /api/admin/accounts{ username, role, name?, email?, eventIds?, password? }201 { account, password }; password is the generated one, else null
PATCH /api/admin/accounts/:idsame fields, or { resetPassword: true }, { resetTwoFactor: true }{ account, password }
DELETE /api/admin/accounts/:id{ ok }
GET /api/admin/eventsoptional ?page, perPage (1–100, default 20), q (name contains)without page or q: { events: EventSummary[] }, every event; with them: { events, total, page, pageCount }
POST /api/admin/eventsevent fields, name required201 { event }
GET /api/admin/events/:id{ event }
PATCH /api/admin/events/:idany event fields{ event }
DELETE /api/admin/events/:id{ ok }, photos deleted from disk
POST /api/admin/events/:id/rotate{ event } with a new slug
POST /api/admin/events/:id/logomultipart, field logo{ event }
DELETE /api/admin/events/:id/logo{ event }
GET /api/admin/events/:id/qr?format=svg|png&size=512..4096&download=1&spot=<id>The styled QR code; with spot, that place's code (/e/<slug>?at=<id>) captioned with its name
GET /api/admin/invitations{ invitations }: pending ones (owners)
POST /api/admin/invitations{ email, role, eventIds }201 { invitation, url, emailed }
DELETE /api/admin/invitations/:id{ ok }
POST /api/admin/invite/:token{ username, name, password } (public, the token is the credential)201 { ok } and signed in; 404 for a spent, expired or revoked link
GET /api/admin/events/:id/album?layout=1|2|4&scope=visible|favoritesThe printable album, a streamed PDF; 404 without photos, 429 while another album is being made
GET /api/metricsAuthorization: Bearer <METRICS_TOKEN>Prometheus text format; 404 without the token
GET /api/admin/events/:id/cloud{ configured, exports }: the latest cloud exports with their progress
POST /api/admin/events/:id/cloud{ scope: "visible" | "favorites" }202 { export }: queued, sent in the background
GET/PUT/DELETE /api/admin/cloud{ url, user, password, folder, autoExport }The WebDAV settings (owners; the password is never returned)
POST /api/admin/cloud/testsame body{ ok, message? }
POST /api/admin/events/:id/booth{ boothKey }: turns the photo booth on, or replaces its link
DELETE /api/admin/events/:id/booth{ ok }: turns it off
GET /api/admin/events/:id/spots{ spots: [{ id, label, photos }] }
POST /api/admin/events/:id/spots{ label } (1 to 40 characters, up to 100 places)201 { spot }
PATCH /api/admin/events/:id/spots/:spotId{ label }{ spot }
DELETE /api/admin/events/:id/spots/:spotId{ ok }; its photos stay, untagged
GET /api/admin/events/:id/photos?status=&favorites=1&challenge=&before={ photos, nextCursor }
POST /api/admin/events/:id/photos{ action: "approve"|"hide"|"favorite"|"unfavorite"|"delete", ids }{ changed }
POST /api/admin/events/:id/photos/importmultipart: photo, optional credit201 { photo }, visible at once
POST /api/admin/events/:id/duplicate{ name? }Owners: 201 { event }, settings without photos
GET /api/admin/events/:id/archive[?all=1|?favorites=1]Streamed ZIP; all=1 includes pending and hidden, favorites=1 is the visible favorites
GET /api/admin/webhooksOwners: { webhooks, deliveries }
POST /api/admin/webhooks{ name, type: "ntfy"|"discord"|"slack"|"json", url, kinds, secret?, enabled? }201 { webhook }
PATCH /api/admin/webhooks/:id / DELETEsame fields; empty url keeps it{ webhook } / { ok }
POST /api/admin/webhooks/:id/test{ ok }, or 502 { error: "webhook_failed", message }
GET /api/admin/backup[?download=1]Owners: { files, bytes, storage, retentionDays }, or the backup ZIP
POST /api/admin/backup/restorethe ZIP as the body{ ok, manifest }; 400 { details: { file } } if refused
PUT /api/admin/backup/retention{ days }: 0, or 14 to 3650{ retentionDays }
GET /api/admin/updatesOwners: { current, latest, available, checkEnabled, history, updater }
POST /api/admin/updates/check{ available }, or 502 when GitHub can't be reached
POST /api/admin/updates/apply{ version, skipBackup? } (the available version only)202 { job, backup }; 409/502 if the backup or the updater fails
GET /api/admin/stats[?eventId=]Totals, time series, top guests
GET /api/admin/events/:id/messages?status=&before={ messages, nextCursor }
POST /api/admin/events/:id/messages{ action: "approve"|"hide"|"delete", ids }{ changed }
GET /api/admin/events/:id/messages/archiveZIP: guestbook.txt and the voice notes
GET /api/admin/events/:id/shares{ shares }
POST /api/admin/events/:id/shares{ password?, label?, allowDownload?, favoritesOnly?, expiresInDays? }201 { share, password }; password is the generated one, else null
DELETE /api/admin/events/:id/shares/:shareId{ ok }, revoked
POST /api/admin/events/:id/end{ event, result }; 400 if already ended
GET /api/admin/mailSMTP settings (never the password), alerts, queue
PUT /api/admin/mail{ smtp?, adminEmail?, alerts? }Same as GET
POST /api/admin/mail/test{ to? }{ ok, to }, or 502 { error: "smtp_failed", message }
POST /api/admin/mail/retry{ requeued, sent, failed }: failed emails requeued, then the queue is processed

Event fields: name, description, eventDate (YYYY-MM-DD), accentColor (#rrggbb), accessCode (string to set, null to remove), uploadsOpen, uploadsCloseAt (epoch ms or null), moderation (post|pre), captureMode (camera|picker|both), askGuestName, maxPhotosPerGuest, defaultLocale (en|fr|it|es|null), collectGuestEmails, notifyAdminSummary, emailGuestsGallery, galleryLinkDays (0–3650), galleryLinkDownload, galleryMailIncludesPassword, galleryLinkFavorites, guestbookEnabled, guestbookVoice, guestbookInGallery, challenges (list of { id?, label } or strings; ids are kept, new ones assigned), wallStyle (object, see src/lib/wall.ts), frame (none|print|border|stamp), captureFilters, qrStyle (object, see src/lib/qr/style.ts).

Health ​

GET /api/health returns 200 {"status":"ok"}, or 503 when the database can't be read.

Released under the MIT License.