Data APIdocs

Errors

Every error you'll see falls into one of three buckets: request validation errors (422), structured domain errors, or standard HTTP statuses (401, 403, 404, 5xx). This page enumerates each so you can write a single error handler that covers everything the API actually emits.

The envelope

Domain errors are wrapped in a JSON envelope under a top-level error key:

{
  "error": {
    "code": "anchor_required",
    "message": "one of (lat+lng) or poi_id is required",
    "details": {}
  }
}

The ranking routes use a slightly different shape: details is flattened onto the envelope alongside code and message, and a schema_version field is included so clients can key error handling to the contract version.

{
  "error": {
    "code": "max_cells_exceeded",
    "message": "Request exceeds max 5000 cells (got 6000).",
    "schema_version": "2.0.0",
    "limit": 5000,
    "actual": 6000
  }
}

Domain error codes

Application-level codes raised by our routers. Every code below ships inside the envelope shown above; the HTTP status is the one in the third column.

codeRouter familyHTTPWhen it fires
anchor_requiredpois400A POI lookup was called without enough to anchor it — neither (lat, lng) nor poi_id supplied, or only one of lat/lng.
anchor_ambiguouspois400A POI lookup got both (lat, lng) and poi_id. Send one or the other, not both.
range_requiredpois400A POI lookup needs either a radius_m or a k (top-N) — neither was supplied.
invalid_inputpois, geohash400Free-text search q was empty or whitespace-only; or a geohash request set no geographic form, set more than one, or sent resolution where it means precision.
invalid_bboxpois, geohash400A bbox was malformed: wrong component count, non-numeric, out of range, or back to front — south > north / west > east (/v1/pois/* rejects equal edges too).
poi_not_foundpois404Detail lookup for a poi_id that doesn't exist in the requested country.
invalid_cell_idh3400An H3 cell hex string failed to parse.
mixed_resolutionh3400The cells list held more than one H3 resolution. The envelope includes resolutions. Split the request, one resolution per call.
invalid_geohashgeohash400A geohash failed to parse, or its precision isn't one this layer serves (6 or 7). The envelope includes invalid or supported_precisions.
mixed_precisiongeohash400The geohashes list held more than one precision. The envelope includes precisions. Split the request, one precision per call.
unsupported_backendgeohash400Geohash addressing is served from the search index; this deployment reads POIs from a DuckDB bundle, which carries no geohash column. Use /v1/h3/* or /v1/pois/*.
invalid_resolutionh3400The requested H3 resolution isn't supported for the layer. The envelope includes supported_resolutions.
invalid_typeh3400An H3-layer parameter had the wrong shape.
unknown_layerh3400The layer parameter isn't one of the layers we serve.
aggregation_read_budget_exceededh3, geohash400GET/POST /v1/h3/pois, /v1/geohash/pois, or POST /v1/h3/population/aggregate couldn't aggregate the requested area within its read budget. Ask for fewer cells.
records_read_budget_exceededh3, geohash400GET/POST /v1/h3/pois/records or /v1/geohash/pois/records couldn't read the requested area within its read budget. Ask for fewer cells or narrow category.
pt_read_budget_exceededh3400A transport rollup (/v1/h3/transport/stations, /v1/h3/transport/lines) couldn't finish within its read budget. Lower per_cell_limit, or ask for fewer cells.
station_not_foundpt404Station detail lookup for a stable_id that isn't in the requested country.
line_not_foundpt404Line detail lookup for a stable_id that isn't in the requested country.
interchange_not_foundpt400An interchange query for a (line_sid, station_sid) pair with no recorded interchange data.
max_cells_exceededranking, h3400A ranking request's cells dict, or the population aggregate's cell list, exceeded its ceiling. Envelope carries limit and actual.
too_many_cellsh3, geohash400A bbox / radius_m cover, or an explicit cells / geohashes list, resolved to more cells than the 10,000 cap. Envelope carries cell_count and cap.
invalid_precisiongeohash400The precision parameter isn't one this namespace serves. The envelope includes supported_precisions.

Standard HTTP statuses

Anything not in the table above is one of these. They come back with the standard error shape — usually a { "detail": "..." } string body, or { "detail": [...] } for 422.

StatusWhat it means
400 Bad RequestA request parameter (typically on an H3 route) was invalid in a way we caught before validation. Usually paired with a code from the table above, but a small number of cases raise a plain HTTPException with a detail string.
401 UnauthorizedThe Authorization header was missing, malformed, or the bearer token failed verification (bad signature, expired JWT, revoked or unknown API key). See Authentication.
403 ForbiddenThe caller is authenticated but not allowed on this endpoint. Today this surfaces on internal-only and admin-only routes; customer-facing routes return 401 when auth is the problem, not 403.
404 Not FoundThe path doesn't exist, or a category/POI/station/line id doesn't exist in the requested country. Most resource-level 404s carry a domain code (e.g. poi_not_found, station_not_found); path-level 404s use the standard shape.
422 Unprocessable ContentRequest validation failed on the body or query parameters. The response is { "detail": [{ "type", "loc", "msg", "input", "ctx" }, ...] } — one entry per failed field.
429 Too Many RequestsA rate limit on a TravelTime backend service the API depends on was hit. We don't apply per-key rate limits to customer-facing routes ourselves yet (tracked in issue #12).
500 Internal Server ErrorUnhandled exception in the API. We don't surface stack traces; if you see one, please email us with the request details so we can correlate it to our logs.
502 Bad GatewayA TravelTime backend service the API depends on returned an error we can't recover from.
503 Service UnavailableThe health-check endpoint returns 503 when the API isn't ready to serve. Normal customer-facing routes don't surface this directly.
504 Gateway TimeoutA TravelTime backend service the API depends on didn't respond in time.

A validation-error example

The most common error path is 422 — the server rejected something in your request body or query parameters. A real response:

{
  "detail": [
    {
      "type": "too_short",
      "loc": ["body", "cells"],
      "msg": "Dictionary should have at least 1 item after validation, not 0",
      "input": {},
      "ctx": { "field_type": "Dictionary", "min_length": 1, "actual_length": 0 }
    }
  ]
}

Every entry in detail describes one failed field:

  • type — the validation error type (missing, too_short, extra_forbidden, string_pattern_mismatch, etc.).
  • loc — the path to the offending field (["body", "cells"], ["query", "country"]).
  • msg — a human-readable summary.
  • input — the actual value we received.
  • ctx — optional, type-specific context.

Treat the array as exhaustive: every problem with the request is reported in one response, not one-at-a-time.

What to surface to your end user

A reasonable client-side error handler looks something like this:

async function handle(response: Response) {
  if (response.ok) return response.json()

  const body = await response.json().catch(() => null)

  if (body?.error?.code) {
    // Structured domain error — show body.error.message, branch on body.error.code if needed.
    throw new ApiError(body.error.code, body.error.message)
  }

  if (Array.isArray(body?.detail)) {
    // 422 validation error — usually a programmer error, log it loudly.
    throw new ValidationError(body.detail)
  }

  if (response.status === 401) throw new AuthError('Please sign in again')
  if (response.status === 429) throw new RateLimitError(response.headers.get('Retry-After'))
  throw new UpstreamError(response.status, body?.detail ?? response.statusText)
}

The branches map 1-to-1 onto the buckets at the top of this page: structured envelope, validation array, standard status. If you cover those three you've covered everything the API emits.