Skip to content

Instantly share code, notes, and snippets.

@temich
Created June 2, 2026 21:03
Show Gist options
  • Select an option

  • Save temich/bf74e5cc834b2c9e8be8f88942fc094d to your computer and use it in GitHub Desktop.

Select an option

Save temich/bf74e5cc834b2c9e8be8f88942fc094d to your computer and use it in GitHub Desktop.
Stripe integration

Stripe Web Subscriptions Integration

Mirror the proven appstore.* design with parallel stripe.* components. Web client redirects to Stripe Checkout; Stripe webhooks drive accounts.premium through the same sync mechanism Apple uses.

Target flow

flowchart LR
  Web -->|"POST /stripe/checkout/:identity"| SC["stripe.checkout (Stripe gateway)"]
  SC -->|"create session"| StripeAPI["Stripe API"]
  SC -->|"{ url }"| Web
  Web -->|redirect| StripeHosted["Stripe hosted page"]
  Web -->|"POST /stripe/transactions/:identity { sessionId }"| ST["stripe.transactions"]
  ST -->|"retrieve (verify)"| SC
  ST -->|"created event"| SS["stripe.subscriptions"]
  StripeHosted -->|"webhook (raw body + Stripe-Signature)"| SW["stripe.notifications"]
  SW -->|"decode (HMAC verify)"| SC
  SW -->|"subscription event"| SS
  SS -->|"sync event (expires)"| ACC["accounts.premium"]
Loading

Two channels feed stripe.subscriptions, mirroring the appstore design (appstore.transactions client-submit + appstore.notifications webhook):

  • Return-side: after redirect the client posts the sessionId to stripe.transactions for immediate confirmation.
  • Webhook: stripe.notifications is the async source of truth (works even if the user closes the tab; handles renewals/cancellations).

Identity mapping: pass account id as client_reference_id + subscription_data.metadata.identity when creating the session; read metadata.identity back from the session/subscription/webhook objects. expires = current_period_end*1000, timestamp = event.created*1000.

Webhook authenticity: real Stripe HMAC (raw body)

Stripe signs `${t}.${rawBody}` (HMAC-SHA256, secret = endpoint signing secret) and sends it in the Stripe-Signature header. Verification requires the exact original bytes — re-serialized JSON will not match.

TOA support is implemented (see map.feature): the map:buffer: <prop> directive maps the raw request body into operation input, and application/json; charset=utf-8 is accepted. The original rawbody.md spec is therefore resolved.

stripe.notifications ingest uses map:buffer for the raw payload + map:headers for stripe-signature, then passes both to the stripe.checkout.decode operation, which calls the Stripe SDK on the client instance: client.webhooks.constructEvent(payload, signature, webhookSecret) (here client.webhooks is the SDK's webhooks namespace, unrelated to our stripe.notifications component).

Note: map:buffer delivers the body as a UTF-8 string, not a Buffer (map.md#raw-body). Decode it back to bytes in the operation before verifying, e.g. client.webhooks.constructEvent(Buffer.from(payload, 'utf-8'), signature, webhookSecret), so the HMAC is computed over the original bytes.

Purchase workflow (end to end)

What happens between the user clicking "Purchase" and seeing "Thank you". The success_url redirect is UX only; premium is granted by verified data from either the return-side sessionId (immediate) or the webhook (async source of truth) — both converge on stripe.subscriptions.merge and are idempotent.

sequenceDiagram
  participant U as User (browser)
  participant B as evn.toa backend
  participant S as Stripe

  U->>B: POST /stripe/checkout/:identity (authed)
  B->>S: checkout.sessions.create (price, success/cancel urls, metadata.identity)
  S-->>B: Session { id, url }
  B-->>U: 201 { url }
  U->>S: redirect to hosted Checkout page
  Note over U,S: User enters card on Stripe's domain (PCI handled by Stripe)
  U->>S: submit payment
  S->>S: create Customer + Subscription, charge first invoice
  S-->>U: redirect to success_url?session_id=cs_... ("Thank you" page)
  par Return-side (immediate)
    U->>B: POST /stripe/transactions/:identity { sessionId }
    B->>S: checkout.sessions.retrieve (expand subscription)
    S-->>B: Session (payment_status=paid, subscription, metadata.identity)
    B->>B: stripe.transactions.create -> created -> subscriptions.merge
  and Webhook (async, authoritative)
    S--)B: POST /stripe/notifications/ (raw body + Stripe-Signature)
    B->>B: constructEvent verify -> store -> subscription event -> subscriptions.merge
  end
  B->>B: accounts.assign premium = expires (via sync event)
  B--)U: realtime default.accounts.sync (premium) / client re-fetches /accounts/echo
