API Conventions
The { data } / { error } envelope, error codes, and middleware every route shares.
Every response the API sends is one of two shapes, so the client never has to guess. A success carries a data field; a failure carries an error with a stable code and a human message. One envelope means one way to read a response, one closed set of codes to switch on, and one place, not every handler, that decides what a failure looks like.
The envelope
Success:
{ "data": { ... } }Failure:
{ "error": { "code": "ERROR_CODE", "message": "Human-readable message" } }Handlers return the success side directly with return c.json({ data }). They never build the error side by hand. Instead they throw, and one handler shapes every failure.
Paging a list
A list route answers with its collection and four flat fields beside it:
{
"data": {
"users": [{ "banned": false, "email": "ada@example.com", "id": "u_1" }],
"hasNextPage": true,
"page": 2,
"perPage": 25,
"total": 80
}
}page and perPage echo what was asked, total counts every matching row, and hasNextPage is the end signal. The server computes it, rather than leaving the caller to compare what it has loaded against total: rows deleted mid-scroll move the total under an infinite list, and a client inferring from a stale one keeps asking for a page that no longer exists.
Flat siblings rather than a nested object, which is what Django REST Framework and Laravel answer with and what a reader expects. Stripe and GitHub go further and omit the total, because on a large table counting is the expensive half; these tables are staff-sized and the console shows the count, so it stays.
Keys are ordered by relevance first, then A→Z. Here the collection is what the response is about, so it leads and the paging fields sort after it, which keeps the envelope looking the same on every list. A row's own fields are plain A→Z, id among them.
Paging is by offset (?page=2&perPage=25), which can skip or repeat a row if rows shift between requests. A keyset cursor is the fix, and the shape above is additive, so a nextCursor can join it without moving anything.
Acting on a set
The console's writes act on rows the caller picked: banning a selection, changing a role across it, removing several rules. Ids travel in the body, never in the path, and a single row is simply a set of one:
PATCH /api/v1/admin/users/role { ids: string[], role }
PATCH /api/v1/admin/users/status { ids: string[], banned }
DELETE /api/v1/admin/allowlist { ids: string[] }
DELETE /api/v1/admin/waitlist { ids: string[] }There is no /users/:id/role beside these. A path parameter names a resource, and what these routes change is not a resource but a set the caller assembled, so putting one id in the path would only be right when the set happens to hold one. Two routes per action, one for a row and one for a set, would also mean two copies of the same guard, transaction and outcome shape, which is how the two drift apart.
Changing one row therefore sends { ids: [id] } and reads the first outcome. The row menu in the console does exactly that.
Every guard in this API runs per target, which means a batch can legitimately change three accounts and refuse two. That cannot answer with one { data } or one { error }, so each id carries its own outcome inside { data }:
{
"data": {
"results": [
{ "id": "a", "ok": true },
{ "code": "FORBIDDEN", "id": "b", "message": "You cannot ban an owner.", "ok": false },
{ "code": "NOT_FOUND", "id": "c", "message": "User not found", "ok": false }
]
}
}An { error } from one of these still means what it means everywhere else: nothing happened. The request was refused as a whole (not signed in, not an admin, malformed body, more than MAX_BATCH ids), so there are no per-row outcomes to report.
That is the trade a caller acting on one row accepts: a guard refusing that row is a 200 with ok: false rather than a 403, so the client raises it from the outcome instead of the envelope.
Three things follow from that shape:
- The status stays 200. 207 Multi-Status describes exactly this, and it was considered and dropped: it is still
2xx, sounwrapon the web treats it identically to 200, while every route's response set, the error map and the docs gain a status nothing else uses. The information lives in the body either way. - A refusal code is narrower than an error code. A row can be
FORBIDDEN,NOT_FOUNDorCONFLICT; anything else is a whole-request failure the envelope already covers. idsis capped atMAX_BATCH(100), the wayperPageis, so one request cannot hold a transaction open over the whole table. Ids are deduplicated and answered in the order asked.
A request runs as one transaction, which is the point: another admin's batch cannot interleave with the ids in it, and one request pays one session read and one rate-limit unit instead of one per row.
A selection larger than MAX_BATCH is split by the client into that many requests, run one after another, because the console's tables load more as you scroll and select-all takes every loaded row. So the guarantee is per request, not per selection: 250 selected rows are three transactions, and another admin's write can land between them. The alternative was rejecting the selection outright, which is what a cap alone does, and that fails the exact action the routes exist for.
Errors are thrown, shaped in one place
A route signals failure by throwing:
throw new ApiError(status, code, message, extra?): a domain code fromapi/hono/src/lib/error.ts.throw new HTTPException(status, { message }): a standard Hono error.
The central errorHandler (registered via app.onError in api/hono/src/index.ts) catches every thrown error and turns it into the { error } envelope. It dispatches in order: ApiError (uses its own code) → ZodError (400 VALIDATION_ERROR) → HTTPException (status mapped to a code) → anything else (500 INTERNAL_SERVER_ERROR). Because the shaping lives in one function, a new route can't accidentally invent a new error shape.
Detailed messages are local-only
A 500's real message is surfaced only when NODE_ENV === "local". Every other environment returns a generic "Internal Server Error" so internals never leak. Thrown ApiError / HTTPException messages are developer-authored and always shown.
Error codes
The codes are the ErrorCode union in api/hono/src/lib/error.ts: the single source of truth, re-exported to the web client so both ends share one closed set. Adding a code means extending that union, which keeps the throw site, the client type, and this table in sync.
| Code | Status | When |
|---|---|---|
AGENT_LOGIN_FAILED | 500 | Local agent sign-in failed (agents.ts) |
BAD_REQUEST | 400 | Malformed JSON / form-data body (Hono HTTPException) |
CONFLICT | 409 | The value already exists, or the row changed mid-request |
ERROR | (varies) | HTTPException whose status isn't mapped to a code |
FORBIDDEN | 403 | Insufficient permissions (e.g. /headers outside local/dev) |
INTERNAL_SERVER_ERROR | 500 | Unhandled server error |
NOT_FOUND | 404 | The route or the addressed resource does not exist |
TOO_MANY_REQUESTS | 429 | Rate limit exceeded |
UNAUTHORIZED | 401 | Missing or invalid session |
VALIDATION_ERROR | 400 | Zod schema validation failed |
Validation errors
A request validator throws too. The waitlist POST (api/hono/src/routers/waitlist.ts) shows the pattern. Its sValidator hook throws on failure so errorHandler shapes the 400 in one place:
sValidator("json", joinSchema, (result) => {
if (!result.success) {
throw new ApiError(400, "VALIDATION_ERROR", "Invalid email address", { issues: result.error })
}
})The extra payload (here issues) rides through ApiError into the envelope, so a 400 carries the failing fields, and unwrap preserves them on the client:
{
"error": {
"code": "VALIDATION_ERROR",
"issues": [{ "message": "Invalid email address", "path": ["email"] }],
"message": "Invalid email address"
}
}On the client
Wrap any call in unwrap to get { data, error }. error.code is this same ErrorCode union plus the transport codes unwrap adds (NETWORK_ERROR, UNKNOWN_ERROR), so it stays a closed set with autocomplete. See The Type-Safe API.
Middleware
Every request passes the global middleware in order (from api/hono/src/index.ts): CORS (trusted origins from HONO_TRUSTED_ORIGINS, credentials on) → logger → rate limiter. Protected routes add auth on top.
Rate limiting
The limiter keys each request by user id, then API key, then IP, and gives authenticated users a higher tier. Exceeding it returns 429 TOO_MANY_REQUESTS in the same envelope. See Rate Limiting for the tiers, the environment variables, and the response headers.
Auth
authMiddleware (on /api/v1/*) reads the session from request headers via Better Auth, returns 401 UNAUTHORIZED when there is none, and otherwise sets session and user on the context (typed by the { Variables: Session } generic) for downstream handlers. It also swaps in the 2× per-user rate limiter.
The console middleware (on /api/v1/admin/*, stacked after authMiddleware) re-reads the session with disableCookieCache: true and returns 403 FORBIDDEN unless the caller clears a rung of the role ladder. Every route under it is a console surface for admins, whether that is who reaches the console, the rules that let them, the trail of those changes, or the waitlist, so it requires admin throughout. The middleware is one instance of a factory taking the minimum rank, so a future surface for a lower rung states what it needs rather than repeating the rule. The fresh read matters: Better Auth's cookie cache holds a session snapshot for ~5 minutes, so without it a revoked admin could keep hitting privileged routes until the cache expired. This mirrors the freshness of the /console page gate (see Authentication); routes that serve admin data get forbiddenErrorResponses from lib/error.ts in their OpenAPI spec. The cost is one uncached session read per admin request, on top of the cached one authMiddleware already did, so an admin surface that fetches in batches (an infinite-scroll table does) pays it per batch. That is the price of a revocation taking effect on the next request rather than up to five minutes later.
Next
- The Type-Safe API: how these responses reach a fully-typed client.
- Authentication: the sessions the auth middleware checks.