API Conventions
Standardized API response format, error handling, and middleware patterns.
Overview
The Hono API server follows consistent conventions for responses, errors, middleware, and route organization. Understanding these patterns is essential for extending the API.
Response Format
All API responses use a standardized JSON envelope:
Success
{
"data": { ... }
}Error
{
"error": {
"code": "ERROR_CODE",
"message": "Human-readable message"
}
}Routes never build this envelope by hand. They throw — new ApiError(status, code, message, extra?) for a domain code, or a Hono HTTPException(status, { message }) for a standard one — and the central onError handler in api/hono/src/lib/error.ts is the single place that turns any thrown error into the envelope (via the internal jsonError builder). onError dispatches in order: ApiError (its own code) → ZodError (400) → HTTPException (status-mapped code) → 500. Middleware and notFound may still call jsonError directly.
Consuming responses on the client
On the frontend, wrap any RPC call in unwrap (from @/lib/api/client) to get a { data, error } result, exactly one is non-null, and it never throws:
import { apiClient, unwrap } from "@/lib/api/client"
const { data, error } = await unwrap(apiClient.waitlist.$get())
if (error) {
// error: { code, message }
} else {
// data
}With TanStack Query, throw on the error arm, in a queryFn (so the query reports isError), or in a useMutation mutationFn and toast it in onError.
error.code is typed as the ErrorCode union (re-exported from @api/hono) plus the two transport codes unwrap itself produces (NETWORK_ERROR, UNKNOWN_ERROR), so it is a closed union on the client, with autocomplete and exhaustive switch.
Error Codes
| Code | Status | When |
|---|---|---|
AGENT_LOGIN_FAILED | 500 | Local agent sign-in failed (api/hono/src/routers/agents.ts) |
BAD_REQUEST | 400 | Malformed JSON/form-data body (Hono HTTPException) |
ERROR | (varies) | HTTPException with a status not mapped to a specific code |
FORBIDDEN | 403 | Insufficient permissions (e.g., /headers in production) |
INTERNAL_SERVER_ERROR | 500 | Unhandled server error |
NOT_FOUND | 404 | Route does not exist |
TOO_MANY_REQUESTS | 429 | Rate limit exceeded |
UNAUTHORIZED | 401 | Missing or invalid session |
VALIDATION_ERROR | 400 | Zod schema validation failed |
These codes are the ErrorCode union in api/hono/src/lib/error.ts — the single source of truth, re-exported to the web client. A router emits a domain code by throwing new ApiError(status, code, message, extra?) (e.g. agents.ts throws ApiError(500, "AGENT_LOGIN_FAILED", …)). Adding a code means extending the ErrorCode union, so the throw site, the client type, and this table stay in sync.
Validation Errors
When a Zod validation fails, the response includes the full issues array:
{
"error": {
"code": "VALIDATION_ERROR",
"message": "Invalid request payload",
"issues": [{ "path": ["name"], "message": "Required" }]
}
}Route-level validators (e.g. sValidator on the waitlist POST) throw new ApiError(400, "VALIDATION_ERROR", message, { issues }) on failure, so onError shapes the 400 in one place. Note that Hono's validator only parses the body when its content-type matches (JSON validators ignore a non-JSON body and validate {}), so a wrong content-type surfaces as a normal field-level issue.
A body that is present but unparseable (malformed JSON, malformed form-data) is a client error, not a server error: Hono throws an HTTPException, and errorHandler returns it with the thrown status (400 BAD_REQUEST) instead of masking it as a 500.
Environment-Aware Errors
Detailed error messages surface only when NODE_ENV === "local". Every other environment returns a generic message to avoid leaking internals.
- Local only: Error messages include the actual error details for debugging. Detailed messages are shown only when
isLocal(env.NODE_ENV), i.e.NODE_ENV === "local"(api/hono/src/lib/error.ts). - All other environments: Generic
"Internal Server Error"message to avoid leaking internals.development,test,staging, andproductionall get the generic message.
Middleware Stack
Every request passes through these global middlewares in order:
- CORS: Allows configured trusted origins with credentials
- Logger: Logs request/response to console
- Rate Limiter: Enforces per-key rate limits
CORS Configuration
Configured in api/hono/src/index.ts:
- Origins: From
HONO_TRUSTED_ORIGINS(comma-separated URLs) - Methods: GET, OPTIONS, POST, PUT
- Headers: content-type, authorization
- Exposed Headers: content-length
- Credentials: Enabled (for cookies)
- Max Age: 600 seconds
Rate Limiting
The key generator resolves keys in this priority order:
- User ID: If authenticated (highest priority)
- API Key: Hash of
Authorizationheader - IP Address: Detected via Arcjet IP
- Random UUID: Fallback if IP detection fails
The API-key rate-limit tier is currently unwired and falls back to per-IP. No limiter passes an API key, so that tier stays dead until you wire getApiKey into a limiter.
In the current wiring, only the user-ID tier (via the authenticated limiter) and the IP tier (via the global limiter) are active.
| Context | Limit | Window | Environment Variable |
|---|---|---|---|
| Unauthenticated | 60 req | 60s | HONO_RATE_LIMIT / HONO_RATE_LIMIT_WINDOW_MS |
| Authenticated | 120 req (2x) | 60s | Same variables, doubled automatically |
When a rate limit is exceeded, the API returns:
{
"error": {
"code": "TOO_MANY_REQUESTS",
"message": "Too Many Requests"
}
}Auth Middleware
Applied to protected routes (e.g., /api/v1/*):
- Extracts session from request headers via Better Auth
- Returns
401 UNAUTHORIZEDif no valid session - Sets
sessionandusercontext variables for downstream handlers - Creates a user-specific rate limiter with 2x the global limit
Route Organization
/ → Version and environment info
/headers → Request headers (local/dev only)
/api/health → Health check with OpenAPI docs
/api/openapi.json → OpenAPI specification
/api/docs → Scalar API documentation UI
/api/auth/* → Better Auth handlers (session, OAuth, etc.)
/api/auth/get-session → Session endpoint hit by the Next server-side auth.api.getSession helper
/api/agents/* → Agent sign-in (local only, trusted Origin)
/api/v1/* → Protected routes (requires auth middleware)
/api/v1/session → Current session data
/api/v1/user → Current user dataAdding a New Route
- Create or edit a router in
api/hono/src/routers/:
import type { Session } from "@packages/auth"
import { Hono } from "hono"
import { describeRoute, resolver } from "hono-openapi"
import { z } from "zod"
import { authErrorResponses } from "@/lib/error"
import { authMiddleware } from "@/middlewares"
export const v1Router = new Hono<{ Variables: Session }>().use("/*", authMiddleware).get(
"/items",
describeRoute({
tags: ["Items"],
description: "List items",
responses: {
200: {
description: "OK",
content: {
"application/json": {
schema: resolver(z.object({ data: z.array(z.object({ id: z.string() })) })),
},
},
},
...authErrorResponses,
},
}),
(c) => {
const user = c.get("user")
return c.json({ data: [] })
},
)The new Hono<{ Variables: Session }>() generic (with import type { Session } from "@packages/auth") is what makes c.get("user") and c.get("session") typed, matching the real v1Router. The authMiddleware populates those context variables.
To emit an error from a handler, throw new ApiError(status, code, message) (a domain code) or throw new HTTPException(status, { message }) (a standard one); onError builds the envelope, so never construct it inline.
- Register in
api/hono/src/index.tsif it's a new router. The app sets.basePath("/api"), so the route path is relative to/api:
const routes = app.basePath("/api").route("/v1", v1Router) // served at /api/v1- The route is automatically available in the frontend via
apiClient.v1.items.$get()
OpenAPI Documentation
Routes decorated with describeRoute appear in the Scalar API docs at /api/docs. You can add code samples:
describeRoute({
tags: ["v1"],
description: "Get session",
...({
"x-codeSamples": [{
lang: "typescript",
label: "hono/client",
source: `const { data, error } = await unwrap(apiClient.v1.session.$get())`,
}],
} as object),
responses: { ... },
})Documenting error responses
A route should only advertise the error statuses it can actually return, so the docs stay honest. The defaultOptions in index.ts apply globalErrorResponses (429, 500, the only statuses reachable on any matched route, via the global rate limiter and onError) to every GET/POST. A route then adds the rest in its own responses (which merge over the defaults), using the helpers in api/hono/src/lib/error.ts:
authErrorResponses(401), spread into routes behindauthMiddleware(e.g./api/v1/*).validationErrorResponses(400), spread into routes with a request validator (e.g. the waitlistPOST).
responses: {
200: { description: "OK", content: { ... } },
...authErrorResponses,
}So /api/health documents only 200/429/500, /api/v1/* adds 401, and validated routes add 400, each error status shows its own code/message example.