Implementing the Transactional Outbox Pattern for Webhooks

The bug is always the same shape. Your handler commits the order to PostgreSQL, then calls queue.publish(order_created_event) on the next line. Between those two lines the pod is evicted, the connection to the broker times out, or the process is OOM-killed. The order exists. The webhook does not, and never will — no retry can help, because the event was never recorded anywhere. This is the dual-write problem, and the transactional outbox is the standard fix. This guide, part of Delivery Guarantee Levels, implements it end to end in Python and PostgreSQL. If you have not yet decided what semantics you are promising consumers, settle that first in choosing a webhook delivery guarantee level — the outbox is how you actually deliver on at-least-once, not a substitute for choosing it.

The core idea is a single sentence: the event is written to your own database, in the same transaction as the state change it describes, and something else publishes it later. Two writes to two systems become one write to one system, and atomicity does the rest. What you buy is the elimination of lost events. What you pay is a background relay to operate and a table to prune.

Prerequisites

Step 1: Write the event to an outbox table in the same transaction

The outbox is an ordinary table in the same database and schema as your business data. That co-location is the entire mechanism: because it is the same database, the INSERT into outbox joins the same transaction as the INSERT into orders, and the two either both exist or neither does.

The outbox write closes the crash window Both the business row and the outbox row are inserted inside one transaction and committed together, after which a separate relay poller reads the outbox and delivers the webhook. single DB transaction orders row INSERT outbox row INSERT COMMIT API handler business call Relay poller reads outbox HTTP POST to subscriber a crash after COMMIT cannot lose the event
Because the outbox insert shares the transaction, there is no instant at which the order exists without a durable record of the event it should produce.

The schema carries the routing information the relay needs, plus the columns that make ordering and pruning possible later:

import psycopg

SCHEMA_SQL = """
CREATE TABLE IF NOT EXISTS outbox (
    id             BIGSERIAL PRIMARY KEY,
    event_id       UUID        NOT NULL UNIQUE,     -- consumer-facing idempotency key
    aggregate_type TEXT        NOT NULL,            -- 'order', 'account', ...
    aggregate_id   TEXT        NOT NULL,            -- ordering lane
    aggregate_seq  BIGINT      NOT NULL,            -- monotonic within the lane
    event_type     TEXT        NOT NULL,
    payload        JSONB       NOT NULL,
    created_at     TIMESTAMPTZ NOT NULL DEFAULT now(),
    claimed_until  TIMESTAMPTZ,                     -- lease held by a relay worker
    attempts       INT         NOT NULL DEFAULT 0,
    sent_at        TIMESTAMPTZ,                     -- NULL means still pending
    UNIQUE (aggregate_type, aggregate_id, aggregate_seq)
);

-- Partial index: only unsent rows are ever scanned by the relay, so the index
-- stays tiny even when the table holds millions of already-sent rows.
CREATE INDEX IF NOT EXISTS outbox_pending_idx
    ON outbox (aggregate_id, aggregate_seq)
    WHERE sent_at IS NULL;
"""

def migrate(conn: psycopg.Connection) -> None:
    with conn.cursor() as cur:
        cur.execute(SCHEMA_SQL)
    conn.commit()

The write path is then a single transaction with two statements. The critical detail is that enqueue_event takes the cursor, not a connection or a session factory — passing a cursor makes it structurally impossible to accidentally write the event on a different connection, which is the most common way teams reintroduce the dual write they were trying to remove:

import json
import uuid

def enqueue_event(cur: psycopg.Cursor, *, aggregate_type: str, aggregate_id: str,
                  event_type: str, payload: dict) -> str:
    """Append an event to the outbox on the CALLER'S cursor, inside their transaction."""
    event_id = str(uuid.uuid4())
    cur.execute(
        """
        INSERT INTO outbox (event_id, aggregate_type, aggregate_id, aggregate_seq,
                            event_type, payload)
        VALUES (%s, %s, %s,
                (SELECT COALESCE(MAX(aggregate_seq), 0) + 1
                   FROM outbox
                  WHERE aggregate_type = %s AND aggregate_id = %s),
                %s, %s)
        """,
        (event_id, aggregate_type, aggregate_id,
         aggregate_type, aggregate_id, event_type, json.dumps(payload)),
    )
    return event_id

