Skip to content

Suppliers + fulfillment

The two suppliers

SupplierCatalog scopeWebhook authCA fulfillment
GelatoApparel + non-apparel print-on-demand (mugs, posters, etc.) — ~177K variantsHMAC body signatureToronto hub (gln-toronto); Quote API computes per-variant
PrintfulEU/US apparel + non-apparelURL-key shared secret (no body signing — Postmark-Inbound pattern)Subset of catalog ships from CA-friendly fulfillment centers

Both suppliers share one architectural surface — SupplierVariantRaw is the local mirror, mapping tables hold the per-supplier axis codes, FulfillmentTask is the per-supplier order task. Differences are isolated to the provider modules under apps/api/src/modules/suppliers/providers/.

A single multi-supplier MerchOS order can produce N FulfillmentTask rows — one per supplier the cart routed to. Each task is submitted independently to its supplier’s API.

The local mirror

SupplierVariantRaw rows are upserted by nightly per-supplier sync crons:

  • RawSyncService (Gelato) — crawls Gelato’s catalog API; ~177K variants today.
  • PrintfulRawSyncService (Printful) — crawls Printful’s product API.

Both write the same row shape: productUid is the supplier-supplied stable key (PK), attributes is a JSONB blob with everything else (color, size, print method, prices, CA-availability — all the supplier-specific schema lives here). attributesHash is a SHA-256 of the canonical-JSON attributes; on resync, only rows whose hash changed get re-written. lastSeenAt is updated on every sweep; rows older than the current sweep’s start time are soft-deleted (deactivatedAt = now).

Sync is append-driven. You never hand-edit a SupplierVariantRaw row.

The RawStyle aggregation

A RawStyle represents a product in the supplier-agnostic sense — “Gildan 64000 unisex T-shirt.” Multiple suppliers can fulfill the same RawStyle; the linkage lives in RawStyleSupplierLink[rawStyleId, supplierCode, supplierExternalKey, variantProductUids[]].

Natural key:

  • Apparel → (brand, styleCode). Gelato + Printful both list Gildan 64000; the natural key collapses them into one RawStyle automatically.
  • Non-apparel → productUid. Printful’s “White Glossy Mug” id=19 and Gelato’s mug_white_glossy_oz_11 are NOT automatically collapsed — they create separate RawStyle rows. Cross-supplier non-apparel merge is operator-driven (deferred — see POST_DEPLOY_BACKLOG.md).

CA-fulfillability — the filter Joy actually uses

Most YCM customers are Canadian; their employees / shoppers need orders that fulfill from a CA hub (or ship to Canada efficiently). The Raw Catalog UI’s “Fulfilled in Canada” filter is one of the most-used surfaces.

Powered by the caFulfillableVariantCount column on the raw_catalog_styles matview. Computed from two JSONB paths:

  • Printful sideattributes #>> '{PrintfulCaAvailability,selling_regions,0,availability}' IN ('in stock', 'out of stock'). Populated by PrintfulRawSyncService during sync (Printful’s product API surfaces this inline).
  • Gelato sideattributes #>> '{GelatoCaAvailability,selling_regions,0,availability}' IN ('in stock', 'out of stock'). Populated by a separate worker (scripts/backfill-gelato-ca-availability.cjs operator-run + GelatoCaAvailabilityRefreshCron weekly Sunday 02:00 UTC) because Gelato’s catalog API doesn’t surface CA-fulfillability inline — it’s resolved via the Quote API per (style, color, size). ~21h to fully refresh 26K Gelato style groups at the rate-limit-safe 5 RPS.

Phase 0155 (2026-05-19) shipped this surface. Accuracy issue from earlier data (64% accurate per a spot-check) is being repaired by a full --force re-quote of the Gelato catalog.

Webhooks — the path of an order back from the supplier

Every supplier event MerchOS cares about routes through BillingController.webhook (Stripe) or FulfillmentService.handleWebhook (suppliers). Both verify auth, then fan out to per-purpose handlers that discriminate by metadata kind / supplier code.

Gelato webhooks

Body-signed via HMAC-SHA256 against a shared secret in GELATO_WEBHOOK_SECRET. Events MerchOS handles:

EventHandlerWhat it does
order_status_updatedFulfillmentService.handleGelatoStatusUpdateUpdates FulfillmentTask.status + tracking fields
shipment_dispatched(same)Captures carrier + tracking URL
shipment_delivered(same)Sets FulfillmentTask.deliveredAt

ADR-0050 (Proposed) tracks completion work — shipped/delivered customer emails + idempotency hardening + internal-admin event surface.

Printful webhooks

ADR-0085. No body signing. Webhook auth is a URL-key shared secret: register the webhook URL with ?key=<shared-secret> query param; the handler verifies the query param constant-time on receipt.

Same pattern as Postmark Inbound, surprisingly. If you’re new and this looks weird — read ADR-0085 §Notes for why this is actually the spec’d Printful auth pattern, not a YCM shortcut.

Events MerchOS handles:

  • order_updated — status transitions
  • package_shipped — sets FulfillmentTask.shippedAt + tracking
  • package_returned — flips status; operator follow-up

Webhook registration is API-only — no Printful dashboard config field. MerchOS calls POST /webhooks with auth Bearer <PRINTFUL_API_KEY> + body {url, types} at startup.

The webhook event router

Stripe webhooks fan out to four handlers in BillingController.webhook:

  • BillingService.handleSubscriptionWebhook — invoice events + subscription state.
  • BillingService.handleSetupFeeActivationWebhook — activation Stripe PI for setup-fee.
  • OrdersService.handleMemberPaidOrderWebhook — Phase 7a / MEMBER_PAYS.
  • OrdersService.handleSplitTenderOrderWebhook — Phase 0141 / split-tender.
  • AnonymousOrdersService.handlePaymentWebhook — anonymous shopper checkout.

