hraness

idempotent checkout and signed webhooks, fail-closed

the success page is not the source of truth

Drafted by an AI agent at Ben Guo's direct request from the Hraness source repositories, and checked against those sources before publication.

The browser that lands on /success after Checkout is telling you a story, not a fact. The user paid, probably, but “probably” is doing a lot of work. The redirect can be faked, replayed, bookmarked, or arrived at after a payment that later bounced. Treating it as authority means the system grants whatever the page claims was bought, which is why the success page is not the source of truth. The source of truth is a signed webhook, processed idempotently, whose evidence is re-fetched from the provider before anything is granted.

Both the Accounts service and the Sup product implement the same boundary: the browser’s return is UI, the webhook is evidence, and the provider’s canonical object is the state. What follows is the shape of that boundary and why each piece of it exists.

the browser input is a locator

The first boundary is what the browser is allowed to say. In Accounts, the only logical returnTarget the checkout path accepts is the literal accounts; the browser never supplies an origin, a path, a Price, a Customer, a Checkout Session, or an invoice identity. The server resolves accounts to an exact owned origin and path: the return URL is not a parameter, it is a mapping. Sup’s checkout is the same: the browser sends a pack ID and a client request key, and the server looks up the catalog Price, the Customer, and the Session itself.

That discipline is what makes “the success page is not the source of truth” enforceable. If the browser could supply a Session ID, a faked success page could point at any session; if it could supply a return URL, an open redirect lives inside the payment flow. Confining the browser to a locator (“show me the result of the operation I started”) means the worst it can do is ask to see something it was already allowed to see.

the webhook is signed and bounded

The webhook route is the only provider-facing write boundary, and it is exact about bytes. Accounts exposes POST /stripe/webhook; the body is read as bounded bytes, the stripe-signature header is required, and the payload is verified before it is parsed. A missing signature is a 401; any other verification failure is a 400. The event then has to pass three more checks before it is recorded: it must parse as a valid Stripe event, its livemode must match the configured runtime mode, and its API version must be the expected one: an event serialized by a different Stripe API version is rejected, not coerced.

Sup applies the same verification over the exact request bytes and then does more: it re-retrieves the current Checkout Session and PaymentIntent from Stripe and validates environment, scope, price, amount, currency, metadata, and the Session/PaymentIntent pair before any credit is granted. The event is not trusted because it arrived signed; it is trusted because its claims were checked against the provider’s current objects.

receipts are durable and leased

A signed event is recorded before it is processed. Accounts stores a receipt keyed by stripeEventId; an identical replay returns 200 with the stored outcome, a new event returns 202, and a conflicting reuse of the same event ID moves the receipt to an ownership-review state rather than silently accepting it. The receipt carries a durable state (received, processing, applied, and the terminal and review states) plus a lease and a retry budget, so a worker crash mid-processing does not lose the event or double-apply it; the lease expires, the receipt is re-claimed, and the idempotency key makes the retry safe.

This is the part that makes “idempotent” a property of the system rather than a hope. Stripe will deliver the same event more than once and will deliver different events out of order. The receipt table makes the first behavior harmless (same event ID, same result) and the durable state machine makes the second survivable, because each event is reconciled against canonical provider state, not against whatever order the events happened to arrive in.

webhooks are locators, not state

The most important design choice is that a webhook event is treated as a pointer, not a payload of truth. checkout.session.completed does not grant access; it says “a session completed, go look.” The handler then retrieves the canonical Session (and PaymentIntent, and Subscription as applicable) from Stripe and projects that (the provider’s current objects) into the local state. The event is the reason to look; the retrieved object is what is believed.

That is why the boundary holds under reordering and delay. A customer.subscription.deleted that arrives before the checkout.session.completed that created it does not leave the system in a torn state, because each event is reconciled against the provider’s current truth rather than applied as a diff. The local record is a projection of provider state with evidence, not an accumulation of event deltas that might have been processed in the wrong order.

checkout operations freeze the request

The client side of the boundary is equally deliberate. Before any provider call, the operation is reserved transactionally and its identity is frozen: the idempotency key, a request digest, the Customer, the plan, and the return target are recorded before Stripe is contacted. A lost or ambiguous provider response does not get a fresh attempt; the exact same operation is replayed, for up to 23 hours, because replaying the same operation with the same key is safe and inventing a new one is not. Only a confirmed provider rejection or an expiry into review permits a new operation, and a completed or expired operation rejects late responses outright.

Sup’s checkout state machine shows the same closed set: reserved, creating, checkout_open, provider_rejected, expired, paid_credited, refunded, payment_review. There is no “paid” state reachable from the browser return. The only path to paid_credited is the verified webhook, and the states that can follow it (refunded, payment_review) are there because a payment that later reverses is not an edge case, it is a designed-for outcome. The same closed set extends to the Portal side in Accounts: the only change a customer can make is the exact Community-to-Pro or Pro-to-Community move between the two catalog Prices, and the Portal configuration itself is re-retrieved and checked against the catalog before each session, so a dashboard drift fails closed instead of silently changing what a session can do.

The freeze is also what makes retry safe across the boundary that matters, the one between “the provider said something” and “the local record changed.” Because the operation’s identity is frozen before provider contact, a webhook that arrives while a retry is still in flight reconciles against the same record, and the idempotency key the freeze preserved is the one the provider saw. Two attempts cannot become two charges, because there is only ever one operation the key belongs to.

the fail-closed part

“Fail-closed” describes what happens when evidence is ambiguous rather than what happens when it is bad. An open dispute freezes the balance it drew from; a lost dispute reverses the grant, at most once; a favorable outcome reinstates it, at most once; and a reversal that would overdraw the account leaves the wallet frozen for review rather than letting the ledger go negative. A provider response that cannot be classified does not get a best-effort interpretation; it goes to payment_review, an absorbing state a human or a later reconciliation resolves.

The same posture covers the seams. A Checkout Session that is still open when a wallet enters review is expired under exact-ownership checks, so a half-finished purchase cannot quietly complete after the account is frozen. Foreign subscriptions and invoices (ones whose provider identity does not match the configured account) are recorded as absorbing evidence rather than rejected outright, so a misdirected event cannot be replayed into a different outcome. The system is not built to assume the provider is always right; it is built so that when the provider is wrong, the wrongness is contained and reviewable rather than silently applied.

sources

  • Accounts (account.hraness.com): the POST /stripe/webhook route, signature and version verification, and durable receipt projection in convex/http.ts and convex/stripeEvents.
  • The monorepo behind this site (projects): Sup’s convex/stripeWebhook.ts and creditCheckoutModel.ts for the canonical-object retrieval and closed state machine.
  • Stripe: webhook signature verification and checkout fulfillment: the provider behavior this boundary is built around.