def create_order(conn: psycopg.Connection, customer_id: str, total_cents: int) -> str:
    with conn.transaction():                       # one transaction, two writes
        with conn.cursor() as cur:
            cur.execute(
                "INSERT INTO orders (customer_id, total_cents) VALUES (%s, %s) "
                "RETURNING id",
                (customer_id, total_cents),
            )
            order_id = str(cur.fetchone()[0])
            enqueue_event(
                cur,
                aggregate_type="order",
                aggregate_id=order_id,
                event_type="order.created",
                payload={"order_id": order_id, "customer_id": customer_id,
                         "total_cents": total_cents},
            )
    return order_id

Note what is absent: no HTTP call, no broker publish, no network I/O of any kind inside the transaction. Holding a database transaction open across a network call is how outbox implementations turn into lock-contention incidents.

Step 2: Relay claimed rows and mark them sent

The relay is a separate process that repeatedly claims a batch of unsent rows, publishes them, and marks them sent. Correctness here rests on two things: multiple relay workers must never claim the same row, and the mark-sent update must happen in its own transaction after the publish succeeds.

SELECT ... FOR UPDATE SKIP LOCKED gives you the first property for free. Workers that collide simply skip the locked rows and take the next ones, so you can scale the relay horizontally with no coordination.

Relay claim, publish, mark-sent sequence The application inserts into the outbox inside its transaction; the relay later claims a batch with SKIP LOCKED, posts to the subscriber, and only then sets sent_at. App transaction outbox table Relay poller Subscriber INSERT in the same tx claim batch, SKIP LOCKED POST signed payload 200 OK UPDATE sent_at = now() a crash before the UPDATE re-sends: at-least-once, never zero
The mark-sent update deliberately trails the publish, so the only failure window produces a duplicate rather than a loss.

The relay loop, with a lease so a worker that dies does not strand its batch forever:

import datetime as dt
import httpx

CLAIM_SQL = """
UPDATE outbox
   SET claimed_until = now() + %s::interval,
       attempts = attempts + 1
 WHERE id IN (
       SELECT id FROM outbox
        WHERE sent_at IS NULL
          AND (claimed_until IS NULL OR claimed_until < now())
        ORDER BY aggregate_id, aggregate_seq
        LIMIT %s
          FOR UPDATE SKIP LOCKED
 )
RETURNING id, event_id, aggregate_id, aggregate_seq, event_type, payload, attempts;
"""

class Relay:
    def __init__(self, conn: psycopg.Connection, client: httpx.Client,
                 endpoint: str, lease: str = "60 seconds", batch: int = 200,
                 max_attempts: int = 8):
        self.conn = conn
        self.client = client
        self.endpoint = endpoint
        self.lease = lease
        self.batch = batch
        self.max_attempts = max_attempts

    def claim(self) -> list[tuple]:
        with self.conn.transaction(), self.conn.cursor() as cur:
            cur.execute(CLAIM_SQL, (self.lease, self.batch))
            return cur.fetchall()

    def mark_sent(self, row_id: int) -> None:
        with self.conn.transaction(), self.conn.cursor() as cur:
            cur.execute(
                "UPDATE outbox SET sent_at = now(), claimed_until = NULL "
                "WHERE id = %s AND sent_at IS NULL",
                (row_id,),
            )

    def release(self, row_id: int) -> None:
        """Give the row back immediately so the next poll retries it."""
        with self.conn.transaction(), self.conn.cursor() as cur:
            cur.execute("UPDATE outbox SET claimed_until = NULL WHERE id = %s",
                        (row_id,))

    def publish(self, row: tuple) -> None:
        row_id, event_id, agg_id, seq, event_type, payload, attempts = row
        resp = self.client.post(
            self.endpoint,
            json={"event_id": str(event_id), "type": event_type,
                  "aggregate_id": agg_id, "sequence": seq, "data": payload},
            headers={"X-Idempotency-Key": str(event_id),
                     "X-Event-Sequence": str(seq)},
            timeout=httpx.Timeout(connect=3.0, read=10.0, write=10.0, pool=3.0),
        )
        resp.raise_for_status()

    def run_once(self) -> dict:
        sent = failed = dead = 0
        for row in self.claim():
            row_id, attempts = row[0], row[6]
            try:
                self.publish(row)
                self.mark_sent(row_id)             # only after a 2xx
                sent += 1
            except Exception:
                if attempts >= self.max_attempts:
                    self.dead_letter(row)
                    self.mark_sent(row_id)         # terminal: stop re-claiming it
                    dead += 1
                else:
                    self.release(row_id)
                    failed += 1
        return {"sent": sent, "failed": failed, "dead": dead}

