Errors
The Data API returns three kinds of error: validation errors (422), errors with a Data API error code, and standard HTTP errors. This page lists each one and shows how to handle them.
The error envelope
An error with a Data API error code has a JSON body with one top-level error key. Branch
on code. Log message. details carries values you can act on, such as the invalid
input. This request has no radius_m and no k, so it returns 400 with the code
range_required.
text
curl -H "Authorization: Bearer $POI_API_KEY" \
"$POI_BASE/v1/pois/near?country=GB&lat=51.5074&lng=-0.1278&category=cafe&limit=5"json
{
"error": {
"code": "range_required",
"message": "one of radius_m or k is required",
"details": {}
}
}POST /v1/cells/rank and the 429 response use a flat form. The details sit beside code
and message, and schema_version names the contract version. This is the response to a
ranking request with 10,001 cells.
json
{
"error": {
"code": "max_cells_exceeded",
"message": "Request exceeds max 10000 cells (got 10001).",
"schema_version": "2.0.0",
"limit": 10000,
"actual": 10001
}
}Read error.code and error.message and both forms work.
Error codes
Every code below arrives in the envelope. The third column is the HTTP status.
code | Endpoints | HTTP | When you get it |
|---|---|---|---|
anchor_required | /v1/pois/near | 400 | lat and lng were not both sent, and no poi_id was sent. |
anchor_ambiguous | /v1/pois/near | 400 | Both a coordinate and a poi_id were sent. Send one or the other. |
range_required | /v1/pois/near | 400 | Neither radius_m nor k was sent. |
invalid_input | /v1/pois/search, /v1/h3/*, /v1/geohash/*, /v1/scores/cells | 400 | q was empty. Or a cell request set no geographic form, or more than one. Or a geohash request sent resolution instead of precision. Or /v1/h3/population or /v1/h3/transit-coverage got a circle. Or /v1/scores/cells or /v1/h3/features got a reach key the country times nothing for. |
invalid_input | /v1/h3/pois, /v1/h3/pois/records, /v1/geohash/pois, /v1/geohash/pois/records | 422 | counts_only=true and breakdown=true were both sent. Or match=primary was sent without category, to a records endpoint or with counts_only=true to an aggregate. |
invalid_bbox | /v1/pois/in-bbox, /v1/pt/stations, /v1/h3/*, /v1/geohash/*, /v1/scores/cells | 400 | bbox has the wrong number of parts, a value that is not a number or out of range, or south is not below north or west not left of east. Equal edges are rejected too. |
cell_features_not_found | /v1/scores/listings, /v1/scores/cells | 404 | The country has no cell features index, so it cannot be scored. |
unknown_key | /v1/scores/listings, /v1/scores/cells | 422 | A profile names an unknown category or brand key, a census field, an unknown preference, or an avoided environment preference. The message lists them. |
poi_not_found | /v1/pois/{poi_id} | 404 | No place with that id in the country. |
invalid_cell_id | /v1/h3/*, /v1/scores/cells | 400 | A cell id is not a valid H3 cell. details.invalid lists the first few. |
mixed_resolution | /v1/h3/* except /v1/h3/divisions and /v1/h3/population/aggregate | 400 | cells held more than one resolution. details.resolutions lists them. Send one resolution per request. |
invalid_resolution | /v1/h3/*, /v1/divisions/{division_id}/cells | 400 | The dataset does not have that resolution. details.supported_resolutions lists the ones it has. compact=true refuses any res but 9. |
invalid_cell_resolution | /v1/h3/transit-coverage, /v1/h3/*/aggregate, /v1/h3/divisions | 400 | A cell is not at a resolution the endpoint takes: 9, or the coarser ones its own page lists. details.invalid lists the cells. |
overlapping_cells | /v1/h3/population/aggregate | 400 | A cell lies inside another cell in the same cells list, so its area would count twice. details.overlapping lists the first few. |
invalid_type | /v1/h3/land-cover/{type_} | 400 | The type is not green-space or beaches. |
invalid_layer | /v1/h3/environment | 400 | layer names a measure that does not exist. details.supported_layers lists the six. |
invalid_geohash | /v1/geohash/* | 400 | A geohash did not parse, or its precision is not 6 or 7. details.supported_precisions lists the two. |
mixed_precision | /v1/geohash/* | 400 | geohashes held more than one precision. details.precisions lists them. Send one precision per request. |
invalid_precision | /v1/geohash/* | 400 | precision is not 6 or 7. details.supported_precisions lists the two. |
too_many_cells | /v1/h3/*, /v1/geohash/*, /v1/scores/cells | 400 | The area covers more than 10,000 cells, or a /v1/scores/cells cells list holds more. details.cell_count and details.cap give the numbers. Narrow the area or use a lower resolution. On /v1/h3/divisions the count is the resolution 9 cells the input expands to, so send finer cells, not coarser ones. |
max_cells_exceeded | /v1/cells/rank, /v1/h3/population/aggregate, /v1/h3/environment/aggregate | 400 | The cell list is over the cap: 10,000 for ranking, 100,000 for population (or the area cap on the population page), 50,000 for environment. limit and actual give the numbers. |
aggregation_read_budget_exceeded | /v1/h3/pois, /v1/geohash/pois, /v1/h3/population/aggregate | 400 | The area took longer to add up than the read budget allows. Ask for fewer cells. |
records_read_budget_exceeded | /v1/h3/pois/records, /v1/geohash/pois/records | 400 | The area took longer to read than the read budget allows. Ask for fewer cells or narrow category. |
pt_read_budget_exceeded | /v1/h3/transport/stations, /v1/h3/transport/lines | 400 | The same, for transport. Lower per_cell_limit or ask for fewer cells. |
land_cover_read_budget_exceeded | /v1/h3/land-cover | 400 | The same, for green space. Ask for fewer cells or fewer types. |
invalid_sort | /v1/divisions, /v1/h3/transit-coverage/divisions | 400 | sort is not a value the endpoint accepts. The message lists the values. |
invalid_cursor | /v1/divisions/{division_id}/cells | 400 | cursor is not a canonical H3 cell id at the requested res. compact=true refuses any cursor. |
footprint_unavailable | /v1/divisions/{division_id}/cells | 503 | compact=true: the divisions index predates the footprint, or the footprint does not match cell_count. Retry after the index is rebuilt. |
no_footprint | /v1/divisions/{division_id}/cells | 404 | compact=true: the division has no polygon to fill. Don't retry. |
station_not_found | /v1/pt/stations/{sid} | 404 | No station with that id in the country. |
line_not_found | /v1/pt/lines/{sid} | 404 | No line with that id in the country. |
interchange_not_found | /v1/pt/lines/{line_sid}/stops/{station_sid}/interchanges | 404 | The line does not call at that station in the country. |
country_required | all | 422 | country was left out and the request has no geography to work it out from. Send an ISO 3166-1 alpha-2 code. |
country_unresolved | all | 422 | country was left out and the geography is outside every country the Data API covers. |
rate_limited | all | 429 | The key is over its rate limit. Flat form, with a Retry-After header. See 429 below. |
index_unavailable | all | 503 | The Data API has no data for the country, or the data is being swapped in during an update. GET /v1/countries lists the covered countries. If the country is listed, retry. |
primary_category_unindexed | /v1/pois/near, /in-bbox, /search, /v1/*/pois/records, /v1/search/* | 503 | The country's data predates the primary category, which match=primary, fields=slim and POST /v1/search/reachable read. It clears at the next update, not on retry. |
verified_unindexed | /v1/pois/near, /in-bbox, /search, /v1/h3/pois*, /v1/geohash/pois* | 503 | The country's data predates the verified filter. Leave verified out until the next update. |
closed_unindexed | /v1/pois/near, /in-bbox, /search, /v1/h3/pois*, /v1/geohash/pois* | 503 | The country's data predates closed places. Leave include_closed=false out until the next update. |
HTTP statuses
An error without a Data API code has a detail field instead of error. detail is a
string, or a list for 422.
| Status | What it means |
|---|---|
| 400 Bad Request | A parameter was invalid. Most carry a code from the table above. A country that is not a country code returns detail as a string. |
| 401 Unauthorized | The Authorization header is missing or malformed, or the key is unknown or revoked. detail says which. See Base URL and authentication. |
| 403 Forbidden | The key is valid but not allowed on the endpoint. POST /v1/search/reachable returns 403 for a key without TravelTime access. See Use with the TravelTime API. |
| 404 Not Found | The path does not exist, or the id does not. An unknown place, station, line or interchange id returns a code from the table above. An unknown path returns { "detail": "Not Found" }. GET /v1/h3/environment/cell/{h3} returns detail as a string when the cell has no environment data, and GET /v1/benchmark does the same for a country with no results. |
| 422 Unprocessable Entity | Validation of the body or query failed. detail is a list with one entry per failed field. Four cases return the envelope instead: country_required, country_unresolved, invalid_input and unknown_key. match=primary without category on /v1/pois/*, and an unknown category value, return detail as a string. |
| 429 Too Many Requests | An API key allows 10 requests/second and 20,000/day, enforced per worker process — production runs 4 workers, each with its own bucket — so the ceiling a key can actually reach is up to 4× either number. Above either limit the response is 429 with the code rate_limited and a Retry-After header: 1 for the per-second limit, or the seconds until midnight UTC for the daily limit. POST /v1/search/reachable also returns 429 when the TravelTime API rate limit is hit. That response has a detail object. |
| 500 Internal Server Error | An error we did not handle. The response carries no stack trace. Contact us with the request and the time. See Get help. |
| 502 Bad Gateway | The TravelTime API returned an error the Data API could not recover from. Only POST /v1/search/reachable calls it. detail.upstream_status carries its status. |
| 503 Service Unavailable | The Data API could not read the data for the request. index_unavailable and the _unindexed codes are in the table above. The other codes are transient: opensearch_read_timeout, opensearch_unavailable, opensearch_request_rejected, opensearch_error, pt_read_incomplete, records_read_incomplete and division_read_incomplete. Retry after a few seconds. GET /health also returns 503 while the API is starting. |
| 504 Gateway Timeout | The TravelTime API did not respond in time. Only POST /v1/search/reachable calls it. |
With Accept: application/x-ndjson, POST /v1/search/reachable streams its reply in two
lines and the status goes out with the first. An error in the second step arrives as a final
line {"error": {"code": "...", "message": "..."}} on a 200. The code is the one the plain
reply would have returned, such as a 503 code above, or internal_error for anything
else. See Stream the reply.
A validation error
422 is the most common error while you build a request. This POST /v1/h3/pois body sets
no geography, so the API rejects it.
text
curl -X POST -H "Authorization: Bearer $POI_API_KEY" -H "Content-Type: application/json" \
-d '{}' "$POI_BASE/v1/h3/pois?country=GB"json
{
"detail": [
{
"type": "value_error",
"loc": ["body"],
"msg": "Value error, exactly one of cells / bbox / (center_lat+center_lng+radius_m) must be set",
"input": {},
"ctx": {
"error": {}
}
}
]
}Each entry in detail describes one failed field.
typeis the kind of failure, such asmissing,too_shortorvalue_error.locis the path to the field, such as["body", "cells"]or["query", "country"].msgis a sentence for your logs.inputis the value we received.ctxis extra context for some types.
Every problem with the request is in the one response. You do not need to fix one and resend to find the next.
What to show your users
Show error.message for an error with a code. Treat a 422 as a bug in your request:
log it and do not show it. On 401, ask the user to sign in again. On 429, wait for the
number of seconds in Retry-After, then retry. This handler covers every error the Data
API returns.
ts
async function handle(response: Response) {
if (response.ok) return response.json()
const body = await response.json().catch(() => null)
if (body?.error?.code) {
// Data API error code: 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: a bug in the request, so log it.
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)
}Get help
Contact us at traveltime.com/contact-us.