ZeroStarter

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 thrownew 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

CodeStatusWhen
AGENT_LOGIN_FAILED500Local agent sign-in failed (api/hono/src/routers/agents.ts)
BAD_REQUEST400Malformed JSON/form-data body (Hono HTTPException)
ERROR(varies)HTTPException with a status not mapped to a specific code
FORBIDDEN403Insufficient permissions (e.g., /headers in production)
INTERNAL_SERVER_ERROR500Unhandled server error
NOT_FOUND404Route does not exist
TOO_MANY_REQUESTS429Rate limit exceeded
UNAUTHORIZED401Missing or invalid session
VALIDATION_ERROR400Zod 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, and production all get the generic message.

Middleware Stack

Every request passes through these global middlewares in order:

  1. CORS: Allows configured trusted origins with credentials
  2. Logger: Logs request/response to console
  3. 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:

  1. User ID: If authenticated (highest priority)
  2. API Key: Hash of Authorization header
  3. IP Address: Detected via Arcjet IP
  4. 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.

ContextLimitWindowEnvironment Variable
Unauthenticated60 req60sHONO_RATE_LIMIT / HONO_RATE_LIMIT_WINDOW_MS
Authenticated120 req (2x)60sSame 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/*):

  1. Extracts session from request headers via Better Auth
  2. Returns 401 UNAUTHORIZED if no valid session
  3. Sets session and user context variables for downstream handlers
  4. 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 data

Adding a New Route

  1. 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.

  1. Register in api/hono/src/index.ts if 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
  1. 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 behind authMiddleware (e.g. /api/v1/*).
  • validationErrorResponses (400), spread into routes with a request validator (e.g. the waitlist POST).
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.