Skip to main content
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);
Node + Postgres
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()
}
FastAPI + asyncpg
@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.