Storing webhook idempotency keys in PostgreSQL

Once you have decided that a durable key is the right mechanism — the choice laid out in idempotency keys vs deduplication windows — the remaining work is a schema and four SQL statements. This page is the PostgreSQL half of the pattern described in idempotency in webhooks: what the table looks like, why the UNIQUE constraint does the locking for you, and how to stop the table growing forever.

Postgres is a good fit here for one specific reason. A unique index is already a serialization point: when two workers insert the same key concurrently, one blocks on the other’s uncommitted tuple and then discovers the conflict at commit. You get mutual exclusion without a distributed lock, a lease timeout, or a second datastore to keep alive during an incident. The handler shape around it — key extraction, response caching, transactional boundaries — is covered in how to design idempotent webhook consumers; here the focus stays on the store itself.

Prerequisites

Step 1: Design the table and its UNIQUE constraint

The table stores one row per logical operation, not one row per HTTP request. Its natural key is the pair (source, event_id): source because two providers can and will issue the same identifier, event_id because that is what stays constant across the provider’s retries. Making that pair the primary key means the constraint is not a validation afterthought — it is the concurrency control.

Every other column exists to answer a question you will have during an incident: what was the payload, what did we reply, and when.

Idempotency row anatomy The columns of the webhook idempotency table are broken out, with notes describing the role each one plays in claiming, replaying and expiring a key. webhook_idempotency PRIMARY KEY (source, event_id) the concurrency primitive, not a check payload_digest bytea detects a reused id with a changed body response_status, response_body replayed verbatim to every later duplicate claimed_at timestamptz drives retention and the stuck-claim sweep completed_at timestamptz NULL means the claim never committed one row per logical operation, not per HTTP request
Every column earns its place during an incident: the digest catches id reuse, the cached response answers duplicates, and the claim timestamp is what makes retention a partition drop.
CREATE TABLE webhook_idempotency (
  source           TEXT        NOT NULL,
  event_id         TEXT        NOT NULL,
  payload_digest   BYTEA       NOT NULL,
  response_status  SMALLINT,
  response_body    JSONB,
  claimed_at       TIMESTAMPTZ NOT NULL DEFAULT now(),
  completed_at     TIMESTAMPTZ,
  PRIMARY KEY (source, event_id)
);

-- Retention sweeps read this; keep it BRIN-cheap on an append-ordered column.
CREATE INDEX webhook_idempotency_claimed_idx
  ON webhook_idempotency USING BRIN (claimed_at);

-- Find rows that were claimed but never completed (crashed mid-flight).
CREATE INDEX webhook_idempotency_stuck_idx
  ON webhook_idempotency (claimed_at)
  WHERE completed_at IS NULL;

Resist the urge to reach for declarative partitioning here on day one. Postgres can only enforce a unique constraint across partitions when the partition key is part of that constraint, so partitioning by claimed_at would push claimed_at into the primary key — and since it defaults to now(), every retry would get a fresh timestamp and the conflict you depend on would never fire. Global uniqueness on (source, event_id) is the property the whole design rests on; Step 5 covers how to keep the table bounded without giving it up.

The response_body column is JSONB rather than TEXT so you can query it during an incident, and payload_digest is a BYTEA SHA-256 rather than the whole body: you want to detect a provider reusing an id with different content without storing a second copy of every payload.

Step 2: Claim the key with INSERT ON CONFLICT DO NOTHING

The claim is a single statement. INSERT ... ON CONFLICT DO NOTHING RETURNING event_id inserts the row if nobody has it and returns one row; if the key is taken it returns zero rows without raising. Zero rows means “somebody else owns this event” — no SELECT first, no read-then-write window for a concurrent delivery to slip through.

That distinction is the whole point. A SELECT-then-INSERT pair has a gap between the check and the write, and under a provider’s retry storm two workers will both see “not present” and both run the side effect. The single-statement claim has no gap: the second insert blocks on the first transaction’s uncommitted row and, when that transaction commits, finds the conflict.

Two workers racing on one key Worker A inserts the key and holds the row uncommitted; worker B's identical insert blocks, then returns zero rows once A commits, so only A runs the side effect. Worker A PostgreSQL Worker B INSERT evt_91f2 INSERT evt_91f2 B blocks on A's tuple 1 row: A owns the claim A commits 0 rows: B is a duplicate no SELECT first, so there is no window for both workers to win
The unique index is the lock: worker B never sees an empty table, it waits on A's uncommitted tuple and is told it lost the claim.
# store.py
import hashlib
from dataclasses import dataclass
from typing import Any