Loading

Step by step:

  1. User clicks Purchase. Browser calls POST /stripe/checkout/:identity (authenticated as that identity).
  2. stripe.checkout.create resolves the chosen plan to a Stripe price via config (prices[plan], rejecting unknown plans) and calls Stripe checkout.sessions.create with mode: subscription, line_items: [{ price: prices[plan], quantity: 1 }], success_url (including ?session_id={CHECKOUT_SESSION_ID}), cancel_url, client_reference_id: identity, and subscription_data.metadata.identity (so the identity rides along onto the Subscription object). The Stripe secret key stays server-side here.
  3. Backend returns 201 { url }; browser redirects to Stripe's hosted Checkout page. Card data is entered on Stripe's domain — we never touch it.
  4. On payment success Stripe creates a Customer + Subscription and redirects the browser to success_url?session_id=cs_... (our "Thank you" page).
  5. Return-side confirmation: the Thank-you page posts the session_id to POST /stripe/transactions/:identity. stripe.transactions.createstripe.checkout.retrieve(id) (expanding the subscription), checks payment_status === 'paid', then stores and emits createdstripe.subscriptions.merge. Gives the user instant premium without waiting for the webhook.
  6. Webhook (independent, authoritative): Stripe sends events to POST /stripe/notifications/customer.subscription.created on first purchase, invoice.paid + customer.subscription.updated on renewals, customer.subscription.deleted on cancellation. stripe.notifications verifies the signature and emits the subscription event → stripe.subscriptions.merge.
  7. stripe.subscriptions.merge reads metadata.identity and current_period_end and upserts the Subscription entity (keyed by Stripe subscription id, so the return-side and webhook paths are idempotent — whichever arrives first wins, later ones are no-ops via the timestamp guard). Its sync event sets accounts.premium = expires.
  8. The Thank-you page reflects premium via the realtime default.accounts.sync event (or by re-fetching GET /accounts/echo/).

Why the redirect itself can't grant premium: it is unauthenticated and can be skipped/lost. Both real grant paths verify with Stripe — the return-side by retrieving the session via the secret key, the webhook by HMAC signature — same dual model Apple uses (appstore.transactions + appstore.notifications).

Components (under application/components/)

Stripe object types below are from the stripe package (import type Stripe from 'stripe'); account identity is the 32-hex account id. Entities extend the framework Entity (import type { Entity } from 'types').

stripe.checkout

The Stripe gateway: the only component that holds Stripe credentials and the SDK, so no other component touches Stripe directly. It both exposes the public purchase-start endpoint and provides the session-retrieval and webhook-decode helpers used by stripe.transactions and stripe.notifications. Combines the public entrypoint with the credential/SDK isolation that appstore.verifier provides for the appstore set.

Operations

  • create: begin a purchase for an account — open a hosted Checkout session at Stripe and return the URL the browser should be redirected to. (Public endpoint.)
type Input = { identity: string; plan: string }  // identity from path; plan = chosen option key
type Output = { url: string }                     // hosted Checkout URL to redirect to
// `plan` is resolved to a Stripe price id via config `prices` (reject unknown plans); urls come from config
  • retrieve: read a Checkout session back from Stripe's API (authoritative) to learn whether it was paid and which account/subscription it belongs to. (Used by stripe.transactions.)
type Input = { sessionId: string }            // cs_...
type Output = Stripe.Checkout.Session          // with expanded `subscription`
  • decode: validate a pushed webhook's Stripe signature and return its decoded event; verification is intrinsic to decoding and rejects forgeries. (Used by stripe.notifications.)
type Input = { payload: string; signature: string }  // raw body (utf-8) + Stripe-Signature
type Output = Stripe.Event                            // throws/returns Error on bad signature

Events

None.

State

None (stateless; holds only configuration. Identity is round-tripped through Stripe via client_reference_id / metadata).

stripe.transactions

