Building an Idempotent Webhook Receiver

Every webhook provider you will integrate — Stripe, GitHub, Shopify, Paddle, Slack — promises at least once delivery. None of them promise exactly once, and none of them promise order. That means your receiver will eventually be handed the same payment_intent.succeeded twice, and it will be handed a subscription.updated that describes an older state than the one you already stored. If the handler is not idempotent, you refund twice, email twice, or downgrade a paying customer back to free. This page shows the four mechanics that make a receiver safe: an event-id ledger, an atomic claim, versioned state transitions, and a replay tool. It is part of Processing Webhooks with Python, which covers the surrounding receive-verify-enqueue shape.

The recommendation up front: put the deduplication ledger in the same Postgres database as your business data and claim rows with INSERT ... ON CONFLICT DO NOTHING. Redis is faster and wrong for this, because a dedup marker that survives a crash but a state write that does not is worse than no dedup at all.

Why duplicates and reordering are the normal case

Duplicates are not an exotic failure. A provider retries whenever it does not see a 2xx inside its timeout — typically 10 to 30 seconds. Your handler can commit the database write, take 12 seconds to call a slow downstream API, and then get killed by the platform's request timeout before the response flushes. From the provider's point of view nothing happened, so it redelivers. You now have one committed write and one pending redelivery of the same event.

Reordering is just as ordinary. Providers fan delivery out across workers, so two events emitted 40 milliseconds apart travel independent paths. Add a retry to the first one and the second arrives first. Stripe explicitly documents that it does not guarantee ordering, and if you are running more than one receiver instance behind a load balancer you have added a second source of interleaving on your own side.

Emitted order versus received order for three webhook events Three events are emitted in sequence at the provider, but arrive at the receiver in a different order, and the first event is delivered a second time after a retry. Same three events, two different orders Emitted created v1 updated v2 updated v3 Received updated v3 created v1 updated v2 created v1 again The newest state lands first, and the oldest event lands twice

The event ledger: one row per event id, forever

Every serious provider puts a stable, unique id on the event itself — evt_... on Stripe, the X-GitHub-Delivery header on GitHub, X-Shopify-Event-Id on Shopify. That id is your idempotency key. Do not invent your own by hashing the body: a provider that re-sends a payload with a refreshed timestamp will hash differently and slip past you.

Store one row per event id in a dedicated table, with the raw payload and a status column. That table is doing three jobs at once — deduplication, an audit log you can hand to a customer arguing about a charge, and the input to the replay tool later on. Keep the payload; the storage is trivial next to the support time it saves. At 50,000 events a month with a 2 KB average payload you are storing 100 MB a month, which costs cents on any managed Postgres.

Python
import asyncio
import os

import asyncpg

DATABASE_URL = os.getenv("DATABASE_URL", "")
if not DATABASE_URL:
    raise RuntimeError("DATABASE_URL is required")

DDL = """
CREATE TABLE IF NOT EXISTS webhook_event (
    event_id     text PRIMARY KEY,
    source       text        NOT NULL,
    event_type   text        NOT NULL,
    payload      jsonb       NOT NULL,
    status       text        NOT NULL DEFAULT 'claimed',
    attempts     integer     NOT NULL DEFAULT 0,
    last_error   text,
    received_at  timestamptz NOT NULL DEFAULT now(),
    settled_at   timestamptz
);

CREATE INDEX IF NOT EXISTS webhook_event_pending_idx
    ON webhook_event (status, received_at)
    WHERE status IN ('claimed', 'failed');
"""


async def migrate() -> None:
    conn = await asyncpg.connect(DATABASE_URL)
    try:
        await conn.execute(DDL)
    finally:
        await conn.close()


if __name__ == "__main__":
    asyncio.run(migrate())

The partial index matters more than it looks. Once the table holds two million settled rows, a worker scanning for pending work against a full index pays for every historical row on every poll. The WHERE clause keeps the index at the size of your actual backlog.

The atomic claim: let the database decide who wins

