One Stripe webhook needed two different idempotencies

Replay and reorder are different failures, an assignment and a delta are different writes, and each pairing needs its own defence. The billing code says so at length; this is the short version.

Reference: Plans and prepaid packs

Tamperlens bills in USD and BRL through one Stripe account: subscriptions for the plans, and one-time Checkout for the prepaid credit packs the Brazilian market prefers, boleto included. It all lands on one webhook. That webhook has to survive the two things Stripe is explicit about: delivery happens at least once, and order is not promised. Those are two different problems, and each needs its own kind of idempotency, the property that says handling the same event twice leaves the same result. The second one is the expensive one.

Problem one: a retry that arrives after the future

Every plan handler here is an absolute assignment: "this account is now on team". That single property makes a replay harmless, because applying the same event twice converges. What it does not survive is reordering, and the sequence that costs money is ordinary:

A delivery fails, and its retry arrives after the next event

T+0   customer.subscription.updated  price=solo   (delivery fails)
T+30  customer.subscription.updated  price=team   -> account on team
T+90  Stripe retries the T+0 event                -> account back on solo

The customer paid for Team and is now metered as Solo. Both deliveries show a 200 OK in the Stripe dashboard, and nothing in our logs reads as wrong.

So each account carries a watermark: the created stamp of the last event applied. An older event is acknowledged and dropped.

One detail took longer than the feature. The comparison is strictly less-than, not less-than-or-equal. created has one-second resolution, and a single purchase routinely emits two events inside the same second. Rejecting the second would be a regression wearing a guard's clothes.

Problem two: the replay the watermark cannot see

Credit packs broke the pattern. A grant is not an assignment, it is a delta: balance plus 500. Applying a delta twice does not converge, it doubles. And the watermark is useless here, in a way worth spelling out, because it fails in both directions at once.

  • It does not catch the replay. A redelivered event carries the same created as the original. So "older than the last applied" is false, and the pack is granted again: 2,500 documents given away, with a 200 next to both deliveries. The watermark orders events. It does not identify them.
  • Consulting it would drop legitimate purchases. A credit checkout is not ordered against a subscription event at all. An account that upgraded on Tuesday, then buys a pack whose delivery is retried, would see the pack discarded as stale. Money taken, nothing granted.

So the grant branch neither reads nor writes the watermark. Replay protection is a uniqueness constraint instead: a PRIMARY KEY on the Stripe Checkout Session id, written in the same transaction as the balance update. A second copy of the same event cannot insert, so it cannot grant. Identity for the problem that is about identity, ordering for the problem that is about order. The regression test posts the identical signed payload twice and asserts the balance. Without the grants table, it doubles.

The boleto lesson: "checkout completed" is not "paid"

The 2026-08-24 security review found the gap that only exists because this product sells in Brazil. checkout.session.completed fires the moment Checkout finishes. For a card, that coincides with payment. For a delayed method like boleto, the buyer's favourite for one-time purchases, it fires with payment_status: "unpaid", days before any money moves or fails to.

The handler used to grant the pack right there. So a buyer could receive 2,500 documents for a boleto they never pay, with nothing ever revoking them.

Fulfilment now releases only on paid or no_payment_required. checkout.session.async_payment_succeeded fulfils through the same helper, deduplicated by the same session id. async_payment_failed is acknowledged, with nothing granted.

What I deliberately did not do is pin checkout to cards. The gate makes delayed methods safe, so boleto and PIX stay available. For this market, that is the point of the packs existing at all.

Two smaller rules from the same review

Subscription status became an allow-list. Only active and trialing apply a paid plan. A hard-stopped status drops to free. The ambiguous middle is acknowledged without touching the plan, so an incomplete subscription that never paid can no longer gain one.

A storage failure inside the webhook now returns 500 rather than 200. Every handler is idempotent, so letting Stripe redeliver is safe. It also recovers a paid upgrade that a swallowed error would have dropped on the floor.

Why this is a fraud-product post and not a billing post

The two idempotencies teach the same lesson the engine keeps learning in its own domain. A mechanism is only as good as the failure class it was built for. A watermark against replays, or a dedupe key against reordering: both sound plausible, and both are wrong. It is the same mistake as a signal that is true of the bytes but the wrong severity for the document.

The fix in both worlds is the same. Name the failure precisely. Then check the defence against that name, in a test that fails when someone swaps them.

The metering all of this protects starts free. The API quickstart shows an anonymous inspection that needs no account at all, and a free key covers 50 documents a month. The billing only exists past the point where the reports earn it.