API Reference

All merch backend API endpoints

Overview

All routes are in apps/zooly-app/app/api/merch/. All are public (no auth required). All return CORS headers and handle OPTIONS for preflight.

Campaign Routes

GET /api/merch/campaigns

List all active campaigns.

Response (200):

{ "campaigns": [{ "id": "...", "slug": "...", "name": "...", "talentName": "...", "status": "LIVE" }] }

GET /api/merch/campaign/[slug]

Full campaign config with resolved asset URLs and products.

Response (200): { "data": MerchCampaignConfig } — includes branding, copy, products, studio, legal, plaqueConfig, shipping config.

Errors: 404 (campaign not found), 500

GET /api/merch/campaign/[slug]/lifecycle

Campaign lifecycle state for polling.

Response (200): { "status", "shutdownMode", "shutdownStartedAt", "shutdownEndsAt" }

Session Routes

POST /api/merch/session/create

Create a new fan session.

Body: { "campaignSlug": string, "role?": string, "ipAddress?": string, "userAgent?": string }

Response (201): { "sessionId": string }

Also creates an analytics sidecar record via ensureAnalyticsSidecar().

GET /api/merch/session/[id]

Fetch session by ID.

Response (200): { "data": MerchSession }

PATCH /api/merch/session/update

Update session fields. Triggers analytics step tracking when currentStep or lastRoute changes.

Body: { "sessionId": string, ...fields }

Response (200): { "data": UpdatedSession }

Upload Routes

POST /api/merch/upload

Upload a selfie image to S3.

Body: FormData with file field

Response (200): { "filename": string }

Upload target: merch/selfie/ prefix in S3. Default MIME: image/jpeg.

POST /api/merch/upload/verify

Verify an uploaded image using Gemini vision AI.

Body: { "imageName": string }

Response (200): { "data": { "personDetected": bool, "isBlurry": bool, "isNudity": bool, "isCelebrity": bool } }

POST /api/merch/upload/presigned-url

Get a presigned S3 URL for direct image upload.

Body: { "filename": string, "contentType": string }

Response (200): { "url": string, "key": string }

POST /api/merch/verify-image

Alternative image verification endpoint (face detection).

Body: { "imageKey": string }

Response (200): { "data": VerificationResult }

Generation Routes

POST /api/merch/ai/generate

Multi-attempt AI art generation with character consistency scoring.

Body: { "sessionId": string }

Response (200):

{
  "aiArtKey": "merch/ai-candidates/sess-1-best-1234.png",
  "candidates": [
    { "artKey": "merch/ai-candidates/sess-1-best-1234.png", "url": "https://...", "score": 0.92 },
    { "artKey": "merch/ai-candidates/sess-2-alt-1234.png", "url": "https://...", "score": 0.85 },
    { "artKey": "merch/ai-candidates/sess-3-alt-1234.png", "url": "https://...", "score": 0.71 }
  ]
}

Timeout: 300 seconds. Runs 3 parallel generation attempts, scores by likeness, returns all candidates sorted by score (best first). The aiArtKey is the top-scored candidate. All candidates are also stored in the session as aiArtCandidates. Sends delayed email if delayedEmail is set on session.

POST /api/merch/ai/select

Select a specific art candidate as the active art for a session.

Body: { "sessionId": string, "artKey": string }

Response (200):

{
  "aiArtKey": "merch/ai-candidates/sess-2-alt-1234.png",
  "plaqueIllustrationKey": null
}

Validates that artKey exists in the session's aiArtCandidates. Updates session.aiArtKey to the selected candidate. Returns the updated aiArtKey and current plaqueIllustrationKey.

Errors: 400 (missing params), 404 (session not found), 400 (artKey not in candidates)

POST /api/merch/mockup/generate

Generate a product mockup (Sharp composite or FAL AI try-on).

Body: { "sessionId": string, "productType": string }

Response (200): { "mockupFilename": string }