The naive version is a SELECT to check whether the id exists, then an INSERT if it does not. That is a race, and it fires exactly when you least want it to: two retries of the same event arriving milliseconds apart on two workers both see nothing, both insert, both process. Fix it by making the insert itself the test. INSERT ... ON CONFLICT (event_id) DO NOTHING RETURNING event_id returns a row for the first caller and nothing for everyone after. The primary key constraint is the lock, and it is enforced by Postgres, not by your code.

Python
import os

import asyncpg
import orjson
from fastapi import FastAPI, Request, Response

app = FastAPI()
SOURCE = os.getenv("WEBHOOK_SOURCE", "stripe")
CLAIM_SQL = """
INSERT INTO webhook_event (event_id, source, event_type, payload)
VALUES ($1, $2, $3, $4::jsonb)
ON CONFLICT (event_id) DO NOTHING
RETURNING event_id
"""


@app.post("/webhooks/inbound")
async def inbound(request: Request) -> Response:
    raw = await request.body()
    # verify_signature() raises before anything touches the database
    event = verify_signature(raw, dict(request.headers))

    pool: asyncpg.Pool = request.app.state.pool
    async with pool.acquire() as conn:
        claimed = await conn.fetchval(
            CLAIM_SQL,
            event["id"],
            SOURCE,
            event["type"],
            orjson.dumps(event).decode(),
        )

    if claimed is None:
        return Response(status_code=200)  # duplicate, already owned

    await enqueue_job(event["id"])
    return Response(status_code=200)

Two details carry the whole design. First, verification happens before the insert — never write an unverified payload into a table you later trust, and see Verifying Stripe Webhook Signatures for the exact check. Second, a duplicate still returns 200. Returning an error on a duplicate teaches the provider to retry harder, which is the opposite of what you want, and it turns a healthy dedup into a red delivery log the customer sees.

Request path through signature verification and the atomic claim A POST is verified, its event id extracted, and an insert with on conflict do nothing decides whether the request enqueues work or acknowledges as a duplicate; both paths return 200. POST /webhooks raw body Verify signature reject on fail Read event_id provider id only Claim row ON CONFLICT skip 1 row 0 rows New event enqueue worker job Duplicate no work at all The claim is the idempotency boundary: exactly one caller ever gets a returned row. Return 200 — the worker does the real work

The row's lifecycle, and who is allowed to move it

A claimed row is not a finished row. The worker moves it through processing to settled, or to failed with an incremented attempt counter. Only the transition into processing needs to be exclusive, and UPDATE ... WHERE status = 'claimed' with a RETURNING clause gives you that for free — the update takes a row lock and the second worker sees zero rows affected. If you already run background jobs with Celery, the queue gives you concurrency and the ledger gives you correctness; they are not substitutes.

State machine for a single webhook event row A claimed row moves to processing, then to settled on commit or failed on error; failed rows retry back into processing and move to a dead letter state after five tries, while duplicate posts are acknowledged without changing state. Lifecycle of one webhook_event row claimed worker picks processing commit settled raise retry failed 5 tries dead letter duplicate POST → 200 Only the move into processing needs exclusivity

Versioned state transitions beat "last write wins"

Deduplication solves repeats. It does nothing for order. If subscription.updated v3 lands before v2, both are unique events, both pass the claim, and the later-processed older event overwrites your customer's plan with stale data. The fix is a monotonic version on the entity, not on the event: Stripe objects carry created and Shopify carries updated_at, and both providers include the full object in the payload. Store the version you applied, and make every write conditional on moving forward.

Version guard outcomes for four incoming events A table showing that events with a version higher than the stored version are applied, while an older version and an exact replay are both skipped. Guard: UPDATE ... WHERE version < incoming Incoming Stored Outcome v1 none apply, store 1 v3 1 apply, store 3 v2 (late) 3 skip, keep 3 v3 replay 3 skip, no-op Zero rows updated is a success, not an error

Here is the worker side. Note the match on event type and the single transaction that applies business state, bumps the version, and settles the ledger row together — if anything raises, all three roll back and the retry finds the row exactly as it was.

