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, "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.
Fetch session by ID.
Response (200): { "data": MerchSession }
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:
campaignId to the adopted campaign's ID.merch_session_analytics row exists for this (session, campaign) combo.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. 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).
Get a presigned S3 URL for direct image upload.
Body: { "filename": string, "contentType": string }
Response (200): { "url": string, "key": string }
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
}
}
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):
designId > auto-select by catalog product ID (listDesignsByCatalogProductId) > legacy product-type fallback > session fallback. Always resolves to a top-level design.getActiveSelfie) to get demographics (gender, ageGroup)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.resolveDesignConfig3Level(designId, productType, { gender, ageGroup }, fallbackChain, catalogProductId) which walks the 3-level hierarchy:
productType is ignored, treated as universal default)templateImageUrl, prompt, modelEndpoint, qualityTiers from the merged configmodelEndpoint from the design config as the primary endpoint; falls back to the priority model from merch_ai_model table only when absent.qualityTiersaiArtKey = top-scored candidateaiArtCandidatesdelayedEmail is set on sessionSelect 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)
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.
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.
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 }
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).
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/renderLegacy render routesCheckout 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-eventGET /api/merch/history