Outbound

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 InMemoryStorage adapter or any database-backed Storage adapter — 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

What's available

✅ = available now · ⏳ = config slot present, adapter/runtime planned.

FeatureStatusNotes
Transactional outboxsend() 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-disableExponential or linear backoff, configurable per endpoint, with circuit breakers and auto-disable on persistent failure.
Replay as a first-class verbreplay({ messageId }), replay({ endpointId, since }), or replay({ filter }). No queue surgery, no manual SQL.
FanoutOne send() → N endpoints, filtered by event type, channel, or arbitrary predicate.
Endpoint lifecycleendpoints.create/update/delete/list/get/disable, with rotateSecret({ keepPreviousFor }) for overlap-window rotation.
SigningHMAC (HmacV1()) or Ed25519 (Ed25519V1a()). Asymmetric signing pairs with JWKS publication so receivers verify without a shared secret.
Key managementSymmetric (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 introspectionmessages.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 introspectiontenants.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.
StorageInMemoryStorage() 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 · ⏳ limitsTenant-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 · ⏳ queuesThe in-process worker pool ships and is the default; BullMQ / PgBoss / External are config slots that throw NotImplementedError until their adapters ship.
KMS integration⏳ laterPlaintextKms only today; AwsKms / GcpKms / Vault are config slots that throw NotImplementedError until envelope encryption ships.
Observability✅ logger · events · health · ⏳ otel/metricsobservability.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⏳ laterretention, 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/compliance suite driving the sender behaviors against the running runtime.
  • The in-process InMemoryStorage adapter — 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 NotImplementedError today; 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 the http.tls / http.dns knobs. Their config slots are typed but unwired, so configuring them throws NotImplementedError rather than silently no-opping. observability.logger is wired today and receives delivery / circuit events; full OpenTelemetry + Prometheus instrumentation is deferred.

The pages above cover the runtime. For the design rationale behind the library-not-service shape, read Why Postel.

On this page