Skip to content

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

json
{ "name": "string", "email": "string", "password": "string" }

Response 201

json
{ "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

POST /v1/consent/biometric

Give biometric processing consent. Required before a selfie can be uploaded.

Body

json
{ "consentVersion": "string" }

Response 201


List all consent records for the authenticated user (including revoked).

Response 200 — array of consent rows.


Revoke biometric consent. Triggers a cascade:

  1. Selfie row and R2 object deleted
  2. All auto-tagged photoTags soft-deleted
  3. 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

json
{ "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

json
{ "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

json
{
  "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

json
{ "projectId": "uuid", "termsAccepted": true }

Response 201

json
{ "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

json
{ "isFavourite": true }

Errors

  • 404 — photo not found
  • 403 — user is not a member of the photo's project

POST /v1/photos/:id/report

Submit a tag report on a photo.

Body

json
{ "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

json
{ "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

json
{
  "projectId": "uuid",
  "photoIds": ["uuid"],
  "promoCode": "string?"
}
  • photoIds — 1–100 UUIDs of ready photos in the project.
  • promoCode — optional. Validated for global cap, per-user cap, and validity dates.

Response 201

json
{
  "purchaseId": "uuid",
  "clientSecret": "string | null",
  "totalCents": 499,
  "subtotalCents": 499,
  "discountAppliedCents": 0
}
  • clientSecret — Stripe PaymentIntent client secret. null if 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 project
  • 422 — no active pricing rule for this project, or a photo is not ready

GET /v1/purchases

List all purchases for the authenticated user.

Response 200

json
[
  {
    "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 → sets purchase.status = 'paid', increments promoCodes.usedCount if a promo code was used, enqueues notify_purchase_confirmed
  • payment_intent.payment_failed → sets purchase.status = 'failed'

Wairo — Internal Documentation