How you trigger run_once is a real trade-off, and it is where most of the latency budget goes:

Relay trigger Added latency Ordering Database load Operational cost
Tight poll loop, 200 ms 100–300 ms Preserved by ORDER BY Constant small index scan Lowest: one process
Batched poll, 5 s 2.5–5 s Preserved by ORDER BY Negligible Lowest: one process
LISTEN/NOTIFY wakeup 10–50 ms Preserved by ORDER BY Near zero when idle Notify must fire post-commit
Logical replication (CDC) 10–50 ms Follows WAL order Replication slot only Highest: connector to run

Start with the 200 ms poll. It is one process, it has no extra moving parts, and the partial index makes the query cost effectively constant regardless of table size. Move to change data capture only when sub-100 ms delivery latency is a stated requirement, not because it sounds more elegant — a stalled replication slot can fill a disk and take the primary down with it.

Step 3: Preserve per-entity ordering with a monotonic sequence

ORDER BY id is not enough. BIGSERIAL values are handed out when the INSERT executes, but rows become visible when the transaction commits — so a long transaction that grabbed id 100 can commit after a short one that grabbed id 101, and a relay ordering by id will publish 101 first and may never see 100 again if it has already advanced past it.

The fix that matters in practice is to stop caring about global order and enforce order only where it is semantically required: within one aggregate. The aggregate_seq column, unique per (aggregate_type, aggregate_id), gives each entity its own strictly increasing counter, and the relay dispatches one lane per aggregate. Different aggregates run in parallel; one aggregate is always serial. This is the same discipline described in implementing strict ordering for financial webhooks, applied at the outbox rather than at the queue.

Per-aggregate ordering lanes Interleaved outbox rows for two accounts are split into one lane per aggregate identifier, each lane replaying its own sequence numbers in order. outbox table aggregate_id seq event_type acct-1 1 order.created acct-2 1 user.updated acct-1 2 order.paid acct-1 3 order.shipped acct-2 2 user.deleted one worker per aggregate_id acct-1 lane seq 1, 2, 3 applied in order acct-2 lane seq 1, 2 applied in order lanes run in parallel
Ordering is enforced per aggregate, not globally, so throughput scales with the number of distinct entities instead of being pinned to a single serial stream.

Dispatching in lanes is a grouping plus a thread pool that never puts two rows from one aggregate in flight at once:

from collections import defaultdict
from concurrent.futures import ThreadPoolExecutor

def dispatch_in_lanes(relay: Relay, rows: list[tuple], max_lanes: int = 16) -> dict:
    lanes: dict[str, list[tuple]] = defaultdict(list)
    for row in rows:
        lanes[row[2]].append(row)                  # row[2] is aggregate_id
    for aggregate_rows in lanes.values():
        aggregate_rows.sort(key=lambda r: r[3])    # row[3] is aggregate_seq

    def run_lane(aggregate_rows: list[tuple]) -> int:
        delivered = 0
        for row in aggregate_rows:
            try:
                relay.publish(row)
                relay.mark_sent(row[0])
                delivered += 1
            except Exception:
                relay.release(row[0])
                break                              # stop the lane; later seqs must wait
        return delivered

    with ThreadPoolExecutor(max_workers=max_lanes) as pool:
        return {"delivered": sum(pool.map(run_lane, lanes.values()))}

The break is the whole point. When sequence 2 fails, sequences 3 and 4 for that aggregate stay unsent and unclaimed, and the next poll retries from 2. Without it, a transient failure on one event silently reorders the entity’s stream.

Step 4: Make consumers idempotent for at-least-once relay