Mode selected by MERCH_MOCKUP_MODE env var (composite default or ai-tryon).

POST /api/merch/plaque/render

Render plaque illustration with text overlay.

Body: { "sessionId": string, "fanName": string, "plaqueVariant?": string }

Response (200): { "plaqueFilename": string }

Timeout: 60 seconds. Uses Sharp layered composition with opentype.js for text.

Checkout Routes

POST /api/merch/calc-price

Calculate order totals (pricing only, no PaymentIntent creation).

Body: { "sessionId": string }

Response (200):

{
  "subtotal": 69.0,
  "shippingCost": 6.95,
  "total": 75.95,
  "stripePublishableKey": "pk_...",
  "allowSkipPayment": false
}

Tester mode (role === "tester"): returns allowSkipPayment: true.

POST /api/merch/create-payment-intent

Create a Stripe PaymentIntent for a merch session. Delegates to createMerchPaymentIntent from @zooly/srv-stripe-payment.

Body: { "sessionId": string }

Response (200):

{
  "stripePaymentId": "internal DB id",
  "stripeClientSecret": "pi_xxx_secret_xxx",
  "stripePublishableKey": "pk_xxx"
}

This endpoint:

  1. Calls the auth service (POST /api/users/get-or-create) with the buyer's email and name to get or create a guest user identity. The user_id is stored on the session. If the auth service is unreachable, the payment proceeds without a user_id.
  2. Recalculates the cart total server-side (products + shipping)
  3. Creates a stripe_payment DB record (payFor: "MERCH", payForId: sessionId, buyerUserId: guestUserId, sellerAccountId from campaign)
  4. Creates a Stripe PaymentIntent with idempotency key merch-pi-{paymentId} and metadata { stripePaymentId, merchSessionId }
  5. Stores both paymentIntentId (Stripe PI ID) and stripePaymentDbId (internal ID) on the session

POST /api/merch/order/complete

Single entry point for all merch order completion — both real payments and demo/tester mode.

Body: { "sessionId": string, "ppuCode?": string, "previewType?": string }

Response (201):

{ "orderId": "...", "orderNumber": "ORD-...", "ppuCode": "..." }

Real payment flow:

  1. Reads stripePaymentDbId from the session (set during PI creation)
  2. Calls completePaymentFromClient(stripePaymentDbId) from @zooly/srv-stripe-payment — verifies PI at Stripe, creates share tracking records, marks payment SUCCEEDED
  3. Creates or retrieves the merch order via createOrGetMerchOrderFromSession, passing session.userId (guest identity) to associate the order with the buyer
  4. Sends confirmation email (non-fatal)

Demo mode flow (ppuCode === "DEMO_SKIP_PAYMENT"):

  1. Requires session.role === "tester"
  2. Skips payment verification
  3. Creates order directly

The Stripe webhook (charge.succeeded) acts as a fallback — the same idempotent completePayment() is called if the webhook arrives before or after the client endpoint.

POST /api/merch/order/by-session

Fetch an existing completed order by session ID.

Body: { "sessionId": string }

Response (200): { "order": MerchOrder } or { "order": null }

Share & Tracking Routes

POST /api/merch/share/create

Create a shareable result link.

Body: { "campaignId": string, "aiArtKey": string, "orderId?": string }

Response (201): { "shareId": string }

GET /api/merch/share/[shareId]

Fetch share details for public share page.

Response (200): { "share": { "id", "aiArtKey", "campaign": { ... } } }

POST /api/merch/tracking/record

Record a campaign visit + tracking events. Always returns 200 (never throws).

Body: { "campaignId": string, "slug?": string }

IP-deduplication for visits, time-gated tracking links, preview links excluded from counts.

POST /api/merch/journey-event

Record a journey analytics event. Always returns 200 (never throws).

Body: { "eventType": string, "eventData?": object, "merchSessionId?": string, "userSessionId?": string }