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.
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:
// 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.
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.
Related content
Sprout: A Next.js + Supabase Template Where Feedback Writes Its Own Git Branch
A template that wires a public feedback form straight into GitHub — every submission becomes a git branch via Claude Code, with no human touching the pipeline in between.
Making Stripe Webhook Handlers Idempotent After a Duplicate Purchase Grant
TIL that Stripe can and will deliver the same webhook event more than once, and a handler that reacts to checkout.session.completed by granting access will happily grant it twice — the fix was recording processed event IDs and treating the handler as a set operation, not an append.
Designing a Complete Delivery-Management System as a Solo Developer
TIL that a system spanning a browser extension, a React Native app, an API, and a database only stayed maintainable solo because I treated the API as the one real product and everything else as a thin client of it.