Errors
Shape
Most endpoints return a JSON body on failure:
{ "error": "human-readable message", "code": "machine-readable-code" }error is always present. code is present on every authentication and plan-enforcement
failure, and on every account API failure. Most 400s from the data plane carry only error,
because the message names the exact parameter at fault and that is the useful part.
Tile, terrain, contour, glyph, and sprite requests are the exception. Their error bodies are
plain text, not JSON: invalid tile coordinates, invalid fontstack, not found, and so on.
MapLibre GL never reads these bodies itself; it only checks the status code and moves on. Every
other endpoint on the gateway (geocoding, routing, styles, and the account API) returns JSON, as
does the one JSON error the terrain and contour paths do send, the 404 for an unconfigured
archive.
The code union
type ErrorCode =
| "unauthorized" // 401: missing, malformed, or unrecognised key
| "rate_limited" // 429: over the per-key burst limit
| "quota_exceeded" // 429: hobby plan at its monthly included volume
| "spend_cap" // 429: paid plan at its spend cap
| "billing_required" // 429: a failed payment on a paid plan
| "origin_not_allowed" // 403: this key's allowed-origins list does not cover the request
| "addon_required" // 403: paid plan, this key does not hold the named capability
| "dev_only" // 403: dev plan, premium capability, production origin
| "usage_limit_reached" // 403: dev plan past its monthly premium-call ceiling
| "invalid_theme" // 400: a `?theme=` code that does not decode or validate
| "invalid_location" // 400: /route, /matrix, /match, /optimized-route: a coordinate is missing or malformed
| "invalid_mode" // 400: /route, /isochrone: an unknown travel mode
| "invalid_route_option" // 400: a truck dimension, avoid, use_highways, use_hills or a contour cap is
// out of range or unknown, or was sent on a mode whose costing has
// no such option; also avoid=highways sent with use_highways
| "invalid_body" // 400: /matrix, /match, /optimized-route: the body is missing, not JSON, or over the size cap
| "too_many_locations" // 400: /matrix, /match, or /optimized-route exceeded its origin, destination, point, or stop cap
| "no_route" // 422: /route, /matrix, /match, /optimized-route: the graph holds no answer for the request
| "routing_failed" // 502: the routing container failed for some other reason
| "service_unavailable" // 503: a container-backed service did not answer in time
| "invalid_token" // account API only: bad or expired magic-link / session token
| "not_found" // account API only
| "conflict" // account API only: the job state does not allow this
| "payload_too_large" // 413: POST /geocode/batch with more than 100 queries; also account upload over the plan ceiling
| "plan_required" // account API only: this needs a paid plan
| "bad_request"; // account API onlyThe first eighteen are what your application's API-key traffic can hit. The last six belong to the
account dashboard's own endpoints (/account/*), which authenticate with a browser session
rather than an API key. invalid_theme and the seven routing codes above are the data-plane
400, 422, and 502 responses that carry a code: a silently ignored theme is exactly the
failure a theme builder's users cannot debug, and a coded routing failure lets a client branch on
too_many_locations versus invalid_location instead of parsing the message. invalid_theme
also carries a detail array naming what was wrong with the code. See
Routing for which endpoint emits which routing code, and
ROUTING_ERROR_CODES in apps/gateway/src/routing/errors.ts for the source list.
On the data plane, payload_too_large is the 413 for POST /geocode/batch when queries
has more than 100 entries. Split the list; do not retry the same body. The account codes
conflict and plan_required belong to the leftover hosted-upload surface, not to
POST /geocode/batch. There is no job API today; see Batch geocoding.
Headers a browser can read
Cross-origin JavaScript can only see response headers the server explicitly exposes, and a header
your code cannot read is a header that does not exist as far as your retry logic is concerned.
The API exposes Retry-After, ETag, Content-Length, Content-Range and Accept-Ranges.
Retry-After is the one that matters here: it is on every 429 except billing_required, and on
the retryable 503, so a browser client can honour it rather than guessing at a backoff.
Status codes
401: missing, malformed, or unrecognised API key. Codeunauthorized. Every data-plane401also carries an RFC 6750WWW-Authenticatechallenge naming the scheme and pointing at the metadata that explains it:Bearer realm="api.unmap.dev", resource_metadata="https://api.unmap.dev/.well-known/oauth-protected-resource", witherror="invalid_token"added when a key was presented but not recognised.429: one of four things, told apart bycode:rate_limited: over the per-key burst limit (1,000 requests per 60 seconds per Cloudflare location).Retry-After: 60.quota_exceeded: a hobby key past its 10,000 included calls for the month.Retry-After: 3600; the block lifts on the next monthly reset.spend_cap: a paid key at its configured spend cap.Retry-After: 3600; raise the cap in the dashboard.billing_required: the last payment on a paid plan failed. NoRetry-After, because no amount of waiting fixes it; update the payment method in the dashboard.
403: one of four things, told apart bycode:origin_not_allowed: the key carries a non-empty allowed-origins list and this request is not covered by it. A missingOriginheader, an unparseable one, and a well-formed origin that is simply not on the list all get this same answer, and the body never tells you what the list contains. A key with an empty list is unrestricted and never sees this. See Authentication.addon_required: a paid-plan key that does not hold the capability this layer or overlay needs. The body carriescapability(legalLand,energy,agriculture,remote, ormining),addonnaming the cheapest SKU that would grant it (addon-legal-land,addon-energy,addon-agriculture,addon-remote,addon-mining), and amessagewritten for a person whereerroris written for a log. See Layers. Keys without an account (founder and integration keys) are exempt.dev_only: a dev-plan key using a premium capability from a production origin. The same key is allowed fromlocalhost,127.0.0.1,*.localhost,*.pages.devand*.workers.dev, where every capability is free. It carries the samecapability,addonandmessagefields, but the fix is different and that is why the code is: a dev-plan account has no subscription for an add-on to attach to, so the step is to subscribe. See Layers.usage_limit_reached: a dev-plan key past its 5,000 premium calls for the month. The same key's core traffic is unaffected and keeps serving: only the gated capabilities refuse. It clears at the start of the next month, and a paid plan removes the ceiling entirely. Carries the samecapability,addonandmessagefields.
400: a malformed request parameter. Bad tile coordinates, a missingqon a geocoding call, a malformedbbox, non-numericlon/lat, an unknowncategory, both or neither ofnearandbboxon a category search, more than four isochrone contours or one over 120 minutes, an invalid theme code, an unknown/data/querylayer, and so on. The message string names the parameter.501:GET /data/queryon a catalogued tile-only layer (no PostGIS table). The message lists the queryable ids. See Layers.404: an unknown style on/styles/{style}.json, a missing glyph range or sprite sheet (plain text, not JSON, see above), a terrain or contour archive that is not configured (JSON), a path the gateway does not serve at all (plain text404 Not Found), or an unknown resource on the account API (codenot_found).204: a tile request beyond the archive's zoom ceiling, or for a tile that holds no data. An empty answer, not a failure: MapLibre overzooms from the deepest tile it already has.422:/routecould not find a path between the two points you gave it. The body is{ "error": "no route" }, which a502on the same endpoint also carries, so branch on the status. The usual cause is coverage: the graph is Canada-only, so an endpoint outside Canada, in open water, or not connected to the road network (Iqaluit to the mainland) is a422./matrixand/matchanswer the same422with the same code: a matrix fails as a whole when any pair is unreachable or beyond the engine's road-distance limit, rather than nulling that one cell, and a trace that cannot be snapped to any road fails outright./optimized-routeis expected to fail the same whole-request way, because it solves a matrix underneath, but that has not yet been confirmed against the deployed gateway; see Routing.502: the routing or geocoding container answered, but with a failure of its own.503: three different situations:-
The container did not answer at all, or did not answer inside the gateway's deadline. This is the one you may actually see in production, during a container restart. The body carries a
code, aretryableflag, and a shortRetry-After: 5:{ "error": "geocode temporarily unavailable", "code": "service_unavailable", "retryable": true }The service name is
geocodeorrouting. Nothing about why is disclosed, deliberately: a container's own error text can name an image tag or a database message. -
The service is not wired up at all. You see this running the gateway locally without the containers attached, not in production. The body has no
code; instead it repeats the endpoint's expected query shape, so a caller learns the contract from the failure:{ "error": "routing is not deployed yet", "contract": "?from=lng,lat&to=lng,lat&mode=auto|car|bicycle|pedestrian|truck" } -
A category search can return
503with{ "error": "category search requires a corpus rebuilt with categories" }, which means the running dataset predates category tagging. That one clears on the next data build.
-
Each API page lists its own 400 messages verbatim: the Maps API,
Geocoding, Routing.
The bodies, verbatim
The six failures you are most likely to meet, exactly as the gateway sends them. Every one of these is copied from a real response, not paraphrased.
Missing key, 401. No Authorization: Bearer, no X-API-Key, no ?key=:
{ "error": "missing API key", "code": "unauthorized" }Invalid or disabled key, 401. The key is well-formed but not in the registry, or it has
been revoked. The two cases are deliberately indistinguishable from outside:
{ "error": "invalid or disabled API key", "code": "unauthorized" }Over a limit, 429. Four different situations behind one status, told apart by code.
rate_limited is the per-key burst limit and carries Retry-After: 60:
{ "error": "rate limit exceeded", "code": "rate_limited" }The other three are the monthly enforcement flags, written by the five-minute cron rather than
counted per request. quota_exceeded and spend_cap carry Retry-After: 3600; billing_required
carries no Retry-After at all, because no amount of waiting fixes a failed payment:
{
"error": "monthly quota exceeded: raise your spend cap or upgrade at unmap.dev/dashboard/billing",
"code": "quota_exceeded",
}
{ "error": "spend cap reached: raise it at unmap.dev/dashboard/billing", "code": "spend_cap" }
{
"error": "payment failed: update your payment method at unmap.dev/dashboard/billing",
"code": "billing_required",
}Unsupported routing mode, 400. /route and /isochrone validate mode rather than
falling back, so a typo is loud:
{ "error": "mode must be one of auto, car, bicycle, pedestrian, truck" }Invalid theme code, 400. The one data-plane 400 with a code, plus a detail array
saying what was wrong. A code that is not a ut1. document or a short preset fails one way, and a
ut1. code whose payload will not inflate fails another:
{
"error": "invalid theme",
"code": "invalid_theme",
"detail": ["code must start with \"ut1.\" or be a short preset"],
}
{
"error": "invalid theme",
"code": "invalid_theme",
"detail": ["code is not valid base64url deflate data"],
}Unknown style, 404. /styles/{style}.json lists the names that are real, so a bad one is
self-correcting:
{
"error": "unknown style",
"styles": ["base", "muted", "outdoor", "blueprint", "blush", "orchid", "canopy", "lagoon", "tropic", "sunset", "bold", "pastel"],
}In the SDK
@unmap/geocoding and @unmap/routing (and so @unmap/sdk) throw on any non-2xx response.
GeocoderError and RouterError carry the HTTP status and, when the body had one, the
machine-readable code above; the gateway's own error message becomes the thrown error's
message, so a caller who logs it reads the reason rather than only a number. A body that is not
JSON, such as a tile path's plain text, simply leaves code undefined. Map tiles, glyphs, and
sprites are fetched by MapLibre itself, which surfaces failures as error events on the map
rather than as thrown exceptions.
Retrying
Only 429, 502, and 503 are worth retrying, and only with backoff. A 429 already tells you
how long to wait via Retry-After, and a 429 with code: "billing_required" will not clear
until the payment method is fixed. A 503 with code: "service_unavailable" carries
Retry-After: 5 and says retryable: true in the body, which is the one failure where retrying
promptly is the right move. A 400, 401, 403, 404, or 422 describes something about the
request or the account that a retry will not fix; fix the request instead.