Webhook delivery is at-least-once. Your handler will occasionally see the same delivery twice — a network blip after your handler completed, a retry that crossed paths with your 2xx, a manual replay during debugging. Make duplicates a no-op.
This page is about delivery-side idempotency: deduping repeats of the same webhook event on your server. For request-side idempotency — retrying a POST to the Awardee API safely with Idempotency-Key — see Idempotency. Same idea, different direction.
The dedupe key
Every delivery carries a unique id in the X-Awardee-Delivery header. The same id is reused across retry attempts of the same event — so seeing it twice means the same logical event, and you should skip the work.
A different id, even for what looks like “the same” payload, is a different event (likely two genuine state changes in quick succession). Process it.
The pattern
Store seen delivery ids in a small table. Insert with ON CONFLICT DO NOTHING on receipt. If no row was inserted, skip the rest of the handler.
CREATE TABLE webhook_deliveries_seen (
id TEXT PRIMARY KEY,
event_type TEXT NOT NULL,
received_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Optional: keep the table small.
CREATE INDEX ON webhook_deliveries_seen (received_at);
import { Pool } from "pg"
const db = new Pool()
export async function handleWebhook(req, res) {
const deliveryId = req.header("X-Awardee-Delivery")
const eventType = req.header("X-Awardee-Event")
const { rowCount } = await db.query(
`INSERT INTO webhook_deliveries_seen (id, event_type)
VALUES ($1, $2)
ON CONFLICT (id) DO NOTHING`,
[deliveryId, eventType],
)
if (rowCount === 0) {
// Already processed — acknowledge silently.
return res.status(200).end()
}
await processEvent(eventType, req.body)
return res.status(200).end()
}
@app.post("/webhooks/awardee")
async def handle_webhook(request: Request):
delivery_id = request.headers["X-Awardee-Delivery"]
event_type = request.headers["X-Awardee-Event"]
inserted = await db.execute(
"""
INSERT INTO webhook_deliveries_seen (id, event_type)
VALUES ($1, $2)
ON CONFLICT (id) DO NOTHING
""",
delivery_id,
event_type,
)
if inserted == "INSERT 0 0":
return Response(status_code=200)
payload = await request.json()
await process_event(event_type, payload)
return Response(status_code=200)
Insert the dedupe row in the same transaction as the side effects of processing the event. Otherwise a crash between the insert and the work will mark the delivery as seen without actually doing the work.
Pruning the table
The table grows by one row per unique delivery. A retention job that deletes rows older than ~30 days is sufficient — Awardee retries for at most 24 hours, so any id older than that will never be re-delivered. The remaining ~6 days of headroom catches manual replays from the dashboard.
DELETE FROM webhook_deliveries_seen WHERE received_at < now() - interval '30 days';
What about ordering?
Idempotency dedupes redelivery. It does not order out-of-order events. For ordering, see Retries and ordering — the short version is: compare the timestamp inside the payload to whatever your downstream store has, and skip stale updates.