Each handler discriminates via metadata.merchos_kind (or absence-of-it for anonymous) so they don’t collide. Adding a new handler means picking a new merchos_kind and adding it to the fan-out.

Multi-supplier routing — one cart, N supplier tasks

ADR-0084 (Phase 0129) made multi-supplier first-class. Pre-Phase 0129 there was a single Order.fulfillmentProvider column; one order = one supplier. Post-Phase 0129, FulfillmentTask is the per-supplier-per-Order row:

Order (top-level customer-facing row)
├── FulfillmentTask supplier=gelato status=SHIPPED trackingUrl=...
└── FulfillmentTask supplier=printful status=IN_PRODUCTION

Order.status aggregates from its tasks via MIN-status-rank — the slowest task wins. When all tasks reach SHIPPED, Order is SHIPPED. When one is SHIPPED and another is still IN_PRODUCTION, Order stays IN_PRODUCTION. Storefront customers see the package count + per-package tracking links in the success screen and the order-confirmation email (ADR-0084 Slice 5 §Decision 5: supplier-agnostic UI copy — customer sees “your order will arrive in 2 packages” not “Gelato + Printful”).

How a cart routes to suppliers

FulfillmentService.routeCart() decides per-OrderItem which supplier fulfills it. The decision walks:

  1. Read the Sku’s primarySupplierCode (Sku.supplierIdSupplier.code).
  2. For each OrderItem: look up the SkuColorChoice/SkuSizeChoice/SkuPrintOption axes the customer picked → resolve via the supplier mapping tables to a concrete supplier code triple.
  3. Look up the matching SupplierVariantRaw row by (supplierCode, attributes → tuple). The row’s productUid is what the supplier expects in their order submission API.
  4. If the primary supplier doesn’t carry the picked tuple, fall back to a configured alternate supplier (today: per-Sku; future: per-tuple).

Cross-supplier carts produce N FulfillmentTask rows — one per supplier the routing visited.

Variant cell state — the precomputed unfulfillable set

ADR-0115, Phase 0153. The order resolver short-circuits via SkuVariantCellState:

SkuVariantCellState[skuId, color, size, print, supplierId, kind]
kind = supplier_unavailable // supplier sync said this tuple isn't fulfillable
kind = manual_excluded // operator chose to exclude (e.g. seasonal)

Default-allow on empty: a Sku with no cell-state rows is assumed fully fulfillable. The supplier-sync hook + a 30-min cron + post-choice-edit hooks (Phase 0155 / 2026-05-19) keep this current. Storefront swatch + size buttons show as disabled (40% opacity, line-through) when the current cart pick would resolve unfulfillable.

Order shipping + tax

Both suppliers compute shipping via their respective Quote APIs at order-submit time. Gelato has 24h-TTL caching via FulfillmentQuoteService to amortize quote calls — same (productUid, country, quantity) tuple hits cache.

Tax — for anonymous-shopper orders (ONE_TIME_SETUP) — runs through Stripe Tax on the PaymentIntent. The MerchOS-side Order.taxCents is set at draft time from tax.calculations.create; on payment_intent.succeeded we record a tax.transaction so Stripe Tax’s filing reports are populated. Calculations expire after ~48h; abandoned-cart sweeper retires PENDING_PAYMENT orders well before that.

Tax for gated MONTHLY_SUBSCRIPTION orders is more nuanced — billing flow handles it on the company invoice. See Pricing models for the per-family breakdown.

What’s next

  • Storefronts + commerce (Chapter 8) — the visible-half walk: gated vs ungated, anonymous checkout, GivesBack.
  • Billing + Stripe (Chapter 10) — how all of this lands on the Company’s books.

Canonical sources

  • prisma/schema.prismaSupplier, SupplierVariantRaw, RawStyle, RawStyleSupplierLink, FulfillmentTask, FulfillmentQuote models.
  • apps/api/src/modules/suppliers/RawSyncService (Gelato) + PrintfulRawSyncService + provider modules + FulfillmentQuoteService + GelatoCaAvailabilityService.
  • apps/api/src/modules/fulfillment/FulfillmentService.routeCart + submitForOrder + webhook dispatch.
  • ADR-0068 — Gelato Raw Catalog mirror architecture.
  • ADR-0084 — Printful onboarding + cross-supplier curation.
  • ADR-0085 — Printful URL-key webhook auth (no body signing).
  • ADR-0050 — Gelato webhook completion (Proposed).
  • ADR-0080 — multi-supplier canonical identity rules.
  • ADR-0115 — SkuVariantCellState additive override.
  • ADR-0116 — mapping tables.
  • docs/services/suppliers.md — service-level deep read.
  • docs/services/fulfillment.md — service-level deep read on routeCart + webhook dispatch.
  • FIRST_ORDER_RUNBOOK.md — operator runbook including the 168-line Printful supplement.

Triggers for update

Update this chapter if you:

  • Add or retire a supplier.
  • Change the RawStyle natural-key rule (apparel: brand, styleCode; non-apparel: productUid).
  • Change the mapping-table shape (ADR-0116).
  • Add or change a webhook event type for either supplier.
  • Change the multi-supplier routing decision (FulfillmentService.routeCart).
  • Change the Order.status aggregation rule across FulfillmentTask rows.
  • Change CA-fulfillability data path (today: Printful inline at sync; Gelato via Quote API worker).
  • Move tax calculation between Stripe Tax and an in-house path.