Main API
Base URL: http://localhost:3002 (dev) · port 8002 (Docker)
JWT audience: main — authenticated routes require a Bearer token unless noted.
Auth
POST /v1/auth/register
Register a new end user account.
Public
Body
{ "name": "string", "email": "string", "password": "string" }Response 201
{ "token": "string" }POST /v1/auth/login
Log in as an end user.
Public
Body { "email": "string", "password": "string" }
Response 200 { "token": "string" }
POST /v1/auth/forgot-password
Request a password reset email. Always returns 200 — never reveals whether an email is registered.
Public
Body { "email": "string" }
Response 200 {}
POST /v1/auth/reset-password
Set a new password using a reset token from the email link. Token expires after 1 hour and is single-use.
Public
Body { "token": "string", "password": "string (min 8 chars)" }
Response 200 {}
Errors
400— token invalid, expired, or already used
Consent
POST /v1/consent/biometric
Give biometric processing consent. Required before a selfie can be uploaded.
Body
{ "consentVersion": "string" }Response 201
GET /v1/consent/biometric
List all consent records for the authenticated user (including revoked).
Response 200 — array of consent rows.
DELETE /v1/consent/biometric
Revoke biometric consent. Triggers a cascade:
- Selfie row and R2 object deleted
- All auto-tagged
photoTagssoft-deleted - Consent row marked
revokedAt
All steps are wrapped in a DB transaction; R2 side effects are enqueued after commit.
Response 204
Projects (public)
GET /v1/projects/:id/public
Return minimal project info. No authentication required.
Used by the shareable join URL flow to show the customer the event name before they sign up.
Public
Response 200
{ "id": "uuid", "name": "string" }GET /v1/projects/:projectId/terms
Return the current (most recent) Terms & Conditions version for a project.
Returns 404 if no terms have been stored yet (no members have joined). The frontend falls back to rendering the platform-default text in that case.
Public
Response 200
{ "version": "1", "content": "string", "effectiveAt": "ISO 8601" }GET /v1/projects/:projectId/my-terms
Return the exact T&C version the authenticated caller accepted when they joined the project, along with the acceptance timestamp.
For members who joined before the termsVersionId column was introduced, returns the current terms as a best-effort fallback.
Auth required
Response 200
{
"version": "1",
"content": "string",
"effectiveAt": "ISO 8601",
"acceptedAt": "ISO 8601 | null",
"projectName": "string | null"
}Errors
404— caller is not a member of this project
QR Join
POST /v1/qr/join
Join a project as an authenticated user. Creates a projectMembers row (with a termsVersionId FK pointing at the current T&C snapshot) and issues a QR token.
Idempotent per (userId, projectId) — returns 409 if already a member.
Body
{ "projectId": "uuid", "termsAccepted": true }Response 201
{ "projectMemberId": "uuid", "qrToken": "string", "expiresAt": "ISO 8601" }Photos
GET /v1/photos
List all ready photos across the authenticated user's enrolled projects. Photos where the user has a confirmed tag are returned first (with tagged: true), followed by all other project photos (tagged: false).
Response 200 — array of photo summaries including tagged: boolean, hasPurchase: boolean, isFavourite: boolean, and presigned thumbnail URLs.
POST /v1/photos/:id/favourite
Toggle the favourite state for a photo. Requires project membership.
Response 200
{ "isFavourite": true }Errors
404— photo not found403— user is not a member of the photo's project
POST /v1/photos/:id/report
Submit a tag report on a photo.
Body
{ "reportType": "incorrect_tag" | "missing_tag" }Response 204 — idempotent per (userId, photoId, reportType).
Errors
403— user is not a member of the photo's project
GET /v1/photos/:id/download
Get a presigned GET URL (15-minute expiry) for a watermarked photo.
WARNING
Only available after the user has a paid purchase that includes this photo (a purchaseDownloads row exists). Requires project membership.
Response 200
{ "downloadUrl": "string", "expiresInSeconds": 900 }Purchases
POST /v1/purchases
Create a purchase for a set of photos. Price is resolved server-side from the project's active pricing rule.
If totalCents === 0 (free after discount), the purchase is immediately set to paid and no Stripe PaymentIntent is returned.
Body
{
"projectId": "uuid",
"photoIds": ["uuid"],
"promoCode": "string?"
}photoIds— 1–100 UUIDs ofreadyphotos in the project.promoCode— optional. Validated for global cap, per-user cap, and validity dates.
Response 201
{
"purchaseId": "uuid",
"clientSecret": "string | null",
"totalCents": 499,
"subtotalCents": 499,
"discountAppliedCents": 0
}clientSecret— Stripe PaymentIntent client secret.nullif the purchase is free.totalCents— amount to charge after discount.subtotalCents— amount before discount.discountAppliedCents— discount savings applied.
The clientSecret is passed to Stripe.js on the frontend to complete payment. On success, Stripe sends a webhook that marks the purchase paid.
Errors
403— user is not a member of the project422— no active pricing rule for this project, or a photo is notready
GET /v1/purchases
List all purchases for the authenticated user.
Response 200
[
{
"id": "uuid",
"status": "paid",
"projectName": "string",
"photoCount": 5,
"photosDownloaded": 3,
"subtotalCents": 2495,
"discountAppliedCents": 499,
"totalCents": 1996,
"appliedPromoCode": "SUMMER20",
"createdAt": "ISO 8601"
}
]Stripe Webhook
POST /v1/stripe-webhook
Receives Stripe webhook events. Requires raw request body (no JSON parsing) for HMAC verification.
Handles:
payment_intent.succeeded→ setspurchase.status = 'paid', incrementspromoCodes.usedCountif a promo code was used, enqueuesnotify_purchase_confirmedpayment_intent.payment_failed→ setspurchase.status = 'failed'