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, "startPage?": string, "role?": string, "ipAddress?": string, "userAgent?": string, "mode?": string }

  • campaignSlug: Optional. If omitted, creates a campaign-null journey session (for superstore entries). If provided, creates a campaign-bound session.
  • startPage: Optional. The originating URL of the session (used to route the fan back on "add another" actions).
  • mode: Optional. e.g., PREVIEW for carrying demo/test roles.

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

Note: Creates an analytics sidecar record only if campaignSlug is provided and resolved.

GET /api/merch/session/[id]

Fetch session by ID.

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

PATCH /api/merch/session/update

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

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

  • campaignSlug: Optional. If provided and it differs from the session's existing campaignId, it triggers campaign adoption:
    1. Validates the campaign is active and matches the environment.
    2. Sets campaignId to the adopted campaign's ID.
    3. Resets all campaign scratch fields (design, generation candidates, mask key, illustration keys) while preserving identity fields (email, selfie, shipping).
    4. Guarantees a merch_session_analytics row exists for this (session, campaign) combo.

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. When sessionId is provided, the extracted demographics (gender, ageGroup, peopleCount) are persisted on the session's active merch_selfie record (resolved via session.activeSelfieId).

Body: { "imageName": string, "sessionId?": string }

Response (200):

{
  "data": {
    "personDetected": true,
    "isBlurry": false,
    "isNudity": false,
    "isCelebrity": false,
    "gender": "female",
    "ageGroup": "20s",
    "peopleCount": 1
  }
}

gender/ageGroup are null when no person is visible. Demographic persist failures are logged but never fail the request. The demographics are used during AI generation to resolve the best-matching demographic sub-design (see Business Logic).

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. Same shape as /upload/verify; accepts an optional sessionId and persists extracted demographics on the session's active merch_selfie record.

Body: { "imageName": string, "sessionId?": string }

Response (200):

{
  "status": "success",
  "data": {
    "personDetected": true,
    "isBlurry": false,
    "isNudity": false,
    "isCelebrity": false,
    "gender": "male",
    "ageGroup": "30s",
    "peopleCount": 1
  }
}

Generation Routes

POST /api/merch/ai/generate

AI art generation with character consistency scoring, configurable quality tiers, and demographic sub-design resolution.

Body: { "sessionId": string, "designId?": string, "catalogProductId?": string, "productType?": string, "forceRegenerate?": boolean }

  • forceRegenerate: when true, bypasses the server-side generation cache (merch_session_generation) and forces a fresh generation. Use when the user explicitly clicks "Regenerate".
  • designId: selected top-level design ID from the fan flow. When omitted, the server auto-selects the first active design associated with catalogProductId, with a legacy productType fallback.
  • catalogProductId: selected catalog product ID used for design association and product-level config overrides.
  • productType: selected catalog product type (tshirt, hoodie, plaque, digital-img, general) used for fallback-chain config resolution.

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.

Generation flow (3-level hierarchy resolution):

  1. Design selection: explicit designId > auto-select by catalog product ID (listDesignsByCatalogProductId) > legacy product-type fallback > session fallback. Always resolves to a top-level design.
  2. Loads the active selfie (getActiveSelfie) to get demographics (gender, ageGroup)
  3. Generation cache: checks merch_session_generation for a cached result matching the (sessionId, effectiveDesignId, productType, selfieKey) tuple. On cache hit, updates the session's aiArtKey and aiArtCandidates in the DB and returns immediately.
  4. Calls resolveDesignConfig3Level(designId, productType, { gender, ageGroup }, fallbackChain, catalogProductId) which walks the 3-level hierarchy:
    • Level 1: Design base config (first config row on the top-level design — productType is ignored, treated as universal default)
    • Level 2: Product variation override (if one exists for the requested catalog product)
    • Level 3: Demographic variation override (if one exists for the selfie's gender/ageGroup)
  5. Extracts templateImageUrl, prompt, modelEndpoint, qualityTiers from the merged config
  6. AI model: Uses modelEndpoint from the design config as the primary endpoint; falls back to the priority model from merch_ai_model table only when absent.
  7. Runs one generation attempt per entry in qualityTiers
  8. Returns all candidates sorted by score (best first); aiArtKey = top-scored candidate
  9. All candidates stored in the session's aiArtCandidates
  10. 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/render

Render the active generated art onto a catalog product using the product's catalog_renderer_config. This is the current catalog-backed render path for apparel, plaque, and digital image products.

Body: { "sessionId": string, "catalogProductId": string, "cartItemId?": string, "fanName?": string }

Response (200):

{
  "previewFilename": "render-sess-1-cat-1-preview-1234.webp",
  "cleanFilename": "render-sess-1-cat-1-clean-1234.webp"
}

The route resolves session.aiArtKey through the media table, applies background removal for non-disabled renderer configs (reusing session.bgMaskKey when present), renders the product composite, uploads both outputs, registers media records, and stores the media filenames on the cart item when cartItemId is provided.

Legacy render routes

Older endpoints such as /api/merch/mockup/generate, /api/merch/garment/render, and /api/merch/plaque/render may still exist for backwards compatibility or admin tooling, but new shopper flows should use /api/merch/render.

Rendered shopper assets are delivered as media filenames and should be resolved through the media proxy. New code should not persist raw S3 URLs for render outputs.

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 }

GET /api/merch/history

List the authenticated user's completed orders and incomplete sessions (used by the /history page).

Response (200):

{
  "orders": [
    {
      "id": "…",
      "orderNumber": "…",
      "aiArtKey": "ai-art-sess-1.png",
      "aiArtPreviewKey": "attempt-1-1234-preview.jpg",
      "items": [ /* … */ ]
    }
  ],
  "incompleteSessions": [
    {
      "id": "…",
      "aiArtKey": "ai-art-sess-2.png",
      "aiArtPreviewKey": "attempt-1-5678-preview.jpg",
      "selfieKey": "…"
    }
  ]
}

Each entry is enriched with aiArtPreviewKey, resolved by scanning the origin session's aiArtCandidates for the candidate matching aiArtKey and returning its watermarked previewKey. The client renders thumbnails from aiArtPreviewKey ?? aiArtKey, so history thumbnails stay watermarked (with a graceful fallback to the clean key when the origin session has expired or has no candidate list).