Outbound

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:

  1. send() writes an outbox row. It does not deliver. Delivery is the worker's job, asynchronously, after commit.
  2. The outbox row joins your transaction. Pass { tx } and the row commits or rolls back with everything else under that transaction handle.
  3. 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

  1. The worker (in-process by default; configurable) selects unprocessed outbox rows.
  2. For each row, it computes the signature over the event payload, opens an HTTP connection to each matching endpoint, and POSTs the request.
  3. Successes mark the attempt complete. Failures schedule the next retry per the configured retryPolicy (Retries & backoff).
  4. 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.

lib/postel.ts
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.)

On this page