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.
List all active campaigns.
Response (200):
{ "campaigns": [{ "id": "...", "slug": "...", "name": "...", "talentName": "...", "status": "LIVE" }] }
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
Campaign lifecycle state for polling.
Response (200): { "status", "shutdownMode", "shutdownStartedAt", "shutdownEndsAt" }
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().
Fetch session by ID.
Response (200): { "data": MerchSession }
Update session fields. Triggers analytics step tracking when currentStep or lastRoute changes.
Body: { "sessionId": string, ...fields }
Response (200): { "data": UpdatedSession }
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.
Verify an uploaded image using Gemini vision AI.
Body: { "imageName": string }
Response (200): { "data": { "personDetected": bool, "isBlurry": bool, "isNudity": bool, "isCelebrity": bool } }
Get a presigned S3 URL for direct image upload.
Body: { "filename": string, "contentType": string }
Response (200): { "url": string, "key": string }
Alternative image verification endpoint (face detection).
Body: { "imageKey": string }
Response (200): { "data": VerificationResult }
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.
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)
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).
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.
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.
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:
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.stripe_payment DB record (payFor: "MERCH", payForId: sessionId, buyerUserId: guestUserId, sellerAccountId from campaign)merch-pi-{paymentId} and metadata { stripePaymentId, merchSessionId }paymentIntentId (Stripe PI ID) and stripePaymentDbId (internal ID) on the sessionSingle 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:
stripePaymentDbId from the session (set during PI creation)completePaymentFromClient(stripePaymentDbId) from @zooly/srv-stripe-payment — verifies PI at Stripe, creates share tracking records, marks payment SUCCEEDEDcreateOrGetMerchOrderFromSession, passing session.userId (guest identity) to associate the order with the buyerDemo mode flow (ppuCode === "DEMO_SKIP_PAYMENT"):
session.role === "tester"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.
Fetch an existing completed order by session ID.
Body: { "sessionId": string }
Response (200): { "order": MerchOrder } or { "order": null }
Create a shareable result link.
Body: { "campaignId": string, "aiArtKey": string, "orderId?": string }
Response (201): { "shareId": string }
Fetch share details for public share page.
Response (200): { "share": { "id", "aiArtKey", "campaign": { ... } } }
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.
Record a journey analytics event. Always returns 200 (never throws).
Body: { "eventType": string, "eventData?": object, "merchSessionId?": string, "userSessionId?": string }
On This Page
OverviewCampaign RoutesGET /api/merch/campaignsGET /api/merch/campaign/[slug]GET /api/merch/campaign/[slug]/lifecycleSession RoutesPOST /api/merch/session/createGET /api/merch/session/[id]PATCH /api/merch/session/updateUpload RoutesPOST /api/merch/uploadPOST /api/merch/upload/verifyPOST /api/merch/upload/presigned-urlPOST /api/merch/verify-imageGeneration RoutesPOST /api/merch/ai/generatePOST /api/merch/ai/selectPOST /api/merch/mockup/generatePOST /api/merch/plaque/renderCheckout RoutesPOST /api/merch/calc-pricePOST /api/merch/create-payment-intentPOST /api/merch/order/completePOST /api/merch/order/by-sessionShare & Tracking RoutesPOST /api/merch/share/createGET /api/merch/share/[shareId]POST /api/merch/tracking/recordPOST /api/merch/journey-event