Outbound API

OutboundConfig, the full OutboundApi (send, endpoints, keys, tenants, replay, reconcile, messages), and every supporting type — the send-side surface of @postel/core.

View as Markdown

Every signature on this page is exported from @postel/core. See Core API for Postel()/config, Inbound API for the receive side, Strategies for retryPolicy/workers/signing/kms, and Errors for what each of these throws.

OutboundConfig

interface OutboundConfig<TTx = unknown, TEvents extends OutboundEventRegistry = NoEventsRegistered> {
  readonly storage: Storage<TTx>;
  readonly events?: TEvents;
  readonly signing?: SigningStrategy;
  readonly retryPolicy?: RetryStrategy;
  readonly workers?: WorkerStrategy;
  readonly kms?: KmsStrategy;
  readonly http?: HttpDefaults;
  readonly circuitBreaker?: CircuitBreakerDefaults;
  readonly autoDisable?: AutoDisableDefaults;
  readonly replay?: ReplayDefaults;
  readonly retention?: RetentionDefaults;
  readonly ephemeralKeys?: EphemeralKeysDefaults;
  readonly clock?: Clock;
  readonly defaultTenantId?: string | null;
}

The value of PostelConfig.outbound. Only storage is required — every other slot has a runtime default, except retention and ephemeralKeys, which throw NotImplementedError at construction if set at all (see below). signing/retryPolicy/workers/kms are factory-built values from Strategies.

events — a registry mapping event type strings to a Standard Schema: the send-side mirror of InboundSource.schema. A registered type's data is validated (and typed) on every send() call; see EventDataOf.

type OutboundEventRegistry = Record<string, StandardSchemaV1<unknown, unknown>>;

Defaults interfaces

interface HttpDefaults {
  readonly requestTimeout?: DurationMs;
  readonly overallDeadline?: DurationMs;
  readonly tls?: { readonly verify?: boolean };   // typed, not wired — setting it throws NotImplementedError
  readonly dns?: { readonly pinResolution?: boolean }; // typed, not wired — setting it throws NotImplementedError
  readonly ssrf?: {
    readonly blockPrivateRanges?: boolean;
    readonly allowedRanges?: ReadonlyArray<string>;
  };
  readonly userAgent?: string;
  readonly fetch?: typeof globalThis.fetch;
}

interface CircuitBreakerDefaults {
  readonly threshold?: number;
  readonly cooldown?: DurationMs;
}

interface AutoDisableDefaults {
  readonly failureRate?: number;
  readonly window?: DurationMs;
  readonly minAttempts?: number;
}

interface ReplayDefaults {
  readonly defaultThroughput?: number;
}

interface RetentionDefaults {
  readonly messages?: number | string;
  readonly attempts?: number | string;
}

interface EphemeralKeysDefaults {
  readonly rotateEvery: DurationMs;
}

http, circuitBreaker, and autoDisable set org-wide defaults; each can be overridden per endpoint via the matching field on EndpointCreateOptions. Setting retention or ephemeralKeys at all — any value, not just a truthy one — throws NotImplementedError at Postel(...) construction time: both are typed config slots with no runtime yet (no automatic pruning, no timer-driven key rotation). This is deliberate fail-fast behavior, not a bug — see NotImplementedError.

OutboundApi

interface OutboundApi<TTx = unknown, TEvents extends OutboundEventRegistry = NoEventsRegistered> {
  send<T extends string>(
    event: SendEvent<EventDataOf<TEvents, T>> & { readonly type: T },
    options?: SendOptions<TTx>,
  ): Promise<SendResult>;
  send<TData = unknown, T extends string = string>(
    event: SendEvent<TData> & { readonly type: Exclude<T, keyof TEvents> },
    options?: SendOptions<TTx>,
  ): Promise<SendResult>;