Accepts the client's proof of payment when it returns from Stripe and confirms it by reading the session back from Stripe's API, giving the user immediate confirmation. Mirrors appstore.transactions.

Operations

  • create: confirm a checkout session submitted by the returning client (verify it is paid and belongs to this account) and record it as a confirmed purchase.
type Input = { identity: string; sessionId: string }  // identity from path, sessionId from body
type Output = Transaction                              // stored entity; HTTP exposes `id`

Events

  • created: a purchase was confirmed from the client's return. Consumed by stripe.subscriptions.

State

interface Transaction extends Entity {
  identity: string            // account id (32-hex)
  session: Stripe.Checkout.Session  // the verified, paid checkout session
}

stripe.notifications

Receives and authenticates Stripe's asynchronous subscription lifecycle notifications and records them — the reliable source of truth, independent of the browser. Mirrors appstore.notifications.

Operations

  • ingest: authenticate an incoming Stripe webhook and persist the subscription lifecycle event it reports (also serves as audit log / idempotency).
type Input = { payload: string; signature: string }  // map:buffer body + map:headers signature
type Output = Event                                   // stored entity

Events

  • subscription: a subscription was created, renewed, or cancelled at Stripe. Consumed by stripe.subscriptions.

State

interface Event extends Entity {
  id: string                       // Stripe event id (evt_...)
  type: string                     // e.g. 'customer.subscription.updated'
  created: number                  // Stripe event timestamp (seconds)
  subscription?: Stripe.Subscription  // data.object for subscription.* events
}

stripe.subscriptions

Maintains the authoritative subscription expiration per account, reconciling signals from both the return-side and webhook channels. Mirrors appstore.subscriptions.

Operations

  • merge: update an account's subscription expiration from a purchase or lifecycle signal, ignoring stale/duplicate ones (timestamp guard makes the two channels idempotent). Keyed by the Stripe subscription id; both receivers (stripe.transactions.created, stripe.notifications.subscription) normalize their payload into MergeInput.
type MergeInput = {
  subscription: Stripe.Subscription  // carries id, metadata.identity, items[].current_period_end, status
  timestamp: number                  // signal time in ms (event.created*1000, or session/now)
}
type MergeOutput = void              // transition mutates the Subscription entity; framework persists

Events

  • sync: an account's subscription expiration changed. Consumed by accounts to set premium.

State

interface Subscription extends Entity {
  id: string        // Stripe subscription id (sub_...)
  identity: string  // account id (32-hex)
  expires: number   // premium expiration in ms (current_period_end * 1000)
  timestamp: number // time of the last applied signal in ms (obsolete guard)
}

Wiring (existing files)

  • accounts/manifest.toa.yaml: add receiver stripe.subscriptions.sync: assign (next to existing appstore.subscriptions.sync: assign). Reuses appstore.subscriptions.sync.js logic in a new stripe.subscriptions.sync.js mapping expires -> premium.
  • context/compositions.yaml: add a stripe composition listing the four stripe.* components (stripe.checkout, stripe.transactions, stripe.notifications, stripe.subscriptions).
  • context/configuration.yaml: add stripe.checkout config (secretKey: $STRIPE_SECRET_KEY, webhookSecret: $STRIPE_WEBHOOK_SECRET, prices — a map of plan key → Stripe price id, e.g. { monthly: price_..., yearly: price_... }, successUrl, cancelUrl); add $STRIPE_SECRET_KEY and $STRIPE_WEBHOOK_SECRET to env.

Testing (features)

Feature tests must be hermetic — no network, deterministic, no secrets — so they cannot hit Stripe's real API. We use the same trick as appstore: stripe.checkout (the gateway) has an env-gated stub in place of the SDK, analogous to createPlainVerifier in verifier.js which returns the plainPayload as-is when context.env === 'local'.

