PORTFOLIO
Back to writing

Backend

Your Webhook Will Fire Twice. Design Like It Already Did.

July 7th, 20264 min read
Your Webhook Will Fire Twice. Design Like It Already Did.

A customer completed a payment. Stripe charged them exactly once — correctly. But in our system, that single payment was recorded three times. Three confirmation emails. The downstream order flow triggered more than once.

The frustrating part: my payment-handling code was correct. It did exactly what it was supposed to do. The problem was that it ran three times when it should have run once.

The thing nobody warns you about

When you start consuming webhooks or message-queue events, it's natural to assume each event arrives once. It's a reasonable assumption. It's also wrong.

Most event delivery systems — Stripe webhooks, AWS SNS/SQS, and similar — guarantee at-least-once delivery, not exactly-once. If the provider sends you an event and doesn't receive a clean acknowledgment quickly enough (a timeout, a network blip, a slow response on your end), it assumes delivery failed and sends it again. From its point of view, that's correct behavior. From your database's point of view, you just processed the same payment twice.

This is not an edge case you can ignore. At any real volume, duplicate delivery will happen.

The concept that fixes it: idempotency

An operation is idempotent if performing it multiple times has the same effect as performing it once. Reading a value is naturally idempotent. Inserting a payment record is not — do it three times and you have three records.

The goal is to make your event handler idempotent, so that duplicate deliveries are harmless.

How I implemented it

Every event from Stripe (and every SNS/SQS message) carries a unique identifier. The strategy is to track which IDs I've already processed and refuse to process the same one twice.

[@portabletext/react] Unknown block type "codeBlock", specify a component for it in the `components.types` prop

The critical detail is that the check, the work, and the write of the processed ID all happen in one atomic transaction. If any part fails, the whole thing rolls back and the event can be safely retried. If it succeeds, the event ID is now on record, and any duplicate delivery fails the unique constraint and gets safely skipped.

A unique constraint on the event ID is what makes this safe under races: if two duplicate deliveries arrive at the same time, only one insert can win — the other fails the constraint, and I treat that failure as an already-processed duplicate.

What changed

Same event delivered three times now gets processed exactly once. The duplicates are acknowledged so the provider stops retrying, and then discarded. One payment, one record, one confirmation email.

The broader lesson

The reliability of a system doesn't live in the happy path — it lives in how it handles the messy realities of distributed communication: retries, timeouts, and duplicates. "It'll be delivered exactly once" is one of the most expensive assumptions you can make in backend work.

The mental model that's served me well: assume every event can arrive more than once, and design so that it doesn't matter. Once your handlers are idempotent, duplicate delivery stops being a bug you chase in production and becomes a non-event.

Related

More to explore

June 30th, 2026

The Performance Problem That Isn't in Your Latency Dashboard

Every endpoint was fast, but screens still felt slow. The culprit was over-fetching. Here's how I used Claude Code and Codex to trace it and cut payload size by ~30% — without handing over the decisions.

GraphQLAPI DesignWeb PerformanceAI ToolsBackend
Read article

March 10th, 2026

Keeping APIs Non-Blocking: Celery, Redis, and Async Score Calculations

How offloading GHIN/USGA handicap score calculations to Celery + Redis kept a Django REST API responsive under load.

DjangoCeleryRedisBackend
Read article