PRD: Subscription Engine for BigCommerce
Status: Draft v0.6 Owner: Nino Chavez Last updated: 2026-06-27
Changelog
- v0.6 (2026-06-27) — Governance-hygiene reconcile (synthesis #1818): header version corrected v0.2 → v0.6 to match changelog reality; Last-updated refreshed; the §6.3 Gateway Support Matrix changelog reference below fixed (was mislabeled §6.4 — the body has it at §6.3). Folds in the BC-platform shape corrections from the same research-red-team wave (synthesis #1814): removed the fabricated
CATEGORIESvalue from the App-Extension model enum. - v0.5 (2026-05-16) — Corrected BC Payments backend identification: PPCP-powered (PayPal Commerce Platform; not Braintree-direct), per ADR-0037 + memory
bc-payments-mit-verified. Architectural impact: zero (always pointed to right endpoint); descriptive accuracy: improved. Routes §1 Executive Summary + §3 Principles to match §6.2 Path A framing. - v0.3 (2026-04-21) — Added §16 Architectural Alternatives Considered (Stripe Billing weighed head-to-head against native engine) and §6.3 Gateway Support Matrix covering the full BC gateway universe.
- v0.2 (2026-04-21) — BC Payments (launched March 2026, powered by PayPal/Braintree) promoted from Phase 3 to MVP-class processor; payment-vault constraint rewritten; competitive positioning sharpened around single-dashboard unification.
- v0.1 (2026-04-21) — Initial draft.
1. Executive Summary
A BigCommerce-native subscription management platform installed as a marketplace app. Merchants enable subscriptions on existing BC products without duplicating catalog, orders flow through BC's native order system, renewal charges flow through BC Payments (the first-party PPCP-powered (PayPal Commerce Platform) rail launched March 2026) or Stripe, and a dedicated billing engine handles scheduling, dunning, and customer self-service. MVP targets parity with Recharge's core flows on BigCommerce while differentiating on three axes: BC-specific depth (App Extensions, Price Lists, Customer Groups, B2B Edition, Multi-Storefront), operational transparency (every recurring charge is observable, replayable, auditable), and direct integration with BC Payments — single admin dashboard, single payout flow, single payment relationship. Other BC subscription apps today run the payment relationship through a separate Stripe/Braintree account outside BC Payments.
2. Market Context
2.1 Competitive Landscape
| Solution | Positioning | Platform Binding | Billing Engine | Catalog Model | Main Weakness |
|---|---|---|---|---|---|
| Recharge | Shopify-dominant, "Subscribe & Save" SMB/mid-market | Shopify-first, BC via Headless | Own engine + processor adapters (Stripe, Braintree, Authorize.net); BC Payments not a supported gateway (source) | Variant-level attribute | No App Extensions integration (Script Manager injection only — integration overview); price lists incompatible; no Multi-Storefront on BC Checkout Integration; separate processor required |
| Ordergroove | Enterprise retention/reactivation | Multi-platform (SFCC, BC, Magento, custom) | Own engine | Flexible model with predictive offers | Enterprise-only pricing, heavy integration |
| Stripe Billing | Payment-first subscription primitive | Platform-agnostic | Native (Stripe) | Plans/prices in Stripe, not in commerce | No catalog/fulfillment awareness — you build the commerce glue |
| Magento Commerce Subs | Native module | Magento only | Vault-based, gateway-specific | Product attribute | Limited customer self-service |
| SAP Commerce Cloud Subs | Enterprise B2B/B2C | SAP only | Native, ERP-integrated | Plans + entitlements + usage | Implementation complexity, $$$ |
2.2 The Gap in BigCommerce
BC merchants today pick between:
-
Recharge on BC — no App Extensions integration (documented BC integration uses Script Manager injection and product modifier manipulation only; no BC control panel App Extensions on Orders, Products, or Customers pages referenced in any Recharge integration documentation — Recharge integration overview); customer-group price lists explicitly incompatible with the Recharge Checkout (Recharge BC feature compatibility); no Multi-Storefront support on the BigCommerce Checkout Integration (Recharge BC Checkout feature status); requires a separate Stripe, Braintree, or Authorize.net relationship — BC Payments is not a supported gateway on any Recharge BigCommerce integration path (Getting started: Next steps).
Unverified — confirm before citing externally: Search snippets attributed to docs.getrecharge.com indicate Recharge ended active BigCommerce support December 31, 2025 with full EOL October 2026. Static page fetch did not surface this text (consistent with a dynamic banner). Browser-render the canonical URL to confirm. If confirmed, the integration gaps above are secondary — the primary finding becomes: Recharge is sunsetting BigCommerce entirely.
-
Stripe Billing + custom glue — low platform fit, merchant operates two order systems and two payment relationships.
-
PayWhirl / Rebillia / MINIBC / Subscrimia — available on BC but architecturally diffuse: PayWhirl is Shopify-first by product architecture (dedicated Shopify app with Built-for-Shopify certification; BC handled under a multi-platform catch-all shared with Wix, Squarespace, and WordPress — PayWhirl BC setup); PayWhirl's BC-native customer account interface currently lacks a subscription section — customers manage subscriptions at a separate PayWhirl-hosted URL (PayWhirl's own docs: "the customer portal for BigCommerce doesn't yet have a subscription section"). Rebillia's entry tier ($49/mo, capped at 100 subscriptions) provides only a basic customer portal with no analytics; paid tiers ($180+/mo) unlock MRR, churn, LTV, and subscriber-growth dashboards and a full self-service portal (Rebillia pricing, Rebillia BigCommerce). PayWhirl, Rebillia, and MINIBC each require a separate processor relationship with no documented integration with BigCommerce Payments (PayWhirl gateway docs, MINIBC gateway list); Subscrimia's BC Payments compatibility has not been independently verified.
-
Build in-house — common at mid-market, but every merchant rebuilds the same billing engine.
There is no subscription product that is BC-first, mid-market-ready, and unified with BC Payments. That is the gap — and BC Payments' launch on March 31, 2026 made the gap demonstrably larger: every subscription app verified in this competitive scan — Recharge, PayWhirl, Rebillia, and MINIBC — requires a separate Stripe, Braintree, or Authorize.net processor relationship and has no documented integration with BC Payments, which launched with no published subscription or recurring billing support (BC Payments announcement). Merchants adopting BC Payments for their primary checkout today have no confirmed subscription app running inside that same payment relationship. We close that gap.
3. Strategic Positioning
Five pillars that define the product:
- BC-native depth. App Extensions on Orders, Products, and Customers pages. Price Lists drive subscription-only pricing. Customer Groups segment subscribers. Multi-Storefront/channel-aware from day one. B2B Edition support (approval workflows, purchase orders) by Phase 3.
- Orders are BC orders. Payments are BC Payments. Every recurring charge produces a native BC order and (for BC Payments merchants) settles through the merchant's BC Payments account into their BC Money dashboard. Inventory decrements, shipping engines, tax services, warehouse integrations, payouts, and reconciliation work through BC's existing surfaces rather than a parallel system. Other BC subscription apps today run the payment relationship through a separate Stripe/Braintree account outside BC Payments — a structural difference driven by product age, not ability.
- Primary processor is BC Payments; Stripe is the alternate path. Reversed from v0.1. BC Payments (PPCP rails underneath) ships as the primary processor adapter because it removes an onboarding step for merchants already on BC Payments — no separate Stripe Connect signup, no second payout, no second dashboard. Stripe remains a fully-supported adapter for merchants already on Stripe who do not plan to switch.
- Operationally observable. Every scheduled charge, retry, webhook, and order creation has a replayable audit record. Merchants can see why a renewal failed without opening a support ticket.
- Headless-ready from MVP. Customer portal ships as both (a) hosted BC-iframe widget and (b) headless SDK for Catalyst / custom storefronts. No retrofit later.
4. Non-Goals (MVP)
Declaring these up front to bound scope:
- Not a general billing platform. No standalone invoicing, no B2B SaaS billing, no general-purpose metering-as-a-service. Usage-based subscription pricing (consumption × rate, US-6.6) is in scope; arbitrary billing-platform metering is Stripe Billing's job.
- Not a loyalty / rewards engine. Subscriptions may earn rewards through third-party loyalty apps; we do not build the rewards system.
- Not a CRM or marketing automation platform. We emit events; Klaviyo/Mailchimp/Ortto consume them.
- Not a custom checkout. MVP uses BC's native checkout with a subscription-capture layer; we do not replace Optimized One-Page Checkout.
- Not a tax or shipping calculation engine. We delegate to BC's native tax (BC + Avalara/TaxJar) and shipping rules.
- Not a payment processor. We integrate with processors; we do not store PCI-scope card data ourselves.
5. Personas
5.1 Merchant Admin (primary buyer + daily user)
- Runs a BC store doing $1M–$50M GMV
- Sells CPG, vitamins, coffee, pet food, or consumables
- Has existing BC catalog and payment processor — usually BigCommerce's auto-enabled PayPal-powered-by-Braintree default, with Stripe common upmarket and the rest a long tail (the precise processor split is an internal [assumption], not sourced market data — see #1581)
- Pain: Recharge's BC port feels bolted on; reconciliation between Recharge orders and BC orders is painful; Shopify-speak in the UI doesn't match BC concepts.
- Success: "Every subscriber looks like a regular BC customer with a recurring order rhythm."
5.2 Subscriber (end consumer)
- Logged-in BC customer
- Expects: skip next order, swap a product, change cadence, pause, update payment, reschedule — all from one portal, zero support tickets
- Pain: most BC subscription portals are 2010-era tables
- Success: portal that feels native to the storefront brand
5.3 Developer (implementation partner + in-house)
- BC agency partner or mid-market in-house dev
- Needs: headless SDK, webhooks, stable API, migration path from legacy subscription apps
- Success: ships a custom subscriber portal in <2 weeks
5.4 Support / Ops (merchant side)
- Reconciles failed renewals, handles cancel requests, processes refunds
- Needs: one screen to see subscriber, upcoming charge, last 5 orders, payment method health, dunning state
- Success: resolves failed renewal without switching between 3 tools
6. Platform Constraints (BC Reality Check)
Honest inventory of what BC gives us and what it does not — this drives the architecture.
6.1 What BC provides that we use
- Admin APIs (V3/V2): Products, Variants, Customers, Orders, Carts, Price Lists, Customer Groups, Channels, Webhooks, Store Information.
- Storefront GraphQL API: product catalog, cart mutations — used by headless subscriber portal.
- App Extensions (GraphQL): PANEL or LINK context on PRODUCTS, PRODUCT_DESCRIPTION, ORDERS, and CUSTOMERS — BC's documented model enum is exactly these four. Neither CARTS nor CATEGORIES is a supported model; cart-side merchant UX must use a different surface (Stencil widget, Storefront Scripts API, or a customer-page deep-link).
- Webhooks (registered):
store/order/created,store/order/updated,store/cart/created,store/cart/updated,store/cart/deleted,store/customer/updated,store/product/updated,store/product/deleted.store/app/uninstalledis auto-fired by BC on uninstall and does not require explicit subscription registration. - Custom Fields + Metafields: Attach subscription configuration to products.
- Price Lists: Subscription-only pricing (e.g., "Subscribe & Save 10%").
- Customer Groups: Segment subscribers for pricing, shipping, tax rules.
- Channels / Multi-Storefront: Scope subscriptions to specific sales channels.
- Scripts API / Stencil: Inject subscription widgets on PDP/cart.
- B2B Edition APIs (Phase 3): Company accounts, buyer hierarchies, approval workflows.
6.2 What BC does not provide — and how we work around it
The payment vault landscape (vault-based rail per ADR-0037).
BC's stored-instruments vault is a gateway-agnostic recurring-charge rail (ADR-0037): any BC-supported gateway with "Stored Payments" enabled routes charges through payments.bigcommerce.com, with no gateway-specific tokenization SDK required in our subscription engine. At standard Stencil/Catalyst checkout, the shopper enters card data into BC's hosted checkout UI; BC/BigPay internally sets vault_payment_instrument: true on the payment request, vaults the card with the provider, and returns an opaque bigpay_token. Our app never receives raw card data and makes no Stored Instruments API call at checkout — we harvest the vault token via GET /v3/orders/{id}/transactions in the store/order/created webhook handler. (Instrument Access Token (IAT) direct-entry applies only to our portal add-payment-method flow — BRD US-19.1 — where the shopper adds a new card from within our portal UI.) Three charge paths remain, differing by whether the merchant uses BC's vault or a direct-processor connection:
Path A — BC stored-instruments vault (primary MVP path, canonical per ADR-0037).
Merchant uses any BC-supported gateway with "Stored Payments" enabled (BC Payments/PPCP, Braintree, Auth.Net, and others). Card-on-file is vaulted at checkout by BigPay — when the shopper ticks "save for future use", BC's hosted checkout UI sets vault_payment_instrument: true on the BigPay payment request. BigPay executes a vault-and-pay (or pay-and-vault, or vault-via-notification) flow with the provider, stores provider tokens, and returns a bigpay_token. Our app harvests this token from GET /v3/orders/{id}/transactions in the store/order/created webhook — no IAT, no direct Stored Instruments API call from our app at checkout. (The scopes store_payments_access_token_create and store_stored_payment_instruments power our S2S MIT renewals and the portal add-PM flow respectively — not checkout vaulting.) For recurring off-session renewals, we charge the stored instrument via:
- Canonical three-call sequence: (1)
POST /v3/payments/access_tokensto mint a Payment Access Token (is_recurring: true); (2)GET /v3/payments/methods?order_id=Nto discover the stored-instrument token; (3)POST payments.bigcommerce.com/stores/{hash}/paymentsto execute the charge. Charges are auto-classified MIT by BC's gateway abstraction (MRECflag for recurring renewals — see §6.3.1). Validated by Day-0 spikeeb94cf4aagainst internal Confluence + Wren Laboratories production telemetry. - Fallback (capability, never primary): direct Braintree SDK charge using the Braintree payment-method token surfaced via Stored Instruments. Maintained as adapter capability for edge cases; not a documented production target.
Worldpay/Paymetric NTI note (non-blocking per ADR-0037): BC's network_transaction_id threading has a gap for Worldpay/Paymetric merchants (~4358 instruments empty across top 3 stores). Tracked by PI-5062. This does NOT block standard merchant adoption — BC Payments/PPCP, Braintree, and Auth.Net work without it. Until PI-5062 closes, our adapter persists NTI from the CIT response and re-attaches on every MIT for Worldpay/Paymetric stores (see PRD-COMPANION D17).
Settlement lands in the merchant's BC-connected gateway account and surfaces in BC's native reporting. Zero external processor relationship for vault-path merchants. This is the differentiator.
Path B — Stripe (alternate MVP path).
Merchant is on Stripe — a material secondary gateway in our target segment, with first-class off-session/MIT primitives. We onboard them via Stripe Connect, vault the PM at Stripe (payment_method attached to a Stripe Customer), and charge Stripe directly on renewal with an idempotency key. Settlement lands in the merchant's Stripe account, not BC Payments.
Path C — Other (Phase 2). Braintree standalone, Authorize.net, adyen, etc. Clean adapter interface already exists once Paths A + B are proven.
The data model rule is the same across paths: subscription holds a payment_method_ref + processor_connection_id. The adapter abstracts gateway specifics. On successful charge, we create the BC order with payment_status: captured and the processor transaction ID.
Multi-phase capture timing (ADR-0038). The three-call charge sequence above is atomic (authorization + capture in one call) in Phase 1 — correct for digital goods and immediate-fulfillment merchants. Physical-goods merchants (EU PSD2 compliance, warehouse deferred-capture workflows) need capture deferred to shipment or fulfillment confirmation. Phase 1 ships a stores.capture_timing column (immediate | on_fulfillment | on_ship, default immediate) as advisory configuration with no behavior change. Phase 2 wires the actual deferred semantics by splitting adapter.charge() into adapter.authorize() + adapter.capture(), with store/shipment/created and store/order/statusUpdated webhook handlers driving capture events, and an auth-expiry sweep reaping stale authorizations nearing gateway auth-window limits. See ADR-0038 for the full deferred-build spec; see STATE.md R-12/R-13 for the open compliance risk until Phase 2 ships. BRD US-10.9 + US-2.7 track the user-story AC.
Why Path A is the primary MVP path (reversed from v0.1):
- New BC merchants signing up in 2026+ default to BC Payments — lowest install friction.
- Existing PPCP merchants have a BC-driven migration path to BC Payments; we ride that wave.
- Eliminates Stripe Connect onboarding step from our own install flow.
- Single payment dashboard is a concrete UX differentiator over every competitor on BC (none of which currently integrate with BC Payments).
Other gaps:
| BC Gap | Our Approach |
|---|---|
| No native recurring order model | Store subscription state in our Postgres; generate BC orders on schedule |
| No native subscription product type | Product becomes "subscribable" via metafields + Price List assignment |
| No native dunning / retry logic | Our billing engine owns retry state machine |
| No native pause/skip primitive | Our domain model; exposes via API + portal |
| No native subscriber portal | We provide both hosted and headless |
| Orders API can't attach "recurring" metadata natively | Use order custom_fields + order metafields to tag source subscription |
| No native prepaid / fixed-term subscriptions | Modeled as pre-computed charge schedule in our engine |
6.3 Gateway Support Matrix
Note (synthesis 7574bb48-c8f0-4943-aed4-19ecb2e5584e): §6.3.1 below is the new "Stored Credential Indicators" subsection added by this synthesis. The gateway support matrix that follows is unchanged.
BC supports ~65 gateways. Not all expose the primitives a subscription engine needs (vault, MIT/off-session charge, stable webhooks, refund API). BC's own Stored Instruments API is gateway-gated — per BC docs: "Not all gateways support processing a payment using both stored payment instruments and raw card data." Current BC docs specifically name PayPal Powered By Braintree and PayPal (Commerce Platform) as vault-capable for stored PayPal accounts via this API.
Our explicit support posture:
| Gateway | Region | Vault | MIT / Off-session | Our MVP | Notes |
|---|---|---|---|---|---|
| BC Payments (PayPal/Braintree) | US (expanding) | Yes, via BigPay vault layer + Braintree provider tokens; managed S2S via Stored Instruments API | Yes (via Braintree; BC API surface TBC — see §13) | Yes (primary) | Only path unified with BC Money dashboard |
| Stripe | Global | Yes (Stripe Customer + PaymentMethod) | Yes (PaymentIntent off_session:true) |
Yes (alt) | Material secondary gateway; first-class off-session/MIT |
| Braintree (standalone, non-BC-Payments) | Global | Yes | Yes (Braintree Transaction API) | Phase 2 | Redundant for BC Payments merchants; needed for legacy Braintree merchants who haven't migrated |
| Authorize.net | US/CA | Yes (CIM) | Yes | Phase 2 | Long tail of older merchants |
| Adyen | Global (strong EU) | Yes | Yes | Phase 2 | Critical for EU mid-market; hold until Phase 2 because adapter work is non-trivial |
| Cybersource | Global | Yes | Yes | Phase 2 | Common in enterprise |
| Worldpay / FIS | UK/EU | Yes | Yes | Phase 2 | UK mid-market |
| Checkout.com | Global | Yes | Yes | Phase 2 | EMEA merchants |
| Square | US/AU/UK/CA | Yes (Cards API) | Limited | Phase 3 | Off-session posture less mature |
| eWAY | AU/NZ | Yes | Yes | Phase 3 | AU merchants |
| Klarna / Afterpay / Clearpay | Global | N/A (BNPL) | No (regulatory) | Never | Structurally incompatible with unattended recurring charges |
| Amazon Pay | US/EU/JP | No (session-bound) | No | Never | Not designed for MIT |
| Apple Pay / Google Pay (as wallet passthrough) | Global | Via underlying card | Depends on processor | Inherits from underlying processor | Wallet is a presentment layer, not an adapter |
| All other gateways | Various | Varies | Varies | Not supported | Document explicitly; merchant must switch processor to subscribe |
Merchant-facing consequence: during install, we detect active gateways on the store and show a compatibility badge. If the merchant's gateway is not supported, we offer three paths: (1) switch to BC Payments (one click if eligible), (2) add Stripe as a secondary processor, (3) decline install. No silent degradation.
6.3.1 Stored Credential Indicators
Card networks classify stored-credential transactions into subtypes. Each gets different fraud, SCA, and authorization-rate treatment. Sending the wrong subtype on a recurring renewal causes 3–8% decline-rate degradation and forfeits EU PSD2 RTS Art. 18(b) recurring exemption — 3DS challenges fire off-session and the charge declines.
BC's flag taxonomy (per Day-0 spike eb94cf4a, Confluence 2671869953):
| Cardholder-initiated | Merchant-initiated |
|---|---|
CSTO — initial vault |
MUSE — non-recurring MIT (one-off) |
CUSE — using stored card |
MREC — recurring renewal (our primary) |
CREC — initial subscription cycle (cardholder triggers) |
|
CINS — installments (not applicable in BC) |
|
CGEN — plain checkout |
Our usage:
- Default plan model (fixed-interval/fixed-amount): every renewal sends
MREC. Required for EU SCA exemption. - Usage-based plans (Phase 2+): unscheduled-COF subtype.
- Chain position: captured per-charge in
charges.chain_position('initial'|'subsequent'). Determines the indicator sent on each MIT. - Network transaction ID: persisted on
payment_methods.network_transaction_idfrom the CIT-first auth, re-attached on every subsequent MIT to anchor the recurring chain. Required for SCA exemption preservation across the chain.
The ProcessorAdapter.charge() signature carries this context per BRD US-10.2:
processorAdapter.charge({
idempotencyKey: string,
amount: Money,
paymentMethodRef: string,
mitContext: {
type: 'recurring' | 'unscheduled' | 'installment',
chainPosition: 'initial' | 'subsequent',
networkTransactionId: string | null // null only on chainPosition='initial'
}
})
Per-adapter mapping:
| Adapter | mitContext.type=recurring maps to |
|---|---|
| BC Payments | MREC flag in BigPay payload + persisted NTI re-attached |
| Stripe | payment_method_options.card.mit_exemption.recurring=true + previous_network_transaction_id |
| Braintree (standalone) | transactionSource: 'recurring' + externalVault.previousNetworkTransactionId |
6.4 Cart/Checkout capture constraint
BC's Optimized One-Page Checkout does not expose server-side hooks to capture "this item is a subscription" metadata. Two viable paths:
- Cart-level metafields: Storefront widget writes subscription intent into cart-level metafields (namespace:
bc-subscriptions, key:subscription_intents); thestore/order/createdwebhook handler fetches them viaGET /v3/carts/{cartId}/metafieldsand creates the subscription. (BC's cart API has no line-item-levelcustom_fieldsextension surface.) - Post-purchase hook: Thank-you page JavaScript posts subscription intent; resilient but not atomic with order creation.
MVP uses approach 1. Approach 2 exists as fallback for Stencil/legacy storefronts that cannot render the widget in-cart.
7. Architecture
7.1 App Type
Marketplace-installed app (following the ask-bc / aisles-admin pattern):
- OAuth 2.0 install → Upstash Redis–backed encrypted credential store
- Control-panel iframe via
signed_payload_jwton load - App Extensions registered via GraphQL on install (Orders, Products, Customers panels)
- Webhook subscriptions registered on install
- Uninstall revokes credentials and deregisters webhooks
7.2 Runtime Layout
┌──────────────────────────────────────────────────────────────────┐
│ Merchant UI (BC Admin iframe) Subscriber Portal │
│ Next.js + BigDesign Next.js + headless SDK │
│ /stores/[storeHash]/* /portal/* │
└──────────────────────────────────────────────────────────────────┘
│ │
└──────────────┬───────────────┘
│
┌──────────────────────────────▼───────────────────────────────────┐
│ API Layer (Next.js API routes on Vercel Fluid Compute) │
│ - OAuth, session, App Extension load │
│ - Merchant CRUD (plans, subs, exceptions) │
│ - Subscriber portal API │
│ - Webhook receivers (BC → us, processor → us) │
└──────────────────────────────┬───────────────────────────────────┘
│
┌──────────────────────────────▼───────────────────────────────────┐
│ Billing Engine (Vercel Queues + Cron) │
│ - Scheduler: nightly scan for charges due in next 24h │
│ - Executor: charge processor → create BC order → emit events │
│ - Dunning: tunable retry state machine (default: 5-stage) │
│ - Reconciliation: detect drift between us ↔ BC ↔ processor │
└──────────────────────────────┬───────────────────────────────────┘
│
┌──────────────────────────────▼───────────────────────────────────┐
│ Data Layer │
│ Postgres (Neon): subscriptions, plans, charges, events │
│ Redis (Upstash): BC credentials, idempotency keys, rate limits │
│ Vercel Blob: subscriber-facing exports (CSV) │
└──────────────────────────────────────────────────────────────────┘
│
┌──────────────────────────────▼───────────────────────────────────┐
│ External Systems │
│ BigCommerce APIs (V2/V3/GraphQL/Storefront) │
│ Stripe (MVP) / Braintree / Auth.net (Phase 2) │
│ Email (Resend) / SMS (Twilio) │
└──────────────────────────────────────────────────────────────────┘
7.3 Stack (matches existing workspace conventions)
- Framework: Next.js 16 App Router (matches
aisles-adminprecedent — React 19, BigDesign v2) - Language: TypeScript 5
- UI (admin):
@bigcommerce/big-designv2,tw-prefixed Tailwind - UI (portal): Plain Tailwind, headless components, brand-theme–driven
- Data: Neon Postgres +
@neondatabase/serverlessdriver - Cache/ephemeral: Upstash Redis
- Jobs: Vercel Queues + Vercel Cron (durable, at-least-once)
- Auth: BC signed_payload_jwt → internal JWT (jose) with partitioned cookies (iframe)
- Env:
@t3-oss/env-nextjs+ Zod v4 validation - Config:
vercel.ts(per current platform guidance)
8. Data Model
8.1 Core Entities
<!-- traceability:start:PRD:8.1 --><!-- traceability:end:PRD:8.1 -->Prototype: Membership · Product Panel · Plan Wizard · Plan Activated
Store one per installed BC store
store_hash (PK)
access_token_encrypted
installed_at, uninstalled_at
settings (JSONB: default_retry_policy, currency, timezone, notification_prefs)
Processor Connection one-to-many store → processor
id (PK)
store_hash (FK)
processor (enum: stripe, braintree, authnet, bc_payments)
account_ref (Stripe account, Braintree merchant, etc.)
credentials_encrypted
is_default
Plan subscription configuration template
id (PK)
store_hash (FK)
bc_product_id link to BC product
bc_variant_id (nullable) null = all variants eligible
name, description
interval_unit (enum: day, week, month)
interval_count (int) e.g., 2 weeks = unit=week, count=2
available_intervals (JSONB) offered choices: [{unit, count, label}]
pricing_strategy (enum: bc_price_list, fixed_discount_pct, fixed_price)
bc_price_list_id (nullable) FK to BC Price List
discount_pct (nullable)
trial_days (nullable)
min_cycles (int, nullable) prepaid / commitment
max_cycles (int, nullable)
status (enum: active, archived)
Subscription active subscriber agreement
id (PK)
store_hash (FK)
bc_customer_id (FK)
plan_id (FK)
bc_variant_id
quantity
interval_unit, interval_count
status (enum: active, paused, past_due, cancelled, completed)
next_charge_at (timestamptz)
anchor_date for cadence math
processor_connection_id (FK)
payment_method_ref Stripe PM ID, Braintree token, etc.
shipping_address (JSONB snapshot)
billing_address (JSONB snapshot)
created_at, paused_at, cancelled_at
metadata (JSONB) merchant-defined tags
Charge one attempt to collect one renewal
id (PK)
subscription_id (FK)
scheduled_at, attempted_at, resolved_at
amount_cents, currency
status (enum: scheduled, processing, succeeded, failed, abandoned, refunded)
processor_transaction_id
bc_order_id set once BC order is created
failure_code, failure_message
retry_attempt (int, default 0)
next_retry_at
Event append-only audit log
id (PK)
store_hash (FK)
subscription_id (FK, nullable)
charge_id (FK, nullable)
type (text) sub.created, charge.succeeded, dunning.attempt_2, etc.
source (enum: merchant, subscriber, system, webhook_bc, webhook_processor)
actor_id who triggered it
payload (JSONB)
created_at
Entitlement access granted by a subscription, independent of physical fulfillment
id (PK) (PRD-COMPANION D1, DECIDED 2026-04-23)
store_hash (FK)
subscription_id (FK)
key (text) merchant-defined entitlement key (e.g., "gold_tier", "seat_pro", "course_access")
status (enum: pending, active, suspended, revoked)
provider (text) adapter id: bc_customer_group (v1) | sso_saml | feature_flag | license_server | custom
provider_external_ref (text, nullable) opaque reference from the provider (BC customer_group_id, SSO attr, etc.)
granted_at (timestamptz, nullable)
suspended_at (timestamptz, nullable) set when payment.failed + grace exhausted or operator-suspended
revoked_at (timestamptz, nullable) set when subscription.cancelled + cancel-grace expires
metadata (JSONB) adapter-specific state (role name, SSO assertion attrs, feature-flag targets)
AllotmentGrant admin-granted recurring quota distinct from paid-subscription entitlement
id (PK) (PRD-COMPANION D18, DECIDED 2026-05-07)
store_hash (FK)
bc_customer_id (FK, nullable) target customer (null + buyer_org_ref non-null = org-scoped grant)
buyer_org_ref (text, nullable) B2B Edition buyer-org id; mutually exclusive with bc_customer_id
grant_key (text) merchant-defined wallet name (e.g., "wellness_credit", "shred_pickup_quota")
unit_type (enum: currency, count, duration_minutes)
amount_per_period (numeric) refresh quantum; e.g., 50.00 USD or 4 pickups
currency (text, nullable) set when unit_type=currency
refresh_cadence_unit (enum: day, week, month, year)
refresh_cadence_count (int)
next_refresh_at (timestamptz)
current_balance (numeric) depleted by AllotmentDebit, refreshed at next_refresh_at
rollover_policy (enum: none, cap_to_one_period, accumulate)
status (enum: active, suspended, revoked)
granted_by (text) admin user id who issued the grant
reason (text, nullable) audit context (CS-ticket ref, contract clause)
expires_at (timestamptz, nullable) fixed end-of-grant; null = perpetual until revoke
metadata (JSONB)
AllotmentDebit consumption events against an AllotmentGrant
id (PK)
grant_id (FK)
bc_order_id (text, nullable) if debit funds an order
charge_id (FK, nullable) if debit offsets a subscription charge
amount (numeric)
occurred_at (timestamptz)
reversed_at (timestamptz, nullable) refund / void
notes (text, nullable)
Actor multi-actor membership on a subscription (PRD-COMPANION D19)
id (PK) (DECIDED 2026-05-07)
subscription_id (FK)
store_hash (FK)
bc_customer_id (FK, nullable) resolved BC customer (null when role is platform-managed e.g. CS-rep)
buyer_org_ref (text, nullable) B2B Edition org context
role (enum: owner, payer, beneficiary, manager, org_admin)
payment_method_ref (text, nullable) populated when role=payer; supersedes Subscription.payment_method_ref
processor_connection_ref (FK, nullable) RESERVED for v2 marketplace MoR (ADR-0022); null in Phase 1
notification_prefs (JSONB) per-actor channel routing
added_at, removed_at (timestamptz)
added_by (text) audit
CONSTRAINT exactly-one-payer-per-subscription active payer is unique per subscription_id
DeliveryInstance per-delivery row, distinct from per-billing charge (ADR-0024)
id (PK) (PRD-COMPANION D2, DECIDED 2026-05-07)
subscription_id (FK)
store_hash (FK)
scheduled_date (date) when this delivery is/was supposed to ship
status (enum: pending, fulfilled, skipped, failed, rescheduled)
ship_to_address_id (uuid, nullable) null = subscription default; non-null = per-instance override
line_items (JSONB, nullable) null = subscription default; non-null = per-instance swap
bc_order_id (text, nullable) set when materialized to a BC order
source_charge_id (FK, nullable) informational; n:1 fan-out permitted (Cintas weekly delivery / monthly billing)
notes (text, nullable)
created_at, updated_at (timestamptz)
CustomFieldDefinition merchant-defined typed extensibility on Subscription (PRD-COMPANION D20)
id (PK) (DECIDED 2026-05-07)
store_hash (FK)
scope (enum: subscription, plan, allotment_grant)
field_key (text) snake_case identifier, unique per (store_hash, scope)
display_label (text)
field_type (enum: text, number, boolean, date, enum, json)
enum_values (JSONB, nullable) choices when field_type=enum
required (boolean)
default_value (JSONB, nullable)
visible_to_subscriber (boolean) controls portal display
editable_by_subscriber (boolean)
validation_regex (text, nullable)
description (text, nullable)
status (enum: active, archived)
created_at, updated_at (timestamptz)
-- field data lives on the parent entity's `metadata` JSONB column under reserved key `custom_fields`
-- e.g. subscriptions.metadata = { "custom_fields": { "po_number": "PO-12345", "delivery_window": "morning" } }
Dunning Policy retry strategy per store
id (PK)
store_hash (FK)
attempts (JSONB) [{delay_hours: 12}, {delay_hours: 12}, {delay_hours: 24}, {delay_hours: 48}, {delay_hours: 72}]
on_exhaustion (enum: cancel, pause, notify_only)
is_default
8.2 Storage Strategy — where each piece lives
| Data | Lives In | Why |
|---|---|---|
| Subscription state, schedule, cadence | Our Postgres | BC has no native representation |
| Plan configuration | Our Postgres + BC product metafields (pointer back) | Merchant can see "this product has a subscription plan" inside BC admin via App Extension |
| Customer payment method | Processor (Stripe/Braintree) | PCI scope; we hold only a reference |
| Customer profile | BC Customers (source of truth) | Don't duplicate |
| Product catalog | BC Products (source of truth) | Don't duplicate |
| Order history | BC Orders (source of truth) | We tag orders via custom fields; don't duplicate |
| Upcoming-charge schedule | Our Postgres | Derived from subscription + anchor_date |
| Audit trail | Our Postgres (events table) |
Needed for support and compliance |
| Multi-actor roles on a subscription | Our Postgres (actors table) |
BC customer = identity SoR; role assignment is our concept |
| Per-delivery instances (cadence ≠ billing) | Our Postgres (delivery_instances) |
Lazily populated; BC has no native concept (ADR-0024) |
| Admin-granted recurring quota | Our Postgres (allotment_grants + allotment_debits) |
Independent of paid-subscription entitlement (ADR-0024 sibling, PRD-COMPANION D18) |
| Custom field definitions | Our Postgres (custom_field_definitions) |
Merchant-managed schema; data values live on parent entity metadata.custom_fields |
| Per-org processor connection (v2) | Our Postgres (processor_connections keyed on payer_org) |
RESERVED in Phase 1 (ADR-0022 marketplace-MoR deferral); enabled at v2 with backfill |
8.3 Linking BC Orders to Subscriptions
Every subscription-generated BC order gets:
- Order
custom_fieldsentries:{subscription_id, charge_id, cycle_number} - Order
staff_notesprefix:[SUB] {subscription_id} - Webhook handler on
store/order/*reverse-looks-up bycustom_fieldsto maintain sync
For subscriptions with delivery cadence ≠ billing cadence (Cintas / Lovesac patterns), order custom_fields additionally carry {delivery_instance_id} so the reconciliation worker can close per-instance loops independent of the funding charge (ADR-0024).
8.4 Cross-references to ADRs and PRD-COMPANION
The Phase-1 entity additions above are governed by these ratified decisions — refer to those documents for context, alternatives, and consequences:
- ADR-0022 (
docs/decisions/0022-marketplace-mor-scope.md) — defers per-buyer-org processor routing to v2;Actor.processor_connection_refis the reserved seam. - ADR-0023 (
docs/decisions/0023-b2b-checkout-ownership.md) — admin-only B2B Edition enrollment in Phase 1;Actor.role=managercarries CS-rep semantics. - ADR-0024 (
docs/decisions/0024-delivery-instance.md) — DeliveryInstance as first-class lazily-populated entity;n:1charge-to-delivery fan-out permitted. - PRD-COMPANION D2 (DECIDED) — DeliveryInstance shape (this section's row).
- PRD-COMPANION D18 (DECIDED) — AllotmentGrant primitive distinct from Entitlement (D1).
- PRD-COMPANION D19 (DECIDED) — Multi-actor role enum + cardinality constraints.
- PRD-COMPANION D20 (DECIDED) — CustomFieldDefinition schema + storage location.
- PRD-COMPANION D21 (DECIDED) — NTI freshness policy (Phase 2 charge-orchestration extension; new fields on Subscription deferred to that decision's adoption).
9. Feature Scope
9.1 Catalog Enablement (merchant side)
Goal: Any existing BC product can become subscribable without duplicating SKUs.
Flows:
- Merchant opens a product in BC admin → "Subscriptions" App Extension panel appears
- Panel shows: current plan status, intervals offered, pricing strategy, eligibility
- "Enable subscription" → wizard:
- Pick interval(s) to offer (e.g., monthly, every 2 months)
- Pick pricing: BC Price List link or fixed discount % or fixed price
- Optional: trial, commitment cycles, max cycles
- Pick eligible variants (all / specific)
- Review → activate
- On activation: we write metafields to the BC product (
subscription.enabled=true,subscription.plan_id=...) so the storefront widget knows to render the subscription UI
Price List integration (differentiator):
- Merchant selects an existing BC Price List → subscription price comes from there
- Lets merchants reuse B2B / customer-group pricing structures for subscribers
- Maintains single source of pricing truth (no divergent subscription prices to reconcile)
9.2 Storefront Purchase Flow
Widget on PDP (Stencil + Headless):
- Radio: "One-time purchase" / "Subscribe & save X%"
- Dropdown: interval selection (when multiple offered)
- Writes subscription intent into cart-level metafields (namespace:
bc-subscriptions, key:subscription_intents) on add-to-cart - Renders badge + next-charge preview
Cart page:
- Subscription line items visually distinguished (badge, interval label)
- Non-subscribable items and subscribable items can coexist in one cart
Checkout:
- BC Optimized One-Page Checkout runs normally
- Our webhook on
store/order/createdreads line-item subscription intent, creates Subscription records, captures payment method at processor (see 9.3)
Headless Catalyst path:
- Same backend; SDK provides
useSubscriptionOptions(productId)andaddSubscriptionToCart() - PDP, cart, checkout are all merchant-built against our SDK
9.3 Billing Engine
Scheduling:
- Vercel Cron runs every 15 min
- Query:
SELECT * FROM charges WHERE status='scheduled' AND scheduled_at <= now() + interval '15 min' - Enqueue each due charge into Vercel Queue for workflow-style execution
Executor (Vercel Workflow):
- Acquire idempotency lock on
charge.idin Redis (5-min TTL) - Load subscription, plan, customer, product, processor connection
- Inventory check via BC API (abandon if backorder policy forbids)
- Recalculate line-item pricing from current BC catalog (handle price changes since last renewal)
- Recalculate tax + shipping (call BC's checkout-calculation endpoint with subscription address)
- Dispatch to processor adapter (BC Payments / Stripe / …) with
payment_method_ref, idempotency key =charge.id- BC Payments adapter: preferred path is BC Payments Processing API with a MIT-flagged Payment Access Token against the order; fallback is direct Braintree charge with the Braintree token
- Stripe adapter: PaymentIntent with
off_session: true,confirm: true,customer+payment_method
- On success: create BC order via V2 Orders API with
payment_status: captured, processor transaction ID, tag with custom fields - Update
charges.status=succeeded,charges.bc_order_id=... - Schedule next charge (advance anchor_date by interval)
- Emit
charge.succeededevent + send renewal email - On failure: branch to dunning state machine (see below). Note: for BC Payments renewals, PayPal's network decline signals (soft vs hard) feed directly into retry eligibility — we do not retry hard declines.
Dunning state machine:
- Configurable per store (default: 5-stage retry at +12h, +12h, +24h, +48h, +72h (~6.5 days total), then cancel)
- Each retry re-enters the executor with the same
charge.id(newretry_attempt) - Subscription transitions:
active → past_dueon first failure,past_due → cancelled|pausedon exhaustion per policy - Dunning emails sent at each failure with payment-update portal link
- Subscriber-initiated payment-method update resets attempt counter and enqueues immediate retry
Reconciliation sweep (daily cron):
- Detect: charges stuck
processing> 1h → inspect at processor, resolve - Detect: BC orders tagged as subscription-origin but missing corresponding
charge.bc_order_id→ backfill - Detect: processor succeeded but BC order creation failed → manual queue
9.4 Recurring Order Generation
<!-- traceability:start:PRD:9.4 --><!-- traceability:end:PRD:9.4 -->Prototype: Detail
Every successful charge produces a native BC order via V2 /orders POST, with:
- Customer, billing/shipping from subscription snapshot (or override if customer updated)
- Line items from subscription (quantity, variant)
payment_method: manual,payment_status: captured,payment_provider_id: <processor txn id>custom_fields: subscription_id, charge_id, cycle_number, plan_idstaff_notes:[SUB] {subscription_id} cycle {N}status_id: mapped to merchant's "Awaiting Fulfillment" (configurable)
Merchant's existing OMS/WMS/3PL sees these as regular orders. No parallel fulfillment ops.
9.5 Customer Portal (Subscriber Self-Service)
Routes (hosted; also available as headless SDK):
/portal/login— BC customer email + OTP (magic link) or BC storefront SSO/portal/subscriptions— list/portal/subscriptions/[id]— detail/portal/subscriptions/[id]/payment-method— update/portal/subscriptions/[id]/address— update shipping/portal/subscriptions/[id]/orders— order history/portal/subscriptions/[id]/skip-next/portal/subscriptions/[id]/swap— change variant/portal/subscriptions/[id]/pause— pause with date picker/portal/subscriptions/[id]/reschedule— change next-charge date/portal/subscriptions/[id]/cancel— triggers churn-prevention flow (9.7)
MVP operations list: skip, swap, pause, reschedule, cancel, update payment, update address, update quantity.
9.6 Merchant Admin
Dashboard (/stores/[storeHash]/dashboard):
- MRR, active subs, churn rate (30d), new subs (30d), past_due count
- Upcoming charges (next 7 days) — value + count
- Exception queue count (failed payments, out-of-stock, reconciliation drift)
- Recent events timeline
Subscriptions list:
- Filter: status, plan, customer group, failed payment state
- Sort: next charge date, MRR contribution, lifetime value
- Bulk: skip next charge, pause, export
Subscription detail:
- Current state, schedule, payment method health
- Charge history (succeeded/failed, links to BC orders)
- Event timeline (merchant, subscriber, system actions)
- Manual actions: refund charge, skip, pause, cancel, re-schedule, update PM link
Plans:
- Plan list (per product)
- Create / edit / archive
- Plan performance: active subs, avg cycles completed, churn
Exception queue:
- Failed renewals in dunning → one-click "contact subscriber" / "extend grace"
- Out-of-stock renewals → "use backorder" / "skip cycle" / "substitute SKU"
- Reconciliation drift → "acknowledge" / "open in BC"
Settings:
- Dunning policy editor
- Notification template editor (Resend)
- Processor connection — auto-detects BC Payments if enabled on the store; Stripe Connect onboarding link for Stripe path; only one primary processor at a time in MVP
- Webhook health
9.7 Churn Prevention
Cancel flow is not a single "are you sure?" — it is a decision tree:
- Reason capture (radio): too expensive / don't need right now / product issue / ordering too much / other
- Matched intervention:
- Too expensive → offer discount code (merchant-configured pool)
- Don't need right now → pause 30/60/90 days
- Ordering too much → offer longer interval
- Product issue → escalate to support (Zendesk/Gorgias webhook)
- Other → continue
- Final confirm
- Cancel → emit
sub.cancelledwith reason; write toeventsfor retention analytics
Merchant can configure offers per reason. Phase 2: A/B test intervention efficacy.
9.8 Notifications
Transactional email (Resend) and optional SMS (Twilio). The seven subscriber-facing lifecycle email types:
- Welcome (first charge succeeded →
subscription.activated) - Upcoming charge (T-3 days, configurable →
subscription.upcoming_charge) - Charge succeeded (→
subscription.renewed) - Charge failed (with update-payment CTA →
charge.failed) - Dunning retry notice (per stage →
charge.failed_permanently) - Subscription paused/resumed/cancelled (→
subscription.paused/subscription.resumed/subscription.cancelled) - Shipment generated (piggyback on BC's shipment webhook →
subscription.shipment_generated)
Templates are merchant-editable (MJML or markdown) with standard variables.
Event-pipeline pattern. Producers publish logical lifecycle events via logEvent (ADR-0010 transactional-outbox). The apps/email-consumer Worker subscribes to subs-events, maps event type → template_key at consumption time, fetches the merchant-edited template (falling back to seeded defaults), and dispatches via Resend. Ratified by ADR-0063. The pipeline is not imperative "send this template" — producers do not name templates; they publish facts.
Phase-1 ship status. Renewal-confirmation (subscription.renewed → renewal_confirmation) is shipped end-to-end. The remaining six lifecycle types' consumer mapping + producer wiring are tracked at Hive #1505 (welcome, upcoming-charge T-3 scanner, charge-failed CTA, dunning retry, paused/resumed/cancelled portal events, shipment-generated webhook). Until that Spec lands, those events fire to the events table and queue but are skipped consumer-side.
Custom-domain strategy. Per-merchant DKIM keys via merchant-side DNS verification on notifications.{merchant.com} (primary). subscriptions.bigcommerce.com is the fallback for merchants who skip DNS — measurable activation drag for mid-market (see §13).
Transactional vs marketing routing rules. Every template tagged transactional | marketing. Transactional bypasses subscriber marketing opt-out (legitimate-interest basis under GDPR; CAN-SPAM transactional-message exception). Marketing emits the RFC 8058 one-click List-Unsubscribe-Post: List-Unsubscribe=One-Click header; transactional does not.
PCI variable allowlist. Templating layer enforces — not template-author trust. Allowed payment fields: payment_method.last4, brand, exp_month, exp_year. Blocked: full_pan, cvv, anything matching a card-number regex.
Magic-link security model.
- Token: opaque 32-byte random; bcrypted lookup server-side; never PII in URL
- TTL: 15 min (portal login) / 24h–7d (dunning payment-update, configurable per merchant)
- Placement: token after
#fragment (avoidsRefererleakage) - Single-use: marked consumed atomically on first redemption; subsequent → "request fresh link"
- Phishing resistance: domain matches the storefront the subscriber was last active on; reject mismatch
Idempotency contract. Three independent layers, all required:
- Per-send key
{template_id}:{recipient}:{source_event_id}survives workflow restart - Time-window dedupe (default 5 min, same
(template, recipient)) survives event-storm cascades; off for magic-link / password-reset - Resend
Idempotency-Keyheader survives our retry to Resend
9.8.1 Email security & compliance
Authentication. Every send authenticates via SPF + DKIM + DMARC. Gmail/Yahoo enforce DMARC alignment for ≥ 5K/day senders since Feb 2024 — non-aligned sends route to spam at scale. DKIM signed via merchant-domain key (US-23.9); SPF includes Resend's mail servers; DMARC policy at minimum p=none with reporting, escalating to p=quarantine once aligned.
RFC 8058 one-click unsubscribe. Marketing-flagged sends include List-Unsubscribe: <mailto:...>, <https:...> and List-Unsubscribe-Post: List-Unsubscribe=One-Click. Transactional sends do not (legitimate-interest exemption).
CAN-SPAM (US). Physical merchant address in footer; functioning unsubscribe (marketing); accurate From and Subject (no deceptive routing). Apply to merchant-flagged-marketing sends; transactional exempt from unsubscribe but must still carry merchant identification.
CASL (Canada). Implied consent acceptable for transactional only. Marketing requires explicit opt-in proof retained 36 months.
GDPR (EU). Transactional emails sent under legitimate interest basis; data minimization in templates (only required fields rendered); PII retention rules per Epic 28.
PCI DSS. Variable allowlist enforced at the templating engine, not by reviewer trust. Templates that reference blocked fields fail render and emit alert (US-23.14).
PSD2 SCA. Payment-update magic-links during dunning are security artifacts: short TTL, single-use, opaque token, fragment-only URL placement.
10. Integration Surfaces
10.1 Inbound webhooks — from BC
store/order/created— fetch subscription intent from cart-level metafields (GET /v3/carts/{cartId}/metafields?namespace=bc-subscriptions&key=subscription_intents), create Subscription + Chargestore/order/updated— sync order status to subscription viewstore/order/refund/created— reconcile tocharge.refunded(planned; not yet registered)store/customer/updated— update subscription payment address if customer updated defaultstore/customer/deleted— cancel all customer's subscriptionsstore/product/updated— detect price changes, flag affected plansstore/product/deleted— pause affected subscriptionsstore/app/uninstalled— purge credentials, pause all subscriptions
10.2 Inbound webhooks — from processor
BC Payments path (events consumed via BC webhook subscriptions where exposed; Braintree webhooks as fallback):
- Transaction settled / declined / disputed
- Payment method invalidated / expired
- Dispute opened — auto-pause subscription, alert merchant
- Subscription MIT authorization revoked (if exposed) — flag PM, block further renewals
Stripe path:
payment_intent.succeeded / payment_intent.payment_failed— authoritative charge outcomepayment_method.detached/payment_method.updated— flag PM as invalid, trigger subscriber notificationcharge.dispute.created— auto-pause subscription, alert merchantcustomer.updated— detect default PM changes
Adapter contract: each processor adapter normalizes its webhook stream into a small shared event vocabulary (charge.settled, charge.declined, pm.invalidated, dispute.opened) before it hits our Event table. The billing engine only consumes the normalized vocabulary.
10.3 Outbound (our webhooks + events)
- REST API for merchants:
/api/v1/subscriptions,/api/v1/charges,/api/v1/events - Webhook subscriptions for merchants: event types above
- Event stream via Vercel Queues for internal consumers
10.4 Integrations (Phase 2+)
- Klaviyo / Ortto (subscription lifecycle events)
- Gorgias / Zendesk (cancel-reason escalations)
- Avalara / TaxJar (tax on renewal orders — via BC's native tax, no direct integration needed)
- Shipstation (inherits from BC orders, no direct work)
11. Analytics & KPIs
11.1 Merchant-facing metrics
- MRR, ARR, net new MRR, MRR churn, gross churn, net revenue churn
- Active subscriptions, new subscriptions, cancelled subscriptions (period-over-period)
- Avg revenue per subscriber, avg cycles completed
- Failed-renewal recovery rate
- Portal self-service resolution rate
- Cancel-reason distribution + intervention save rate
11.2 Platform-facing health metrics
- Charge success rate (p50, p95, per-processor)
- Dunning recovery curve
- Time from charge → BC order created
- Webhook processing lag
- Reconciliation drift detection rate
12. MVP Cut & Phased Roadmap
Phasing model superseded by ADR-0021 — delivery scope is now governed by the Tier α/β/γ/δ cohort model, not the Phase 1/2/3 model below. The scope tables below remain accurate as a content enumeration of what ships in each cohort; the sequencing labels (Phase 1 / Phase 2 / Phase 3) map to Tier α / Tier β / Tier γ+δ respectively. Treat ADR-0021 as authoritative when the two diverge.
Phase 1 — MVP (target: 14 weeks; +2 weeks vs. v0.1 to absorb BC Payments adapter)
Ship: BC Payments + Stripe as processor adapters, core loop works end-to-end.
Includes:
- OAuth install, App Extensions (Products, Orders, Customers), webhook registration
- Plan creation (interval + fixed discount % or Price List link)
- Single-product subscriptions (no bundles, no build-a-box)
- Stencil storefront widget + headless SDK
- Cart-metafield subscription intent capture (namespace:
bc-subscriptions, key:subscription_intents) - BC Payments adapter (primary) — Stored Instruments vaulting + MIT renewals (or Braintree fallback per §6.2)
- Stripe adapter (alternate) — Stripe Connect onboarding, PaymentIntent off-session renewals
- Billing engine with tunable dunning (default 5-stage, ~6.5 days), soft/hard decline awareness
- BC order creation on successful charge (tagged with subscription + charge IDs)
- Subscriber portal (skip, swap, pause, reschedule, cancel, update PM, update address)
- Merchant dashboard (MRR, active count, upcoming, exceptions, BC Payments reconciliation link)
- Transactional email via Resend
- Cancel-reason capture + simple pause-instead intervention
- Reconciliation daily sweep
- Audit event log
Deliberately excluded from MVP:
- Braintree standalone / Authorize.net (covered partially via BC Payments' Braintree backend, but no direct Braintree adapter)
- Multi-storefront / channel-scoped subscriptions
- Build-a-box / bundles
- Prepaid / fixed-term subscriptions
- B2B Edition support
- SMS notifications
- Cancel-flow A/B testing
- Customer-group–scoped plans
- Migration from existing subscription apps (see Phase 2)
Phase 2 — Platform Depth (12 weeks post-MVP)
- Braintree standalone + Authorize.net processor adapters
- Multi-Storefront / channel-scoped plans
- Customer-group–scoped plans
- Prepaid / fixed-term subscriptions
- SMS notifications
- Klaviyo + Ortto integrations
- Cancel-flow A/B testing + intervention analytics
- Migration tooling (import from Recharge / PayWhirl / Bold — critical because we can offer these merchants a path to consolidate onto BC Payments)
Phase 3 — Enterprise (12 weeks)
- B2B Edition support (company accounts, approval workflows, purchase orders as payment method)
- Build-a-box / bundle subscriptions
- Usage-based billing primitive
- Advanced segmentation (RFM-based retention campaigns)
- White-label portal (custom domain, full theming)
- International BC Payments regions as BC rolls them out
13. Open Questions & Risks
| Area | Question / Risk | Mitigation |
|---|---|---|
| BC Payments MIT surface (validated; PI-5062 reframed per ADR-0037) | RESOLVED by Day-0 spike eb94cf4a (decision 256591ec): BC's BigPay layer auto-classifies API-initiated stored-instrument charges as MIT (MREC for recurring), production-confirmed at Wren Laboratories. ADR-0037 (2026-05-15) reframes PI-5062: it is a Worldpay/Paymetric-specific NTI threading gap, not a blocker for the standard stored-instruments vault rail. Standard merchants (BC Payments/PPCP, Braintree, Auth.Net) are unaffected. |
Adapter persists NTI from CIT response for Worldpay/Paymetric merchants until PI-5062 closes (PRD-COMPANION D17). EU PSD2 SCA exemption preserved across the chain. |
| BC Payments availability (new) | BC Payments is US-only at launch (March 2026). International merchants cannot use Path A. | Stripe remains fully supported as alt path; international MVP works on Stripe only. Track BC Payments geo rollout and add regions as they land. |
| BC Payments migration pacing (new) | If PPCP → BC Payments migration is slower than BC projects, our TAM for Path A in year 1 is smaller than hoped | Ship both Path A and Path B at MVP. Don't bet on BC Payments alone. |
| Stripe switching cost | What % of target merchants currently on Stripe will accept us as a Stripe-only adapter vs. wait for BC Payments? | Both adapters ship at MVP — no forcing function |
| Cart intent capture | Will Stencil's line-item custom field API hold up for all theme variants? | Build post-purchase fallback from day one; test against top 20 Stencil themes |
| Checkout divergence | Merchants using third-party checkouts (Bolt, Fast, etc.) lose cart-custom-field capture | Document as unsupported for MVP; add case-by-case in Phase 2 |
| Price drift | If product price changes between subscription creation and renewal, which price wins? | Default: current catalog price. Merchant-configurable: lock at subscription creation. |
| Inventory conflicts | What if a renewal runs when the product is out of stock? | Configurable: backorder / skip cycle / substitute / pause subscription |
| Tax jurisdiction changes | Subscriber moves mid-subscription; address change triggers tax re-calc — is that always right? | Yes, always recalc at renewal time from current address |
| GDPR / PII | Subscription history + address snapshots survive in our DB after BC customer deletion | Hard-delete on store/customer/deleted webhook; retain only anonymized revenue records |
| BC API rate limits | Large stores renewing 1000s of subs in the same hour could exceed 450 req/30s limit | Queue-based rate limiting; spread renewals across the day using anchor_date jitter |
| Multi-currency | BC supports multi-currency at the storefront level, but subscription prices are stored once | Plan price denominated in store default currency; convert to presentment currency at display time only |
| Uninstall lifecycle | What happens to active subscriptions if merchant uninstalls the app? | Pause all subs, retain data 30 days, purge or retain-on-merchant-request thereafter |
| Custom-domain DNS adoption rate (proposed) | Merchants who skip DNS verification get the weaker subscriptions.bigcommerce.com fallback — measurable activation drag for mid-market |
Track per-merchant; nudge in onboarding flow; consider tier-gated requirement at Enterprise plan. See Hive proposal e3ff617b. |
| Resend rate-limit fairness across merchants (proposed) | Workspace-level rate limits shared across all merchants; one merchant's burst could starve others | Per-merchant fair-share token bucket; reserve 10% of capacity for transactional regardless of marketing pressure. See Hive proposal e3ff617b. |
14. Success Criteria (MVP exit)
Engine-works metrics — pipeline reliability:
- 10 paying merchants onboarded
- 1,000 active subscriptions across the platform
- 95%+ charge success rate (p50), 90%+ (p95)
- 85%+ dunning recovery rate within 7 days
- <1% reconciliation drift (charges with inconsistent state between us ↔ BC ↔ processor)
- Median subscriber self-service resolution: no merchant support ticket required for skip/swap/pause/PM-update
- Median merchant exception-queue time to resolution: <1 business day
Differentiator metrics — wedge validation:
- Path A mix
[target]: ≥60% of MVP merchants on BC Payments adapter. Validates the BC-Payments-unification thesis. <60% means the pitch isn't landing or the path isn't smooth enough. - Onboarding step count: ≥30% fewer steps end-to-end (install → first subscription enabled) vs Recharge baseline. Empirical, not anecdotal — requires a one-off competitive audit before MVP launch to set the baseline. Validates the "lowest-friction install" claim from §3.
- PM-update friction: ≤2 clicks to update payment method across N subscriptions for a customer with N>1. Validates D5 (
PRD-COMPANIOND17 / customer-levelpayment_customer_ref) — the cross-sub PM update UX that differentiates against Recharge's per-sub flow.
These are exit criteria, measured retrospectively to declare MVP successful — not blockers for shipping.
15. Appendix: Why Not Just Fork an Existing Solution
- Recharge: closed-source, Shopify-first internals, licensing prohibits. BC Payments is not a supported gateway on any Recharge BigCommerce integration path (Recharge payment processors); adding it would require Recharge to re-architect against BC-specific payment rails — no public indication they plan to.
- Stripe Billing: catalog-agnostic, would require building the BC integration surface separately anyway. Does not integrate with BC Payments.
- Bold Subscriptions / PayWhirl / Rebillia: SMB-targeted, none currently integrate with BC Payments. Mid-market observability and B2B coverage gaps documented in §2.2.
- Open source (e.g., Kill Bill): billing-engine-only, still need BC glue, data model, merchant UX, and the BC Payments adapter work.
The value is in the BC-native depth and the BC Payments unification, not the billing primitives.
16. Architectural Alternatives Considered
Rigor requires writing down the paths we did not take and why. Two in particular were re-examined after v0.2 feedback.
16.1 Alternative: Build on top of Stripe Billing instead of a native engine
What this would mean. Stripe Billing is not merely a payment vault — it is a full subscription engine: Product, Price, Customer, Subscription, Invoice, Quote, plus Smart Retries, Automatic Card Updater, Tax, Revenue Recognition, and a hosted Customer Portal that does skip/swap/pause/cancel self-service out of the box. Stripe claims 25M+ renewals per day on this infrastructure.
The alternative architecture would be: for Stripe-path merchants, we thin-shell Stripe Billing — mirror BC products into Stripe Products/Prices, create Stripe Subscriptions on checkout, let Stripe own scheduling/dunning/retries/portal, and consume Stripe webhooks to create matching BC orders after each invoice.paid.
What we would gain.
- ~40% less engine code to build (scheduling, dunning, retries, proration, portal UI for Stripe path)
- Inherited scale, reliability, and recovery tooling (Smart Retries alone recovered $6.5B across Stripe customers in 2024 per Stripe)
- Tax + Revenue Recognition for free on the Stripe path
- Automatic Card Updater network coverage for free
- Stripe-hosted Customer Portal removes one UI surface for MVP
What we would lose — the case against.
-
Catalog schizophrenia. BC is the source of truth for products, variants, and pricing. Stripe Billing requires a parallel
Product/Pricecatalog. Every BC product edit triggers a sync job; every price change triggers a StripePricemigration (Stripe prices are immutable). This is a perpetual reconciliation tax and a constant source of merchant support tickets. -
Order schizophrenia. Stripe Billing generates
Invoices; BigCommerce generatesOrders. Either we reconcile both (merchant sees two artifacts for one transaction) or we suppress Stripe invoices and lose half the Stripe Billing value. Our core positioning is "Orders are BC orders" — Stripe invoices are an alien artifact here. -
Split experience. BC Payments merchants get our native portal + dashboard; Stripe merchants get a Stripe-hosted portal + our dashboard. Two UX codepaths, two support runbooks, two feature roadmaps. Anything we can't expose through Stripe's portal (BC-specific: switch variant to a different BC product, add a BC coupon, apply a BC Price List) is unavailable to Stripe merchants — or requires us to rebuild it anyway.
-
Price List unusable. BC Price Lists are a primary subscription-pricing mechanism in our design. They have no representation in Stripe Billing. On the Stripe path we would lose this entire differentiator, or have to materialize Price List outputs into static Stripe Prices and re-sync on every change.
-
Customer Group / Channel unusable. Same problem. Stripe does not model BC Channels or Customer Groups. Plans scoped by either cannot exist on the Stripe Billing path.
-
Lock-in asymmetry. A merchant leaving Stripe (for BC Payments, say) would need to re-create every subscription from scratch because Stripe Billing state cannot be exported and re-instantiated on BC Payments. Our native engine owns the source of truth and makes processor migration a PM-token swap.
-
Two billing engines to operate anyway. BC Payments path must own scheduling, dunning, and portal logic — Stripe Billing cannot help there. So we end up operating two subscription engines (ours + Stripe's) with different semantics, observability, and failure modes, instead of one.
Verdict: Rejected as default architecture. Re-considered as a future merchant option.
We own the billing engine. Both BC Payments and Stripe adapters call a shared scheduling + dunning + portal layer. The Stripe adapter uses Stripe PaymentIntents + Customers + PaymentMethods + SetupIntents — not Stripe Subscriptions.
One narrow re-entry for Stripe Billing: in Phase 3, offer an "enterprise Stripe mode" for merchants who specifically want Stripe's Tax, Revenue Recognition, and Automatic Card Updater coverage and are willing to accept split catalog. This is opt-in, clearly labeled, and does not pollute the BC-native merchant experience.
16.2 Alternative: Support every major BC gateway at MVP
What this would mean. Ship Stripe + Braintree + Adyen + Cybersource + Worldpay + Authorize.net + others at MVP so any BC merchant with a recurring-capable gateway can install.
Why rejected.
- Each adapter is 2–4 engineer-weeks minimum (vault API + charge API + webhook normalization + idempotency + reconciliation + sandbox testing)
- Adapter surface area dwarfs the rest of MVP — we would ship 6 gateway adapters and no product
- The core of our target mid-market BC segment runs on BC Payments (new/migrating merchants) or Stripe (existing mid-market); BC's other gateways are a long tail — the additional adapters unlock that tail, not the core TAM (segment-share magnitude is an internal [assumption], not sourced market data)
- Adapter work is low-risk and parallelizable — Phase 2 can ship them rapidly once the adapter interface is proven with two production implementations
Verdict: Rejected for MVP. Phase 2 ships Braintree-standalone + Authorize.net + Adyen as the next three, driven by the gateway matrix in §6.3.
16.3 Alternative: Integrate with an existing OSS billing engine (Kill Bill, Lago, Metronome-OSS)
What this would mean. Mount an open-source billing engine behind our API and delegate subscription state, scheduling, and dunning to it.
Why rejected.
- These engines are SaaS-billing-shaped (invoice-first, usage-metering-first) — not commerce-subscription-shaped (order-generation-first, inventory-aware)
- Operational burden: running a billing engine as a sidecar adds a stateful service, a separate DB, and its own operational runbook
- We still need to write every BC integration (catalog, orders, customers, payments) on top of it
- The engine we need is small (a scheduler, a state machine, a dunning policy runner) — forking 3% of Kill Bill's surface is not a win
Verdict: Rejected. Our native engine is scoped to commerce-subscription needs and stays small.
16.4 Alternative: White-label an existing BC subscription app (MINIBC, PayWhirl, Rebillia)
Why rejected. Closed-source, no licensing precedent, and if any of them were the right answer the market opportunity would not exist.