Skip to main content

ADR-0021: Stripe billing — paid conversion (Checkout + signed webhooks)

Status: Accepted · 2026-07-15

Context

The customer journey’s final step (web design §5/§9) is convert: an evaluation becomes a paid subscription without reinstalling the customer platform. This ADR adds Stripe billing to the Account portal, upgrading the account’s edition on the existing control registry so the deployment picks up the paid plan on its next renewal/activation — no reinstall.

Decisions

1. Stripe Checkout for payment; the webhook is the source of truth

An org owner starts an upgrade → the portal creates a Stripe Checkout Session (subscription mode) and redirects to Stripe-hosted checkout (PCI stays entirely with Stripe; the portal never sees card data). The entitlement change happens only on the verified webhook (checkout.session.completed), never on the redirect back — the success URL is UX, the webhook is truth. The account id + target edition ride along as client_reference_id + metadata (on the session and the subscription) so the webhook attributes the payment without extra lookups.

2. The webhook signature is the trust boundary, verified over raw bytes

billing-stripe.ts implements Stripe’s Stripe-Signature scheme itself (no SDK): HMAC-SHA256 over `${t}.${rawBody}` with the webhook signing secret, constant-time compared (length-guarded) against any v1 signature (key rotation), with a 300 s timestamp tolerance for replay protection. The route reads the exact raw body (c.req.text()) and verifies before parsing or acting. An event is acted on only after the signature verifies — so an account can be upgraded/downgraded only by a genuine Stripe event, not a forged POST.

3. Upgrade maps to the existing plan model; cancellation reverts

On a verified upgrade, updateAccountPlan(planForEdition(edition)) replaces the cp_account’s edition/features/capacity/term (subscription), and the Stripe customer + subscription ids are stored. customer.subscription.deleted reverts the account to the evaluation plan. Re-delivered events are idempotent (the same plan re-applied is a no-op in effect). A verified event always returns 200 (even unhandled types) so Stripe stops retrying; only signature failures 4xx.

4. Checkout is authorized; secrets come only from the environment

The checkout route is member-guarded + CSRF (a non-member can’t start a checkout for another org, and can’t pick an arbitrary price — the price is resolved server-side from the requested edition via the configured price→edition map). Billing is feature-gated: off unless both the Stripe secret key and webhook secret resolve (env refs) and at least one price mapping is set. The Stripe secret key never leaves the environment and is used only server-side to create sessions; nothing sensitive is logged. Pricing (the actual Products/Prices) is a business decision configured by the operator in Stripe — the code references price ids, never amounts.

Consequences

  • The commercial loop is complete end to end: evaluate → download → activate → support → convert (pay) → the deployment upgrades on next renewal.
  • The portal holds a Stripe secret key server-side (like the Control signing key) — resolved from env, never committed; PCI scope stays with Stripe.
  • Out of scope (future): a billing portal/self-service plan management (Stripe Customer Portal), proration/seat metering tied to capacity meters, dunning, and invoice history in the portal UI.

Verification

account/billing-stripe.test.ts (14) — signature accept; reject on wrong secret / missing header / tampered body / stale timestamp (replay); multi-v1 rotation; event→intent extraction (paid only); checkout session creation (form/metadata + API error); and the HTTP flow: checkout 503-when-disabled / 404-non-member / 403-no-CSRF / 400-evaluation / 201-with-url, and a signed webhook that actually upgrades the account edition + records the Stripe ids, invalid-signature refusal (no upgrade), cancellation revert, and idempotent re-delivery. All 304 server tests pass; typecheck + lint clean; the upgrade UI verified in a browser; adversarially reviewed.