Look again at the sequence diagram: the relay publishes, gets a 200, and then updates sent_at. If the process dies in between, the row is still unsent, the lease expires, another worker claims it, and the subscriber receives the same event twice. That is not a bug to be engineered away — it is the defining property of the pattern. A transactional outbox delivers at-least-once and nothing stronger. Any attempt to make it exactly-once by reordering the update before the publish converts duplicates into losses, which is strictly worse.

The mitigation lives on the consumer, and the relay’s job is to make it possible by sending a key that is stable across retries. event_id is generated once at insert time and never changes, which is exactly the property required:

def apply_event(cur: psycopg.Cursor, event: dict) -> str:
    """Consumer side: record the key first, and skip the work if it already exists."""
    cur.execute(
        "INSERT INTO processed_events (event_id, received_at) "
        "VALUES (%s, now()) ON CONFLICT (event_id) DO NOTHING",
        (event["event_id"],),
    )
    if cur.rowcount == 0:
        return "duplicate_ignored"                 # already applied, do nothing
    cur.execute(
        "UPDATE orders SET status = %s WHERE id = %s",
        (event["data"]["status"], event["data"]["order_id"]),
    )
    return "applied"

Both statements run in one consumer-side transaction, so the dedup key and the effect it guards commit together. Give processed_events a retention longer than your longest replay window, including anything sitting in a dead-letter queue, or a late replay will be treated as new.

Step 5: Prune the outbox table on a retention window

An unpruned outbox grows forever. The partial index keeps the relay fast, but the table itself consumes storage, slows backups, and eventually causes autovacuum to spend its budget on rows nobody will ever read again.

Outbox row lifecycle An outbox row moves from pending to claimed under a lease, to sent once delivered, and to purged after the retention window; an expired lease returns it to pending. lease expired PENDING sent_at IS NULL CLAIMED lease held SENT sent_at set PURGED row deleted claim delivered after retention prune rows whose sent_at is older than the replay window
A row is only eligible for deletion once it is sent and older than the longest window in which anyone could legitimately ask you to replay it.

Delete in bounded batches. A single unbounded DELETE over millions of rows takes a long-lived lock and generates enough WAL to stall replication:

PRUNE_SQL = """
DELETE FROM outbox
 WHERE id IN (
       SELECT id FROM outbox
        WHERE sent_at IS NOT NULL
          AND sent_at < now() - %s::interval
        ORDER BY id
        LIMIT %s
 );
"""

def prune(conn: psycopg.Connection, retention: str = "30 days",
          batch: int = 5000, max_batches: int = 200) -> int:
    """Delete sent rows past the retention window, a bounded batch at a time."""
    removed = 0
    for _ in range(max_batches):
        with conn.transaction(), conn.cursor() as cur:
            cur.execute(PRUNE_SQL, (retention, batch))
            deleted = cur.rowcount
        removed += deleted
        if deleted < batch:
            break                                  # caught up
    return removed

Set retention to the longest period over which you might legitimately replay: usually the maximum of your dead-letter retention and any contractual “resend the last N days” commitment. Thirty days is a common, defensible default. Run prune on a schedule during off-peak hours, and alert if it ever hits max_batches, because that means deletion is losing the race against inserts.

Verification and testing

The property that justifies the whole pattern is atomicity, so test it by making the business write fail after the outbox insert and asserting that neither survives:

import pytest

def test_rollback_removes_both_the_order_and_the_event(conn):
    before = count(conn, "SELECT count(*) FROM outbox")
    with pytest.raises(RuntimeError):
        with conn.transaction():
            with conn.cursor() as cur:
                cur.execute("INSERT INTO orders (customer_id, total_cents) "
                            "VALUES ('c1', 100) RETURNING id")
                order_id = str(cur.fetchone()[0])
                enqueue_event(cur, aggregate_type="order", aggregate_id=order_id,
                              event_type="order.created", payload={})
                raise RuntimeError("payment authorization failed")
    assert count(conn, "SELECT count(*) FROM outbox") == before
    assert count(conn, "SELECT count(*) FROM orders WHERE customer_id = 'c1'") == 0

