Outbound

Overview

Send Standard Webhooks to your customers' endpoints. Transactional outbox, retries, replay, fanout. Available today.

View as Markdown

The outbound half of Postel delivers Standard Webhooks to your customers' HTTP endpoints. It's the half you reach for when your product emits webhooks — order.created, deployment.finished, message.posted — and someone else's app is the receiver. (Verifying webhooks other services send you is the inbound half.)

The mental model

send() is an outbox INSERT under the hood: it commits or rolls back atomically with your business write, and a worker pool picks the row up and does the HTTP delivery — you never block on the network inside a transaction. The full picture, with diagrams, is on How Postel works.

import { Postel, InMemoryStorage, HmacV1, ExponentialBackoff, InProcess } from "@postel/core";

const postel = Postel({
  outbound: {
    storage: InMemoryStorage(),    // or a DB-backed Storage adapter in production
    signing: HmacV1(),             // or Ed25519V1a() for asymmetric signing
    retryPolicy: ExponentialBackoff({ maxAttempts: 8 }),
    workers: InProcess({ concurrency: 4 }),
  },
});

await postel.start(); // starts the in-process worker pool that delivers queued webhooks

// Inside a transaction:
await db.tx(async (tx) => {
  await db.orders.insert({ id: "ord_123", /* ... */ }, { tx });
  await postel.outbound.send(
    { type: "order.created", data: { id: "ord_123" } },
    { tx },                                  // joins the same transaction
  );
});

The worker only runs between postel.start() and postel.stop(); skip start() (and never call drain() either) and messages queue forever with no delivery and no error. Hosts that can't keep a pool alive — Lambda, Vercel Functions, Cloudflare Workers — call drain() from a cron-triggered handler instead.

Start here

What's available

✅ = available now · ⏳ = config slot present, adapter/runtime planned. The outbound runtime is exercised end-to-end by the @postel/compliance sender suite.

FeatureStatusNotes
Transactional outboxsend() writes an outbox row inside your business transaction — no orphaned events, no "we charged the card but never told the customer."
Retries, circuit breaker, dead-letter, auto-disableScheduled backoff, configurable per endpoint, with breakers and auto-disable on persistent failure.
Replayreplay({ messageId }), replay({ endpointId, since }), or replay({ filter }). No queue surgery, no manual SQL.
FanoutOne send() → N endpoints, filtered by event type globs, channels, structural filters, or a predicate.
Endpoint lifecycleendpoints.create/update/delete/list/get/disable/enable, plus rotateSecret({ keepPreviousFor }) for overlap-window rotation.
Signing & key managementHmacV1() or Ed25519V1a(); key generation, rotation, and keys.publicJwks() served through each adapter's bindJwks() mount.
Admin API@postel/admin — a default-deny HTTP control plane: endpoint CRUD, replay, reconcile, tenants, key generation, GET /health.
Introspectionmessages.get / attempts / list and tenants.get / list, also over HTTP via the admin API.
StorageInMemoryStorage() for tests and single-process demos, plus the full database adapter matrix.
Serverless draindrain({ maxMessages, deadline }) — bounded single-pass delivery, safe alongside a live pool.
ObservabilityLogger pass-through with trace correlation, typed postel.on(...) events, health(), Prometheus-named metrics(), and OTel spans — the whole story is on Observability.
Multi-tenancy✅ scoping · ⏳ limitsTenant-scoped fanout, persistence, and reads ship; per-tenant rate-limit enforcement lands later (the config persists today).
Worker strategies✅ in-process · ⏳ queuesThe in-process pool is the default and only runtime; BullMQ / PgBoss / External slots throw NotImplementedError until their adapters ship.
KMS integration⏳ laterPlaintextKms only; AwsKms / GcpKms / Vault slots throw NotImplementedError until envelope encryption ships.
Unwired slots fail fast⏳ laterretention, ephemeralKeys, http.tls / http.dns, and per-endpoint maxInflight are typed but unwired — configuring any of them throws NotImplementedError rather than silently doing nothing.

Security note: until KMS lands, signing secrets are stored as-is in endpoint_secrets.material (encryption = 'plaintext') — treat your database as the sole boundary protecting signing keys, the same as any secret store with no KMS in front of it.

For the design rationale behind the library-not-service shape, read Why Postel; for the guarantees this half makes and doesn't make, Delivery guarantees.

On this page