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.*.
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 · path | Does |
|---|---|
GET /endpoints · POST /endpoints | list (paginated: ?limit=, ?cursor=) · create |
GET/PATCH/DELETE /endpoints/:id | get · update · delete |
POST /endpoints/:id/disable | disable (pause) |
POST /endpoints/:id/rotate-secret | rotate signing secret |
POST /replay · POST /reconcile | replay · reconcile (body accepts limit / cursor) |
POST /tenants/:id/rate-limit · DELETE /tenants/:id | tenant rate limit · delete |
GET /tenants | list tenants, paginated (?limit=, ?cursor=) |
GET /tenants/:id | fetch one tenant, including its decoded rateLimit |
POST /keys/symmetric · POST /keys/asymmetric | generate signing keys |
GET /messages | list messages (?type=, ?status=, ?since=, ?until=, ?limit=, ?cursor=) |
GET /messages/:id | fetch one message, including its payload |
GET /messages/:id/attempts | the 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.code — ENDPOINT_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.