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.
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"]
Two channels feed stripe.subscriptions, mirroring the appstore design (appstore.transactions client-submit + appstore.notifications webhook):
- Return-side: after redirect the client posts the
sessionIdtostripe.transactionsfor immediate confirmation. - Webhook:
stripe.notificationsis 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.
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.
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
Step by step:
- User clicks Purchase. Browser calls
POST /stripe/checkout/:identity(authenticated as that identity). stripe.checkout.createresolves the chosenplanto a Stripe price via config (prices[plan], rejecting unknown plans) and calls Stripecheckout.sessions.createwithmode: subscription,line_items: [{ price: prices[plan], quantity: 1 }],success_url(including?session_id={CHECKOUT_SESSION_ID}),cancel_url,client_reference_id: identity, andsubscription_data.metadata.identity(so the identity rides along onto the Subscription object). The Stripe secret key stays server-side here.- Backend returns
201 { url }; browser redirects to Stripe's hosted Checkout page. Card data is entered on Stripe's domain — we never touch it. - On payment success Stripe creates a Customer + Subscription and redirects the browser to
success_url?session_id=cs_...(our "Thank you" page). - Return-side confirmation: the Thank-you page posts the
session_idtoPOST /stripe/transactions/:identity.stripe.transactions.create→stripe.checkout.retrieve(id)(expanding the subscription), checkspayment_status === 'paid', then stores and emitscreated→stripe.subscriptions.merge. Gives the user instant premium without waiting for the webhook. - Webhook (independent, authoritative): Stripe sends events to
POST /stripe/notifications/—customer.subscription.createdon first purchase,invoice.paid+customer.subscription.updatedon renewals,customer.subscription.deletedon cancellation.stripe.notificationsverifies the signature and emits thesubscriptionevent →stripe.subscriptions.merge. stripe.subscriptions.mergereadsmetadata.identityandcurrent_period_endand 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). Itssyncevent setsaccounts.premium = expires.- The Thank-you page reflects premium via the realtime
default.accounts.syncevent (or by re-fetchingGET /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).
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').
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.
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 configretrieve: read a Checkout session back from Stripe's API (authoritative) to learn whether it was paid and which account/subscription it belongs to. (Used bystripe.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 bystripe.notifications.)
type Input = { payload: string; signature: string } // raw body (utf-8) + Stripe-Signature
type Output = Stripe.Event // throws/returns Error on bad signatureNone.
None (stateless; holds only configuration. Identity is round-tripped through Stripe via client_reference_id / metadata).
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.
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`created: a purchase was confirmed from the client's return. Consumed bystripe.subscriptions.
interface Transaction extends Entity {
identity: string // account id (32-hex)
session: Stripe.Checkout.Session // the verified, paid checkout session
}Receives and authenticates Stripe's asynchronous subscription lifecycle notifications and records them — the reliable source of truth, independent of the browser. Mirrors appstore.notifications.
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 entitysubscription: a subscription was created, renewed, or cancelled at Stripe. Consumed bystripe.subscriptions.
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
}Maintains the authoritative subscription expiration per account, reconciling signals from both the return-side and webhook channels. Mirrors appstore.subscriptions.
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 intoMergeInput.
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 persistssync: an account's subscription expiration changed. Consumed byaccountsto setpremium.
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)
}- accounts/manifest.toa.yaml: add receiver
stripe.subscriptions.sync: assign(next to existingappstore.subscriptions.sync: assign). Reuses appstore.subscriptions.sync.js logic in a newstripe.subscriptions.sync.jsmappingexpires -> premium. - context/compositions.yaml: add a
stripecomposition listing the fourstripe.*components (stripe.checkout,stripe.transactions,stripe.notifications,stripe.subscriptions). - context/configuration.yaml: add
stripe.checkoutconfig (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_KEYand$STRIPE_WEBHOOK_SECRETto env.
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 thesessionIdcarries the session JSON (the analog of appstore'splainPayload), so the test fully controlspayment_status,metadata.identity, andcurrent_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 }returns201with a stuburl; an unknownplanis rejected. - Notification path:
POST /stripe/notifications/with a JSONcustomer.subscription.createdbody carryingmetadata.identity+current_period_end; assertGET /accounts/echo/then showspremiumand the realtimedefault.accounts.syncevent is received by the account. - Return-side path:
POST /stripe/transactions/:identity { sessionId }(stub returns a paid session) grants the samepremium. - 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.
No Stripe Customer Portal, no proration/upgrade flows, no restore endpoint. Renew/cancel handled only via the webhook events listed above.
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:
- Create a Stripe account; keep the dashboard in Test mode.
- Dashboard → Product catalog → add a Product with one recurring Price per plan option (e.g. monthly, yearly); copy each price id
price_.... - Dashboard → Developers → API keys → copy the test secret key
sk_test_...(hosted Checkout needs no publishable key). - 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.
- Configure local env /
stripe.checkoutconfig:STRIPE_SECRET_KEY=sk_test_...STRIPE_WEBHOOK_SECRET=whsec_...(the value fromstripe listen)pricesmap, e.g.{ monthly: price_..., yearly: price_... }successUrl=http://localhost:<web>/thanks?session_id={CHECKOUT_SESSION_ID},cancelUrl=http://localhost:<web>/cancel
- Start the app (the
stripecomposition) and keepstripe listenrunning.
Exercising the flow:
- Client calls
POST /stripe/checkout/:identity(authed) with the chosen{ plan }→ receives a real test Checkouturl. - Open the
url, pay with a Stripe test card (4242 4242 4242 4242, any future expiry, any CVC/ZIP). - Stripe redirects to
successUrl?session_id=cs_test_...; the Thank-you page posts thatsession_idtoPOST /stripe/transactions/:identity(immediate grant). - In parallel,
stripe listenforwardscustomer.subscription.created(and later renewals/cancellations) to/stripe/notifications/(authoritative grant). - Verify with
GET /accounts/echo/→premiumis 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/.
- Write
features/resources/stripe.featurescenarios (checkout returns url; notification + return-side grant premium; idempotency) mirroringappstore.feature. - Create
stripe.checkoutgateway component (Stripe SDK + credentials):create,retrieve,decodeoperations; config schema;package.jsonstripedependency; env-gated stub. - Create
stripe.transactionscomponent:POST /:identity { sessionId }→stripe.checkout.retrieve+ verify → store → emitcreated→subscriptions.merge. - Create
stripe.notificationscomponent: anonymousingest, verify Stripe-Signature HMAC over raw body (viastripe.checkout.decode), store entity, emitsubscriptionevent. - Create
stripe.subscriptionscomponent mirroringappstore.subscriptions(merge transition, identity from metadata, expires from current_period_end). - Add
accountsreceiverstripe.subscriptions.sync: assignandstripe.subscriptions.sync.jsmappingexpires -> premium. - Add
stripecomposition incompositions.yamlandstripe.checkoutconfig +STRIPE_SECRET_KEY/STRIPE_WEBHOOK_SECRETinconfiguration.yaml.