Skip to content

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.

codeEndpointsHTTPWhen you get it
anchor_required/v1/pois/near400lat and lng were not both sent, and no poi_id was sent.
anchor_ambiguous/v1/pois/near400Both a coordinate and a poi_id were sent. Send one or the other.
range_required/v1/pois/near400Neither radius_m nor k was sent.
invalid_input/v1/pois/search, /v1/h3/*, /v1/geohash/*, /v1/scores/cells400q 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/records422counts_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/cells400bbox 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/cells404The country has no cell features index, so it cannot be scored.
unknown_key/v1/scores/listings, /v1/scores/cells422A 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}404No place with that id in the country.
invalid_cell_id/v1/h3/*, /v1/scores/cells400A 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/aggregate400cells held more than one resolution. details.resolutions lists them. Send one resolution per request.
invalid_resolution/v1/h3/*, /v1/divisions/{division_id}/cells400The 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/divisions400A 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/aggregate400A 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_}400The type is not green-space or beaches.
invalid_layer/v1/h3/environment400layer names a measure that does not exist. details.supported_layers lists the six.
invalid_geohash/v1/geohash/*400A geohash did not parse, or its precision is not 6 or 7. details.supported_precisions lists the two.
mixed_precision/v1/geohash/*400geohashes held more than one precision. details.precisions lists them. Send one precision per request.
invalid_precision/v1/geohash/*400precision is not 6 or 7. details.supported_precisions lists the two.
too_many_cells/v1/h3/*, /v1/geohash/*, /v1/scores/cells400The 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/aggregate400The 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/aggregate400The 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/records400The 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/lines400The same, for transport. Lower per_cell_limit or ask for fewer cells.
land_cover_read_budget_exceeded/v1/h3/land-cover400The same, for green space. Ask for fewer cells or fewer types.
invalid_sort/v1/divisions, /v1/h3/transit-coverage/divisions400sort is not a value the endpoint accepts. The message lists the values.
invalid_cursor/v1/divisions/{division_id}/cells400cursor is not a canonical H3 cell id at the requested res. compact=true refuses any cursor.
footprint_unavailable/v1/divisions/{division_id}/cells503compact=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}/cells404compact=true: the division has no polygon to fill. Don't retry.
station_not_found/v1/pt/stations/{sid}404No station with that id in the country.
line_not_found/v1/pt/lines/{sid}404No line with that id in the country.
interchange_not_found/v1/pt/lines/{line_sid}/stops/{station_sid}/interchanges404The line does not call at that station in the country.
country_requiredall422country was left out and the request has no geography to work it out from. Send an ISO 3166-1 alpha-2 code.
country_unresolvedall422country was left out and the geography is outside every country the Data API covers.
rate_limitedall429The key is over its rate limit. Flat form, with a Retry-After header. See 429 below.
index_unavailableall503The 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/*503The 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*503The 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*503The 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.

StatusWhat it means
400 Bad RequestA 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 UnauthorizedThe Authorization header is missing or malformed, or the key is unknown or revoked. detail says which. See Base URL and authentication.
403 ForbiddenThe 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 FoundThe 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 EntityValidation 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 RequestsAn 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 ErrorAn error we did not handle. The response carries no stack trace. Contact us with the request and the time. See Get help.
502 Bad GatewayThe 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 UnavailableThe 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 TimeoutThe 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.

  • type is the kind of failure, such as missing, too_short or value_error.
  • loc is the path to the field, such as ["body", "cells"] or ["query", "country"].
  • msg is a sentence for your logs.
  • input is the value we received.
  • ctx is 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.