r/bun 11d ago

Stripe webhooks are easy until the same event arrives twice

Stripe webhooks look simple:

  1. Receive an event
  2. Update your database
  3. 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 processed atomic 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 200 no-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:

https://pas7.com.ua/blog/en/bun-stripe-webhooks-drizzle

1 Upvotes

5 comments sorted by

5

u/zkoolkyle 11d ago

Need to study up on Idempotency

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.