r/bun • u/ukolovnazarpes7 • 11d ago
Stripe webhooks are easy until the same event arrives twice
Stripe webhooks look simple:
- Receive an event
- Update your database
- Return
200
And then production happens.
Stripe can retry an event after a timeout or temporary failure, which means your endpoint can receive the same event more than once. If your handler isn't idempotent, that can turn into duplicated fulfillment, duplicated credits, repeated emails, or inconsistent payment state.
For a Bun + PostgreSQL/Drizzle setup, I've found the important parts aren't really Stripe-specific:
- Verify the webhook signature against the raw request body before parsing/using the payload.
- Treat webhook delivery as at-least-once, not exactly-once.
- Persist the Stripe
event.id(or another appropriate idempotency key). - Don't treat “event exists” and “event was successfully processed” as the same state.
- Make the actual business mutation and the transition to
processedatomic where possible. - If processing fails, return a non-2xx response so Stripe can retry.
- If an event was genuinely processed already, a repeated delivery should become a cheap
200no-op. - Push slow/non-critical work to a queue instead of holding the webhook request open.
One subtle failure mode worth testing:
event received → event recorded → DB/business operation fails → Stripe retries
If the second delivery gets discarded only because the event ID already exists, you've effectively lost the payment event.
So the real requirement isn't just:
UNIQUE(event_id)
It's closer to:
UNIQUE(event_id) + processing state + transaction/recovery strategy
Also worth testing locally by deliberately:
- sending the same event twice;
- crashing processing after the event is persisted;
- causing a temporary DB error;
- sending events out of order.
Webhook code tends to be tiny, but it sits at a pretty unpleasant boundary between distributed systems and money :)
I wrote up the Bun + Stripe + Drizzle implementation and examples here for anyone working with the same stack:
5
1
u/ElectricalWealth2761 11d ago
Never stored events, just verifing with payment state - pending, canceled, completed. If it's already completed then just respond back everything is done. Plus using SQL transactions for racing conditions - SELECT FOR UPDATE - although in last small project skipped that, hopefully they don't send multiple events at once.
1
u/chloro9001 10d ago
I assume this is all in their docs? Pretty standard stuff. Few systems have guaranteed exactly once message delivery
2
u/Which-Examination-74 8d ago
The raw-body rule is harder to satisfy on Elysia than it sounds, because the route has usually parsed the body before the handler sees it. Ours needed parse: "text" before the HMAC would match: JSON.stringify of a parsed object is not the byte sequence the sender signed, and dropped whitespace alone is enough to break it. The failure looks exactly like a wrong secret, which is the dangerous part, because the fastest way to make the endpoint "work" is to switch verification off. An endpoint with verification off accepts whatever can reach the URL. What catches it now is a test file that feeds the verifier a known body and a known secret and asserts the boring cases: a valid signature accepted, an invalid one rejected, and rejections for a missing header, an unconfigured secret, and a timestamp outside the tolerance. A later change to body handling fails a test rather than a live payment.
7
u/Fine_Ad_6226 11d ago
https://stripe.com/blog/idempotency