# Sending & the outbox



`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 [#the-shape]

```ts
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 }),
  },
});

await postel.start(); // starts the worker pool; call postel.stop() on shutdown

// 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-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](https://github.com/postel-sh/postel/blob/main/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 [#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](/docs/outbound/retries)).
4. Persistent failures trigger the circuit breaker or auto-disable per endpoint.

## Fanout [#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.

```ts
await postel.outbound.endpoints.create({
  url: "https://example.com/webhooks",
  types: ["order.*", "shipment.*"],
});
```

Filter strings support globs; for advanced cases there's a structural `filter` clause and a custom `filterFn` predicate. See [Endpoints](/docs/outbound/endpoints).

## Validating what you send [#validating-what-you-send]

Attach a [Standard Schema](https://github.com/standard-schema/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](/docs/inbound/verify#validating-the-payload)): `@postel/core` takes no dependency on the schema library.

```ts title="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? [#what-about-idempotency-on-send]

`SendEvent` accepts an optional `idempotencyKey`. If the caller provides one, the outbox uses a unique constraint on `(tenant_id, idempotency_key)` to enforce at-most-once *enqueuing* — a repeated call with the same key **and the same tenant** resolves to the existing message's `id` with `reused: true` instead of inserting a second row. The key is scoped per tenant: the same key under two different `tenantId`s creates two messages. 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](/docs/inbound/deduplication) handles the at-least-once contract on the wire.)

## Read next [#read-next]

* [Retries & backoff](/docs/outbound/retries) — what happens after a 5xx.
* [Replay](/docs/outbound/replay) — re-emitting historical messages.
* [Endpoints](/docs/outbound/endpoints) — managing the fanout matrix.
