Docs/Outbox

How the outbox works

Save events in your database, in the same transaction as your data. A worker sends them later and retries until they succeed.

With direct() delivery, retries live in memory and a crash loses them. The outbox moves that state into a table you already back up: an event is only written if your transaction commits, and it stays pending until a subscriber answers with a 2xx or it runs out of attempts.

Outbox playgroundPick what goes wrong, press Run, and watch the outbox deal with it.
Scenario
your code
COMMIT
outbox
delivered
worker
done
subscriber
204 OK
Rowobx_7c1e…
Statusdelivered
Failed attempts2 / 20
Next attempt—

+0.4sworker.tick() · claim(100) → 1 row

+0.4sPOST hooks.northwind.dev/billing · attempt 1

+0.5sresponded 503 · retry in 30s

+30.9sworker.tick() · claim(100) → 1 row

+30.9sPOST hooks.northwind.dev/billing · attempt 2

+40.9stimed out after 10s · retry in 1m

+1m41sworker.tick() · claim(100) → 1 row

+1m41sPOST hooks.northwind.dev/billing · attempt 3

+1m41scomplete(row) · delivered

+1m41sdelivered on attempt 3, 1m41s after commit

1s here ≈ 30s of real timeoutbox(adapter, { maxAttempts: 20 })

Enqueue inside your transaction

Pass an adapter to outbox(). Then wh.with(tx).send() writes the row with the same transaction client as your own writes.

src/webhooks.ts
import { webhooks, outbox } from "@typedhook/sdk";
import { prismaOutboxAdapter } from "@typedhook/prisma";
import type { Prisma } from "@prisma/client";

export const wh = webhooks({
  events,
  subscribers,
  signing: { secret: process.env.WEBHOOK_SECRET! },
  delivery: outbox(prismaOutboxAdapter<Prisma.TransactionClient>(prisma)),
});

await prisma.$transaction(async (tx) => {
  const invoice = await tx.invoice.create({ data });
  await wh.with(tx).send({
    event: "invoice.paid",
    subject: invoice.accountId,
    data: { invoiceId: invoice.id, amountCents: invoice.total },
  });
});

This is the one place send() throws. If the insert fails, the error propagates so your transaction rolls back. Validation errors still return { ok: false }.

Run a worker

The worker claims due rows, signs each one with a fresh timestamp and posts it. Keep it running in a long-lived process, or call tick() from a cron job.

const worker = wh.worker({
  pollIntervalMs: 1000,
  batch: 100,
  hooks: {
    onDead: (row, error) => alerts.notify(`webhook ${row.eventId} died: ${error}`),
  },
});

worker.start();
process.on("SIGTERM", () => worker.stop()); // waits for in-flight deliveries
OptionDefaultWhat it does
pollIntervalMs1000Wait between ticks when running with start()
batch100Rows claimed per tick
timeoutMs10000Per-request timeout. A timeout counts as a failed attempt
hooks—onDelivered, onRetry, onDead, onError. A throwing hook never stops the loop
fetchImplfetchSwap the HTTP client, for tests or proxies

Delivery guarantees

  1. At least once. A worker that crashes mid-delivery leaves its rows claimed. They become due again and are sent again, so receivers must dedupe on webhook-id.
  2. Safe with many workers if claim marks the rows it returns. The Prisma adapter uses FOR UPDATE SKIP LOCKED and pushes nextAttemptAt forward.
  3. Checked by the compiler. wh.with() and wh.worker() only exist when delivery is outbox(...). Calling them otherwise is a type error.
  4. No redirects. A 3xx counts as a failed attempt, so a signed payload is never replayed to another host.

Adapters

Use the Prisma adapter on Postgres, memoryAdapter() in tests, or write your own in five functions.

Edit on GitHubLast updated on October 14, 2026

On this page

For coding agents

Every page is available as Markdown. Point your agent at the full docs in one file.