# Endpoints



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

## Lifecycle [#lifecycle]

```ts
// 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);

// Re-enable (manual recovery from disabled or circuit-open)
await postel.outbound.endpoints.enable(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](/docs/outbound/tenants#list-tenants-paginated).

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

## What reads return [#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`, `http`, `circuitBreaker`, `autoDisable`) plus `id`, `url`, `state`, `createdAt`, and `updatedAt`. (`maxInflight` is typed but not wired yet — passing it throws `NotImplementedError` at create/update rather than silently no-opping.)

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 [#filtering]

The `types` filter accepts string globs:

| Pattern           | Matches                                            |
| ----------------- | -------------------------------------------------- |
| `"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.

```ts
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:

```ts
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) [#transformation-per-endpoint-payload-shape]

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

```ts
await postel.outbound.endpoints.create({
  url:    "https://legacy-customer.example.com/webhooks",
  types:  ["order.*"],
  // transform receives the event as `unknown` — narrow it yourself
  transform: (event) => {
    const e = event as { type: string; data: { id: string } };
    return { type: e.type, id: e.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 [#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`):

```ts
// 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:

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

### Rotation [#rotation]

Rotate the signing secret with `rotateSecret`:

```ts
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](/docs/inbound/key-rotation#multi-verifier-overlap-hmac).

## Headers [#headers]

Static or dynamic headers on outgoing requests:

```ts
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 [#per-endpoint-overrides]

The full set of outbound config defaults — `retryPolicy`, `circuitBreaker`, `autoDisable`, `http` — can be overridden per endpoint. See [Retries & backoff](/docs/outbound/retries).

## Read next [#read-next]

* [`send()` and the outbox](/docs/outbound/send) — how `send` chooses which endpoints to enqueue.
* [Replay](/docs/outbound/replay) — re-emitting historical messages to a specific endpoint.