Python
import os

import asyncpg
import orjson

DATABASE_URL = os.getenv("DATABASE_URL", "")
LEASE_SQL = """
UPDATE webhook_event SET status = 'processing', attempts = attempts + 1
WHERE event_id = $1 AND status IN ('claimed', 'failed')
RETURNING payload
"""
APPLY_SQL = """
UPDATE subscription SET plan = $2, state = $3, version = $4
WHERE stripe_id = $1 AND version < $4
"""


async def process(pool: asyncpg.Pool, event_id: str) -> str:
    async with pool.acquire() as conn, conn.transaction():
        raw = await conn.fetchval(LEASE_SQL, event_id)
        if raw is None:
            return "already-leased"

        event = orjson.loads(raw)
        obj = event["data"]["object"]
        version = int(obj["created"])

        match event["type"]:
            case "customer.subscription.updated" | "customer.subscription.created":
                await conn.execute(
                    APPLY_SQL, obj["id"], obj["items"]["data"][0]["price"]["id"],
                    obj["status"], version,
                )
            case "customer.subscription.deleted":
                await conn.execute(APPLY_SQL, obj["id"], None, "canceled", version)
            case _:
                pass  # unhandled types still settle, so they stop retrying

        await conn.execute(
            "UPDATE webhook_event SET status = 'settled', settled_at = now()"
            " WHERE event_id = $1",
            event_id,
        )
    return "settled"

Where the ledger should live

StoreSurvives crashCost at 1M eventsVerdict
Postgres tableYes, same txn~1 GB storageUse this
Redis SET NXOnly if persistedSub-cent, RAM-boundCache only
In-process setNoFreeNever

The Postgres row wins because the dedup marker and the business write commit or roll back together. A Redis marker set before a state write that then fails leaves you permanently deaf to that event — the retry gets skipped as a duplicate and the work never happens. Redis is still useful in front, as a 60-second bloom-style short-circuit that keeps a retry storm off the database, but it is an optimisation, not the source of truth. If your ledger writes start dominating latency, that is a connection-pool problem before it is a storage problem; see Fixing Connection Pool Exhaustion.

When to build this, and when to skip it

Build the full ledger the moment a webhook touches money, provisioning, or anything a customer can see. That is day one for a billing integration. Below roughly 10,000 events a month the whole thing costs a single table and about 30 lines, so the "too early" argument does not survive contact with one duplicated refund.

Skip it when the handler is already naturally idempotent — writing a fact that is true regardless of repetition, like upserting a contact record keyed on email, or setting a flag to the same value. A GitHub webhook that triggers a rebuild of a static site can happily run twice. Also skip the version guard specifically when events carry deltas rather than snapshots: an "increment usage by 5" event has no version to compare, and you must dedupe on event id alone, which is exactly why delta events make the ledger non-optional.

Migrating a receiver that already runs

You can add this to a live endpoint in one deploy without a maintenance window:

  1. Ship the webhook_event table and the partial index. Adding an unused table is a zero-risk migration.
  2. Insert the claim before your existing handler and log-only for a day: record what would have been skipped, but still process everything. Structured logs make the count easy to pull, as covered in Structured Logging with structlog.
  3. Read the duplicate rate. Anything above zero proves the endpoint has been double-processing all along.
  4. Flip the claim to enforcing, so claimed is None returns 200 and does no work.
  5. Add the version column to the affected business tables, backfill it from the current updated_at, then add AND version < $n to the writes.

Steps 1 to 4 are reversible with an environment flag. Step 5 is the only one that needs care, because a backfill that leaves versions at 0 will let one stale event through per entity.

Replaying the events you missed

Deduplication protects you from too many deliveries. Replay protects you from too few. When your endpoint returns 500 for forty minutes, most providers eventually give up, and those events are gone unless you pull them back. Stripe keeps events queryable for 30 days, GitHub exposes recent deliveries per hook, and both let you page from a timestamp. The replay tool is small because the claim already makes it safe: re-POST everything in the window and let the ledger discard what you already have.

