Skip to content

50,000 free calls a month, card-free. Get an API key →

Documentation menu
docs / conventions · raw .md

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:

sh
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:

ini
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 /terms
  • POST /v1/keys (self-serve signup) and GET /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} and GET /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 /geocode
  • GET /geocode/reverse
  • GET /geocode/suggest
  • GET /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_RPM requests/minute, over which the answer is 429 rate-limited with Retry-After;
  • an optional daily cap, SN_DEMO_DAILY (0 disables it), over which the answer is 429 demo-daily-exceeded with Retry-After to 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}.

WhereUnit
GET /route/v1/... responsesdistance metres, duration seconds
POST /matrix responsesdurations seconds, distances metres (null = unreachable)
POST /route responsestime seconds; length in the request's units (kilometres unless you ask for miles)
POST /isochrone contourstime in minutes or distance in kilometres (request)
Vehicle dimensionsmetres (height_m, width_m, length_m)
Vehicle weightsmetric tonnes (gross_weight_t, axle_load_t)
Vehicle speedkm/h
Offline download allowanceMiB 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.

FieldShapeMeaning
exclude_polygonsarray of rings, each an array of [lon, lat] pairsRoads intersecting any ring are removed from the search.
exclude_locationsarray of {lat, lon} objectsEach 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:

LimitValue
Rings per request50
Total ring vertices per request500
exclude_locations per request50
sh
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.

LimitValue
Provisional key (before email verification)1,000 calls total, expires after 72 h
Verified free tier50,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 identity5
Key issuances per IP3 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:

json
{
  "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: +)StatusMeaningFix
unauthorized401Missing, malformed, unknown, revoked or expired keySend Bearer snk_...; reissue at POST /v1/keys
admin-unauthorized401Wrong admin token (self-host)Check the operator token
forbidden403Key valid but not permitted, e.g. a provisional key attempting a bulk territory-layer downloadVerify your email
bad-request400Invalid body or parametersdetail says what
costing-conflict400Your own costing_options contradict the adr profile; conflicting_fields lists themRemove the conflicting fields or make them agree
tos-not-accepted400Signup without "accept_tos": true; tos echoes the terms URLFetch the terms, re-submit with accept_tos: true
invalid-email400Rejected email (syntax or disposable domain)Use a real address
not-found404Unknown resourceCheck the id/path
key-cap409Identity already has the maximum active keys (max_active_keys)Revoke one or reuse an existing key
no-identity-ledger409Admin credit aimed at an operator-issued keyCredit the identity instead
invalid-theme422Style theme failed validation; problems lists each offending keyCorrect the named slot/layer and resubmit
optimisation-too-large422Problem exceeds the fair-use location cap of 200 unique locations (max_locations, locations)Split the problem
matrix-too-large422Matrix over the 10,000-element cap (max_pairs, max_square_locations, sources, targets, pairs)Split the request
matrix-span-too-large422The 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-splittable422More 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 alternatesdetail says which; make a boundary waypoint a break, drop date_time/alternates, or shorten the route
quota-exceeded429Monthly quota exhausted with no payment path (quota, used)Verify email, top up credit, or wait for the month
rate-limited429Per-minute limit fired; retry_after seconds, also sent as a Retry-After headerBack off and retry
issuance-velocity429Too many key issuances from your IP today (limit_per_day)Come back tomorrow
internal500Unexpected failure; detail is logged server-side, never leakedRetry; report if persistent
upstream-error400/502Routing backend (or Photon geocoder) rejected the request (400 for an upstream 4xx, 502 otherwise; upstream_status)Check the request; else retry
geocoding-not-enabled501/geocode on a deployment with neither SN_GEOCODE_DIR nor SN_PHOTON_URL (self-host)Operator sets one of them
optimisation-not-enabled503Deployment has no VROOM sidecar configured (self-host)Operator sets SN_VROOM_URL
upstream-unavailable503Routing backend unreachableRetry
upstream-timeout504Routing backend timed outRetry

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:

  1. 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 failed X-PAYMENT verification. Full shape and payment flows: machine payments.

  2. 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 getWhen
429 quota-exceededA free-tier identity that has never bought credit exceeds its monthly quota; or an operator-issued key exceeds its quota
429 rate-limitedAny 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):

json
{
  "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": true with no tunnel_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):

CategoryRestricts
ANo restrictions for dangerous goods
BGoods which may lead to a very large explosion
C... or a large explosion, or a large toxic release
D... or a large fire
EAll dangerous goods except UN 2919, 3291, 3331, 3359 and 3373

Tunnel restriction codes (assigned to the load, ADR 8.6.4):

CodePassage forbidden through
BCategories B, C, D and E
B1000CCategory B above 1,000 kg total net explosive mass per transport unit; always C, D and E
B/DCategories B and C when carried in tanks; always D and E
B/ECategories B, C and D when carried in tanks; always E
CCategories C, D and E
C5000DCategory C above 5,000 kg total net explosive mass per transport unit; always D and E
C/DCategory C when carried in tanks; always D and E
C/ECategories C and D when carried in tanks; always E
DCategories D and E
D/ECategory D when carried in bulk or in tanks; always E
ECategory 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