Outbound

Admin API

A framework-agnostic HTTP control plane over the outbound sender — endpoints, replay, key rotation — with default-deny auth and tenant scoping.

@postel/admin turns your configured Postel instance into a privileged HTTP control plane. adminRouter(postel, { authorize }) returns a Web (Request) => Promise<Response> that maps REST routes to postel.outbound.*.

lib/admin.ts
import { adminRouter } from "@postel/admin";
import { postel } from "./postel";

export const admin = adminRouter(postel, {
  // boolean | { allow, status?, tenantId? } | Promise<...>
  authorize: (req) => checkAdminToken(req.headers.get("authorization")),
});

Mounting

The web adapters mount the router for you with hwa.admin.bindAdminRoutes(prefix, opts)authorize flows straight through:

// Hono / Express / Fastify — same shape
HonoWebAdapter(postel, app).admin.bindAdminRoutes("/admin", {
  authorize: (req) => checkAdminToken(req.headers.get("authorization")),
});

Or mount the adminRouter Fetch handler yourself — Hono speaks Fetch natively; Node frameworks bridge through fetchToExpress / fetchToFastify:

app.all("/admin/*", (c) => admin(c.req.raw));
import { fetchToExpress } from "@postel/express";

app.use("/admin", fetchToExpress(admin));
import { fetchToFastify } from "@postel/fastify";

app.all("/admin/*", fetchToFastify(admin));

Authorization — default-deny

authorize is effectively required: with none configured, every request is 403 (and logs once). It receives the raw Request, so read whatever you authenticate with — bearer token, mTLS header, cookie. Return true/false, or { allow, status?, tenantId? } to choose 401 vs 403 and bind the caller to a tenant.

When authorize returns a tenantId, every route is scoped to it: lists and creates are constrained, and by-id read routes return 404 (not 403) for another tenant's resources — so the API never leaks which ids exist in other tenants. The tenant write routes (POST /tenants/:id/rate-limit, DELETE /tenants/:id) are the one exception: they respond 403 on cross-tenant access rather than 404. Prefer transport auth in front? Run it and pass authorize: () => true.

Routes

Method · pathDoes
GET /endpoints · POST /endpointslist (paginated: ?limit=, ?cursor=) · create
GET/PATCH/DELETE /endpoints/:idget · update · delete
POST /endpoints/:id/disabledisable (pause)
POST /endpoints/:id/rotate-secretrotate signing secret
POST /replay · POST /reconcilereplay · reconcile (body accepts limit / cursor)
POST /tenants/:id/rate-limit · DELETE /tenants/:idtenant rate limit · delete
GET /tenantslist tenants, paginated (?limit=, ?cursor=)
GET /tenants/:idfetch one tenant, including its decoded rateLimit
POST /keys/symmetric · POST /keys/asymmetricgenerate signing keys
GET /messageslist messages (?type=, ?status=, ?since=, ?until=, ?limit=, ?cursor=)
GET /messages/:idfetch one message, including its payload
GET /messages/:id/attemptsthe message's delivery-attempt history (?status= to narrow by delivery status)

Every list route is paginated with one convention: a bounded page (default limit 100) plus a nextCursor continuation token — { endpoints, nextCursor }, { messages, nextCursor }, { tenants, nextCursor }, and { messageIds, nextCursor } for POST /reconcile. nextCursor is null on the last page; otherwise pass it back (as ?cursor=, or cursor in the reconcile body) to fetch the next page. No list route returns an unbounded array.

Failures map to status by PostelError.codeENDPOINT_NOT_FOUND → 404, ENDPOINT_VALIDATION → 422, ENDPOINT_DISABLED → 409, MIGRATION_REQUIRED → 503 — with a { errorCode, error } JSON body. A read for a message id that doesn't exist (or is outside the caller's tenant) responds 404 with errorCode: "MESSAGE_NOT_FOUND"; the same goes for a tenant id, with errorCode: "TENANT_NOT_FOUND". A malformed cursor or non-positive limit on any list route responds 400 with errorCode: "INVALID_QUERY". The structural filter ({ dataPath, equals }, or an array of clauses) is plain JSON and is configurable over HTTP like any other endpoint field. Code-only endpoint options (filterFn, transform, callable headers) aren't configurable over HTTP; set them in your Postel config.

The read routes (GET /messages…, GET /tenants…) are the HTTP projection of the message introspection and tenant read APIs, and are tenant-scoped exactly like the control-plane routes: a tenant-bound caller sees only its own tenant's messages, attempts, and tenant record — listing tenants as a bound caller returns at most that one tenant.

On this page