Python
import asyncio
import os

import httpx

API_BASE = os.getenv("STRIPE_API_BASE", "")
API_KEY = os.getenv("STRIPE_API_KEY", "")
RECEIVER_URL = os.getenv("REPLAY_TARGET_URL", "")
REPLAY_TOKEN = os.getenv("REPLAY_TOKEN", "")


async def replay(since_epoch: int) -> dict[str, int]:
    stats = {"fetched": 0, "accepted": 0}
    params = {"created[gte]": since_epoch, "limit": 100}

    async with httpx.AsyncClient(timeout=30.0) as client:
        while True:
            page = await client.get(
                f"{API_BASE}/events", params=params, auth=(API_KEY, "")
            )
            page.raise_for_status()
            body = page.json()

            for event in body["data"]:
                stats["fetched"] += 1
                sent = await client.post(
                    RECEIVER_URL,
                    json=event,
                    headers={"X-Replay-Token": REPLAY_TOKEN},
                )
                if sent.status_code < 300:
                    stats["accepted"] += 1

            if not body.get("has_more"):
                return stats
            params["starting_after"] = body["data"][-1]["id"]


if __name__ == "__main__":
    print(asyncio.run(replay(int(os.getenv("REPLAY_SINCE", "0")))))

Give the replay path its own header token and let the receiver bypass signature verification only for that token, because a re-serialised event no longer matches the original signature. Below is a real run against a 40-minute gap: 4,812 events came back from the provider, 4,790 were already in the ledger, and 22 were genuinely new.

Missed events recovered per five-minute bucket Bar chart of the twenty-two events recovered during a forty-minute outage window, bucketed in five-minute intervals from 14:12 to 14:47. 22 missed events, recovered per 5-minute bucket 0 5 2 14:12 3 14:17 5 14:22 4 14:27 3 14:32 3 14:37 1 14:42 1 14:47 4,812 fetched, 4,790 discarded by the claim, 22 new

Builder verdict

Put the ledger in Postgres and claim with ON CONFLICT DO NOTHING. It is one table, one insert, and one branch, and it removes an entire category of support ticket that costs you far more than the millisecond it adds. Redis-only dedup looks cheaper on a latency graph and loses events the first time a worker dies mid-transaction; that trade is bad at any volume where webhooks matter. Add the version guard on the same day you add the ledger, because reordering shows up long before you have enough traffic to notice a pattern, and add the replay tool the first time you have an outage — it takes an hour and it is the difference between "we recovered the 22 events" and "please check your account". If you are still deciding whether to accept webhooks at all, the trade-off against pulling on a schedule is laid out in When to Use Webhooks Instead of Polling.

FAQ

What does the ledger cost at a million events a month? About 2 GB of Postgres storage at a 2 KB average payload, which is a few dollars on any managed plan, plus one extra insert per request. Prune settled rows older than 90 days with a scheduled job and the table stabilises. That is far cheaper than one duplicated payout.

Can I dedupe on a hash of the body instead of the event id? Only as a last resort for providers that ship no id. Hashes break when the provider re-serialises the payload or refreshes an embedded timestamp, and then you process the duplicate anyway. Prefer the provider's id, and fall back to a hash of the stable business fields.

Does this survive rotating my webhook signing secret? Yes — the ledger never touches secrets. Verification happens before the claim, so during a rotation you accept both the old and new secret for a few minutes and the dedup logic is unaffected.

How risky is retrofitting this onto a live billing endpoint? Low, if you run the claim in log-only mode for a day first. The insert is additive, the enforcing flip is one environment variable, and the only irreversible step is backfilling the version column on business tables.

Do I still need the ledger if my queue promises exactly-once delivery? Yes. The queue can only deduplicate work it accepted; the duplicate arrives over HTTP before the queue ever sees it. The claim is the boundary between the internet and your system, and no broker can stand in for it.

Same track:

Other tracks: