WODsmith · Internal research memo June 11, 2026

Selling merch through
registration.

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.

01

Research findings

The industry converged on two patterns — and they're not competitors

Every registration platform surveyed (RunSignup, Eventbrite, Competition Corner, CrossFit Games/Open) ships one or both of the same two shapes:

PatternWhat it isWhen 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.

The configuration surface that actually matters

Distilling RunSignup's add-on and store config down to what organizers demonstrably use:

CapabilityDetailPriority for v1
VariantsOptions per product (size, color) — each with its own inventory count, optionally its own price. Non-negotiable for apparel.Required
Inventory capsTotal quantity + per-variant quantity. Sold-out variants hide or disable.Required
PriceFlat price per product (variant price overrides are a nice-to-have).Required
Quantity limitsMax 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 editingRegistrants buy or change add-ons after registering (RunSignup gates this by a time window).v2
Custom fieldsFree-text personalization per item (name on jersey).Skip
Fulfillment reportingCounts-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

Order-bump UX: what converts

E-commerce literature on checkout add-ons is consistent, and it favors exactly the low-lift version:

10–30%
of cart total is the sweet-spot price for an add-on. A $25–35 tee on a ~$100+ registration sits right in band.
+30%
average-order-value increase reported by SamCart across sellers using order bumps (their $7B+ transaction corpus).
1 click
Add-to-order must be a single tap inside the existing flow — no redirect, no second checkout, no new form.

Where should the product catalog live? (the Stripe question)

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:

ModelHow it worksVerdict
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.

Fulfillment: the scope cliff to avoid

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."

02

Translation to WODsmith

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.

What already exists

AssetWhereRelevance
ADDON product typesrc/db/schemas/commerce.tscommerceProductTable.type enum: COMPETITION_REGISTRATION | ADDONThe product table anticipated this feature. resourceId + unique (type, resourceId) gives a slot to hang add-on config off.
Multi-line-item Checkoutregistration-fns.tsinitiateRegistrationPaymentFnMulti-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 + feespayment_intent_data.transfer_data.destination, application_fee_amountMerch 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 workflowsrc/workflows/stripe-checkout-workflow.ts on checkout.session.completedIdempotency, capacity re-check + auto-refund, financial events. Needs one branch: ADDON purchases complete without creating a registration.
Financial events ledgerfinancial-events.ts (append-only)Merch payments/refunds drop straight into the existing audit trail and payout reporting.
Per-competition settings JSONcompetitions.settings + parseCompetitionSettings()Ready-made home for a store.enabled toggle — no schema change for the flag itself.
Entitlements (ADDON_ACCESS)src/db/schemas/entitlements.tsOptional: gate merch-selling as a paid platform feature per organizing team.
Refunds w/ reverse_transferrefundRegistrationFn (Express-only, row-locked, partial-capable)Pattern copies directly to add-on refunds.
Sectioned registration formcomponents/registration/registration-form.tsxAn AddOnsSection slots cleanly between the division picker and the fee summary; FeeSummarySection already itemizes.

What's genuinely missing

WODsmith-specific edge cases to design for

Edge case — free registration, paid shirt

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.

Edge case — coupons must not discount merch

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.

Edge case — capacity auto-refund

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.

Edge case — inventory races

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.

Edge case — deadline semantics & timezone

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).

Edge case — registration transfers & refunds

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.

03

Build approaches

B
Standalone store
/compete/$slug/store · separate cart & checkout
Phase 2/3

A storefront page on the public competition site with its own cart and its own Checkout Session, open to registrants and optionally the public. This is RunSignup's "Race Store" tier.

Lift:

Pros

  • Spectator/coach sales; post-registration purchases for free
  • Decoupled from registration conversion risk entirely
  • Same catalog tables as Option A — it's additive, not a rewrite

Cons

  • New surfaces: cart state, guest/identified checkout, a second webhook purchase path, store page design
  • Public buyers may expect shipping — the scope cliff
  • Research says most organizer demand is satisfied by in-flow add-ons; store traffic without registration intent is small for local comps

Sequencing note: a cheap intermediate exists — a "buy add-ons" page gated to already-registered athletes (reachable from their registration confirmation/dashboard). That captures "I changed my mind, I do want the shirt" without guest checkout or shipping.

C
Stripe-native catalog
organizer manages products in their Express dashboard · Connect webhook sync
Rejected

The "detect product.created in the organizer's account" idea: let organizers create products in Stripe, mirror them into WODsmith via a Connect webhook endpoint (event.account identifies the organizer), render them in the flow.

Lift: — but the lift buys complexity, not capability

Apparent appeal

  • No product-CRUD UI to build
  • Organizer "owns" their catalog in Stripe

Why it loses

  • Connected-account Prices can't be used on platform-account destination-charge sessions — you mirror to inline price_data anyway
  • Stripe Products have no variants or inventory; each size = a hand-made product
  • Express dashboard catalog UI is minimal; organizers (gym owners) get a worse editor than a native tab
  • New failure modes: sync drift, deletes, webhook ordering, second endpoint + secret
  • No competition context (which comp is this shirt for?) without metadata conventions organizers must follow by hand

Side-by-side

A · In-flow add-onsB · Standalone storeC · Stripe-native
LiftSmall — mostly reuseMedium-largeMedium, poorly spent
Buyer reachRegistrants at registrationAnyone, anytimeRegistrants
Conversion qualityBest (order-bump moment)Lower per-visitorSame as A
Variants/inventoryNativeNativeNot modeled in Stripe
New Stripe surfaceNoneSecond session typeConnect webhook + sync
Shipping pressureNone (pickup)High if publicNone

Recommended path

Phase 1 the organizer's t-shirt, soon

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).

Phase 2 if athletes ask

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.

Phase 3 only on demonstrated demand

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.