Webhook idempotency and dedupe for SQLite and Postgres
· 9 min read
On this page (10)
Every webhook provider delivers at least once, which is a polite way of saying you will get duplicates. Retries after a timeout, a manual redelivery from a dashboard, a network blip that loses your 200 but not their request: all of these hand your handler the same event again. This post covers how to recognise "the same event", how to make a database refuse the copy, how long to remember, and why dedupe at the door is necessary but not sufficient.
Two layers of protection
Who needs this
A shop that issues licence keys on payment. The provider sends order.paid, your handler creates a key and emails it. The email provider is slow, your handler times out, the provider retries, and the buyer gets two emails with two different keys. Support tickets follow, and one of the keys is now unaccounted for.
A CI trigger fed by GitHub pushes. A push event arrives, your service starts a build and deploys. You restart the service, redeliver the last event from the GitHub UI to check it works, and a second deploy of the same commit starts while the first is still running. Two deploys racing on one server is a bad afternoon.
A subscription app on Stripe. Stripe retries failed deliveries for days and does not promise ordering. An invoice.paid that you handled on Monday can arrive again on Wednesday after a deploy broke your endpoint for a day. Without dedupe you extend the customer's subscription twice.
In all three, the cost of a duplicate is real money or real confusion, and the fix is a few lines of SQL if you put them in the right place.
Delivery guarantees in plain terms
Senders cannot know whether you processed an event. They know only whether they got a success response in time. If you processed it and the response was lost, from their side it failed, so they send it again. The alternative, "at most once", means they give up after one try and you silently lose events. Nobody chooses that for payments, so the industry standard is at least once.
That moves a duty onto the receiver: make processing idempotent, meaning the second run has the same visible effect as one run. Two mechanisms do that work, and you want both.
- Dedupe at the door. Refuse to store the same delivery twice.
- Idempotent effects. Make each side effect safe to repeat, because a crash can happen after the effect and before you record that it happened.
Pick the id carefully
The unit of "same event" must be an identifier the sender keeps stable across retries.
| Provider | Stable id | Where it lives |
|---|---|---|
| Standard Webhooks (Polar) | webhook-id | Header; identical on each retry of a message |
| Stripe | id of the event, evt_... | Inside the JSON body |
| GitHub | X-GitHub-Delivery | Header; a GUID that a manual redelivery keeps |
Some distinctions matter.
- Stripe: the event id is not the object id. One
checkout.sessionproduces several events, so dedupe the event, then make the effect idempotent on the session or payment id. - GitHub: the delivery GUID identifies one delivery. Two different pushes have two different GUIDs, and a redelivery of the same push reuses its GUID.
If a sender offers no id, hash the raw body with SHA-256 and use that. It works when the sender resends byte-identical payloads, which most do. It has two weaknesses:
- Two genuinely separate events with identical content collapse into one.
- A sender that adds a changing field such as an attempt counter produces a new hash on every retry.
Rule of thumb: use a real id whenever one exists, and fall back to a body hash only when none does.
Scope the key by source as well, so two senders that both use short numeric ids never collide: UNIQUE (source, delivery_id).
The unique constraint is the dedupe
Do not check "have I seen this?" with a SELECT and then insert. Two concurrent deliveries can both pass the check. Let the database decide atomically.
SQLite
CREATE TABLE events (
id INTEGER PRIMARY KEY,
source TEXT NOT NULL,
delivery_id TEXT NOT NULL,
payload BLOB NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
created_at INTEGER NOT NULL,
UNIQUE (source, delivery_id)
);
INSERT OR IGNORE INTO events (source, delivery_id, payload, created_at)
VALUES ('polar', 'msg_p5jXN8AQM9LWM0D4loKWxJek', ?, unixepoch());
In Go, RowsAffected() after that statement is 1 for a new event and 0 for a duplicate. unixepoch() needs SQLite 3.38 or newer, which any current driver bundles. Answer 202 in both cases, since the sender's delivery did succeed.
PostgreSQL
CREATE TABLE events (
id bigserial PRIMARY KEY,
source text NOT NULL,
delivery_id text NOT NULL,
payload jsonb NOT NULL,
status text NOT NULL DEFAULT 'pending',
created_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (source, delivery_id)
);
INSERT INTO events (source, delivery_id, payload)
VALUES ($1, $2, $3)
ON CONFLICT (source, delivery_id) DO NOTHING
RETURNING id;
RETURNING id yields a row for a new event and no row for a duplicate, so sql.ErrNoRows from QueryRow means "already seen". One caution on jsonb: Postgres normalises it, so it no longer holds the original bytes. Store the raw body as bytea or text if you may need to re-verify a signature or show the exact payload later, and parse into jsonb only if you want to query it.
Record at receipt, or at completion?
There are two places to write the dedupe record, and they give different guarantees.
At receipt (what the starter does): the unique row is inserted when the request arrives, before any work. A duplicate that arrives while the first is still processing is dropped, and that is correct, because the first copy is already queued. The price is that if processing fails permanently, the event exists as dead and the sender's later retry is dropped too. That is fine, since you hold the payload and can replay it yourself.
At completion: you insert the dedupe row only after the work succeeded. A duplicate during processing gets processed twice, which is wrong, unless you also add a lock or a processing state. Most people who start here end up reinventing the receipt-time design.
Where to write the dedupe row
Receipt-time dedupe plus a queue gives you the property you actually want: exactly one stored event per delivery, processed at least once, with effects that tolerate a repeat.
The dedupe window
You cannot remember every id forever, and you do not need to. You need to remember for longer than the sender can plausibly redeliver.
- Stripe retries failed live-mode deliveries for up to three days.
- A manual redelivery from a GitHub or Polar dashboard can happen whenever a person clicks, which could be weeks later.
- Anything that rewinds your own system, like restoring a backup, can make you process old events again.
A sensible default is 30 days of rows for events, and longer for the narrow table of "effects already applied". The starter's server purge --days 30 and RETENTION_DAYS delete only finished rows and say plainly what that costs: once a row is deleted, a redelivery of that id months later would be processed again, because the row was the memory. Choose retention longer than the longest redelivery you care about.
For the effect itself, the better memory is a business key, not the event row. An order id has a natural lifetime of forever, so make the licence table the thing that remembers.
Make the effect idempotent too
Door-level dedupe cannot cover a crash between the side effect and the status update. Your handler will sometimes run twice for one event. Three techniques, from strongest to weakest:
Making an effect safe to repeat
A unique business key in the same transaction. For work inside your own database:
BEGIN;
INSERT INTO licenses (order_id, email, key, created_at)
VALUES ($1, $2, $3, now())
ON CONFLICT (order_id) DO NOTHING;
UPDATE events SET status = 'done' WHERE id = $4;
COMMIT;
Either both happen or neither does. A rerun finds the licence and does nothing. Generate the key inside this statement or check first; do not mint a random key before knowing the row is new.
An idempotency key on the outbound call. For effects at another service, pass a key derived from the event, such as the order id, if that service supports it. Stripe's API accepts an Idempotency-Key header for this. Many email providers do not, which leads to the third option.
Record the send, then send. For an email, write a notifications row with UNIQUE (order_id, kind) in state pending, send, then mark it sent. A crash between send and mark yields at most one duplicate email on rerun, and you can see exactly which ones from the table. Perfect exactly-once to an external system is not available. Aim for "at most one visible repeat, and I can see it".
For the GitHub-triggered build, the business key is the commit SHA: record deployed_sha and have the deploy script exit successfully if it matches. That also protects against two near-simultaneous pushes of the same commit.
Ordering is a separate problem
Dedupe says nothing about order. Stripe documents that it does not guarantee delivery order, and with several workers or retries, order changes in your own queue as well. If a later event depends on an earlier one, do not rely on arrival order. Either fetch the current object from the provider's API when you handle the event, or compare a version or timestamp in the payload and ignore stale updates:
UPDATE subscriptions
SET status = $1, updated_at = $2
WHERE id = $3 AND updated_at < $2;
With one worker the starter runs events strictly in order, which can be enough for small apps, but retries still reorder a failed event behind newer ones.
Mistakes we see
SELECT then INSERT. Racy under concurrent deliveries. Use the constraint.
An in-memory set or cache as the dedupe store. It vanishes on restart, which is exactly when retries pile up. A durable constraint survives restarts.
Using the body hash when an id exists. It collapses distinct events and splits identical retries when the sender adds attempt metadata.
Deduping on the object id instead of the event id. One order legitimately produces several events. Dedupe the delivery, then key effects on the object.
Answering an error for a duplicate. The sender reads that as failure and retries more. Duplicates get a 2xx.
Purging dedupe memory too early. Keep rows longer than the sender's retry horizon, and keep business-key tables forever.
Trusting dedupe instead of making effects safe. It narrows the window; it does not close it.
FAQ
Is a webhook id the same as an idempotency key?
They solve related problems from opposite sides. An idempotency key is sent by a client to a server so the server can recognise a repeated request. A webhook id is sent by a server to you so you can recognise a repeated delivery. You use the webhook id as your dedupe key, and you may derive an idempotency key from it when calling someone else.
How long should I keep processed event ids?
Longer than the longest redelivery you expect, plus some margin. Thirty days is a practical default for a small app, and the table of business keys should live as long as the business records. If disk is the concern, store ids and status but drop large payloads after processing.
Can I dedupe in Redis instead of the database?
You can, with SET key value NX EX seconds, and it is fast. The risk is that the dedupe store and your event store can disagree after a crash. With a database unique constraint, the insert of the event and the claim of the id are one atomic step, so for a single server it is simpler and safer.
What if the same event arrives twice at the same instant?
Both requests try the insert, the database serialises them, one wins and the other sees a conflict. That is the reason to use a constraint rather than a prior SELECT.
Do duplicates mean something is wrong with my endpoint?
Not by themselves. A few per day is normal. A sudden rise usually means you are answering slowly or with errors, so check your response times and status codes before blaming the sender.
The queue side of this, with leases and backoff, is in a durable webhook retry queue in SQLite, and the front door is in receiving signed webhooks in Go. If you want all of it assembled and tested, the Go Webhook Receiver Starter stores each delivery under a unique key and drains it with retries.