Overview
Send Standard Webhooks to your customers' endpoints. Transactional outbox, retries, replay, fanout. Available today.
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
Sending & the outbox
The transactional contract — an outbox INSERT that joins your business write.
Retries & backoff
The retry schedule, circuit breaker, auto-disable.
Replay
First-class replay verbs — by message, endpoint, or filter.
Endpoints
Manage the receiver side of your fanout.
Serverless
Bounded drain() calls for Lambda, Vercel, and Cloudflare — no long-lived process.
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.
| Feature | Status | Notes |
|---|---|---|
| Transactional outbox | ✅ | send() 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-disable | ✅ | Scheduled backoff, configurable per endpoint, with breakers and auto-disable on persistent failure. |
| Replay | ✅ | replay({ messageId }), replay({ endpointId, since }), or replay({ filter }). No queue surgery, no manual SQL. |
| Fanout | ✅ | One send() → N endpoints, filtered by event type globs, channels, structural filters, or a predicate. |
| Endpoint lifecycle | ✅ | endpoints.create/update/delete/list/get/disable/enable, plus rotateSecret({ keepPreviousFor }) for overlap-window rotation. |
| Signing & key management | ✅ | HmacV1() 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. |
| Introspection | ✅ | messages.get / attempts / list and tenants.get / list, also over HTTP via the admin API. |
| Storage | ✅ | InMemoryStorage() for tests and single-process demos, plus the full database adapter matrix. |
| Serverless drain | ✅ | drain({ maxMessages, deadline }) — bounded single-pass delivery, safe alongside a live pool. |
| Observability | ✅ | Logger 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 · ⏳ limits | Tenant-scoped fanout, persistence, and reads ship; per-tenant rate-limit enforcement lands later (the config persists today). |
| Worker strategies | ✅ in-process · ⏳ queues | The in-process pool is the default and only runtime; BullMQ / PgBoss / External slots throw NotImplementedError until their adapters ship. |
| KMS integration | ⏳ later | PlaintextKms only; AwsKms / GcpKms / Vault slots throw NotImplementedError until envelope encryption ships. |
| Unwired slots fail fast | ⏳ later | retention, 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.
Where to read next
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.