Skip to content

Backoffice API

Base URL: http://localhost:3001 (dev) · port 8001 (Docker)

JWT audience: backoffice — all routes require a valid Bearer token unless noted.

Roles: operator, photographer


Auth

POST /v1/auth/register

Register a new operator account.

Public — no token required.

Body

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

Response 201

json
{ "token": "string" }

POST /v1/auth/login

Log in as an operator or photographer.

Public — no token required.

Body

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

Response 200

json
{ "token": "string" }

POST /v1/auth/register-photographer

Register a new photographer under the authenticated operator.

Role operator

Body

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

Response 201

json
{ "id": "uuid", "name": "string", "email": "string" }

Projects

GET /v1/projects

List all projects belonging to the authenticated operator.

Role operator

Response 200 — array of project summaries.


POST /v1/projects

Create a new project.

Role operator

Body

json
{
  "name": "string",
  "type": "private | public",
  "status": "draft | live",
  "locationName": "string?",
  "startAt": "ISO 8601?",
  "endAt": "ISO 8601?",
  "projectLink": "url?",
  "termsAndConditions": "string?",
  "timezone": "string?"
}
  • type — defaults to "private". Private projects require email-bound invite links; public projects are open to anyone with the link or QR code.
  • status — defaults to "draft". Use "live" to publish immediately.

Response 201 — project detail with empty photographers array.


GET /v1/projects/:id

Get project detail including assigned photographers.

Role operator, photographer


PATCH /v1/projects/:id

Update project fields (name, type, location, dates, timezone).

Role operator


PATCH /v1/projects/:id/status

Transition a project to a new status.

Role operator

Body

json
{ "status": "draft | live | closed | archived" }

Status transitions:

  • draftlive — publishes the project; participants can join and upload selfies.
  • livedraft — hides the project from participants; existing joins are preserved.
  • liveclosed — prevents new joins; existing members retain access.
  • closedarchived — hides the project from all views.

Response 200 — updated project detail.


POST /v1/projects/:id/photographers

Assign a photographer to a project.

Role operator

Body { "photographerId": "uuid" }

Response 204


DELETE /v1/projects/:id/photographers/:photographerId

Remove a photographer from a project.

Role operator · Response 204


GET /v1/projects/:id/discounts

List discounts linked to a project.

Role operator

Response 200 — array of discount objects.


POST /v1/projects/:id/discounts

Link a discount to a project.

Role operator

Body { "discountId": "uuid" }

Response 204


DELETE /v1/projects/:id/discounts/:discountId

Unlink a discount from a project.

Role operator · Response 204


GET /v1/projects/:id/invites

List all invites for a project, with each invitee's registration status.

Role operator

Response 200 — array of invite items, newest first.

json
[
  {
    "id": "uuid",
    "email": "alice@example.com",
    "registrationStatus": "pending",
    "createdAt": "ISO 8601",
    "expiresAt": "ISO 8601"
  }
]

registrationStatus values:

  • pending — invite sent, user has not registered yet
  • registered — user has created an account but not joined the project
  • joined — user has joined the project
  • expired — invite expired without the user registering

POST /v1/projects/:id/invites/bulk

Send email invitations to a list of users who have opted in to receive photos from this project.

Role operator

Body

json
{ "emails": ["alice@example.com", "bob@example.com"] }
  • emails — 1–500 valid email addresses.

Response 202

json
{ "queued": 2, "skipped": 0 }
  • queued — number of invite emails enqueued.
  • skipped — emails that were deduplicated: already project members or already have a live pending invite.

Skipping is silent (not an error). Re-invite after expiry is supported — a new invite is created once the previous one expires.


DELETE /v1/projects/:id

Permanently delete a project and all its photos (R2 objects and DB rows). This action cannot be undone.

Role operator · Response 204


Photographers

GET /v1/photographers/me/projects

List projects the authenticated photographer is assigned to.

Role photographer


GET /v1/photographers/me/projects/:id

Get a single project the photographer is assigned to.

Role photographer


GET /v1/photographers

List all photographers belonging to the authenticated operator, including the projects each photographer is assigned to.

Role operator

Response 200 — array of photographers, each with a projects: [{ id, name }] array.


PATCH /v1/photographers/:id

Update a photographer's name or email.

Role operator


DELETE /v1/photographers/:id

Delete a photographer. Fails with 409 if the photographer is assigned to any project.

Role operator


Pricing

GET /v1/pricing/rules

List all pricing rules for the authenticated operator (operator default + all project overrides).

Role operator

Response 200 — array of pricing rule objects.


POST /v1/pricing/rules

Create a pricing rule. If projectId is omitted, the rule becomes the operator default.

Role operator

Body

