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.
+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
outbox(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.
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| Option | Default | What it does |
|---|---|---|
pollIntervalMs | 1000 | Wait between ticks when running with start() |
batch | 100 | Rows claimed per tick |
timeoutMs | 10000 | Per-request timeout. A timeout counts as a failed attempt |
hooks | — | onDelivered, onRetry, onDead, onError. A throwing hook never stops the loop |
fetchImpl | fetch | Swap the HTTP client, for tests or proxies |
Delivery guarantees
- 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. - Safe with many workers if
claimmarks the rows it returns. The Prisma adapter usesFOR UPDATE SKIP LOCKEDand pushesnextAttemptAtforward. - Checked by the compiler.
wh.with()andwh.worker()only exist whendeliveryisoutbox(...). Calling them otherwise is a type error. - 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.