  endpoints: {
    create(opts: EndpointCreateOptions & { tx?: TTx }): Promise<Endpoint>;
    update(id: string, opts: EndpointUpdateOptions & { tx?: TTx }): Promise<Endpoint>;
    delete(id: string, opts?: { purgeAttempts?: boolean; tx?: TTx }): Promise<void>;
    list(opts?: EndpointListOptions<TTx>): Promise<Page<Endpoint>>;
    get(id: string, opts?: { tx?: TTx }): Promise<Endpoint>;
    disable(id: string, opts?: { tx?: TTx }): Promise<void>;
    enable(id: string, opts?: { tx?: TTx }): Promise<void>;
    rotateSecret(id: string, opts: RotateSecretOptions<TTx>): Promise<void>;
  };

  keys: {
    generateSymmetric(): string;
    generateAsymmetric(): Promise<AsymmetricKeypair>;
    publicJwks(opts?: { tenantId?: string; tx?: TTx }): Promise<Jwks>;
  };

  tenants: {
    setRateLimit(tenantId: string, opts: SetRateLimitOptions<TTx>): Promise<void>;
    delete(tenantId: string, opts?: { tx?: TTx }): Promise<void>;
    get(id: string, opts?: { tx?: TTx }): Promise<Tenant | undefined>;
    list(opts?: TenantListOptions): Promise<TenantPage>;
  };

  replay(opts: ReplayOptions<TTx>): Promise<ReplayResult>;
  reconcile(opts: ReconcileOptions<TTx>): Promise<Page<MessageId>>;

  messages: {
    get<TData = unknown>(id: string, opts?: { tx?: TTx }): Promise<Message<TData> | undefined>;
    attempts(id: string): Promise<ReadonlyArray<DeliveryAttempt>>;
    list(opts?: MessageListOptions): Promise<Page<Message>>;
  };
}

postel.outbound — present only when PostelConfig.outbound is set. endpoints.get/endpoints.update (when patching url/allowHttp/http) throw EndpointNotFound for an unknown id; see Errors.

send()

send is overloaded on TEvents, the registry from OutboundConfig.events: when the call site's literal type matches a registered key, data is typed as (and validated against) that schema's output; any other type falls through to the unregistered-type overload, which accepts any TData. See Send.

interface SendEvent<TData = unknown> {
  readonly type: string;
  readonly data?: TData;
  readonly channels?: ReadonlyArray<string>;
  readonly idempotencyKey?: string;
  readonly version?: string;
  readonly timestamp?: string | Date;
  readonly ttl?: DurationMs;
  readonly tenantId?: string;
}

interface SendOptions<TTx = unknown> {
  readonly tx?: TTx;
}

interface SendResult {
  readonly id: MessageId;
  readonly reused: boolean; // true when idempotencyKey matched an existing message
}

type MessageId = string;

EventDataOf

type EventDataOf<E extends OutboundEventRegistry, T extends string> = T extends keyof E
  ? E[T] extends StandardSchemaV1<unknown, infer D> ? D : unknown
  : unknown;

The data type for a given literal event type: the registered schema's output when T is a registered key, otherwise unknown. Mirrors EventOf on the inbound side.

endpoints

interface EndpointCreateOptions {
  readonly url: string;
  readonly types?: ReadonlyArray<string>;
  readonly channels?: ReadonlyArray<string>;
  readonly filter?: StructuralFilter;
  readonly filterFn?: (event: FilterEnvelope) => boolean;
  readonly transform?: (event: unknown) => unknown;
  readonly retryPolicy?: RetryStrategy;
  readonly headers?: Record<string, string> | ((ctx: { message: unknown }) => Record<string, string>);
  readonly signing?: SigningStrategy;
  readonly circuitBreaker?: CircuitBreakerDefaults;
  readonly autoDisable?: AutoDisableDefaults;
  readonly http?: HttpDefaults;
  readonly metadata?: Record<string, unknown>;
  readonly tenantId?: string;
  readonly allowHttp?: boolean;
  readonly maxInflight?: number; // typed, not wired — passing it throws NotImplementedError
  readonly provisionSecret?: boolean;
}

interface EndpointUpdateOptions extends Partial<EndpointCreateOptions> {}

interface EndpointListOptions<TTx = unknown> extends CursorOptions {
  readonly tenantId?: string;
  readonly tx?: TTx;
}

