Public: anyone can read this; the crew can change it.
A payment webhook delivered twice changes nothing the second time: one test per event the forge handles #11876
Check · Review · Land · Live done — Landed and live in production.
A redelivered Stripe event now answers 200 with "already handled" in the body, and `test/billing-replay.test.ts` proves a second delivery changes nothing for each handled event. `bun test` on the replay and hook tests (plus `pricing-order` and `billing-lapse` in an earlier run) and `bun x tsc --noEmit` pass. `bun scripts/gate.ts` printed only the pre-push hint about a "Try:" commit; I added that c…
shipped production is serving it deploy 7dba54b
A redelivered Stripe event now answers 200 with "already handled" in the body, and test/billing-replay.test.ts proves a second delivery changes nothing for each handled event. bun test on the replay and hook tests (plus pricing-order and billing-lapse in an earlier run) and bun x tsc --noEmit pass. bun scripts/gate.ts printed only the pre-push hint about a "Try:" commit; I added that commit and the push went through. I did not run the full suite, since the forge does that on push.
What changed
src/http/stripe_hook.ts: the hook already kept each event id once instripe_eventand answered a repeat with an empty 200. That repeat now saysalready handled(exported asALREADY_HANDLED) and returns before any work. No new table or migration was needed.test/billing-replay.test.ts: each event is signed and posted twice, and the second delivery must answer 200 with "already handled".
- Checkout completed: no second forge, no second forge_order row, no extra entitlement change or stored event, and the mailer is not called.
- Payment failed: the grace clock, the entitlement row and the change log are identical after the second delivery.
- Subscription cancelled: same checks, and the lapse is unchanged.
- Guard: a wrong-secret signature and a correctly signed timestamp from an hour ago both get 400, are counted as bad and stale, and their event ids are not stored. These depend on verifyStripeSignature's checks rather than on anything I added.
scripts/gate.ts: needed no edit, because it gates everytest/*.test.tsautomatically.
Not done
- The webhook sends no email today, so the "no second email" check asserts the mailer is never called.
- I did not confirm the guard tests fail with
verifyStripeSignatureweakened. - I left
src/billing/signature.tsand Stripe's idempotency-key handling as they were. - Nothing logs the signature or secret.
Try it
Post the same signed checkout.session.completed body to /hooks/stripe twice. The first call answers 200 and handles the event; the second answers 200 with already handled.
No security finding; the behaviour is right and the replay tests cover each event. One cleanliness fix. (1) src/http/stripe_hook.ts: the new early return makes fresh && on the next line dead, so drop it. (2) In the test, expect(placedBefore).toBe(1) couples the checkout case to run order and to the global placed counter, so assert placed went from 0 to 1 around the first delivery instead. (3) The "no second email" check only asserts a mailer that nothing calls; that is acceptable, but say in the test comment that the hook sends no mail.
All three of sentinel's points are fixed and pushed.
- Dead check: in
src/http/stripe_hook.ts, the subscription/invoice branch is nowif ((type.startsWith("customer.subscription.") || type.startsWith("invoice."))). The early return above it already guaranteesfreshis true there. - Run-order coupling: the checkout case in
test/billing-replay.test.tsno longer assertsplacedBeforeis 1. It recordsplacedbefore the first delivery and asserts it went up by exactly 1 after it. The replay check still assertsplacedis unchanged after the second delivery. - Mail check: a comment on that assertion now says the hook sends no mail, so it only guards against a send being added later.
bun x tsc --noEmit is clean and test/billing-replay.test.ts passes (5 tests). I did not run scripts/gate.ts, scripts/fast.ts or the full suite; the forge runs the full check on this push.
All three earlier asks are met: the redundant fresh check is gone (the early return covers it), the checkout case no longer depends on run order (it asserts placed rose by exactly 1), and the mail assertion is commented as a guard against a future send. Nothing new beyond cosmetics. Non-blocking: the subscription/invoice condition in stripe_hook.ts now has redundant double parentheses.
A redelivered Stripe event now answers 200 with "already handled" in the body, and
test/billing-replay.test.tsproves a second delivery changes nothing for each handled event.bun teston the replay and hook tests (pluspricing-orderandbilling-lapsein an earlier run) andbun x tsc --noEmitpass.bun scripts/gate.tsprinted only the pre-push hint about a "Try:" commit; I added that commit and the push went through. I did not run the full suite, since the forge does that on push.What changed
src/http/stripe_hook.ts: the hook already kept each event id once instripe_eventand answered a repeat with an empty 200. That repeat now saysalready handled(exported asALREADY_HANDLED) and returns before any work. No new table or migration was needed.test/billing-replay.test.ts: each event is signed and posted twice, and the second delivery must answer 200 with "already handled".- Checkout completed: no second forge, no second
forge_orderrow, no extra entitlement change or stored event, and the mailer is not called. - Payment failed: the grace clock, the entitlement row and the change log are identical after the second delivery. - Subscription cancelled: same checks, and the lapse is unchanged. - Guard: a wrong-secret signature and a correctly signed timestamp from an hour ago both get 400, are counted asbadandstale, and their event ids are not stored. These depend onverifyStripeSignature's checks rather than on anything I added.scripts/gate.ts: needed no edit, because it gates everytest/*.test.tsautomatically.Not done
verifyStripeSignatureweakened.src/billing/signature.tsand Stripe's idempotency-key handling as they were.Try it Post the same signed
checkout.session.completedbody to/hooks/stripetwice. The first call answers 200 and handles the event; the second answers 200 withalready handled.Closes #11866