Outbound

Endpoints

Manage the receivers your application sends to — create, update, filter, rotate secrets.

Endpoints are the receiver-of-record for fanout. A send() reaches every endpoint whose types filter matches the event.

Lifecycle

// Create
const ep = await postel.outbound.endpoints.create({
  url:    "https://customer.example.com/webhooks",
  types:  ["order.*", "shipment.delivered"],
  tenantId: "cust_42",
  metadata: { displayName: "ACME Co — Production" },
});

// Update
await postel.outbound.endpoints.update(ep.id, {
  types: ["order.*"],
});

// Disable (delivery stops; row remains for audit)
await postel.outbound.endpoints.disable(ep.id);

// Delete (delivery stops; row removed unless purgeAttempts: false)
await postel.outbound.endpoints.delete(ep.id, { purgeAttempts: true });

// List / get — list returns a bounded page, newest-first
const page = await postel.outbound.endpoints.list({ tenantId: "cust_42", limit: 50 });
page.items;       // Endpoint[]
page.nextCursor;  // an opaque string, or null on the last page
const one = await postel.outbound.endpoints.get(ep.id);

list is paginated with the same envelope as every other list read ({ limit?, cursor? } in, { items, nextCursor } out; default limit 100). Walk every page by feeding nextCursor back in as cursor — the same loop shown for tenants.

Every endpoint operation accepts an optional tx so endpoint state changes can join the same DB transaction as your business write.

What reads return

create, update, get, and list return the full endpoint — every serializable field you passed in (types, channels, filter, retryPolicy, plain-record headers, metadata, allowHttp, maxInflight, http, circuitBreaker, autoDisable) plus id, url, state, createdAt, and updatedAt.

Function-shaped options stay off the read shape — they are code rather than data, so they run at dispatch time but can't round-trip through a read, and the shape is identical whichever storage adapter you use:

  • filterFn and transform are absent from the returned endpoint.
  • headers given as a callback, and a Custom(...) retry policy (whose compute is a function), read back as null. ExponentialBackoff and LinearBackoff policies round-trip unchanged.
  • The returned http carries your stored overrides minus the fetch key.
  • signing is never echoed back because a strategy can carry key material.

Filtering

The types filter accepts string globs:

PatternMatches
"order.created"Exactly order.created
"order.*"order.created, order.refunded, order.shipped
"*.created"order.created, user.created, …
"*"Everything

For "does this data field equal this value" checks, pass a structural filter{ dataPath, equals }, or an array of clauses ANDed together. dataPath is a dot-separated path into the event's data; unlike a predicate function, this is plain JSON: it round-trips through get/list, survives a restart on real storage, and is safe to expose over the admin HTTP API.

await postel.outbound.endpoints.create({
  url:   "https://internal.example.com/audit",
  types: ["*"],
  filter: { dataPath: "plan", equals: "enterprise" },
});

// Multiple clauses are ANDed:
await postel.outbound.endpoints.create({
  url:   "https://eu-gold.example.com/webhooks",
  types: ["*"],
  filter: [
    { dataPath: "region", equals: "eu" },
    { dataPath: "tier", equals: "gold" },
  ],
});

For anything a structural clause can't express, drop to filterFn — a predicate (event) => boolean receiving a typed { type, data, channels?, timestamp? } envelope:

await postel.outbound.endpoints.create({
  url:   "https://internal.example.com/audit",
  types: ["*"],
  filterFn: (event) =>
    event.type === "user.signup" && (event.data as { plan?: string }).plan === "enterprise",
});

filterFn is code, not data: it runs at dispatch time, in-process, and — like transform — is held in a process-local registry on non-memory storage, so it does not survive a restart unless you re-register it (via create/update) and does not cross the admin HTTP API. Prefer the structural filter unless you genuinely need arbitrary logic. Both filter and filterFn apply together, ANDed with types/channels: filter is evaluated first, then filterFn.

Transformation (per-endpoint payload shape)

Some endpoints want a subset of the data, or a flattened shape. Pass transform:

await postel.outbound.endpoints.create({
  url:    "https://legacy-customer.example.com/webhooks",
  types:  ["order.*"],
  transform: (event) => ({
    type: event.type,
    id:   event.data.id,
    // Drop everything else.
  }),
});

The transformed payload is what gets signed and sent. The original payload remains in the outbox row for replay (in its original form).

Signing secrets

Creating an endpoint provisions its initial primary signing secret automatically, from the resolved signing strategy — the per-endpoint signing option, else the outbound signing default, else HMAC (v1):

// HMAC (v1) by default — a symmetric secret is minted and stored.
const hmacEp = await postel.outbound.endpoints.create({
  url:   "https://customer.example.com/webhooks",
  types: ["order.*"],
});

// Ed25519 (v1a) — a keypair is minted and the public key is published via
// JWKS immediately, with no rotation required.
const edEp = await postel.outbound.endpoints.create({
  url:     "https://partner.example.com/webhooks",
  types:   ["order.*"],
  signing: Ed25519V1a(),
});

const jwks = await postel.outbound.keys.publicJwks(); // already includes edEp's public key

If you manage signing material yourself — a KMS-backed flow, or your own secret store — pass provisionSecret: false so create writes no secret:

await postel.outbound.endpoints.create({
  url:             "https://customer.example.com/webhooks",
  signing:         Ed25519V1a(),
  provisionSecret: false, // you provision / manage the secret out of band
});

Rotation

Rotate the signing secret with rotateSecret:

await postel.outbound.endpoints.rotateSecret(ep.id, {
  keepPreviousFor: "7d",   // overlap window
});

During the overlap, both the previous and current secrets are valid signers. After keepPreviousFor elapses, the previous secret is purged. The mirror image of the receiver-side multi-secret window.

Headers

Static or dynamic headers on outgoing requests:

await postel.outbound.endpoints.create({
  url:     "https://customer.example.com/webhooks",
  types:   ["*"],
  headers: {
    "x-tenant-id":     "cust_42",
    "x-environment":   config.environment,
  },
});

// Or dynamic:
await postel.outbound.endpoints.create({
  url:     "...",
  types:   ["*"],
  headers: ({ message }) => ({
    "x-correlation-id": (message as { correlationId: string }).correlationId,
  }),
});

Per-endpoint overrides

The full set of outbound config defaults — retryPolicy, circuitBreaker, autoDisable, http — can be overridden per endpoint. See Retries & backoff.

  • send() and the outbox — how send chooses which endpoints to enqueue.
  • Replay — re-emitting historical messages to a specific endpoint.

On this page