import psycopg
from psycopg.rows import dict_row

CLAIM_SQL = """
INSERT INTO webhook_idempotency (source, event_id, payload_digest)
VALUES (%(source)s, %(event_id)s, %(digest)s)
ON CONFLICT (source, event_id) DO NOTHING
RETURNING event_id
"""

LOOKUP_SQL = """
SELECT payload_digest, response_status, response_body, completed_at
FROM webhook_idempotency
WHERE source = %(source)s AND event_id = %(event_id)s
"""


@dataclass(frozen=True)
class Claim:
    won: bool
    cached_status: int | None = None
    cached_body: Any = None
    in_flight: bool = False


def digest(raw_body: bytes) -> bytes:
    return hashlib.sha256(raw_body).digest()


def claim(cur: psycopg.Cursor, source: str, event_id: str, raw_body: bytes) -> Claim:
    """Atomically claim an event. Must run inside an open transaction."""
    cur.execute(CLAIM_SQL, {"source": source, "event_id": event_id, "digest": digest(raw_body)})
    if cur.fetchone() is not None:
        return Claim(won=True)

    # Lost the race, or this is a plain retry of an event we already finished.
    cur.row_factory = dict_row
    cur.execute(LOOKUP_SQL, {"source": source, "event_id": event_id})
    row = cur.fetchone()
    if row is None:
        # The winner rolled back between our INSERT and this SELECT: treat as retryable.
        return Claim(won=False, in_flight=True)
    if row["completed_at"] is None:
        return Claim(won=False, in_flight=True)
    if row["payload_digest"] != digest(raw_body):
        raise ValueError(f"event id {event_id} reused with a different body")
    return Claim(won=False, cached_status=row["response_status"], cached_body=row["response_body"])

in_flight matters more than it looks. If the winner is still working, you cannot answer with a cached response that does not exist yet, and you must not run the side effect yourself. Return 409 Conflict or 503 with a short Retry-After and let the provider redeliver — by then the winner has committed and the duplicate gets the cached answer.

Step 3: Commit the claim and the side effect together

The claim and the business write must land in the same transaction. Claim, commit, then crash before the side effect, and the retry finds a completed-looking row and skips work that never happened — the event is silently lost despite at-least-once transport. Do the side effect first and commit the claim afterwards and a crash in between re-runs the effect on retry.

One transaction removes both windows. Either the row and the write are visible together, or neither is, and the provider’s next retry starts from a clean slate.

# handler.py
import json
from typing import Any

import psycopg

from store import Claim, claim

COMPLETE_SQL = """
UPDATE webhook_idempotency
SET response_status = %(status)s,
    response_body = %(body)s,
    completed_at = now()
WHERE source = %(source)s AND event_id = %(event_id)s AND completed_at IS NULL
"""


def apply_side_effect(cur: psycopg.Cursor, payload: dict[str, Any]) -> dict[str, Any]:
    """The real work — must only touch this same transaction's cursor."""
    cur.execute(
        "INSERT INTO orders (order_id, total_minor) VALUES (%s, %s)"
        " ON CONFLICT (order_id) DO NOTHING",
        (payload["order_id"], payload["total_minor"]),
    )
    return {"order_id": payload["order_id"], "status": "recorded"}


def process(conn: psycopg.Connection, source: str, event_id: str, raw_body: bytes) -> tuple[int, Any]:
    payload = json.loads(raw_body)
    with conn.transaction():          # BEGIN ... COMMIT around everything below
        with conn.cursor() as cur:
            outcome: Claim = claim(cur, source, event_id, raw_body)
            if outcome.in_flight:
                return 409, {"status": "in_flight"}
            if not outcome.won:
                return outcome.cached_status or 200, outcome.cached_body

            body = apply_side_effect(cur, payload)
            cur.execute(
                COMPLETE_SQL,
                {"status": 200, "body": json.dumps(body), "source": source, "event_id": event_id},
            )
            return 200, body

Two constraints hold this together. The side effect must use the same cursor, because a write on another connection is not covered by this transaction and will survive a rollback. And nothing that cannot be rolled back — sending an email, charging a card through a third-party API — belongs inside the block; those go through a transactional outbox row written here and dispatched after commit.

