# Admin API



`@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.*`.

```ts title="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 [#mounting]

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

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

<Tabs items="[&#x22;Hono&#x22;, &#x22;Express&#x22;, &#x22;Fastify&#x22;]">
  <Tab value="Hono">
    ```ts
    app.all("/admin/*", (c) => admin(c.req.raw));
    ```
  </Tab>

  <Tab value="Express">
    ```ts
    import { fetchToExpress } from "@postel/express";

    app.use("/admin", fetchToExpress(admin));
    ```
  </Tab>

  <Tab value="Fastify">
    ```ts
    import { fetchToFastify } from "@postel/fastify";

    app.all("/admin/*", fetchToFastify(admin));
    ```
  </Tab>
</Tabs>

## Authorization — default-deny [#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 [#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/enable`                           | re-enable (back to `active`)                                                                                                 |
| `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)                                             |
| `GET /health`                                          | the same `{ ok, outboxDepth, oldestPendingAge, workerCount }` shape as `postel.health()`, `200` when healthy, `503` when not |

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](/docs/outbound/messages) and [tenant read](/docs/outbound/tenants) 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.
