Skip to content

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/intent surface (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-contract and are imported by the webhook, the Svelte cart server, and cart-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 commit 8b75791a).
  • Affirmative consent gates subscription activation per-merchant. When a store opts into require_subscribe_consent, a subscription created without a captured consent block activates paused instead of active/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::handleOrderCreatedcreateSubscriptionsFromOrder) 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):

  1. REST cart-metafield (primary)GET /v2/orders/{id} for the order's cart_id, then GET /v3/carts/{cart_id}/metafields filtered to the bc-subscriptions namespace (webhooks.ts:394-412, fetchCartMetafieldIntentsRest).
  2. 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).
  3. Line-item custom fields (tertiary)GET /v2/orders/{id}/products for a sub_plan_id custom 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 guardsisProductExcluded and isProductInCategoryAllowlist (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 immutable consent_record (US-28.7); its absence with enforcement ON emits a subscription.consent_missing event 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' with bc_order_id set, never routed through a ProcessorAdapter.charge call 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-level derives_from pins worker.ts, webhooks.ts's dispatch path, and subscribe-end-to-end.scenario.ts, among others — the same single-source frontmatter as the dunning and canonical-charge-rail transclusions from this file). It carries sign_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 inside createSubscriptionsFromOrder — those are prose above, traced separately against webhooks.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 no requireConsent check at all and hardcodes status: '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.ts and the PDP widget's add-to-cart handler carry zero references to consent. A merchant who enables require_subscribe_consent today 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: isProductExcluded and isProductInCategoryAllowlist (Epic-26, webhooks.ts:622-658). Neither guard appears in fallback-capture.ts, so a fallback-created subscription can materialize for a product a merchant has since excluded.
  • Named-deferred — BRD's own ui-states block for US-9.3 (post-purchase fallback banner) and US-9.4 (checkout order-summary subscription annotation + consent checkbox) both read surface: "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 in SubscriptionWidget.svelte's actual add-to-cart handler calls it — the live widget only posts subscription_intent to the SvelteKit ?/addToCart action, which writes the cart metafield, not line-item custom fields, via setSubscriptionIntent.
  • 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 real applySchema'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 (missing store_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 with canonical-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_capabilities admin 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_id and subscriptions.created_from_order_id before 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 which intentSource fired (rest-cart-metafield / graphql-cart-metafield / custom-fields / none). A none result with a real subscribe intent usually means the cart metafield write never landed — check cart.ts::setSubscriptionIntent for GraphQL errors, or (for third-party integrations) that the caller posted to cart-intents.ts before 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-contract directly 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) and isProductInCategoryAllowlist (routes/admin/category-inclusions.ts), both called from createSubscriptionsFromOrder (webhooks.ts:622-658) but absent from fallback-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 calling thank-you-fallback.ts::runThankYouFallback from 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 (SubscriptionConsent in packages/storefront-contract) and the server-side gate (webhooks.ts:742) already exist; the storefront gap is populating intent.consent from the PDP widget before the add-to-cart POST — cart.ts and SubscriptionWidget.svelte are the two files with no consent references today.

Confidence notes

  • webhooks.ts:742 and the surrounding consent block were read directly to confirm the exact ternary and the consent_record / consent_missing event branches (webhooks.ts:735-818); Input-B's characterization matches what's in code.
  • Zero-caller claims for runThankYouFallback, storeIntentInSession, and setCartLineCustomFields are based on a grep across apps/storefront-svelte/src/routes and apps/storefront-svelte/src/lib in this session, matching Input-B's own search scope — I did not extend the search to apps/storefront-catalyst or 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.md reports 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 how canonical-charge-rail.md treated its own live-state attestation.
  • ADR-0026's "Catalyst caller… deleted" scope — I confirmed the ADR's text and did not separately re-verify that packages/storefront-catalyst/src/SubscriptionWidget.tsx still 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.