Subscribe¶
Generated from a canonical source
This page is a read-only projection of docs/handoff-corpus/subscribe.md.
Edit the canonical file, then run npm --prefix tools/project-knowledge-derive run derive.
What subscribe is for¶
The invariant you must not break: never materialize a second Subscription
for a BC order that has already produced one. BC can redeliver the
store/order/created webhook up to 12×, and the post-purchase fallback can
race the webhook for the same order — a duplicate would double the recurring
commitment and double the cycle-0 charge the customer already paid. The guard
checks two signals before creating anything: charges.bc_order_id (set on a
normal paid checkout) and subscriptions.created_from_order_id (stamped for
every order-created subscription, including $0 trials that never get a
cycle-0 charge) — webhooks.ts:599-620, mirrored in
fallback-capture.ts:120-132. Caveat traced, not assumed away: this is a
SELECT-then-INSERT at the application layer; subscriptions.created_from_order_id
carries no UNIQUE constraint (apps/api/migrations/schema/0001_baseline.sql
line 1069) — a true concurrent double-delivery (a webhook retry racing the
fallback, or two redeliveries racing each other) is a narrower, unconfirmed
risk, not a closed one.
Subscribe is how a BC order becomes a Subscription row. The capability breaks into six reader-facing features:
- Subscribe & save on the product page — a shopper picks a plan and cadence on the PDP widget, and their intent rides the cart into checkout with zero extra steps (US-8.1, US-9.1)
- One checkout for subscription and one-time items together — a shopper buys a subscription coffee and a one-time mug in a single BC checkout instead of two (US-9.4)
- Subscription appears automatically after checkout — no manual step; the subscription is live in the portal within seconds of the thank-you page (US-9.2)
- Recovery when a theme strips custom fields — a post-purchase fallback creates the subscription even when the storefront's checkout can't carry cart data through (US-9.3, backend only — see typed deltas)
- Guest subscribe stays first-class — sign-in is never required to
subscribe; it is offered as an affordance for real benefits (skip the
post-purchase add-card step, self-service management), never a gate
(PRD §6.3 funnel decision, operator-ratified 2026-07-02, hive decision
051b26d2) - Clear disclosure and consent before the first charge — a subscriber sees renewal terms and gives affirmative, un-pre-checked consent before the recurring commitment starts (US-9.6)
The load-bearing decisions, each carrying its own rationale:
- Cart-flow, not client-trusted checkout-intent, is the canonical subscribe
path. The shopper's identity and payment come from the BC order itself,
never a client-POSTed customer id; the legacy
/api/checkout/intentsurface (and its Catalyst caller) is deleted (ADR-0026). - One shared wire-contract package closes the boundary a retro found dead.
Namespace, metafield key, and intent shape live once in
@bc-subscriptions/storefront-contractand are imported by the webhook, the Svelte cart server, andcart-intents.ts; before this, hand-copied ("port-not-pull," synthesis #282 §3) contracts drifted silently and the entire subscribe→create flow never worked in production (storefront-contract-drift retro, fix commit8b75791a). - Affirmative consent gates subscription activation per-merchant. When a
store opts into
require_subscribe_consent, a subscription created without a captured consent block activatespausedinstead ofactive/trialing; cycle-0 (already paid at checkout) still records, but no recurring MIT runs until consent flips the status (ADR-0079). - Three-path intent recovery, tried in priority order — REST
cart-metafield (primary) → storefront GraphQL cart-metafield (secondary) →
line-item custom fields (tertiary) — so a subscription intent survives even
when the primary write path or token doesn't resolve for a given
store/theme (
webhooks.ts::handleOrderCreated, header comment lines 339-357).
Canonical-framing attestation (operator-ratified 2026-07-02). The BC
cart-flow path is canonical: the PDP widget drives Add-to-Cart via the
storefront's own SvelteKit server action, which writes the subscription
intent into the BC cart's subscription_intents metafield (GraphQL,
cart.ts::setSubscriptionIntent); BC's hosted checkout collects identity +
payment; the store/order/created webhook
(webhooks.ts::handleOrderCreated → createSubscriptionsFromOrder)
materializes the Subscription server-authoritatively from the BC order. The
legacy POST /api/checkout/intent client-trusted-ID path is retired and
deleted. cart-intents.ts (REST cart-metafield CRUD) is a sanctioned SECOND
producer for hosts that cannot run the SvelteKit server action
(third-party/headless integrations, and the live-sandbox e2e journey) — not
residue; it writes the same shared-contract namespace/key the webhook reads.
The client-side thank-you-page trigger for the post-purchase fallback
(thank-you-fallback.ts::runThankYouFallback, and the sessionStorage/custom-fields
capture in cart-capture.ts that feeds it) is real, reachable code with zero
callers from any route — BRD's own US-9.3 ui-states block marks that
surface "NOT YET BUILT — forward-looking contract," so this is not residue
either; it is a documented gap between a built backend
(fallback-capture.ts::handleAttachSubscription, live-routed and
G4-tested) and an unbuilt storefront trigger. Deciding decision:
ADR-0026.
How it actually works¶
The widget only ever posts one thing. SubscriptionWidget.svelte's
add-to-cart handler builds a form and POSTs to the SvelteKit ?/addToCart
action with a subscription_intent field
(SubscriptionWidget.svelte:408-411). There is no second code path in the
live widget for line-item custom fields or sessionStorage — +page.server.ts's
addToCart action calls cart.ts::setSubscriptionIntent, which does a
read-modify-write on the cart's bc_subscriptions / subscription_intents
metafield via BC's Storefront GraphQL API (cart.ts:464-511). The value is
serialized with the shared codec (encodeSubscriptionIntents) from
@bc-subscriptions/storefront-contract — not raw JSON.stringify — so the
webhook's decoder can never drift from the producer's encoder
(cart.ts:471-475).
On the API side, the same shared package is what closes the loop. BC
fires store/order/created; worker.fetch routes it to
routes/webhooks.ts::handleBcWebhook, which verifies the standardwebhooks
HMAC signature before dispatching by scope to handleOrderCreated
(webhooks.ts:364-486). Reading the intent back is not a single GraphQL
call — it's three fallback paths tried in order, because the webhook payload
itself never carries cart metafields (webhooks.ts:359-362):
- REST cart-metafield (primary) —
GET /v2/orders/{id}for the order'scart_id, thenGET /v3/carts/{cart_id}/metafieldsfiltered to thebc-subscriptionsnamespace (webhooks.ts:394-412,fetchCartMetafieldIntentsRest). - Storefront GraphQL (secondary) — tried only when path 1 finds nothing;
resolves a per-store storefront token and reads
order.cartMetafields(namespace:"bc_subscriptions")(webhooks.ts:414-430). - Line-item custom fields (tertiary) —
GET /v2/orders/{id}/productsfor asub_plan_idcustom field, tried last (webhooks.ts:432-464).
Once an intent is found, createSubscriptionsFromOrder
(webhooks.ts:510-) does the materialization work per intent, in order:
- Idempotency check first — the invariant guard above, before anything
else runs (
webhooks.ts:599-620). - Defense-in-depth product guards —
isProductExcludedandisProductInCategoryAllowlist(Epic-26) can drop the intent silently even though the cycle-0 charge already succeeded at BC checkout (webhooks.ts:622-658). - Consent gate (US-9.6 / ADR-0079) —
status: !intent.consent && requireConsent ? 'paused' : isTrial ? 'trialing' : 'active'(webhooks.ts:742); a captured consent block writes an immutableconsent_record(US-28.7); its absence with enforcement ON emits asubscription.consent_missingevent for the exception queue (webhooks.ts:804-818). - Cycle 0 and cycle 1 charge rows — cycle 0 represents the BC order
itself, recorded
status='succeeded'withbc_order_idset, never routed through aProcessorAdapter.chargecall because the customer already paid at BC checkout; cycle 1 is the first real renewal, scheduled for the scheduler's normal cron tick (webhooks.ts:494-503).
Read the create path as: BC delivers the signed webhook → HMAC verify →
dispatch to handleOrderCreated → resolve the intent via the three-path
fallback → idempotency check → product-exclusion guards → consent gate →
create the subscription + cycle-0/cycle-1 charge rows → log
subscription.created.
Read this diagram as the webhook-driven create path — HMAC verification through the dispatch to
handleOrderCreated, ending at the 200 BC sees.
sequenceDiagram
autonumber
participant BC as BigCommerce (store/order/created webhook)
participant Worker as Worker.fetch() (worker.ts handleFetch)
participant WH as routes/webhooks.ts::handleBcWebhook
participant Verify as standardwebhooks signature verify
participant OrderH as handleOrderCreated (order webhook handler)
participant BCApi as api.bigcommerce.com (order / cart-metafields / transactions GET)
participant DB as D1
participant Queue as EVENTS_QUEUE
BC->>Worker: POST /webhooks/bc (scope: store/order/created, signed payload)
Worker->>WH: handleBcWebhook(request, env) (worker.ts:540-542)
WH->>Verify: verify HMAC signature (v1,<base64> over raw body)
alt bad signature
Verify-->>WH: fail
WH-->>BC: 401
else signature ok
WH->>OrderH: dispatch by scope → handleOrderCreated
OrderH->>BCApi: GET order, cart-metafields, transactions (stubbed in CI · real HTTP in prod)
BCApi-->>OrderH: order + line item metafields carrying encoded subscription intent
Note over OrderH: intent decoded via decodeSubscriptionIntents (shared contract package @bc-subscriptions/storefront-contract) — same encode/decode pair the storefront uses, closing the prior wire-contract drift.
OrderH->>DB: createSubscription (subscriptions row, status per plan: 'active' or 'trialing')
OrderH->>DB: createCharge (cycle 0 charge row, chain_position:'initial')
OrderH->>DB: logEvent('subscription.created', actor_kind:'system', payload:{from_order_id})
OrderH-->>Worker: 200 { subscription_id, ... }
Worker-->>BC: 200
end
Diagram provenance. Transcluded verbatim from § 2 "Subscribe — order/created webhook → subscription creation" of the canonical, code-sourced
docs/architecture/sequence-diagrams.md(its file-levelderives_frompinsworker.ts,webhooks.ts's dispatch path, andsubscribe-end-to-end.scenario.ts, among others — the same single-source frontmatter as the dunning and canonical-charge-rail transclusions from this file). It carriessign_off: pending— accurate to the code, not yet human-attested, so read it as the current mechanism, not a ratified contract. This diagram deliberately omits the three-path intent-resolution fallback and the per-intent exclusion/allowlist/consent logic insidecreateSubscriptionsFromOrder— those are prose above, traced separately againstwebhooks.ts:394-820, not part of this transcluded sequence. In the handoff pipeline this is a build-time include of that one source, never a hand-copied fork.
The sanctioned second producer. cart-intents.ts implements the same
read-modify-write pattern directly over BC's REST metafields API
(GET/POST/PUT/DELETE /api/v1/storefront/cart/:cartId/intents), with
a CAS retry loop on BC's 409-on-conflict response
(cart-intents.ts:378-433). It imports CART_METAFIELD_NAMESPACE /
CART_METAFIELD_KEY from the same shared package the SvelteKit action and
the webhook use, so a write from this route and a write from the storefront
server action land in the identical metafield shape
(cart-intents.ts:44-64). The route's own header names its real caller as
the CDN PDP widget for hosts that can't run the SvelteKit action, and
e2e/journey/cit-to-mit-sandbox.spec.ts posts to it directly.
The post-purchase fallback (US-9.3) — backend built, storefront trigger
absent. fallback-capture.ts::handleAttachSubscription handles POST
/api/v1/storefront/orders/:orderId/attach-subscription — it validates the
order against BC, checks the same idempotency signal (existing charge by
bc_order_id), resolves the plan and a real BigPay stored instrument, then
creates the subscription and cycle-0/cycle-1 charges
(fallback-capture.ts:59-244). Its storefront-side trigger,
thank-you-fallback.ts::runThankYouFallback, reads an intent stashed in
sessionStorage by cart-capture.ts::storeIntentInSession and POSTs to this
endpoint — but grepping every route under
apps/storefront-svelte/src/routes for callers of runThankYouFallback,
storeIntentInSession, or setCartLineCustomFields turns up zero matches.
The function is real, typed, and covered by unit tests
(fallback-capture.test.ts); nothing in the live storefront calls it.
Where intent and reality diverge¶
The coverage matrix
(_coverage-matrix.json) reports
all six of Epic-9's stories at g4_status: pass (US-9.1 through US-9.6) —
each has a behavioral scenario exercising the real handler. That is true,
and for this domain specifically it hides more than usual. Six typed
deltas:
- Verified-but-incomplete — ADR-0079 consent-gating runs on the primary
webhook-materialization path (
webhooks.ts:742,status: !intent.consent && requireConsent ? 'paused' : ...) but the fallback path (fallback-capture.ts::handleAttachSubscription) has norequireConsentcheck at all and hardcodesstatus: 'active'(fallback-capture.ts:172) — a subscription created via the fallback bypasses consent-gating entirely regardless of the store's setting. - Verified-but-incomplete — the affirmative-consent block
(
SubscriptionConsent,packages/storefront-contract) is defined and server-enforced, but no storefront surface populates it:cart.tsand the PDP widget's add-to-cart handler carry zero references toconsent. A merchant who enablesrequire_subscribe_consenttoday would pause every subscription created through the live storefront, not just non-consenting ones, since the field is always absent on the wire. - Verified-but-incomplete — the fallback path also skips the two
defense-in-depth guards the webhook path runs:
isProductExcludedandisProductInCategoryAllowlist(Epic-26,webhooks.ts:622-658). Neither guard appears infallback-capture.ts, so a fallback-created subscription can materialize for a product a merchant has since excluded. - Named-deferred — BRD's own
ui-statesblock for US-9.3 (post-purchase fallback banner) and US-9.4 (checkout order-summary subscription annotation + consent checkbox) both readsurface: "NOT YET BUILT — forward-looking contract"(docs/audits/derived/brd-epics/epic-09-cart-and-checkout-subscription-intent-capture.md). The backend for US-9.3 exists and is G4-tested (fallback-capture.ts); its storefront trigger (thank-you-fallback.ts::runThankYouFallback) has zero callers in any route. - Built-but-untrodden — the sessionStorage/custom-fields capture pair in
cart-capture.ts(storeIntentInSession,setCartLineCustomFields) is real, typed, and covered by unit tests, but nothing inSubscriptionWidget.svelte's actual add-to-cart handler calls it — the live widget only postssubscription_intentto the SvelteKit?/addToCartaction, which writes the cart metafield, not line-item custom fields, viasetSubscriptionIntent. - Contract-verified, not live-verified — the strongest scenario for the
core create path (
subscribe-end-to-end.scenario.ts) drives the real HTTP router, real HMAC verification, and a realapplySchema'd D1, but stubs every outbound BC call; its own comment states live BC checkout can't be completed in CI (PI-5062). The one live-sandbox subscribe journey currently skips its cart-intent-write step on an app-scope error (missingstore_v2_default_cart, Hive #1443); the Stencil consumer-flow subscribe test is also skipped, citing the same PI-5062 rationale — a rationale now in tension withcanonical-charge-rail.md's ratified finding that the canonical charge rail is live-proven on this same sandbox via Stripe OCS (ADR-0082). This tension is reported here, not resolved — Input-B's own attestation flags it as unreconciled by its trace. - Dependency-gated — US-9.5 (third-party/incompatible-checkout
declaration) is G4-verified against the
store_capabilitiesadmin surface and its three named fallbacks (header script, thank-you fallback, manual admin reconciliation), but two of those three named fallbacks (header script, thank-you fallback) are themselves the not-yet-built surfaces above — the declaration is real; the fallbacks it points to are partially aspirational.
How to operate & extend¶
- The invariant you must not break: never materialize a second
Subscription for a BC order that already produced one. Both create paths
(webhook, fallback) check
charges.bc_order_idandsubscriptions.created_from_order_idbefore writing — extend both checks together if you add a third create path. - Debugging a missing subscription after checkout: start at
handleOrderCreated's three-path intent resolution (webhooks.ts:394-464) — check server logs for whichintentSourcefired (rest-cart-metafield/graphql-cart-metafield/custom-fields/none). Anoneresult with a real subscribe intent usually means the cart metafield write never landed — checkcart.ts::setSubscriptionIntentfor GraphQL errors, or (for third-party integrations) that the caller posted tocart-intents.tsbefore checkout. - Adding a new intent-producing surface (a new storefront, a headless
integration): write through
cart-intents.ts's REST endpoints, or import@bc-subscriptions/storefront-contractdirectly and replicate its namespace/key/codec — never hand-copy the metafield shape. That shared package is the whole fix for the 2026-06-25 dead-since-inception incident. - Where the product-guard extension points live:
isProductExcluded(routes/admin/product-exclusions.ts) andisProductInCategoryAllowlist(routes/admin/category-inclusions.ts), both called fromcreateSubscriptionsFromOrder(webhooks.ts:622-658) but absent fromfallback-capture.ts— wire both in there before relying on the fallback path for a merchant using either guard. - Finishing US-9.3 for real: the backend
(
fallback-capture.ts::handleAttachSubscription) is done; the missing piece is callingthank-you-fallback.ts::runThankYouFallbackfrom the BC thank-you page template/lifecycle, and adding the consent + product-guard checks the webhook path already has. - Wiring consent capture: the wire shape
(
SubscriptionConsentinpackages/storefront-contract) and the server-side gate (webhooks.ts:742) already exist; the storefront gap is populatingintent.consentfrom the PDP widget before the add-to-cart POST —cart.tsandSubscriptionWidget.svelteare the two files with noconsentreferences today.
Confidence notes¶
webhooks.ts:742and the surrounding consent block were read directly to confirm the exact ternary and theconsent_record/consent_missingevent branches (webhooks.ts:735-818); Input-B's characterization matches what's in code.- Zero-caller claims for
runThankYouFallback,storeIntentInSession, andsetCartLineCustomFieldsare based on a grep acrossapps/storefront-svelte/src/routesandapps/storefront-svelte/src/libin this session, matching Input-B's own search scope — I did not extend the search toapps/storefront-catalystor any non-Svelte host, since Input-B's canonical-framing attestation scopes the live storefront to the Svelte app. - The PI-5062 / ADR-0082 tension (Stencil subscribe test citing
PI-5062 as a blocker for live BC Payments on the same sandbox
canonical-charge-rail.mdreports as live-proven) is relayed as Input-B states it — unreconciled. I did not attempt to resolve which artifact is stale; that determination needs someone to re-run the Stencil test against the sandbox and see whether it still fails on that ground. - Live-state attestation evidence for the fallback backend — Input-B
cites subscription
d14dd777-61ec-43e0-83c3-a9916ed96e92(BC order 192, attach-subscription 201, then charged via order 193) as evidence living only in GitHub issue #1882's acceptance comments and live D1, not in any repo artifact. I did not independently query the live D1 or open issue #1882 in this session — I am relaying the ratified attestation, not re-deriving it, consistent with howcanonical-charge-rail.mdtreated its own live-state attestation. ADR-0026's "Catalyst caller… deleted" scope — I confirmed the ADR's text and did not separately re-verify thatpackages/storefront-catalyst/src/SubscriptionWidget.tsxstill carries its documented deprecation error; the ADR is cited for the cart-flow-is-canonical decision, not for present-day Catalyst state, which this domain page does not otherwise cover.