Webhooks retry, so make the receiver idempotent — not the sender reliable

TIL that webhook delivery is at-least-once, not exactly-once. Instead of trying to prevent duplicate deliveries, derive a deterministic ID from the event and let the receiver refuse to redo work it already did.

2 min read

Sabin Shrestha

Full-Stack Developer — Next.js, React & React Native

Today I learned not to fight webhook retries — design around them instead.

The problem

On Sprout, a Supabase Database Webhook fires on every INSERT into a feedbacks table, which relays into a GitHub repository_dispatch event, which runs Claude Code to create a branch from the feedback message. My first instinct was to make delivery reliable: retry the outbound fetch on failure, add timeouts, log errors. That solves the wrong problem. The webhook sender — Supabase, Stripe, GitHub, any of them — already retries on timeout or a non-2xx response, because it has no way to know if I actually processed the event. That's at-least-once delivery, and it means my endpoint will see the same event twice sooner or later, retry logic or not.

What actually matters

If the receiver can't tell a redelivery from a new event, "at-least-once" becomes "however-many-times." The fix isn't a dedup table or a message queue — it's making the side effect itself safe to repeat, by deriving something deterministic from the event instead of generating something fresh each time:

feedback-branch.yml (prompt fed to Claude Code)
// Branch name is deterministic from the row's primary key —
// not a timestamp, not a random slug.
const branch = `feedback/${feedbackId}-${slugify(message)}`;
 
// Redelivery produces the *same* branch name, so "create it"
// naturally becomes "check if it exists, stop if it does."

Because feedbackId is the row's UUID, a redelivered webhook computes the exact same branch name. The instruction to Claude Code is simply: check whether that branch already exists, and stop if it does. No dedup table, no idempotency-key header, no distributed lock — the entity's own identity is the idempotency key.

Why it works

Idempotency keys are usually framed as something you generate and store (Stripe's Idempotency-Key header is the canonical example). But when the operation already has a natural identifier — a row's primary key, an order ID, a delivery UUID — you don't need to invent one. You just need the output of the operation to be a deterministic function of that identifier, so running it twice produces the same result instead of two results.

Tip

This only works if the derived name encodes enough to be unique per event, not per type of event. feedback/<slug> alone would collide across two different feedback rows with similar wording; feedback/<id>-<slug> can't, because no two rows share a UUID.