Step 4: Store and replay the cached response

The cached response is what makes the duplicate path correct rather than merely quiet. A duplicate that gets a bare 200 OK while the original got 200 {"order_id": ...} will break any provider that reconciles response bodies, and it destroys your ability to answer “what did we tell them?” during an audit.

Write the status and body onto the claimed row inside the same transaction, as the UPDATE above does, and serve it back verbatim on every later delivery. The completed_at IS NULL predicate in that statement is a cheap assertion: it means a second completion attempt updates zero rows instead of overwriting the recorded answer.

Cache only successful outcomes. If the side effect raises, the transaction rolls back and the claim row disappears with it, which is precisely right — the provider retries, a fresh claim is made, and the work is attempted again. Persisting a failure would convert a transient database blip into a permanently poisoned event.

Step 5: Expire keys with a bounded sweep

The table grows at exactly your event rate, so it needs a floor. What it must not get is DELETE FROM webhook_idempotency WHERE claimed_at < now() - interval '30 days' in one shot: on a busy table that rewrites millions of tuples in a single transaction, generates WAL proportional to the deletion, holds a snapshot open for minutes, and leaves autovacuum chasing dead rows while your claim latency doubles.

Sweep in bounded batches instead, on a short interval, so the delete never becomes an event. The retention horizon is the one number that carries real risk: it must comfortably exceed the provider’s documented retry window, because the moment a key is gone a late redelivery claims it again and re-runs the side effect.

Key retention timeline A timeline places the provider's 72-hour retry horizon inside a 30-day retention window, with keys older than the window removed by a batched sweep. how long a key must survive older than 30 days swept in batches retention window every key still suppresses retry horizon last 72 hours day -30 day -3 now a key deleted before the horizon closes lets the side effect run twice
Retention is a safety parameter, not a cost knob: the window has to outlast the provider's last possible retry, and everything beyond it is dead weight you sweep in small batches.
# retention.py
import logging
import time

import psycopg

logger = logging.getLogger(__name__)

RETENTION_DAYS = 30
BATCH_SIZE = 5_000

SWEEP_SQL = """
DELETE FROM webhook_idempotency
WHERE ctid IN (
    SELECT ctid FROM webhook_idempotency
    WHERE claimed_at < now() - %(retention)s::interval
    LIMIT %(batch)s
)
"""


def sweep_once(conn: psycopg.Connection) -> int:
    """Delete at most BATCH_SIZE expired keys in its own short transaction."""
    with conn.transaction():
        with conn.cursor() as cur:
            cur.execute(
                SWEEP_SQL,
                {"retention": f"{RETENTION_DAYS} days", "batch": BATCH_SIZE},
            )
            return cur.rowcount


def run_sweeper(conn: psycopg.Connection, max_batches: int = 200) -> int:
    """Called every few minutes from cron. Bounded, so it can never run away."""
    removed = 0
    for _ in range(max_batches):
        deleted = sweep_once(conn)
        removed += deleted
        if deleted < BATCH_SIZE:
            break          # caught up; nothing left older than the horizon
        time.sleep(0.2)    # yield to the claim path between batches
    logger.info("idempotency sweep removed %d expired key(s)", removed)
    return removed

Each batch commits on its own, so the sweeper never holds a long snapshot and autovacuum can reclaim behind it. If the table eventually outgrows this — sustained tens of millions of keys inside the window — that is the point to consider declarative partitioning, and the price is explicit: the partition key must join the unique constraint, so you would be trading global suppression for per-partition suppression and must confirm your retry horizon never crosses a partition boundary.

Size the window off the provider’s documented retry horizon plus a wide margin — 30 days against a 72-hour horizon costs almost nothing and covers the manual replay you will eventually run out of a support ticket.

Verification and testing

The test that matters is the concurrency one, and it needs two real connections: a single-connection test can never reproduce the race because both statements run in the same transaction.

# test_claim.py
import psycopg
import pytest

from store import claim

DSN = "postgresql://localhost/webhooks_test"


