Docs/Outbox

Write an adapter

Store the outbox in any database by implementing five functions.

An adapter tells the outbox where rows live. It is an object with five functions:

import type { OutboxAdapter, OutboxRow } from "@typedhook/sdk";

interface OutboxAdapter<Tx = unknown> {
  insert: (rows: OutboxRow[], tx?: Tx) => Promise<void>;
  claim: (limit: number, now: Date) => Promise<OutboxRow[]>;
  complete: (rowId: string) => Promise<void>;
  retry: (rowId: string, attempts: number, nextAttemptAt: Date, lastError: string) => Promise<void>;
  dead: (rowId: string, attempts: number, lastError: string) => Promise<void>;
}

Tx is your transaction type. It's what wh.with(tx) accepts, so type it precisely.

The row

FieldTypeNotes
idstringobx_…, primary key
eventIdstringevt_…, sent as webhook-id
eventTypestringThe event name
subjectstring | nullSee Subjects
bodystringThe JSON body, sent verbatim
urlstringThe subscriber's URL
secretstring | nullThe signing secret
attemptsnumberFailed attempts so far
nextAttemptAtDateWhen the row is due
status"pending" | "delivered" | "dead"
lastErrorstring | nullThe last failure, like endpoint responded 503
createdAtDate

Store body and secret exactly as given. The worker signs body as-is, and any change breaks the signature.

What each function must do

FunctionContract
insertWrite all rows. Use tx when it's passed, your own connection when it isn't. Reject on failure.
claimReturn up to limit rows with status: "pending" and nextAttemptAt <= now, oldest due first.
completeSet status to "delivered".
retrySet attempts, nextAttemptAt and lastError. Keep status pending.
deadSet status to "dead", with attempts and lastError.

insert is the only call that runs inside your transaction. When it rejects, send() rejects too, so your transaction rolls back.

Claiming with more than one worker

If you run more than one worker, claim must mark the rows it returns so other workers skip them, for example by pushing nextAttemptAt forward. A lock alone isn't enough: it's released when claim returns, before the delivery happens.

On Postgres, the Prisma adapter does it in one statement:

UPDATE "WebhookOutbox" SET "nextAttemptAt" = $1 -- now + claim timeout
WHERE "id" IN (
  SELECT "id" FROM "WebhookOutbox"
  WHERE "status" = 'pending' AND "nextAttemptAt" <= $2 -- now
  ORDER BY "nextAttemptAt"
  LIMIT $3
  FOR UPDATE SKIP LOCKED
)
RETURNING *

If a worker dies after claiming, the row becomes due again once that timeout passes, and another worker sends it. That's why delivery is at least once. Pick a timeout longer than the worker's timeoutMs.

Reference implementations

Adapters for Drizzle, Kysely, TypeORM, MikroORM, Sequelize, Knex and MongoDB are planned.

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.