Overview
Send Standard Webhooks to your customers' endpoints. Transactional outbox, retries, replay, fanout. Available today.
Status: available. The outbound runtime is implemented and runs against the in-process
InMemoryStorageadapter or any database-backedStorageadapter — standalone@postel/pg/@postel/sqlite/@postel/mysql, or the Drizzle / Kysely / Prisma / TypeORM / MikroORM adapters over the ORM you already run. See Storage.
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.
If you're verifying webhooks that other services send to you — that's the inbound half, and it ships today.
Start here
Sending & the outbox
The transactional contract — an outbox INSERT that joins your business write.
Retries & backoff
Retry strategies, circuit breaker, auto-disable.
Replay
First-class replay verbs — by message, endpoint, or filter.
Endpoints
Manage the receiver side of your fanout.
What's available
✅ = available now · ⏳ = config slot present, adapter/runtime planned.
| Feature | Status | Notes |
|---|---|---|
| Transactional outbox | ✅ | send() writes an outbox row inside your business transaction. The webhook is queued only if your business write commits — no orphaned events, no "we charged the card but never told the customer." |
| Retries, circuit breaker, dead-letter, auto-disable | ✅ | Exponential or linear backoff, configurable per endpoint, with circuit breakers and auto-disable on persistent failure. |
| Replay as a first-class verb | ✅ | replay({ messageId }), replay({ endpointId, since }), or replay({ filter }). No queue surgery, no manual SQL. |
| Fanout | ✅ | One send() → N endpoints, filtered by event type, channel, or arbitrary predicate. |
| Endpoint lifecycle | ✅ | endpoints.create/update/delete/list/get/disable, with rotateSecret({ keepPreviousFor }) for overlap-window rotation. |
| Signing | ✅ | HMAC (HmacV1()) or Ed25519 (Ed25519V1a()). Asymmetric signing pairs with JWKS publication so receivers verify without a shared secret. |
| Key management | ✅ | Symmetric (generateSymmetric) and asymmetric (generateAsymmetric) generation; rotateSecret preserves the endpoint's algorithm. Current public keys are retrievable via keys.publicJwks() and served through each adapter's outbound.bindJwks() mount. |
| Admin API | ✅ | @postel/admin — a default-deny HTTP control plane over the outbound API: endpoint CRUD, replay, reconcile, tenants, and key generation. |
| Message & attempt introspection | ✅ | messages.get / attempts / list — read a message and its payload, its delivery-attempt history (status, code, latency), and filter recent messages. Exposed over HTTP as GET /messages… in the admin API. |
| Tenant introspection | ✅ | tenants.get / list — read a tenant back (its rate limit, metadata, creation time), and list tenants with keyset pagination. Exposed over HTTP as GET /tenants… in the admin API. |
| Storage | ✅ | InMemoryStorage() for single-process/tests, plus database-backed adapters — standalone @postel/pg / @postel/sqlite / @postel/mysql and the Drizzle / Kysely / Prisma / TypeORM / MikroORM ORM adapters — or implement the Storage interface against your own database. |
| Multi-tenancy | ✅ scoping · ⏳ limits | Tenant-scoped fanout and persistence ship, along with read access (tenants.get / list); per-tenant rate-limit enforcement and fair-scheduling land later (the config persists today). |
| Worker strategies | ✅ in-process · ⏳ queues | The in-process worker pool ships and is the default; BullMQ / PgBoss / External are config slots that throw NotImplementedError until their adapters ship. |
| KMS integration | ⏳ later | PlaintextKms only today; AwsKms / GcpKms / Vault are config slots that throw NotImplementedError until envelope encryption ships. |
| Observability | ✅ logger · events · health · ⏳ otel/metrics | observability.logger receives the runtime's attempt / circuit-open / circuit-close / dead-letter events as they happen. The same events are typed on postel.on(event, handler) — the handler's payload is inferred from the event name and on returns an Unsubscribe. postel.health() is a readiness probe: { ok, outboxDepth, oldestPendingAge, workerCount }, reporting ok: false when the storage probe fails or a configured observability.health threshold (maxOutboxDepth, maxOldestPendingAge) is exceeded. OpenTelemetry spans and Prometheus metrics are deferred. |
| Unwired config slots fail fast | ⏳ later | retention, ephemeralKeys, and the http.tls / http.dns knobs are typed for forward-compatibility but not yet wired — configuring any of them throws NotImplementedError rather than silently doing nothing. |
The mental model
The same Postel({...}) factory accepts an outbound config alongside inbound. The two are independent — configure one, both, or neither.
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 }),
},
});
// 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 key insight: send() is just an outbox INSERT under the hood. It commits or rolls back atomically with your business write. A separate worker picks the outbox row up and does the HTTP delivery — but you, the application author, never block on the network inside your transaction.
What's available, and what's next
The outbound runtime is implemented and exercised end-to-end by the @postel/compliance sender suite. What's available now:
- The sender-side capability specs (sender, retry-policy, replay-reconciliation, endpoint-management, key-management, filtering-transformation) and their TypeScript runtime.
- The
@postel/compliancesuite driving the sender behaviors against the running runtime. - The in-process
InMemoryStorageadapter — production-shaped for single-process deployments and the natural choice for tests. - The full database-backed storage-adapter matrix — standalone
@postel/pg/@postel/sqlite/@postel/mysql, and the Drizzle / Kysely / Prisma / TypeORM / MikroORM ORM adapters — plus a custom-adapter path for any other database.
Still ahead, in a later release:
- The external job-queue worker adapters (BullMQ, PgBoss) — the config slots exist but throw
NotImplementedErrortoday; the in-process worker pool is the supported runtime. - KMS-backed encryption at rest (
AwsKms/GcpKms/Vault), automatic retention pruning, ephemeral auto-rotating keys, and thehttp.tls/http.dnsknobs. Their config slots are typed but unwired, so configuring them throwsNotImplementedErrorrather than silently no-opping.observability.loggeris wired today and receives delivery / circuit events; full OpenTelemetry + Prometheus instrumentation is deferred.
Where to read next
The pages above cover the runtime. For the design rationale behind the library-not-service shape, read Why Postel.