An organizer wants to sell t-shirts that aren't included in the registration fee. This memo covers how registration platforms expose product configuration, how that maps onto WODsmith's existing commerce stack, and three build directions ranked by lift — with a recommendation that reuses almost everything already shipped.
Every registration platform surveyed (RunSignup, Eventbrite, Competition Corner, CrossFit Games/Open) ships one or both of the same two shapes:
| Pattern | What it is | When it wins |
|---|---|---|
| In-flow add-ons "order bump" |
Optional products offered inside the registration flow, added to the same checkout. Purchase requires registering. RunSignup is explicit: "Users cannot purchase an add-on item without registering for the race." | Most events. Registration is the one moment of guaranteed traffic and an already-open wallet. |
| Standalone store | A separate storefront on the event page, open to registrants and (optionally) the public, with its own cart and checkout. Registrants are still prompted toward it during registration. | Large events with spectator merch demand, post-registration sales windows, or many SKUs. |
RunSignup — the most mature implementation in the endurance-event space — offers both and treats the store as the advanced tier. The add-on system came first and covers the majority of organizer demand. Competition Corner (the closest functional-fitness competitor) and the CrossFit Open both follow the in-flow pattern: the Open sells its registrant-exclusive tee at registration time.
Distilling RunSignup's add-on and store config down to what organizers demonstrably use:
| Capability | Detail | Priority for v1 |
|---|---|---|
| Variants | Options per product (size, color) — each with its own inventory count, optionally its own price. Non-negotiable for apparel. | Required |
| Inventory caps | Total quantity + per-variant quantity. Sold-out variants hide or disable. | Required |
| Price | Flat price per product (variant price overrides are a nice-to-have). | Required |
| Quantity limits | Max per registrant / per transaction. | Cheap, do it |
| Availability deadline | "Order by X date" cutoff (RunSignup's "Available Until"). The dominant pattern for guaranteed merch — "register by June 1 to get a shirt" — because the real constraint is the print shop's order deadline, not stock on hand. Doubles as honest urgency copy in the order bump. | Required |
| Post-registration editing | Registrants buy or change add-ons after registering (RunSignup gates this by a time window). | v2 |
| Custom fields | Free-text personalization per item (name on jersey). | Skip |
| Fulfillment reporting | Counts-by-variant export for the print shop; pickup list for day-of. This is the organizer's real end goal — the purchase is only half the job. | Required |
E-commerce literature on checkout add-ons is consistent, and it favors exactly the low-lift version:
Three ways a Connect platform can model organizer products. This directly answers the earlier question
about detecting product.created in an organizer's Express account:
| Model | How it works | Verdict |
|---|---|---|
| A. Platform DB catalog products in your tables, inline price_data at checkout |
Products are rows in the platform database. Checkout sessions use inline price_data per line item — no Stripe Product objects at all. This is what Eventbrite/RunSignup-style platforms do, and it is exactly how WODsmith already charges registration fees. |
Industry default. Zero sync, full control of variants & inventory (Stripe has neither). |
| B. Stripe Products on the platform account | Mirror each product as a Stripe Product/Price on the platform account, reference price IDs at checkout. | Adds a sync layer and buys nothing — you'd still keep variants/inventory in your DB. Only worth it for subscriptions or Stripe Tax product codes. |
| C. Products in the organizer's connected account the product.created webhook idea |
Organizer creates products in their own Stripe dashboard; platform listens on a Connect webhook endpoint for product.created (the event carries event.account = acct_…) and mirrors them. |
Technically detectable, practically wrong here — see below. |
Connected-account catalogs don't compose with WODsmith's charge model.
WODsmith uses destination charges: the Checkout Session is created on the platform account
with transfer_data.destination. Line items on a platform session cannot reference Price objects
that live in a connected account — you'd have to read the connected product and re-create its price data
platform-side anyway, which is model A with extra steps and sync drift.
On top of that: Express dashboards have minimal catalog UI, Stripe Products have no variant or inventory
concept (every size would be a separate product the organizer hand-creates), and you inherit webhook-ordering
and deletion-sync problems. The product.created Connect event is the right tool for platforms built on
direct charges where the connected account is the merchant of record — not this one.
Shipping (addresses, rates, carriers, tax nexus) is where merch features balloon. Every comparable competition platform sidesteps it the same way: day-of pickup at the venue. The organizer needs two reports — an aggregate counts-by-variant sheet to send the print shop, and a per-athlete pickup list for the check-in table. WODsmith just shipped day-of check-in for in-person competitions, which is a natural home for "athlete has 1 × tee (L) to collect."
The codebase audit is unusually good news: the commerce layer was built with this in mind, and the hardest architectural problems (multi-line-item checkout, durable payment processing, money audit trail) are already solved and battle-tested by multi-division registration.
| Asset | Where | Relevance |
|---|---|---|
ADDON product type | src/db/schemas/commerce.ts — commerceProductTable.type enum: COMPETITION_REGISTRATION | ADDON | The product table anticipated this feature. resourceId + unique (type, resourceId) gives a slot to hang add-on config off. |
| Multi-line-item Checkout | registration-fns.ts → initiateRegistrationPaymentFn | Multi-division checkout already builds N line items with inline price_data and N commercePurchase rows per session. Add-ons are just more line items + more purchase rows. |
| Destination charges + fees | payment_intent_data.transfer_data.destination, application_fee_amount | Merch revenue flows to the organizer with the platform fee skimmed, with zero new Stripe plumbing. (Decide: same 2.5% + $2 on merch, or %-only.) |
| Durable payment workflow | src/workflows/stripe-checkout-workflow.ts on checkout.session.completed | Idempotency, capacity re-check + auto-refund, financial events. Needs one branch: ADDON purchases complete without creating a registration. |
| Financial events ledger | financial-events.ts (append-only) | Merch payments/refunds drop straight into the existing audit trail and payout reporting. |
| Per-competition settings JSON | competitions.settings + parseCompetitionSettings() | Ready-made home for a store.enabled toggle — no schema change for the flag itself. |
Entitlements (ADDON_ACCESS) | src/db/schemas/entitlements.ts | Optional: gate merch-selling as a paid platform feature per organizing team. |
Refunds w/ reverse_transfer | refundRegistrationFn (Express-only, row-locked, partial-capable) | Pattern copies directly to add-on refunds. |
| Sectioned registration form | components/registration/registration-form.tsx | An AddOnsSection slots cleanly between the division picker and the fee summary; FeeSummarySection already itemizes. |
commerceProductTable has a single priceCents and no options concept. T-shirts need sizes. This is the only real schema gap.registrationClosesAt (timezone-aware date cutoff) and division capacity (check-at-submit + re-check-at-webhook), which is exactly what stock-based add-ons need.Free divisions currently bypass Stripe entirely (registerForCompetition() direct). A free registration plus a paid add-on must route through the Stripe path. The mixed free/paid multi-division logic already handles this shape — extend, don't fork.
Coupon discounts currently apportion across all line items via a transient Stripe coupon on the session. Stripe session-level coupons apply to the whole session — either scope the discount calculation to registration line items only (compute a fixed-amount coupon off the registration subtotal), or explicitly decide merch is discountable. Default expectation: registration-only.
The webhook workflow auto-refunds a registration if the division filled while the buyer was in checkout. Decide whether the shirt refunds with it (probably yes — full-session refund keeps support simple) and make the workflow treat the session's purchases as a group.
Two athletes can checkout the last Large simultaneously. Copy the capacity pattern: soft check at submit, authoritative re-check inside the workflow with SELECT … FOR UPDATE, auto-refund the add-on (not the registration) on loss.
Reuse the competition-date pattern: store availableUntil as a YYYY-MM-DD string evaluated in the competition's IANA timezone (same as registrationClosesAt) — "available through June 1" means end-of-day June 1 in the comp's timezone, not UTC. Unlike stock, deadlines need no webhook re-check: availability is validated at checkout creation, and the Stripe session's existing 30-minute expiry bounds the race (someone who starts checkout at 11:58 PM and pays at 12:10 AM still gets the shirt — that's correct behavior, honor the commitment made at checkout time).
purchaseTransfersTable moves registrations between people. Merch should not transfer (buyer keeps the shirt). Organizer-initiated registration refunds should prompt — but not force — an accompanying merch refund.
| A · In-flow add-ons | B · Standalone store | C · Stripe-native | |
|---|---|---|---|
| Lift | Small — mostly reuse | Medium-large | Medium, poorly spent |
| Buyer reach | Registrants at registration | Anyone, anytime | Registrants |
| Conversion quality | Best (order-bump moment) | Lower per-visitor | Same as A |
| Variants/inventory | Native | Native | Not modeled in Stripe |
| New Stripe surface | None | Second session type | Connect webhook + sync |
| Shipping pressure | None (pickup) | High if public | None |
Option A, scoped hard: products + size variants + availability (deadline-mode default, stock optional), one organizer settings tab, one registration-form section, workflow branch, counts-by-variant report. Venue pickup only. settings.store.enabled toggle. Platform fee applies as on registrations (consider %-only, dropping the $2 flat per merch item).
Post-registration purchases: an add-ons page for registered athletes + pickup integration in day-of check-in. Reuses Phase 1's catalog and checkout path wholesale.
Public storefront (Option B) for spectator sales — and only then confront shipping, or keep pickup-only and ship nothing, literally.
The platform research and the codebase point at the same answer: in-flow add-ons with a
platform-owned catalog is both the industry-standard pattern and the smallest lift here,
because multi-division checkout already built the hard parts. The only new schema is products + variants;
the only genuinely new logic is inventory. Skip the Stripe-dashboard catalog — the
product.created webhook is the right tool for direct-charge marketplaces, not this one.