interface RotateSecretOptions<TTx = unknown> {
  readonly keepPreviousFor: DurationMs;
  readonly tx?: TTx;
}

update validates url/allowHttp/http against the effective (post-patch) values whenever any of those three fields changes — a patch can't silently downgrade a safe HTTPS endpoint into a cleartext or SSRF-eligible one. Create-time URL validation throws EndpointValidation; a URL that resolves into a blocked range only at dispatch time throws (internally) SsrfBlocked. See Errors and Endpoints.

filter/filterFn are two ways to scope which sent events reach an endpoint: filter is serializable (round-trips through storage, shows up on Endpoint.filter); filterFn is a code-side predicate (never persisted, absent from the read shape). transform is likewise code-side.

type Json = string | number | boolean | null | ReadonlyArray<Json> | { readonly [key: string]: Json };

interface StructuralFilterClause {
  readonly dataPath: string;
  readonly equals: Json;
}

type StructuralFilter = StructuralFilterClause | ReadonlyArray<StructuralFilterClause>;

interface FilterEnvelope {
  readonly type: string;
  readonly data: unknown;
  readonly channels?: ReadonlyArray<string>;
  readonly timestamp?: string;
}

An array of StructuralFilterClause is evaluated as AND; dataPath is dot-separated into event.data. FilterEnvelope is what filterFn receives — a concrete shape instead of unknown, so a predicate can narrow without a cast.

Endpoint (read shape)

interface Endpoint {
  readonly id: string;
  readonly url: string;
  readonly state: "active" | "disabled" | "circuit-open";
  readonly types: ReadonlyArray<string> | null;
  readonly channels: ReadonlyArray<string> | null;
  readonly filter: StructuralFilter | null;
  readonly retryPolicy: SerializableRetryStrategy | null;
  readonly headers: Readonly<Record<string, string>> | null;
  readonly allowHttp: boolean;
  readonly maxInflight: number | null;
  readonly http: SerializableHttpDefaults | null;
  readonly circuitBreaker: CircuitBreakerDefaults | null;
  readonly autoDisable: AutoDisableDefaults | null;
  readonly createdAt: Date;
  readonly updatedAt: Date;
  readonly tenantId?: string;
  readonly metadata?: Record<string, unknown>;
}

type SerializableRetryStrategy = Exclude<RetryStrategy, { kind: "custom" }>;
type SerializableHttpDefaults = Readonly<Omit<HttpDefaults, "fetch">>;

Every field that round-trips through create/get/list/update is present here, identically across storage adapters. Function-shaped inputs stay off this shape: filterFn/transform are absent keys, a Custom retry strategy or callable headers read back as null, and http drops its fetch function. signing is entirely absent (a strategy can carry key material).

keys

interface AsymmetricKeypair {
  readonly private: string;
  readonly public: string;
}

generateSymmetric() returns a whsec_-prefixed HMAC secret synchronously. generateAsymmetric() returns a whsk_/whpk_-prefixed Ed25519 keypair. publicJwks(opts) pages through every endpoint's non-expired public keys and returns them as a Jwks document — this is what you'd serve from a JWKS route via jwksHandler.

tenants

interface SetRateLimitOptions<TTx = unknown> {
  readonly perSecond: number;
  readonly tx?: TTx;
}

interface Tenant {
  readonly id: string;
  readonly rateLimit: RateLimitStrategy | null;
  readonly metadata: Readonly<Record<string, unknown>> | null;
  readonly createdAt: Date;
}

interface TenantListOptions extends CursorOptions {}
type TenantPage = Page<Tenant>;

rateLimit is decoded from the tenant's stored metadata.rateLimit into a typed RateLimitStrategy; metadata is still exposed raw for anything else stored there. tenants.get resolves undefined (not a throw) for an unknown id.

replay / reconcile

