Skip to content

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 only

The 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. Code unauthorized. Every data-plane 401 also carries an RFC 6750 WWW-Authenticate challenge 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", with error="invalid_token" added when a key was presented but not recognised.
  • 429: one of four things, told apart by code:
    • 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. No Retry-After, because no amount of waiting fixes it; update the payment method in the dashboard.
  • 403: one of four things, told apart by code:
    • origin_not_allowed: the key carries a non-empty allowed-origins list and this request is not covered by it. A missing Origin header, 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 carries capability (legalLand, energy, agriculture, remote, or mining), addon naming the cheapest SKU that would grant it (addon-legal-land, addon-energy, addon-agriculture, addon-remote, addon-mining), and a message written for a person where error is 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 from localhost, 127.0.0.1, *.localhost, *.pages.dev and *.workers.dev, where every capability is free. It carries the same capability, addon and message fields, 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 same capability, addon and message fields.
  • 400: a malformed request parameter. Bad tile coordinates, a missing q on a geocoding call, a malformed bbox, non-numeric lon/lat, an unknown category, both or neither of near and bbox on a category search, more than four isochrone contours or one over 120 minutes, an invalid theme code, an unknown /data/query layer, and so on. The message string names the parameter.
  • 501: GET /data/query on 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 text 404 Not Found), or an unknown resource on the account API (code not_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: /route could not find a path between the two points you gave it. The body is { "error": "no route" }, which a 502 on 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 a 422. /matrix and /match answer the same 422 with 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-route is 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, a retryable flag, and a short Retry-After: 5:

      { "error": "geocode temporarily unavailable", "code": "service_unavailable", "retryable": true }

      The service name is geocode or routing. 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 503 with { "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.