def test_crash_between_publish_and_mark_sent_causes_a_duplicate_not_a_loss(conn):
    relay = Relay(conn, client=RecordingClient(), endpoint="http://x/hooks")
    create_order(conn, "c2", 500)
    rows = relay.claim()
    relay.publish(rows[0])                          # delivered
    # process dies here: mark_sent never runs, lease expires
    conn.execute("UPDATE outbox SET claimed_until = now() - interval '1 minute'")
    again = relay.claim()
    assert len(again) == 1                          # re-claimed, will be re-sent
    assert again[0][1] == rows[0][1]                # same event_id: consumer dedupes

def test_lane_stops_at_the_first_failure(conn):
    rows = [(1, "e1", "acct-1", 1, "a", {}, 0),
            (2, "e2", "acct-1", 2, "b", {}, 0),
            (3, "e3", "acct-1", 3, "c", {}, 0)]
    relay = FailingAtSecond(conn)
    result = dispatch_in_lanes(relay, rows)
    assert result["delivered"] == 1                 # seq 3 must not overtake seq 2

Operationally, the two numbers to watch are outbox lag and unsent depth. Both come straight out of the table:

# Oldest unsent event: this is your true end-to-end delivery lag.
psql -c "SELECT coalesce(extract(epoch from now() - min(created_at)), 0) AS lag_seconds
           FROM outbox WHERE sent_at IS NULL;"

# Backlog and stuck-claim count. A rising claimed-but-unsent count means
# relay workers are dying mid-batch.
psql -c "SELECT count(*) FILTER (WHERE sent_at IS NULL) AS pending,
                count(*) FILTER (WHERE sent_at IS NULL
                                   AND claimed_until > now()) AS in_flight,
                count(*) FILTER (WHERE attempts > 5 AND sent_at IS NULL) AS struggling
           FROM outbox;"

A healthy relay keeps lag_seconds in the single digits, pending near zero between bursts, and struggling at zero.

Failure modes and gotchas

Frequently Asked Questions

Does the MAX(aggregate_seq) subquery serialize writes for a hot aggregate?

It takes no lock, which is exactly the problem: two concurrent transactions inserting for the same aggregate read the same maximum, and one of them loses to the unique constraint at commit. That is the correct outcome, but the caller has to be ready to catch the unique violation and retry the whole business transaction. For an aggregate that is genuinely hot, swap the subquery for a per-aggregate counter row you lock with SELECT FOR UPDATE, trading a little throughput for a deterministic sequence.

How should the lease duration relate to the batch size?

The lease has to cover the worst case for the entire batch, not for one row. A 60-second lease with a batch of 200 and a 10-second read timeout survives only a handful of slow endpoints before it expires mid-batch, at which point a second worker re-claims rows that are still in flight. Either size the lease above batch times timeout, or renew it as the batch progresses; a smaller batch is the cheap fix.

With several subscribers on one event type, is that one outbox row or one per subscriber?

Keep one outbox row per event, because the row records a fact that happened rather than a delivery, and fan out into per-subscriber delivery records when the relay picks it up. If a single row is only marked sent once every subscriber has accepted it, one broken endpoint holds that row and its whole aggregate lane hostage. The split also puts retry budgets, circuit breakers and dead-lettering per endpoint, which is where those decisions belong.

Should the payload column hold a snapshot or just an ID to look up at delivery time?

Store the snapshot. The relay runs after the commit and sometimes minutes later behind a backlog, so a lookup returns whatever the row looks like then rather than what it looked like when the event happened, which makes every replay a subtly different message. A snapshot also keeps the outbox readable during an incident once the source rows have moved on.

Can the relay run against a read replica to keep load off the primary?

No — the claim is an UPDATE that takes row locks, so it belongs on the primary. The partial index keeps that query cheap enough that the load tracks your poll frequency rather than the size of the table, so the tuning knob is the interval, not the node. If the primary really is the constraint, lengthen the poll or move to a NOTIFY wakeup before splitting the relay off the writable node.

What happens to the outbox after a point-in-time restore?

Rows whose sent_at was written after the restore point come back as pending, and the relay republishes events the subscriber already applied. That is why the consumer's dedup-key retention has to outlive not only the retry and dead-letter windows but the oldest backup you would ever restore from. Put it in the runbook, because the first symptom after a restore is a burst of duplicate deliveries that reads like a relay bug and is not one.