Stub behavior (gateway in local/test, no Stripe calls):

  • create → returns a deterministic fake { url } (e.g. https://checkout.stripe.test/<id>), no API call.
  • decode(payload, signature)JSON.parse(payload) and return it as the event, skipping HMAC (just like the appstore plain verifier treats the signed input as already-decoded).
  • retrieve(sessionId) → returns the session supplied inline by the test instead of fetching it: in local the sessionId carries the session JSON (the analog of appstore's plainPayload), so the test fully controls payment_status, metadata.identity, and current_period_end.

features/resources/stripe.feature, mirroring appstore.feature (which posts a plainPayload notification and asserts the realtime default.accounts.sync carries premium):

  • Checkout: POST /stripe/checkout/:identity { plan } returns 201 with a stub url; an unknown plan is rejected.
  • Notification path: POST /stripe/notifications/ with a JSON customer.subscription.created body carrying metadata.identity + current_period_end; assert GET /accounts/echo/ then shows premium and the realtime default.accounts.sync event is received by the account.
  • Return-side path: POST /stripe/transactions/:identity { sessionId } (stub returns a paid session) grants the same premium.
  • Idempotency: applying both channels for the same subscription does not double-apply or regress (timestamp guard).

Why not Stripe test mode here: real test-mode/sandbox calls add network, flakiness, and credentials, breaking hermetic CI. Test mode is for manual end-to-end on localhost — see the next chapter.

Out of scope (per "simplest")

No Stripe Customer Portal, no proration/upgrade flows, no restore endpoint. Renew/cancel handled only via the webhook events listed above.

Client implementation (manual testing on localhost)

How a client developer exercises the real API against Stripe test mode on localhost. The gateway stub is bypassed when a real Stripe key is configured (gate the stub on absence of secretKey, not purely on env), so providing sk_test_... makes the gateway call real Stripe even locally.

Setup steps:

  1. Create a Stripe account; keep the dashboard in Test mode.
  2. Dashboard → Product catalog → add a Product with one recurring Price per plan option (e.g. monthly, yearly); copy each price id price_....
  3. Dashboard → Developers → API keys → copy the test secret key sk_test_... (hosted Checkout needs no publishable key).
  4. Install the Stripe CLI, run stripe login, then forward events to the local notifications endpoint:
stripe listen --forward-to localhost:8000/stripe/notifications/

The CLI prints a webhook signing secret whsec_... — copy it.

  1. Configure local env / stripe.checkout config:
    • STRIPE_SECRET_KEY=sk_test_...
    • STRIPE_WEBHOOK_SECRET=whsec_... (the value from stripe listen)
    • prices map, e.g. { monthly: price_..., yearly: price_... }
    • successUrl=http://localhost:<web>/thanks?session_id={CHECKOUT_SESSION_ID}, cancelUrl=http://localhost:<web>/cancel
  2. Start the app (the stripe composition) and keep stripe listen running.

Exercising the flow:

  1. Client calls POST /stripe/checkout/:identity (authed) with the chosen { plan } → receives a real test Checkout url.
  2. Open the url, pay with a Stripe test card (4242 4242 4242 4242, any future expiry, any CVC/ZIP).
  3. Stripe redirects to successUrl?session_id=cs_test_...; the Thank-you page posts that session_id to POST /stripe/transactions/:identity (immediate grant).
  4. In parallel, stripe listen forwards customer.subscription.created (and later renewals/cancellations) to /stripe/notifications/ (authoritative grant).
  5. Verify with GET /accounts/echo/premium is set.

Useful: replay/trigger events without paying, e.g. stripe trigger customer.subscription.updated. Cancellations/renewals can be simulated from the dashboard or CLI and arrive via /stripe/notifications/.

Implementation tasks

  1. Write features/resources/stripe.feature scenarios (checkout returns url; notification + return-side grant premium; idempotency) mirroring appstore.feature.
  2. Create stripe.checkout gateway component (Stripe SDK + credentials): create, retrieve, decode operations; config schema; package.json stripe dependency; env-gated stub.
  3. Create stripe.transactions component: POST /:identity { sessionId }stripe.checkout.retrieve + verify → store → emit createdsubscriptions.merge.
  4. Create stripe.notifications component: anonymous ingest, verify Stripe-Signature HMAC over raw body (via stripe.checkout.decode), store entity, emit subscription event.
  5. Create stripe.subscriptions component mirroring appstore.subscriptions (merge transition, identity from metadata, expires from current_period_end).
  6. Add accounts receiver stripe.subscriptions.sync: assign and stripe.subscriptions.sync.js mapping expires -> premium.
  7. Add stripe composition in compositions.yaml and stripe.checkout config + STRIPE_SECRET_KEY/STRIPE_WEBHOOK_SECRET in configuration.yaml.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment