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.
code | Router family | HTTP | When it fires |
|---|---|---|---|
anchor_required | pois | 400 | A POI lookup was called without enough to anchor it — neither (lat, lng) nor poi_id supplied, or only one of lat/lng. |
anchor_ambiguous | pois | 400 | A POI lookup got both (lat, lng) and poi_id. Send one or the other, not both. |
range_required | pois | 400 | A POI lookup needs either a radius_m or a k (top-N) — neither was supplied. |
invalid_input | pois, geohash | 400 | Free-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_bbox | pois, geohash | 400 | A 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_found | pois | 404 | Detail lookup for a poi_id that doesn't exist in the requested country. |
invalid_cell_id | h3 | 400 | An H3 cell hex string failed to parse. |
mixed_resolution | h3 | 400 | The cells list held more than one H3 resolution. The envelope includes resolutions. Split the request, one resolution per call. |
invalid_geohash | geohash | 400 | A geohash failed to parse, or its precision isn't one this layer serves (6 or 7). The envelope includes invalid or supported_precisions. |
mixed_precision | geohash | 400 | The geohashes list held more than one precision. The envelope includes precisions. Split the request, one precision per call. |
unsupported_backend | geohash | 400 | Geohash 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_resolution | h3 | 400 | The requested H3 resolution isn't supported for the layer. The envelope includes supported_resolutions. |
invalid_type | h3 | 400 | An H3-layer parameter had the wrong shape. |
unknown_layer | h3 | 400 | The layer parameter isn't one of the layers we serve. |
aggregation_read_budget_exceeded | h3, geohash | 400 | GET/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_exceeded | h3, geohash | 400 | GET/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_exceeded | h3 | 400 | A 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_found | pt | 404 | Station detail lookup for a stable_id that isn't in the requested country. |
line_not_found | pt | 404 | Line detail lookup for a stable_id that isn't in the requested country. |
interchange_not_found | pt | 400 | An interchange query for a (line_sid, station_sid) pair with no recorded interchange data. |
max_cells_exceeded | ranking, h3 | 400 | A ranking request's cells dict, or the population aggregate's cell list, exceeded its ceiling. Envelope carries limit and actual. |
too_many_cells | h3, geohash | 400 | A 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_precision | geohash | 400 | The 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.
| Status | What it means |
|---|---|
| 400 Bad Request | A 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 Unauthorized | The Authorization header was missing, malformed, or the bearer token failed verification (bad signature, expired JWT, revoked or unknown API key). See Authentication. |
| 403 Forbidden | The 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 Found | The 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 Content | Request 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 Requests | A 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 Error | Unhandled 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 Gateway | A TravelTime backend service the API depends on returned an error we can't recover from. |
| 503 Service Unavailable | The health-check endpoint returns 503 when the API isn't ready to serve. Normal customer-facing routes don't surface this directly. |
| 504 Gateway Timeout | A 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.