Sending & the outbox
The transactional outbox pattern, implemented inside the host application's database.
postel.outbound.send(event, options?) is the single entry point for emitting a webhook. Under the hood it's an INSERT into a postel_outbox table, scoped to whichever DB transaction you pass.
The shape
import { Postel, InMemoryStorage, HmacV1, ExponentialBackoff, InProcess } from "@postel/core";
const postel = Postel({
outbound: {
storage: InMemoryStorage(), // swap for a DB-backed adapter in production
signing: HmacV1(),
retryPolicy: ExponentialBackoff({ maxAttempts: 8 }),
workers: InProcess({ concurrency: 4 }),
},
});
// Inside a transaction:
await db.tx(async (tx) => {
await db.orders.insert({ id: "ord_123", status: "paid" }, { tx });
const { id, reused } = await postel.outbound.send(
{ type: "order.created", data: { id: "ord_123", amount: 4200 } },
{ tx },
);
// id is the webhook-id the receiver will see.
// reused is true only when an idempotencyKey matched an existing message.
});The transactional contract
The contract is intentionally narrow:
send()writes an outbox row. It does not deliver. Delivery is the worker's job, asynchronously, after commit.- The outbox row joins your transaction. Pass
{ tx }and the row commits or rolls back with everything else under that transaction handle. - No
tx→ autonomous commit. Pass no{ tx }and the row commits immediately in its own transaction. Use this only when there's no business write to join.
This is the transactional outbox pattern as described in decisions/0007-storage-strategy.md. It's why Postel doesn't need a broker: your DB already does atomic commits, and that's the only atomicity property webhook fanout needs.
What happens after commit
- The worker (in-process by default; configurable) selects unprocessed outbox rows.
- For each row, it computes the signature over the event payload, opens an HTTP connection to each matching endpoint, and POSTs the request.
- Successes mark the attempt complete. Failures schedule the next retry per the configured
retryPolicy(Retries & backoff). - Persistent failures trigger the circuit breaker or auto-disable per endpoint.
Fanout
A single send() reaches every endpoint whose types filter matches. If you create three endpoints — webhook-A (types: *), webhook-B (types: order.created), webhook-C (types: user.signup) — a single send({ type: "order.created", ... }) enqueues attempts for A and B, not C.
await postel.outbound.endpoints.create({
url: "https://example.com/webhooks",
types: ["order.*", "shipment.*"],
});Filter strings support globs and (for advanced cases) a custom filter: (event) => boolean predicate. See Endpoints.
Validating what you send
Attach a Standard Schema — a zod (≥3.24), valibot, or arktype schema — to an event type in outbound.events, and send() validates and types data for that type end to end. This mirrors the inbound side's schema (see Validating the payload): @postel/core takes no dependency on the schema library.
import { Postel, InMemoryStorage } from "@postel/core";
import { z } from "zod";
export const postel = Postel({
outbound: {
storage: InMemoryStorage(),
events: {
"user.created": z.object({ id: z.string() }),
},
},
});
// data is typed { id: string } — inferred from the registered schema.
await postel.outbound.send({ type: "user.created", data: { id: "u_1" } });If data doesn't satisfy the registered schema, send() throws EventValidation (code EVENT_VALIDATION) and does not write an outbox row — the same class and code the inbound side throws, so a single catch handles both directions. Event types not present in events stay exactly as permissive as send() is without an events registry configured at all: data is unknown, no validation runs. This is additive — leave events unset and nothing changes.
What about idempotency on send?
SendEvent accepts an optional idempotencyKey. If the caller provides one, the outbox uses a unique constraint on (idempotencyKey) to enforce at-most-once enqueuing — a repeated call with the same key resolves to the existing message's id with reused: true instead of inserting a second row. That flag is how you tell "accepted" from "deduplicated"; sends without a key always report reused: false.
If you don't pass one, Postel generates a stable webhook-id for the receiver but does not enforce send-side idempotency. (You usually don't need both — the receiver's dedup handles the at-least-once contract on the wire.)
Read next
- Retries & backoff — what happens after a 5xx.
- Replay — re-emitting historical messages.
- Endpoints — managing the fanout matrix.