Documentation menu
API conventions
The cross-cutting rules that every endpoint follows: base URL, authentication,
coordinate order, units, quotas, and the error envelope. Other pages link here
rather than repeating any of it. The machine-readable contract is always
GET /openapi.json on your deployment; where prose and OpenAPI disagree,
OpenAPI wins.
Base URL and availability
The hosted gateway's base URL is https://api.mapmap.ai.
Status, honestly: the hosted gateway is live: sign up for a key and every endpoint on these pages works today. Everything also runs identically against a self-hosted deployment of the same gateway: same endpoints, same error envelope, same keys. Examples throughout the docs use an exported base so they work against either:
export BASE=https://api.mapmap.ai # or your own deployment's origin
export API_KEY=snk_...
Authentication
Metered endpoints take an API key (prefix snk_) either way:
Authorization: Bearer snk_... # preferred
?api_key=snk_... # query parameter, for clients that
# cannot set headers (e.g. tile URLs)
The Authorization header wins when both are present. A missing, malformed,
unknown, revoked or expired key is a 401 problem
(urn:sn-gateway:problem:unauthorized) whose detail says which.
Unauthenticated endpoints (no key needed):
GET /,GET /health,GET /openapi.json,GET /llms.txt,GET /termsPOST /v1/keys(self-serve signup) andGET /v1/keys/verify(magic link)GET /styles,GET /styles/{spec},GET /styles/{spec}/theme: style reads are public so browser map clients can reference them by bare URL (publishing styles is authenticated and metered)GET /fonts/{fontstack}/{range}andGET /sprite/{file}: glyphs and sprites, fetched by bare URL from compiled styles
GET /v1/keys/self is key-authenticated but free: it consumes no quota, no
credit and no rate limit, so agents can poll their own key state.
/admin/* endpoints (self-host only) use a separate static operator token as
a bearer credential, never an snk_ key.
One optional header: SDKs may send X-MapMap-User to meter monthly active
users on identity-owned keys. It is fail-open: MAU billing can never block
or 402 a request.
Keyless demo access
A deployment can open a small, unauthenticated demo lane with SN_DEMO_RPM
(requests per minute). It is off by default (0) on a self-host, and it is
enabled on the hosted gateway at https://api.mapmap.ai, so the calls below
work with no key at all. Everything else on the hosted gateway still needs one.
Requests with no API key to a fixed demo-safe whitelist are admitted
instead of 401:
GET /route/v1/*(OSRM-compatible routing, all profiles)GET /geocodeGET /geocode/reverseGET /geocode/suggestGET /geocode/retrieve
Every other endpoint, and any non-GET method (including POST /route),
still returns 401 without a key.
Two limits apply, both keyed per client IP (the rightmost
X-Forwarded-For hop, the one the proxy in front of the gateway wrote, else
the peer address; the leftmost hop is caller-supplied and is never trusted):
- a token bucket at
SN_DEMO_RPMrequests/minute, over which the answer is429 rate-limitedwithRetry-After; - an optional daily cap,
SN_DEMO_DAILY(0disables it), over which the answer is429 demo-daily-exceededwithRetry-Afterto UTC midnight and a pointer to free-key signup.
Premium work is never served keyless. A keyless call that engages the truck
profile, vehicle dimensions, hazmat or an ADR tunnel_code is refused with a
401 naming the features it engaged, before the daily cap is spent, so the
attempt costs you nothing.
Demo requests are never metered, billed, or drawn against any quota or prepaid
ledger. Keyed requests are unaffected and are always metered to their own
identity. The lane is a taste, not a tier: mint a free key
(POST /v1/keys) for anything you intend to run more than once.
Coordinates and units
Bare coordinate pairs are always lon,lat: in the compatible URL path
(/route/v1/{profile}/{lon},{lat};{lon},{lat}) and in GeoJSON output
(isochrone contours). JSON request bodies use named fields instead:
{"lat": 51.5, "lon": -0.1}.
| Where | Unit |
|---|---|
GET /route/v1/... responses | distance metres, duration seconds |
POST /matrix responses | durations seconds, distances metres (null = unreachable) |
POST /route responses | time seconds; length in the request's units (kilometres unless you ask for miles) |
POST /isochrone contours | time in minutes or distance in kilometres (request) |
| Vehicle dimensions | metres (height_m, width_m, length_m) |
| Vehicle weights | metric tonnes (gross_weight_t, axle_load_t) |
| Vehicle speed | km/h |
| Offline download allowance | MiB per calendar month |
Route geometry is encoded polyline: precision 6 on the native POST /route
response's shape, precision 5 on the compatible GET /route/v1/...
endpoint (matching each format's convention).
Waypoints per route: 500, on POST /route and on GET /route/v1/...
alike. The routing engine takes only 20 per call, so a longer list is cut
into consecutive stretches, routed concurrently and spliced into one
continuous route: legs, manoeuvres and the summary are a true
concatenation, not an approximation, because the engine already computes a
multi-waypoint route as one independent search per pair of consecutive
break waypoints and the cuts are only ever taken at those. Billing
follows: one call per started block of 20 waypoints, so a 20-waypoint
route is the single call it always was and a 500-waypoint route is 25.
Three shapes cannot be split faithfully and are refused with 422 route-not-splittable rather than approximated: a run of more than 20
consecutive through/via/break_through waypoints (the engine forbids a
u-turn across those, and separate calls cannot), a time-dependent route
(date_time type 0, 1 or 2: the engine carries each leg's arrival time
into the next leg's departure, which separate calls cannot), and a request
for alternates (an alternate is a variation of the whole journey, not of
one leg). All three are fine at 20 waypoints or fewer, where nothing is
split.
Avoiding areas and points
Three endpoints let you cut roads out of the search for one request, which is how you ask "if we shut this, what breaks" without editing your own copy of the map and rebuilding tiles.
| Field | Shape | Meaning |
|---|---|---|
exclude_polygons | array of rings, each an array of [lon, lat] pairs | Roads intersecting any ring are removed from the search. |
exclude_locations | array of {lat, lon} objects | Each point is mapped to its nearest road, and that road is removed. |
Accepted on POST /route, POST /isochrone and POST /matrix. On the
compatible GET /route/v1/{profile}/{coordinates} they are query
parameters: exclude_polygons as a JSON array of rings, exclude_locations
as ;-separated lon,lat pairs. Not accepted on /optimise,
/route/along or /v1/fuel/along, which ignore them.
Limits, each a 400 when exceeded:
| Limit | Value |
|---|---|
| Rings per request | 50 |
| Total ring vertices per request | 500 |
exclude_locations per request | 50 |
curl -fsS -X POST "$BASE/matrix" \
-H "Authorization: Bearer $API_KEY" -H "Content-Type: application/json" \
-d '{
"sources": [{ "lat": 52.192, "lon": -2.221 }],
"targets": [{ "lat": 52.207, "lon": -2.243 }],
"costing": "auto",
"exclude_polygons": [[[-2.2245, 52.1918], [-2.2231, 52.1918], [-2.2231, 52.1929], [-2.2245, 52.1929]]]
}'
Two things worth knowing before you build on it. An exclusion is a hard
cut, not a penalty, so it cannot express "avoid if reasonable"; and where
an exclusion makes a destination unreachable, POST /matrix returns null
for that cell while POST /route returns a 400 problem document rather
than an empty route. Exclusions do not change what a request bills.
Quotas and rate limits
Quotas are per identity (your email), shared across all of that identity's keys; issuing more keys does not multiply the quota.
| Limit | Value |
|---|---|
| Provisional key (before email verification) | 1,000 calls total, expires after 72 h |
| Verified free tier | 50,000 included calls per calendar month (a premium truck/ADR call draws 20; commercial use allowed) |
| Rate limit (self-served keys) | 60 requests per minute |
| Active keys per identity | 5 |
| Key issuances per IP | 3 per day |
Beyond the free tier, calls draw prepaid credit at a per-call price with two
classes: standard (car/van/bus/bike/pedestrian routing, matrix,
isochrone, map matching; from 0.05p/call) and premium (anything
truck/ADR: "costing": "truck", a top-level adr object, "hazmat": true,
/adr/check, or the truck profile / truck query parameters on the
compatible endpoint; from 1p/call). Included volume (the free tier and
plans) is denominated in standard calls: one premium call draws 20 included
calls, matching the price ratio; pay-as-you-go money is always priced by
each class's own schedule. Requests bill by size where size varies: a
matrix bills one call per started block of 25 elements (sources × targets, max 10,000 per request), an isochrone one call per contour (max
10), an optimisation 10 calls per started 2,500-pair block of its internal
location-by-location matrix (10 up to 50 locations, 40 at 100, 160 at the
200-location cap), an along-route search a flat 10
calls (both /route/along and /v1/fuel/along, whatever max_results
you ask for, up to the 25-candidate cap), /elevation one call per
started block of 25 points, and /elevation/along a flat 5 calls. Prices
step down with monthly volume; see /pricing.
Money rule: a request answered with a client error (4xx) is never charged; the debit is refunded. It still counts against the monthly quota.
Hitting a limit gives you one of two 429s, or a 402; see the decision
table below.
Errors
Every JSON endpoint answers errors as RFC 9457 application/problem+json,
with one exception: the compatible GET /route/v1/... endpoint answers its
own routing errors (InvalidQuery, InvalidValue, NoRoute, NoSegment,
always 400) in the OSRM {"code": ...} error envelope, because
compatible clients dispatch on code. Auth, quota and payment errors keep
the shared formats even on the compatible path: 401 and 429 are
problem+json, and beyond-tier 402s use the x402 body.
A real body: the verified free tier exhausted by an identity that never bought credit:
{
"type": "urn:sn-gateway:problem:quota-exceeded",
"title": "Monthly quota exceeded",
"status": 429,
"detail": "monthly quota exceeded (50000/50000)",
"quota": 50000,
"used": 50000
}
type is a stable URN you can dispatch on; detail is for humans. Some
problems carry extra machine-readable fields, noted below.
type (urn:sn-gateway:problem: +) | Status | Meaning | Fix |
|---|---|---|---|
unauthorized | 401 | Missing, malformed, unknown, revoked or expired key | Send Bearer snk_...; reissue at POST /v1/keys |
admin-unauthorized | 401 | Wrong admin token (self-host) | Check the operator token |
forbidden | 403 | Key valid but not permitted, e.g. a provisional key attempting a bulk territory-layer download | Verify your email |
bad-request | 400 | Invalid body or parameters | detail says what |
costing-conflict | 400 | Your own costing_options contradict the adr profile; conflicting_fields lists them | Remove the conflicting fields or make them agree |
tos-not-accepted | 400 | Signup without "accept_tos": true; tos echoes the terms URL | Fetch the terms, re-submit with accept_tos: true |
invalid-email | 400 | Rejected email (syntax or disposable domain) | Use a real address |
not-found | 404 | Unknown resource | Check the id/path |
key-cap | 409 | Identity already has the maximum active keys (max_active_keys) | Revoke one or reuse an existing key |
no-identity-ledger | 409 | Admin credit aimed at an operator-issued key | Credit the identity instead |
invalid-theme | 422 | Style theme failed validation; problems lists each offending key | Correct the named slot/layer and resubmit |
optimisation-too-large | 422 | Problem exceeds the fair-use location cap of 200 unique locations (max_locations, locations) | Split the problem |
matrix-too-large | 422 | Matrix over the 10,000-element cap (max_pairs, max_square_locations, sources, targets, pairs) | Split the request |
matrix-span-too-large | 422 | The furthest source-to-target pair is beyond the engine's matrix span: 1,500 km on the hosted deployment, 400 km on a stock self-host (SN_ENGINE_MAX_MATRIX_DISTANCE_M; max_span_km, span_km) | Narrow the study area; splitting the request does not help, because that pair is in whichever block it lands in |
route-not-splittable | 422 | More waypoints than the engine takes in one call, and the route cannot be split faithfully: a long through/via run, a time-dependent (date_time type 0/1/2) route, or a request for alternates | detail says which; make a boundary waypoint a break, drop date_time/alternates, or shorten the route |
quota-exceeded | 429 | Monthly quota exhausted with no payment path (quota, used) | Verify email, top up credit, or wait for the month |
rate-limited | 429 | Per-minute limit fired; retry_after seconds, also sent as a Retry-After header | Back off and retry |
issuance-velocity | 429 | Too many key issuances from your IP today (limit_per_day) | Come back tomorrow |
internal | 500 | Unexpected failure; detail is logged server-side, never leaked | Retry; report if persistent |
upstream-error | 400/502 | Routing backend (or Photon geocoder) rejected the request (400 for an upstream 4xx, 502 otherwise; upstream_status) | Check the request; else retry |
geocoding-not-enabled | 501 | /geocode on a deployment with neither SN_GEOCODE_DIR nor SN_PHOTON_URL (self-host) | Operator sets one of them |
optimisation-not-enabled | 503 | Deployment has no VROOM sidecar configured (self-host) | Operator sets SN_VROOM_URL |
upstream-unavailable | 503 | Routing backend unreachable | Retry |
upstream-timeout | 504 | Routing backend timed out | Retry |
The two 402s (not problem+json)
Two payment errors deliberately use plain application/json bodies instead
of the problem envelope, because machine clients parse them directly:
-
Payment required: the x402 wire format (
x402Version,accepts,error). Returned when a request beyond the free tier cannot be paid: a provisional key past its 1,000 calls, a credited identity whose balance is below the per-call price, or a failedX-PAYMENTverification. Full shape and payment flows: machine payments. -
Download allowance exceeded: an offline territory-layer download would exceed the identity's monthly MiB allowance:
json{ "code": "download_allowance_exceeded", "allowance_mib": 8192, "used_mib": 2100, "message": "offline map download allowance exhausted; upgrade to a plan or the SDK licence for production downloads", "upgrade": "https://api.mapmap.ai/pricing" }
402 or 429?
| You get | When |
|---|---|
429 quota-exceeded | A free-tier identity that has never bought credit exceeds its monthly quota; or an operator-issued key exceeds its quota |
429 rate-limited | Any key exceeds its per-minute rate limit (Retry-After header set) |
402 (x402 body) | A provisional key past its call allowance; a credited identity with insufficient balance; a rejected X-PAYMENT header |
402 (download body) | An offline layer download would exceed the monthly MiB allowance |
Rule of thumb: 429 means stop or wait; 402 means there is a way to
pay: verify your email, top up, or attach an x402 payment.
ADR and tunnel codes
ADR (the European agreement on carriage of dangerous goods by road; tunnel codes B–E restrict which tunnels a hazmat load may use). This section is the canonical reference the other pages link to.
Routing requests declare the load with a top-level adr object
("costing": "truck" required):
{
"adr": {
"dimensions": {
"height_m": 4.0,
"width_m": 2.55,
"length_m": 16.5,
"gross_weight_t": 40.0,
"axle_load_t": 11.5,
"axle_count": 5
},
"tunnel_code": "D",
"hazmat": true
}
}
axle_load_t and axle_count are optional. On the compatible
GET /route/v1/... endpoint the same facts travel as query parameters:
height, width, length, weight, hazmat, tunnel_code.
Semantics, worst-case by design:
"hazmat": false: the tunnel matrix does not apply; every tunnel is permitted (dimensional limits still apply)."hazmat": truewith notunnel_code: treated as the most restrictive non-quantity code (B), since the load's code is unknown."tunnel_code": "(—)": the ADR "no restriction" entry, explicitly allowed through all tunnels.- Quantity- and tank-conditional codes (
B1000C,B/D, ...) are read worst-case: the API cannot know your net explosive mass or whether the goods travel in tanks, so it assumes the restrictive reading.
If your request's own costing_options.truck values disagree with what the
adr profile implies, the request is rejected with a 400 costing-conflict
listing the fields; set them in one place or make them agree.
Tunnel categories (posted on the tunnel, A least to E most restrictive):
| Category | Restricts |
|---|---|
| A | No restrictions for dangerous goods |
| B | Goods which may lead to a very large explosion |
| C | ... or a large explosion, or a large toxic release |
| D | ... or a large fire |
| E | All dangerous goods except UN 2919, 3291, 3331, 3359 and 3373 |
Tunnel restriction codes (assigned to the load, ADR 8.6.4):
| Code | Passage forbidden through |
|---|---|
B | Categories B, C, D and E |
B1000C | Category B above 1,000 kg total net explosive mass per transport unit; always C, D and E |
B/D | Categories B and C when carried in tanks; always D and E |
B/E | Categories B, C and D when carried in tanks; always E |
C | Categories C, D and E |
C5000D | Category C above 5,000 kg total net explosive mass per transport unit; always D and E |
C/D | Category C when carried in tanks; always D and E |
C/E | Categories C and D when carried in tanks; always E |
D | Categories D and E |
D/E | Category D when carried in bulk or in tanks; always E |
E | Category E |
(—) | Nothing; allowed through all tunnels |
To check a load without routing, POST /adr/check takes the same adr
profile plus a tunnel_category ("A"–"E") and returns the entry
decision for that tunnel; see the API reference.
Next steps
- Quickstart: key to dimensioned truck route in two calls
- API reference: every endpoint, truck and ADR parameters
- Analysis endpoints: isochrone, matrix, map matching
- Machine payments: the 402 flows in full, prepaid credit and x402