def test_only_one_of_two_concurrent_claims_wins():
    with psycopg.connect(DSN) as a, psycopg.connect(DSN) as b:
        a.autocommit = False
        b.autocommit = False
        with a.cursor() as ca, b.cursor() as cb:
            first = claim(ca, "acme", "evt_91f2", b'{"order_id":"o1"}')
            assert first.won is True

            # B's INSERT blocks on A's uncommitted tuple, so commit A first,
            # exactly as the two workers would interleave in production.
            a.commit()
            second = claim(cb, "acme", "evt_91f2", b'{"order_id":"o1"}')
            assert second.won is False
            b.rollback()


def test_reused_event_id_with_a_different_body_is_rejected():
    with psycopg.connect(DSN) as conn:
        with conn.cursor() as cur:
            assert claim(cur, "acme", "evt_dup", b'{"order_id":"o1"}').won is True
            cur.execute(
                "UPDATE webhook_idempotency SET completed_at = now(), response_status = 200"
                " WHERE event_id = 'evt_dup'"
            )
            with pytest.raises(ValueError):
                claim(cur, "acme", "evt_dup", b'{"order_id":"o2"}')
        conn.rollback()

Two SQL assertions are worth running against production on a schedule. The first finds claims that never completed, which should be a handful at most and always recent:

SELECT source, event_id, claimed_at
FROM webhook_idempotency
WHERE completed_at IS NULL AND claimed_at < now() - interval '5 minutes'
ORDER BY claimed_at
LIMIT 50;

The second checks the retention horizon from both ends at once — the oldest surviving key must be older than the provider’s retry window (or you are deleting too aggressively) and younger than your retention setting (or the sweeper has stalled):

SELECT min(claimed_at) AS oldest_key,
       now() - min(claimed_at) AS oldest_age,
       count(*) FILTER (WHERE claimed_at < now() - interval '30 days') AS overdue_rows
FROM webhook_idempotency;

Failure modes and gotchas

Frequently Asked Questions

Can the idempotency table live in a different database from the business tables?

Not without giving up the guarantee the design rests on. The claim and the business write have to commit as one unit, and a second database means either two-phase commit or a window where one side committed and the other did not. When policy forces the separation, the honest answer is an outbox row in the business database plus a reconciliation job, not a remote claim you hope lands alongside the local write.

What stops a slow side effect from exhausting the connection pool with blocked duplicates?

Nothing by default, because lock_timeout is zero and every duplicate that arrives while the winner is working waits indefinitely while holding a pooled connection. Set lock_timeout to a few hundred milliseconds on the claim path and map the resulting error onto the same 409 the in-flight branch returns, so the provider backs off instead of your pool filling with waiters. A ten-duplicate retry storm against a two-second side effect is enough to make this visible on a modest pool.

Can a legitimate retry fail the payload digest check?

Yes, when the digest covers raw bytes and the provider re-serialises the payload between attempts: a different key order or extra whitespace yields a different SHA-256 for semantically identical content, and a genuine retry then raises. Hash a canonical form with sorted keys and no insignificant whitespace, or a fixed subset of stable fields, once you have seen a provider behave that way. Byte-exactness stays essential only where the signature is computed over the same bytes.

Will autovacuum keep up with the churn from the sweep?

Not on stock settings once the table is busy. The default scale factor of 20 percent means a 50 million row table accumulates 10 million dead tuples before a vacuum even triggers, and the claim path is reading through that bloat in the meantime. Set autovacuum_vacuum_scale_factor to around 0.02 with a matching threshold on this table specifically, and watch n_dead_tup and the last autovacuum timestamp in pg_stat_user_tables. The per-batch commits in the sweeper already help by giving vacuum a chance to reclaim between batches.

Is BRIN actually the right index for claimed_at?

It is while the heap's physical order still tracks insertion time, which holds for an append-heavy table and costs a tiny fraction of a btree. It degrades once the sweep frees pages that later claims reuse out of order: the correlation reported in pg_stats falls, block ranges summarise wider spans, and the sweep reads far more blocks than it needs. Check that correlation periodically and move to a btree on claimed_at if it drops, since a table kept bounded by retention can afford the extra index.

Why does the sweep select ctid instead of deleting by timestamp with a LIMIT?

PostgreSQL does not accept LIMIT on DELETE, so a subquery is the only way to bound the batch. Selecting ctid lets the delete address the exact physical tuples the subquery already located rather than re-evaluating the timestamp predicate row by row. Any sufficiently unique column would serve, but ctid keeps that second lookup as cheap as it can be.