json
{
  "projectId": "uuid?",
  "pricePerPhotoCents": 499,
  "compareAtPriceCents": 799,
  "validFrom": "ISO 8601?",
  "validUntil": "ISO 8601?"
}

Response 201 — pricing rule object.


PATCH /v1/pricing/rules/:id

Update a pricing rule (price, compare-at price, dates, isActive).

Role operator · Response 200


DELETE /v1/pricing/rules/:id

Delete a pricing rule.

Role operator · Response 204


Discounts

GET /v1/discounts

List all discounts for the authenticated operator.

Role operator

Response 200 — array of discount objects.


POST /v1/discounts

Create a discount.

Role operator

Body

json
{
  "name": "string",
  "type": "percentage | fixed | bundle",
  "value": 20,
  "bundleMinPhotos": 5,
  "validFrom": "ISO 8601?",
  "validUntil": "ISO 8601?"
}
  • type=percentagevalue is percentage points (1–100). bundleMinPhotos ignored.
  • type=fixedvalue is cents. bundleMinPhotos ignored.
  • type=bundlevalue is percentage points; bundleMinPhotos is required (≥2).

Response 201 — discount object.


PATCH /v1/discounts/:id

Update a discount (name, dates, isActive).

Role operator · Response 200


DELETE /v1/discounts/:id

Delete a discount. Returns 409 if the discount is still linked to any project.

Role operator · Response 204


Promo Codes

GET /v1/promo-codes

List all promo codes for the authenticated operator.

Role operator

Response 200 — array of promo code objects.


POST /v1/promo-codes

Create a promo code linked to a discount (the operator must own the discount).

Role operator

Body

json
{
  "code": "SUMMER20",
  "discountId": "uuid",
  "maxUses": 100,
  "maxUsesPerUser": 1,
  "validFrom": "ISO 8601?",
  "validUntil": "ISO 8601?"
}

Response 201 — promo code object.


PATCH /v1/promo-codes/:id

Update a promo code (isActive, limits, dates).

Role operator · Response 200


DELETE /v1/promo-codes/:id

Delete a promo code.

Role operator · Response 204


Operators

GET /v1/operators/me

Get the authenticated operator's profile.

Role operator


PATCH /v1/operators/me

Update profile fields (name, address, billing address, currency, locale, timezone, profile picture URL).

Role operator


Analytics

GET /v1/analytics

Returns KPIs, photos by photographer, revenue by project, and conversion funnel for the operator's account.

Role operator

Query params (all optional, comma-separated UUIDs):

  • projectIds — scope to specific projects
  • photographerIds — scope to specific photographers

Response

json
{
  "kpis": {
    "membersEnrolled": 0,
    "revenueInCents": 0,
    "photosUploaded": 0,
    "usersTagged": 0,
    "purchasesMade": 0
  },
  "photosByPhotographer": [{ "name": "string", "photoCount": 0 }],
  "revenueByProject": [{ "projectName": "string", "purchasesMade": 0, "revenueInCents": 0 }],
  "conversionFunnel": { "invited": 0, "enrolled": 0, "tagged": 0, "purchased": 0 }
}

Orders

GET /v1/purchases

List all purchases across the operator's projects, newest first.

Role operator

Query

  • projectId — filter to a single project (UUID)
  • status — filter by status: pending | paid | failed | refunded
  • page — page number, 1-indexed (default 1)
  • limit — items per page, max 100 (default 50)

Response 200

json
{
  "items": [
    {
      "id": "uuid",
      "status": "paid",
      "photoCount": 5,
      "subtotalCents": 2500,
      "discountAppliedCents": 500,
      "totalCents": 2000,
      "appliedPromoCode": "SUMMER10",
      "stripePaymentIntentId": "pi_xxx",
      "createdAt": "2026-04-08T12:00:00.000Z",
      "project": { "id": "uuid", "name": "string" },
      "user": { "id": "uuid", "email": "string", "name": "string" }
    }
  ],
  "total": 42,
  "page": 1,
  "limit": 50
}

GET /v1/purchases/:id

Get full detail for a single purchase, including the list of purchased photo IDs.

Role operator

Returns 403 if the purchase belongs to a project not owned by the authenticated operator.

Response 200

json
{
  "id": "uuid",
  "status": "paid",
  "photoCount": 5,
  "subtotalCents": 2500,
  "discountAppliedCents": 500,
  "totalCents": 2000,
  "appliedPromoCode": "SUMMER10",
  "stripePaymentIntentId": "pi_xxx",
  "createdAt": "2026-04-08T12:00:00.000Z",
  "project": { "id": "uuid", "name": "string" },
  "user": { "id": "uuid", "email": "string", "name": "string" },
  "downloads": [{ "photoId": "uuid" }]
}

Wairo — Internal Documentation