type ReplayOptions<TTx = unknown> =
  | { readonly messageId: string; readonly freshWebhookId: boolean; readonly tx?: TTx }
  | {
      readonly endpointId: string;
      readonly since: Date | string;
      readonly until?: Date | string;
      readonly types?: ReadonlyArray<string>;
      readonly replayThroughput?: number;
      readonly freshWebhookId: boolean;
      readonly tx?: TTx;
    }
  | {
      readonly filter: (msg: unknown) => boolean;
      readonly replayThroughput?: number;
      readonly freshWebhookId: boolean;
      readonly tx?: TTx;
    };

interface ReplayResult {
  readonly enqueued: number;
}

interface ReconcileOptions<TTx = unknown> extends CursorOptions {
  readonly endpointId: string;
  readonly since: Date | string;
  readonly tx?: TTx;
}

replay takes one of three discriminated shapes — a single message, every message for an endpoint in a time range, or an arbitrary predicate — see Replay. reconcile returns a bounded page of undelivered MessageIds for a backlog audit; it's a paged read, not a streaming iterable, so an arbitrarily large backlog is never materialized in one call.

messages

interface Message<TData = unknown> {
  readonly id: MessageId;
  readonly type: string;
  readonly data: TData;
  readonly channels: ReadonlyArray<string> | null;
  readonly idempotencyKey: string | null;
  readonly version: string | null;
  readonly tenantId: string | null;
  readonly ttlSeconds: number | null;
  readonly createdAt: Date;
  readonly expiresAt: Date | null;
  readonly status: "pending" | "dispatched" | "dead-lettered" | "expired";
  readonly attemptNumber: number;
  readonly scheduledFor: Date | null;
  readonly replayOf: MessageId | null;
}

interface DeliveryAttempt {
  readonly id: string;
  readonly messageId: MessageId;
  readonly endpointId: string;
  readonly tenantId: string | null;
  readonly attemptNumber: number;
  readonly status: "pending" | "success" | "failed" | "failed-permanent" | "dead-letter" | "expired" | "filtered" | "skipped" | "ssrf-blocked";
  readonly scheduledFor: Date | null;
  readonly startedAt: Date | null;
  readonly completedAt: Date | null;
  readonly responseCode: number | null;
  readonly responseHeaders: Readonly<Record<string, string>> | null;
  readonly responseBody: string | null;
  readonly latencyMs: number | null;
  readonly error: string | null;
  readonly replayOf: MessageId | null;
}

interface MessageListOptions extends CursorOptions {
  readonly tenantId?: string;
  readonly types?: ReadonlyArray<string>;
  readonly status?: Message["status"] | ReadonlyArray<Message["status"]>;
  readonly since?: Date | string;
  readonly until?: Date | string;
}

Message.status is the message-level outbox lifecycle; each endpoint's own delivery outcome is on DeliveryAttempt.status — a message can be dispatched overall while one endpoint's attempts are all failed. messages.get resolves undefined for an unknown id; messages.list/attempts back the message-introspection reads used by @postel/admin's GET /messages routes.

drain

Bounded, single-pass delivery for serverless and cron invocations: reserves and dispatches at most maxMessages outbox messages, stopping earlier when deadline elapses. Never starts a persistent loop — a single call always resolves — and reuses the worker pool's reserve/lease mechanism, so it's safe alongside a running postel.start() pool.

drain(opts: DrainOptions): Promise<DrainResult>;

interface DrainOptions {
  readonly maxMessages: number;
  readonly deadline: DurationMs;   // "10s", "500ms", or plain milliseconds
}

interface DrainResult {
  readonly processed: number;
  readonly reachedDeadline: boolean;
}

Storage

OutboundConfig.storage takes any Storage adapter — InMemoryStorage() (below) for the in-process reference, or a standalone package (@postel/pg, @postel/sqlite, @postel/mysql, …) per Storage. The Storage interface itself is the adapter-author SPI, documented separately at Custom adapters rather than here — it's a different surface than the OutboundApi above, aimed at implementers rather than at code calling postel.outbound.*.

function InMemoryStorage(options?: InMemoryStorageOptions): Storage<InMemoryTx>;

InMemoryStorage is exported from @postel/core (every other adapter is its own package) — it's the zero-dependency default for local development, tests, and the compliance suite's reference implementation.

On this page