Skip to content
Documentation menu
docs / api-reference · raw .md
Your first route with curl · 2:01 · all videos

API reference

Summary of the MapMap gateway API. The machine-readable contract is always GET /openapi.json on your deployment. Where this page and that document disagree, OpenAPI wins. A plain-text orientation for LLM agents is at GET /llms.txt on every deployment.

What "compatible" means here. GET /route/v1/{profile}/{coordinates} is URL- and response-compatible with OSRM clients, down to OSRM's own error envelope; POST /route, POST /matrix and POST /isochrone take Valhalla-shaped request bodies and return Valhalla-shaped responses, plus a top-level adr extension of ours. That is the compatible subset. It is not every endpoint: the store-finder, point-cloud, territory, key and usage surfaces are MapMap's own, and response formats differ by endpoint (route geometries and isochrones are GeoJSON or encoded polylines, but POST /matrix answers numeric {durations, distances} arrays, not GeoJSON). Geometry precision differs between the two route surfaces; see the note under Static map images.

Base URL

The base URL is https://api.mapmap.ai.

Status, honestly: the hosted gateway is live: sign up for a key, or run the self-host distro against your own host. Every example below uses $BASE so it works unchanged against either.

Conventions in brief: coordinates are lon,lat order, distances are metres, durations are seconds. The full conventions (auth, units, quotas, the error envelope, 402-vs-429, ADR tunnel codes) live on the conventions page.

Authentication

Metered endpoints require an API key, sent as a bearer token or ?api_key=:

makefile
Authorization: Bearer snk_…

The unauthenticated surface is /, /health, /openapi.json, /llms.txt, /terms, self-serve signup (POST /v1/keys) with its magic-link verification (GET /v1/keys/verify), and the read-only map assets that browser map clients fetch by bare URL: GET /styles/{spec}, GET /styles/{spec}/theme, /fonts/* and /sprite/*. Do not send API keys with those asset requests. GET /styles (the listing) is authenticated and returns only your own styles.

Browser clients can call every endpoint directly: the gateway answers with Access-Control-Allow-Origin: *, including for null-origin contexts (Figma plugins, Electron apps, file:// pages). Keys travel as bearer headers, never cookies, which is what makes the open policy safe.

Self-serve keys come from POST /v1/keys (see the quickstart); self-hosted deployments can also issue keys via the admin API (POST /admin/keys). Keys are shown once at creation, one key per integration.

Rotating a key

Rotation is issue-then-revoke, and it is the control that limits the damage of a key that has leaked. There is no self-serve revoke endpoint yet, so the route is:

  1. Mint the replacement with POST /v1/keys (or POST /admin/keys on a self-hosted deployment). The two keys are independent, so both work while you migrate.
  2. Move your integration onto the new key and confirm the old one has gone quiet in GET /v1/usage.
  3. Revoke the old key. Self-hosted: DELETE /admin/keys/{id} with the admin token. Hosted: email support@mapmap.ai from the address the key was issued to, quoting the key's label and key_id (never the key itself).

Until the old key is revoked it keeps working, so treat the prepaid balance and the monthly quota on a browser key as the real containment: both are set per key, and a key you can afford to lose is one you can rotate calmly.

Browser keys

A key in client-side JavaScript is readable by anyone who views the page. Two optional properties, set when the key is minted, make that safe:

  • allowed_origins: up to 20 websites the key may be called from. Each entry is scheme://host with an optional port and no path (https://app.example.com, http://localhost:5173), or a single-level wildcard subdomain (https://*.example.com, which matches https://staging.example.com but never the bare https://example.com and never https://a.b.example.com).
  • scope: "maps" restricts the key to tiles, style/font/sprite reads, geocoding (including /geocode/suggest and /geocode/retrieve, the /geocode/selection relevance ping and the /places/search and /places/nearest store-finder reads), live bus positions for a map layer (GET /v1/transit/vehicles) and its own status endpoints (/v1/keys/self, /v1/usage). Everything else answers 403, including every write: publishing a style and replacing a places dataset are account operations, not a browser's.

Mint one in a single call:

bash
curl -X POST "$BASE/v1/keys" \
  -H "Content-Type: application/json" \
  -d '{
    "email": "you@example.com",
    "accept_tos": true,
    "label": "website-map",
    "allowed_origins": ["https://app.example.com", "https://*.staging.example.com"],
    "scope": "maps"
  }'

The response echoes back the canonical allowed_origins (case and default ports normalised, duplicates dropped) and the scope, and GET /v1/keys/self reports both for the life of the key. Admin deployments can set the same two fields on POST /admin/keys.

How the check runs: an origin-restricted key requires the request to carry an Origin header, or failing that a Referer whose origin can be derived. Those are the two things a browser sets itself and the page cannot override.

What that does and does not buy you. An allow-list stops your key working from someone else's website in someone else's browser, which is the way a key lifted from your page is usually spent. It does not stop a scripted caller: curl, a server or a script can set any header it likes, including an Origin you have allowed. Treat a browser key as public. The controls that do bind a scripted caller are the maps scope, the key's monthly quota and prepaid balance, and rotation.

A request presenting neither header is refused, so a key replayed by a tool that sends no origin at all gets nothing, and a key with no allowed_origins is the right one for server-to-server work. Ports and schemes are part of the origin: https://app.example.com does not admit http://app.example.com or https://app.example.com:8443.

Both refusals are 403 problem+json, and both are decided before any metering: a refused request costs the key holder no quota, no credit and no units.

json
{
  "type": "urn:sn-gateway:problem:origin-not-allowed",
  "title": "Origin not allowed",
  "status": 403,
  "detail": "origin \"https://copycat.example\" is not allowed for this API key; add it to the key's allowed_origins, or use a key without one",
  "origin": "https://copycat.example"
}

A maps key straying off its surface gets scope-insufficient instead, naming the scope the endpoint needs:

json
{
  "type": "urn:sn-gateway:problem:scope-insufficient",
  "title": "Key scope insufficient",
  "status": 403,
  "detail": "this API key has scope \"maps\", which does not cover /route; that endpoint needs scope \"full\"",
  "scope": "maps",
  "required_scope": "full",
  "endpoint": "/route"
}

Both properties are optional and both default off: a key minted without them behaves exactly as every key did before, unrestricted and full-scope.

Your first request

bash
export BASE=https://api.mapmap.ai
export API_KEY=snk_...

curl "$BASE/route/v1/truck/-0.1276,51.5072;-1.8904,52.4862?height=4.0&weight=44.0&hazmat=true&tunnel_code=D" \
  -H "Authorization: Bearer $API_KEY"

Truncated response (OSRM shape: distance in metres, duration in seconds, location arrays are [lon, lat], geometry is an encoded polyline at precision 5 by default; add ?geometries=polyline6 for six-digit precision, or ?geometries=geojson to skip decoding altogether):

json
{
  "code": "Ok",
  "routes": [
    {
      "distance": 189199.0,
      "duration": 8538.326,
      "weight": 8538.326,
      "weight_name": "duration",
      "geometry": "__kyHh|WDR@L?BAHCNEH...",
      "legs": ["..."]
    }
  ],
  "waypoints": [
    { "name": "", "location": [-0.1276, 51.5072] },
    { "name": "", "location": [-1.8904, 52.4862] }
  ]
}

Waypoint name is present for OSRM compatibility; the hosted engine returns it as an empty string, as here, so use location, and reverse-geocode it if you need a street name.

Zero-install alternative: the playground runs the same requests from your browser.

Endpoints

MethodPathPurpose
POST/routeNative routing request (rich JSON body) with the ADR extension
POST/route/alongAlong-route search over your own places, ranked by honest matrix-measured detour cost (see the analysis guide)
GET/route/v1/{profile}/{coordinates}Compatible URL routing with vendor truck/ADR query params
POST/adr/checkADR 8.6.4 tunnel-category compliance check for a load, no routing
POST/geodata/validateDoes a dataset's declared CRS actually describe its own coordinates? Catches swapped lat/lon axes, degrees labelled as metres and a projection mislabelled with the wrong EPSG code, before the data is drawn on a map; see Validating a dataset's CRS
GET/coordinates/transform · /coordinates/crsReproject up to 50 coordinates between reference systems, and the published list of the ones it will do it for. Every supported CRS states its own datum-shift error, and a code outside the table is refused by name rather than approximated; see Transforming coordinates
GET/geocode · /geocode/reverseForward and reverse geocoding, when the self-hoster enables it (first-party SN_GEOCODE_DIR, or a SN_PHOTON_URL proxy); see Geocoding
GET/geocode/suggestSearch-as-you-type suggestions, with coordinates. Free up to 5,000 requests a day across every key on your account, then one Standard call each; first-party index only, see Search as you type
GET/geocode/retrieveThe full feature for the row the user picked. One Standard call, never discounted; first-party index only, see Search as you type
POST/geocode/batchUp to 1,000 forward and/or reverse geocodes in one call, order preserved, per-query failures inline; see Batch geocoding
POST/isochroneReachability polygons (GeoJSON) for a time or distance budget (see the analysis guide)
POST/matrixMany-to-many duration and distance matrix (see the analysis guide)
POST/elevationPoint elevation for up to 500 coordinates, honest per-sample source/resolution metadata, null (never a guess) wherever the engine has no terrain-tile coverage. Live on the hosted gateway over worldwide-land DEM tiles, 71°N to 56°S at ~30 m; self-hosters stage tiles and set SN_ELEVATION_ENABLED=true, else 501 (see the analysis guide)
POST/elevation/alongElevation profile along a route: distance, elevation and percent grade per sample, plus whole-route ascent/descent. Live on the hosted gateway; self-hosters stage tiles and set SN_ELEVATION_ENABLED=true, else 501 (see the analysis guide)
GET/timezoneIANA timezone id, UTC offset, DST offset and abbreviation at a coordinate and timestamp; 501 unless the deployment has a timezone boundary database
POST/trace_route · /trace_attributesMap matching: snap a GPS trace to the road network, with per-edge attributes (see the analysis guide)
POST/route/reportDistance/duration breakdown of an existing route or trace by road class, admin area, toll/bridge/tunnel and surface; pure aggregation over /trace_attributes, no extra engine call (see Boundaries and route primitives, and the trip-replay and mileage workflow)
POST/route/progressWhere a vehicle is against its plan: snapped position, progress fraction, distance and duration remaining recomputed through the engine, an off-route flag with its measured distance, and an ETA carrying the routing surface's own traffic provenance. Stateless: no positions are stored: the plan and the position arrive on each request and are discarded with it (see the analysis guide)
POST/v1/route/observationsWhat a journey passes, in speakable sentences with the point along the route where each belongs: the named rivers and canals it crosses, the named or numbered road it runs on and for how far, the settlements it passes, the protected landscapes it enters and how high the road climbs. None of this is in a route object, so it is computed by intersecting the route geometry with the basemap archive and the elevation model. Flat 10 Standard calls, full-key only; 501 when the deployment has no basemap archive; see What the journey passes
POST/v1/route/heritageShort spoken lines about the heritage a route passes, each positioned where it belongs on the route. Written and reviewed by hand from public-domain plaque inscriptions and compiled into the binary, so no model runs during a drive. Every line publishes the measured distance from the plaque to the route, and none claims anything is visible or names a side of the road. Seeded on one corridor, the Chelsea riverside; every other route answers with no lines and a census saying so. One Standard call, full-key only; see Heritage lines
GET/boundaryAdministrative boundary lookup: point-in-polygon against the Valhalla admin database → country and sub-divisions with admin_level, iso_code, name, drive_on_right; 501 when the deployment has no admin-boundaries database (see Boundaries and route primitives)
POST/locate · /centroidSnap-to-graph and "meet in the middle" convergence-point search; Valhalla-only, 501 on a GraphHopper-only deployment (see Boundaries and route primitives)
POST/v1/cameras/alongSafety cameras along a route shape, jurisdiction-gated server-side with a conservative off-by-default policy; 501 when the deployment has no camera data (see the analysis guide)
POST/v1/incidents/alongTraffic incidents and closures along a route shape (National Highways closures feed plus its live events feed, merged), with honest per-source coverage metadata; 501 when the deployment has no incident data (see the analysis guide)
GET/v1/transit/vehiclesLive bus positions for England's local buses (DfT Bus Open Data Service) inside a box of at most 4 square degrees, as GeoJSON, with the feed's freshness on every answer: a stale feed is a labelled field, never an empty map. Display only, never an arrival prediction. A browser (maps) key may call it; 501 when the deployment has no live-bus snapshot; see Transit
GET/v1/transit/stopsEngland bus and coach stops (NaPTAN) near a point, nearest first, with ATCO codes, distance, bearing and the lines calling there. A browser (maps) key may call it; 501 when the deployment has no timetable index; see Transit
GET/v1/transit/departuresTimetabled departures at a stop from BODS timetables, basis: "timetable" and the honesty label on every answer, each with any bus seen on its line heading towards the stop. Never an arrival prediction. A browser (maps) key may call it; see Transit
POST/v1/fuel/alongFuel stations along a route ranked cheapest first, each priced with its real engine-computed detour and flagged when the price is over 24 hours old. Every response carries say, the whole answer as one short spoken sentence, and a stale price's sentence claims no saving; 501 when the deployment has no fuel-price file configured (see the analysis guide)
POST/v1/charging/alongEV charge points along a route ranked most powerful first, each costed with its real engine-computed detour, with connector and minimum-kW filters. Every response states which operators the dataset covers, so an empty result is never "no chargers here", and a say sentence carries that scope inside the claim rather than appended to it; 501 when the deployment has no charge-point directory configured (see the analysis guide)
POST/v1/ev/planAn EV journey with its charge stops worked out: physics consumption over the route's legs, charge times integrated over the vehicle's own charging curve rather than energy divided by peak power, and stops chosen so the state of charge never breaches the reserve floor. When no plan exists the answer is an honest feasible: false with the cause and the furthest reachable point, never a fabricated plan; 501 when the deployment has no charge-point directory configured (see the analysis guide)
POST/v1/errands/planA chain of errands ordered against a hard arrival time: 1 to 5 errands, each a place category to find near the route or a place you already know, plus a destination and an arrive_by. The order and the shops come from an exhaustive search over one engine matrix, so the times are the engine's rather than an estimate. When it does not fit the answer is an honest 200 with feasible: false, the errand that costs the most, how late the chain would be, and the largest set that does fit planned in full. Flat 10 Standard calls, full-key only; 501 for a category errand when the deployment has no first-party place index; see Errands against a hard arrival time
POST/v1/voice/clipsGuidance voice clips for the utterances a route will speak: synthesise once, cache forever, in opus or m4a. Answers a url, duration and cache flag per utterance, or "pending": true for clips still being made while synthesis continues in the background. One unit per newly synthesised clip, and cache hits are refunded, so the bill tracks synthesis rather than request size; 501 when the deployment has no voice directory and provider key
GET/v1/voice/clips/{hash}.{ext}One cached clip, Cache-Control: private, max-age=31536000, immutable. Quota-free: fetching a clip that already exists buys no synthesis. 404 when unknown, 501 when the feature is unset
GET/v1/voice/bankThe clips this deployment already holds for one voice and format (hash, text, rate, duration, bytes, url), so an app can bundle the closed-grammar bank at build time. Paged by hash, 1,000 per page. Quota-free; 501 when unset
GET/v1/zonesThe curated UK clean-air / low-emission zone dataset with its provenance: every zone's scheme, operating hours, the vehicle categories it charges or bans with the minimum Euro standard and the daily amount, and separate publisher/URL/licence/checked-date for the charge schedule and for the boundary geometry. Add ?geometry=true for the simplified boundary rings. The audit surface for the figures POST /route annotates with; see Clean-air zones
POST/v1/weather/alongForecast weather per route sample, aligned to each sample's estimated time of arrival rather than the current time; live on the hosted gateway, 501 on a self-hosted deployment with no weather source configured (see the analysis guide)
POST/v1/clearance/alongMeasured overhead clearance along a route shape, judged against surveyed point cloud geometry rather than map tags: a verdict per axis, the limiting point with its measured headroom and uncertainty bound, coverage gaps named apart from measured open sky, and a deep link into the survey viewer. Not a signed or legal height and never a certificate for the route; 501 when the deployment has no clearance artefacts (see Measured clearance along a route and the clearance guide)
GET/pointclouds/{dataset}/{file}Hosted point-cloud payload serving: a baked @mapmap/points payload (<cloud>.bin) or its sidecar (<cloud>.json) for one dataset. Range requests are honoured (206 with Content-Range); ETag/If-None-Match gives 304. A dataset is served only to the keys its manifest names, and everything else, including an unknown dataset and a dataset this key may not see, is an identical 404, so the surface is not an existence oracle. Metered in chunk units at the point-cloud discount rather than per request, so a whole-file download costs the same as fetching it in ranged slices; 404 when the deployment hosts no payloads (see the SDK reference)
POST/optimise (/optimize)Multi-vehicle route optimisation with truck and ADR constraints, multi-trip reload runs and one-shot auto-relaxation (see the optimisation guide)
POST/optimise/replan (/optimize/replan)Re-plan a fleet part-way through its shift: the original problem plus what has been done and what has changed. Completed stops are locked and never re-solved; we store no dispatch state, so you send the day back to us. Billed on the remaining problem; a multi-trip (reloads) problem is refused rather than half-applied; see Re-planning mid-shift
POST/v1/jobs/optimise · /v1/jobs/replan · /v1/jobs/matrixThe same three problems on the asynchronous lane: submit, get a job id back immediately, poll it or take a signed webhook. Ten times the locations and four times the matrix elements, because the synchronous caps are bound by the request deadline rather than by the engine; see Asynchronous jobs
POST/v1/clusterGroup stops into balanced clusters for feeding per-cluster optimisations, optionally within territories. Straight-line, no road network; see the optimisation guide
GET/v1/jobs · /v1/jobs/{id}Your recent jobs, and one job with its result inline once it exists. Quota-free: polling is how the lane is meant to be used; see Asynchronous jobs
POST/v1/keys/self/webhook-secretMint or rotate the key's webhook signing secret, returned exactly once (quota-free); see Verifying a webhook
GET/tiles/{territory}/{z}/{x}/{y}.mvt (also .pbf) · /tiles/{territory}/style.json · .../tiles.jsonHosted vector tiles, MapLibre style and TileJSON; the TileJSON carries data_updated, the ISO-8601 date of the OSM extract behind the territory's latest published package (null if unknown) (see the maps guide)
GET · POST/styles · /styles/{spec} · /styles/{spec}/themeHosted and custom map styles. GET /styles/{spec} and .../theme are public (browser map clients fetch them by bare URL); GET /styles lists your own styles and needs a key; POST is metered and only a style's owner may publish over it (see the maps guide)
PUT · GET · DELETE/placesYour own places dataset (store finder), scoped to your key, up to 10,000 places; see Places
GET/places/search · /places/nearestRanked search and straight-line nearest-N over your own places; see Places
GET/v1/places/in-viewDirectional place lookup: where you are, which way you face, and a category or a name, answered with the places inside that cone. Each carries distance_m, its own bearing_deg, a signed angular_offset_deg and a visibility verdict computed against the bare-earth elevation model and OpenStreetMap building footprints. A place that could not be checked comes back unknown, never silently kept or dropped. Flat 5 Standard calls, full-key only; 501 when the deployment has no first-party geocode index; see What is over there
POST/verify (intended path, not yet implemented on the gateway; today served at POST /api/verify on the website, see below)Geo-hallucination firewall: checks whether AI-mentioned places and itineraries are real, findable and physically possible; see Verify places
GET/fonts/{fontstack}/{range} · /sprite/{file}Glyph PBFs and sprite sheets for rendering, public
GET · POSTmapmap.ai/api/static-mapStatic Map Images: render a PNG/WebP/JPEG of a view, with a route/GeoJSON/marker overlay; see Static map images. Served from the website, not $BASE; no API key
GET · POST/v1/static-mapThe same Static Map Images render, keyed and metered on $BASE at 15 standard units per image. Same parameters, same renderer, no public rate limit; see Keyed and metered renders
GET/territories · /territories/index.sigSigned index of offline territories; each entry carries data_updated, the ISO-8601 date of the OSM extract behind its latest version (null if unknown) (see the territories guide)
GET/territories/{id}/{version}/manifest (also .sig)Signed manifest for one territory version
GET/territories/{id}/{version}/layers/{addr}/{file}Offline layer download, metered against the monthly download allowance; verified accounts only
POST/v1/keysSelf-serve key issuance ({email, accept_tos, label?} returns a provisional snk_ key, plus a zero-authority claim_url + display_to_user for the human when the deployment has a console)
GET/v1/keys/selfKey state: quota, usage, prepaid credit, owning identity email (quota-free)
GET/v1/usage?from=&to=Your own metered units, day by day and endpoint by endpoint (quota-free); see Check your usage
GET/v1/keys/verifyThe page an emailed verification link opens. Shows the request and changes nothing
POST/v1/keys/verify/detailsWhat a verification link's request said about itself (app, time, device). Changes nothing
POST/v1/keys/verifyConfirms the one key a link was sent for, upgrading it to the free tier
POST/v1/keys/verify/deny"This wasn't me": revokes the key a link was sent for
POST/v1/keys/claimMint a fresh claim pointer for the calling key's identity (key-authenticated, quota-free)
POST/v1/console/signinEmail a 15-minute single-use sign-in link (uniform 202 whether or not the address has an account)
GET/v1/console/claims/{token}Resolve a claim pointer to a masked email (read-only, consumes nothing)
POST/v1/console/claims/{token}/signinThe claim page's "email me a sign-in link" action
POST · DELETE/v1/console/sessionExchange an emailed sign-in token for an identity session (POST) · sign out (DELETE)
GET/v1/console/selfIdentity account view for a session: balance, key metadata, usage, credit history; never key material
POST/map-issuesReport a map error: {lat, lon, category, note?, contact?}, where category is one of road-missing, road-wrong, restriction-wrong, name-wrong, place-closed, place-missing, other. One Standard-class call, capped at 50 reports per key per day. This is the only write path onto the public Map Health board, and the same one the report button on the map uses
POST/v1/feedbackSubmit one integration retro (quota-free, never charged); see Integration feedback
GET/healthLiveness/readiness, no auth
GET/v1/platformPublic provenance record: what this deployment actually runs. Gateway build and version, routing engine and version, the graph's OSM snapshot, build and swap dates, the basemap archives, the geocoder and the declared elevation dataset. No auth, no meter, Cache-Control: public, max-age=300. Every field is nullable and null means unrecorded, never "current"; see What this deployment runs
GET/openapi.jsonOpenAPI 3.1 document for this deployment
GET/llms.txtLLM-agent orientation text
POST/admin/keys · /admin/keys/{id}/credit · /admin/identities/creditAdmin (admin token): create keys, credit the prepaid ledger (idempotent by reason)
DELETE/admin/keys/{id}Admin: revoke a key
GET/admin/overview · /admin/keys · /admin/keys/{id}/usage · /admin/identities · /admin/identities/{email}Admin: deployment overview, key listing and usage, identities with balance and credit history
GET/admin/retros · /admin/mcp-map-issuesAdmin: submitted integration retros, and the MCP map-issue NDJSON queue (?limit= 1–500, default 50; ?offset=)
GET/admin/pointclouds · /admin/clearanceAdmin: hosted payload datasets with their declared bytes and each owner's computed monthly hosting fee, and the clearance artefact inventory (what is published, whether it still matches its sources, the bake's own quality statistics, and the reason for any artefact that will not open). Both read-only
POST/admin/identities/{email}/quota · .../download-allowance · .../mau-includedAdmin: set an identity's monthly quota, offline download allowance, or included monthly active users (idempotent by reason)
GET/admin/identities/{email}/quota-outreachAdmin: the quota-threshold notification for an identity exactly as it would go out now, rendered and not sent. Returns the quota.threshold payload, the email subject and body, and the operator message. Writes nothing, sends nothing, and can be run as often as you like

The compatible URL endpoint accepts these {profile} values (with common aliases): driving (car, auto), truck, bus, bicycle (bike, cycling), walking (foot, pedestrian), scooter and motorcycle (motorbike); anything else is rejected with InvalidValue. Each maps to the matching Valhalla costing. On POST /route, costing is one of auto, truck, bus, motor_scooter, motorcycle, bicycle or pedestrian. The truck and ADR parameters below require the truck profile specifically; sending them with any other profile is a 400. Everything except truck and ADR bills at the cheaper Standard rate.

The adr extension is honoured only on POST /route and POST /optimise. The analysis endpoints (/isochrone, /matrix, /trace_route, /trace_attributes) apply no ADR restrictions; an adr object sent there is ignored, so do not rely on them for dangerous-goods compliance.

Truck and ADR parameters

ADR is the European agreement on carriage of dangerous goods by road; tunnel codes B–E restrict which tunnels a hazmat load may use. Slashed codes like C/E mean the restriction differs by carriage mode (tank versus bulk). The full tunnel-code table is on the conventions page; ADR 8.6.4 is the section of the agreement that defines the tunnel restrictions.

On the compatible URL endpoint, standard params (overview, steps, geometries, alternatives: true/false or a number, max 3) are honoured plus vendor extensions:

ParamUnitExample
heightmetresheight=4.0
widthmetreswidth=2.55
lengthmetreslength=16.5
weighttonnesweight=44.0
hazmatbooleanhazmat=true
tunnel_codeADR codetunnel_code=D

With steps=true, the OSRM endpoint also accepts voice_instructions=true (Mapbox-shaped spoken prompts with SSML and distance triggers), banner_instructions=true (visual banners with maneuver glyphs, plus lane diagrams where OSM carries turn:lanes) and language (BCP 47 narration, e.g. en-GB). Navigation UIs built for the Mapbox shapes parse our responses unchanged, and the SDK guidance session surfaces the same instructions on device.

Lane guidance

Where the road approaching a manoeuvre carries OSM turn:lanes, steps=true responses include lane guidance on the step before the manoeuvre (the approach), as steps[].intersections[0].lanes: one object per physical lane, left to right:

FieldTypeMeaning
indicationsstring[]Turn directions the lane allows, as OSRM indication strings (straight, left, slight right, uturn, …). A combined turn:lanes value such as left;through yields multiple entries.
validbooleanThe lane can legally be used for the upcoming manoeuvre.
activebooleanGuidance recommends this lane (implies valid).

With banner_instructions=true the same lanes also appear as Mapbox lane components on the banner's sub line (directions, active, active_direction), so Mapbox-built lane bars render unchanged. Coverage follows OSM tagging: rich on major junctions in well-mapped regions, absent elsewhere; treat a missing lanes array as "no lane guidance", never "one lane". Roundabout manoeuvres carry no lane data.

Restriction coverage on a truck route

A truck route is searched against restrictions as recorded in the routing graph, which is derived from OpenStreetMap tagging. Where a road carries no recorded height or weight limit, nothing is excluded, and the route comes back looking exactly like one through a corridor where every structure is recorded. So a deployment that has baked a coverage grid attaches the measurement to the route rather than leaving the caller to assume completeness. Every truck-costing POST /route response then carries a top-level restriction_coverage object:

json
"restriction_coverage": {
  "grid_version": 1,
  "cells_traversed": 37,
  "by_grade": { "well_tagged": 4, "partial": 9, "sparse": 18, "none": 6 },
  "caveat": "Restriction tagging along this route is uneven. Cells graded sparse or none carry few or no recorded height or weight limits, and absence of a recorded restriction is not evidence a road was checked. Verify structures independently before dispatching."
}
FieldMeaning
grid_versionFormat version of the baked grid, so a caller knows which grading rules produced the counts
cells_traversedUnique 0.1 degree grid cells the returned route geometry passes through
by_gradeHow those cells are graded for restriction-tagging density. Always sums to cells_traversed

The four grades are well_tagged, partial, sparse and none, thresholded on recorded restriction-relevant ways per cell and on how many bridges over roads in the cell carry a height on the road beneath.

It is a receipt about the data, not a claim about the road. A well_tagged cell is one where restrictions are recorded densely; it is not a cell that has been surveyed, and a route across it is not a cleared route. A none cell is not a cell known to be free of restrictions, it is a cell where nothing was recorded either way. That is why the caveat ships inside the object rather than in documentation alone: absence of a recorded restriction is not evidence a road was checked.

The block is present only where the deployment holds a coverage grid; on the hosted gateway that is the United Kingdom, and a truck route elsewhere carries no restriction_coverage rather than an empty one. The same measurement, counted nationally and drawn as a map, is published at what we know and in the truck restriction data audit.

Route options

Boolean route preferences shape the cost model. All default to off; pass true to enable. avoid_tolls and avoid_motorways apply only to motorised profiles (driving, truck, bus, scooter, motorcycle); requesting them on bicycle or walking returns InvalidValue. avoid_ferries and shortest apply to every profile.

ParamEffectProfiles
avoid_tollsPenalise toll roads (use_tolls=0)motorised
avoid_motorwaysPenalise motorways/highways (use_highways=0)motorised
avoid_ferriesPenalise ferries (use_ferry=0)all
shortestOptimise for distance, not time (shortest=true)all
bash
curl "$BASE/route/v1/driving/-0.1276,51.5072;-1.8904,52.4862?avoid_tolls=true&avoid_motorways=true" \
  -H "Authorization: Bearer $API_KEY"

These options are honoured by the hosted gateway's router. The public keyless demo router ignores costing options, so use an API key to see them take effect.

Self-hosters choose the routing engine with SN_ROUTING_ENGINE (valhalla, graphhopper or auto). Most endpoints behave identically whichever is selected, but two differences matter: /locate and /centroid are Valhalla-only and answer 501 on a GraphHopper-only deployment (as are POST /elevation and POST /elevation/along), and a GraphHopper backend ignores exclude_polygons and exclude_locations on POST /matrix and POST /isochrone rather than refusing them. The full table is under Backend support. The hosted gateway runs Valhalla.

On POST /route, the body is a Valhalla-style request, locations (at least two {lat, lon} objects) and a costing, plus the ADR extension: a full vehicle profile in a top-level adr object. dimensions, tunnel_code (nullable) and hazmat are all required keys, which the gateway merges into costing_options.truck (a conflicting value the caller already set is a 400):

json
{
  "locations": [
    { "lat": 51.5072, "lon": -0.1276 },
    { "lat": 52.4862, "lon": -1.8904 }
  ],
  "costing": "truck",
  "adr": {
    "dimensions": {
      "height_m": 4.0, "width_m": 2.55, "length_m": 16.5,
      "gross_weight_t": 44.0, "axle_load_t": null, "axle_count": null
    },
    "tunnel_code": "C/E",
    "hazmat": true
  }
}

ADR tunnel-category enforcement happens in costing and needs an ADR-capable routing backend. Against a backend without ADR costing the dimensions still apply but the tunnel restriction code is not enforced, and there is no per-response warning field, so choose an ADR-capable backend when compliance matters (POST /adr/check is always authoritative). /adr/check takes {"adr": <profile>, "tunnel_category": "A".."E"}.

Avoiding roads: avoid and exclude

Name the road features a route should keep off. POST /route takes two optional top-level string arrays; GET /route/v1 takes the same words as comma-separated avoid= and exclude= query parameters.

json
{
  "locations": [
    { "lat": 51.5072, "lon": -0.1276 },
    { "lat": 52.4862, "lon": -1.8904 }
  ],
  "costing": "auto",
  "avoid": ["tolls", "highways"],
  "exclude": ["ferries"]
}

The two lists are not two ways of saying the same thing, and the difference is the only thing on this page worth memorising.

ListWhat it setsWhat it meansWhat it can do to you
avoidthe profile's use_* willingness factor, set to 0a strong preferencenothing worse than a longer route, and sometimes nothing at all
excludethe profile's exclude_* flag, set to truea hard exclusion: allowed only at the very start or end of the pathNoRoute / 442 instead of a detour

avoid is a preference, and we will not pretend otherwise

The routing engine's own reference says a willingness factor of zero "is not guaranteed to avoid them entirely", and that is not boilerplate. Measured on our own router: avoid: ["tolls"] across the Dartford Crossing returns the same 7.5 km tolled route as no options at all, because the untolled alternative is 59 km and the search would rather pay the toll. avoid: ["highways"] on London → Birmingham does bite: 189 km and 2 h 14 becomes 219 km and 4 h 04, with summary.has_highway flipping to false.

So: read the route's own summary.has_toll, has_highway and has_ferry rather than assuming. A 200 with an avoid block is a receipt for what was asked, never a certificate for what the road actually is.

exclude is hard, and can leave you with no route

Measured: exclude: ["tolls"] across the Dartford Crossing returns a 59.1 km untolled route with has_toll: false. exclude: ["tunnels"] through the Rotherhithe Tunnel returns 442 No path could be found, correctly, because there is no way across without a tunnel or a bridge. Prefer avoid unless "no route" is genuinely a better answer than "a route with a toll on it".

Hard exclusions also depend on the routing engine's own service_limits.allow_hard_exclusions setting. We cannot read that setting through the engine's API, so we never assert it: self-hosters who want exclude guaranteed should set it to true in their valhalla.json.

Supported values, per profile

Valueavoid setsexclude setsProfiles
tollsuse_tolls: 0exclude_tolls: trueavoid: motorised only. exclude: all
highwaysuse_highways: 0exclude_highways: trueavoid: motorised only. exclude: all
ferriesuse_ferry: 0exclude_ferries: trueall
bridgesn/aexclude_bridges: trueall
tunnelsn/aexclude_tunnels: trueall

Motorised means driving/auto, truck, bus, scooter and motorcycle. A value the engine has no field for on the profile you asked for is a 400 naming the supported set, never a quietly ignored option: avoid: ["tolls"] on bicycle would otherwise return a perfectly ordinary route while you believed tolls had been avoided.

Values other engines offer (uturn, sharp_turn, service_road and the rest) are rejected. There is no field behind them here, and accepting a word we cannot act on is worse than refusing it.

json
{
  "type": "urn:sn-gateway:problem:invalid-avoid",
  "title": "Avoid/exclude values rejected",
  "status": 400,
  "problems": [
    "`avoid` value \"uturn\" is not supported; supported values are tolls, highways, ferries"
  ],
  "supported": {
    "avoid": ["tolls", "highways", "ferries"],
    "exclude": ["tolls", "highways", "ferries", "bridges", "tunnels"]
  }
}

Every POST /route response that used either list carries a top-level avoid block echoing what you asked, the exact costing options it became, and the caveats above. The OSRM surface adds no such block: an OSRM response stays an OSRM response.

Setting the same option twice (say avoid=tolls alongside the older avoid_tolls=true) is agreement, not a conflict. Setting it to a different value by hand under costing_options is a 400, so your own number is never silently overwritten.

On POST /optimise there is no avoid field, and none is needed: that endpoint already passes costing_options straight through to the matrix it computes, so "costing_options": {"auto": {"use_tolls": 0}} reaches the solver's cost grid exactly as it reaches a route.

Kerbside arrival: preferred_side

Arrive on the side of the road the door is on: the difference between a van stopping outside the shop and one stopping across a dual carriageway from it. Set it per location on POST /route:

json
{
  "locations": [
    { "lat": 51.5072, "lon": -0.1276 },
    {
      "lat": 51.5215, "lon": -0.1355,
      "preferred_side": "curb",
      "street_side_tolerance": 2,
      "street_side_max_distance": 60
    }
  ],
  "costing": "truck"
}

That says: deliver to the kerbside door. The driver arrives with the delivery point on their own side of the road, so nobody crosses live traffic with a cage.

same is resolved against the country's driving side, not a fixed hand. In the UK, where we drive on the left, same puts the location on your left. In Germany it puts it on your right. There is deliberately no left/right input, because a route that crosses a border would need it to mean two different things.

ValueMeaning
same (alias curb)arrive with the location on your side of the road: kerbside
oppositearrive with the location on the far side
either (alias unrestricted)no preference (the default)

curb and unrestricted are accepted so the OSRM/Mapbox approaches vocabulary spells the same thing; they are normalised to same and either on the way to the engine. On GET /route/v1 the OSRM spelling is what you use: approaches=curb;curb, one entry per coordinate.

The window the preference lives in, and how to close it by accident

Three optional per-location numbers tune it:

FieldDefaultEffect
street_side_tolerance5 mcloser than this to the road centreline, the side is none and the preference is off
street_side_max_distance1000 mfurther than this from the centreline, the side is none and the preference is off
street_side_cutoffservice_otherdisables the preference on roads below this class (motorway…service_other)

The first two bound a window: too close counts as "on the road itself", too far counts as "not on this road at all". A pair with no gap between them (a 50 m tolerance against a 20 m max distance, say) leaves no window at all, and the kerbside preference silently does nothing. We return a 400 naming the pair rather than a 200 with your delivery constraint quietly dropped.

Two honest limits worth knowing before you build a delivery flow on this:

  • It is a snapping preference, not a manoeuvre guarantee. It steers which edge and direction the location correlates to; it does not promise the driver never crosses the road on foot at the end.
  • It needs the point to be genuinely offset from the centreline. A pin dropped on the road, or one at a junction, has no side of street to prefer, and the engine says so by resolving it to none. Geocode to the building, not the street.

Clean-air zones

A POST /route response whose geometry crosses a UK clean-air or low-emission zone carries a top-level zones block by default, with no flag, and nothing new on a route that crosses none. The full treatment, with a worked ULEZ example, is on the truck and ADR page. The contract in brief:

FieldWhereMeaning
emissionsrequest, /route and /optimise{vehicle_category, fuel, euro_standard}. vehicle_category: car, van, minibus, hgv, bus, coach, taxi, phv, motorcycle, motorhome. fuel: petrol, diesel, electric, hydrogen, gas. euro_standard 1–6 (declare heavy-duty Euro VI as 6); required for a combustion fuel, optional for electric/hydrogen
avoid_zonesrequest, /route and /optimiseKeep out of every zone this vehicle is charged or banned in, via the engine's exclude_polygons. Decided from your waypoints before the route runs: no extra engine call, no extra metering
territoriesrequest, /optimise and /v1/cluster[{id, polygon}] with GeoJSON [lon, lat] rings, at most 64 territories of 1,000 positions. On /optimise, paired with per-vehicle territory_ids, it confines a vehicle to named rounds; on /v1/cluster it stops a cluster straddling one. Unrelated to the offline map packages at GET /territories and to the clean-air zones on /v1/zones. No effect on billing
reloadsrequest, per vehicle, /optimise{max_trips (2–5), reload_time_s, depot}: the vehicle may return to its depot, reload and go out again (the waste-collection tipping round). Requires a time_window, which is partitioned into consecutive trip windows separated by reload_time_s; capacity, max_tasks, max_travel_time and costs therefore apply per trip. Routes come back with {vehicle, trip} and a reloads block. Refused on /optimise/replan. No effect on billing: a split adds vehicles, not locations. See reload runs
relax_if_unassignedrequest, /optimise and /optimise/replan{time_windows_by_s, allow_overtime_s}, each at most 604,800 s. If the first solve leaves work unassigned, re-solve once with exactly those relaxations; keep the relaxed plan only if it assigns more work, else return the original untouched. Never a third solve; breaks are never widened. The relaxation block states which plan you got. Bills two solves when the second runs (the matrix is computed once either way). See auto-relaxation
zones.crossed[]responseOne entry per zone the route passes through: assessment (charged, banned, compliant, not_covered, unknown), entry_arc_m/exit_arc_m/distance_in_zone_m, the applicable_rule with its minimum Euro standard and daily charge, and the source (publisher, URL, checked date) for both the schedule and the boundary
zones.estimated_chargesresponsecurrency, daily_total, unresolved_zones, banned_zones. Its own line, never added to trip.summary or any routing cost
zones.avoidanceresponseWhat avoid_zones excluded, and every zone it could not, with the reason

Four rules the surface will not break:

  • No emissions object, no figure. Every crossed zone reports "unknown" and says what would resolve it, rather than guessing a category and quoting a charge.
  • A ban is never a charge. Scotland's four LEZs prohibit non-compliant vehicles; there is no daily fee that buys entry. They report banned with the penalty ladder, and add nothing to the total.
  • Every figure is traceable. The publisher, page and check date travel with each entry, and GET /v1/zones serves the whole dataset for audit.
  • The engine's exclusion budget is stated, not hidden. exclude_polygons is capped at 10 km of total perimeter, and most zone boundaries exceed that on their own: the London ULEZ is 236 km around. A zone that cannot be excluded is reported in avoidance.not_avoidable with the real reason; it is never silently skipped.

Exemptions, discounts and residency schemes are not modelled, and anything turning on a weight the request does not state resolves to unknown with the council's own wording.

Measured clearance along a route

A truck route enforces the restrictions the map records. Where a structure carries no height tag the router has nothing to act on, returns the road as passable, and the response is indistinguishable from one where every structure was checked. An unchanged route is not a clearance.

POST /v1/clearance/along is the separate, measured check. Give it a route shape and a vehicle height and it walks the corridor over a survey's own geometry, station by station, and reports whether the vehicle passes.

bash
curl -X POST "$BASE/v1/clearance/along" \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
        "geometry_polyline6": "u{~vFvyys@fS]...",
        "vehicle_height_m": 4.2,
        "vehicle_width_m": 2.55,
        "margin_m": 0.0
      }'

geometry_polyline6 is typically a shape from a prior route call: routes[0].geometry with ?geometries=polyline6 on the compatible URL endpoint, or geometry_polyline6 from the route MCP tool. It is capped at 10,000 vertices. There is no truck profile and no adr object in the body, and that is deliberate: the two calls are two different claims, and composing them is the caller's job. Agents get the composition as the check_clearance_on_route MCP tool. vehicle_width_m asks the corridor-width axis as well; margin_m is your operating margin, added to the vehicle before the verdict and echoed back, defaulting to 0. datasets narrows the search to named datasets and can never widen it.

The response is a report with a verdict per axis:

verdictMeaning
passEvery assessed station clears the vehicle by more than the stated bound, over coverage with no gaps.
failAt least one station does not clear the vehicle on the measured figure; limiting names the worst and all_failures lists every one.
indeterminateThe vehicle clears the measurement but not the bound on it, or the deciding surface is foliage on a survey too old to trust. reasons is machine-readable; resolution_hint is the sentence for a human.
no_verdictCoverage gaps prevented an answer. Not a fail, and never a pass.

The width axis additionally answers not_assessed, with the reason spelled out, when no vehicle_width_m was supplied and when the height axis could not be assessed either. Supply a width over ground the survey covers too thinly at the corridor edges and the width axis runs the same ladder, returning indeterminate or no_verdict over those stretches while the height axis can still answer for the same ground.

Three things about the shape are worth knowing before you parse it.

Every measured figure carries its bound. headroom_m is what the survey measured; safe_headroom_m is that figure less two sigma (sigma_m) and less a one-sided sampling bias bound (sampling_gap_m). safe_headroom_m is the verdict input and the figure to plan against.

Coverage is two distinct shapes. complete has no field for gaps at all, and only a pass verdict takes it, so a route that passes over ground nobody surveyed cannot be represented. partial carries not_surveyed and insufficient_data as separate lists of located route sections, because "no survey covers this" and "the survey covers this but not well enough" have different remedies. Neither is the same answer as measured open sky, which is a survey looking and finding nothing above the corridor.

It is a measurement, not a certificate. Every response, including the clear one, carries a clearance_enforcement block with route_certified: false and a caveat, and the measurement types have no field for a signed or legal height. Where a posted height and a measured gap disagree, that is a matter for the road authority that posted the sign.

GET /admin/clearance (admin token) is the operator inventory: published artefacts, whether each still matches its sources, the bake's own quality statistics, and the reason for any artefact that will not open. An artefact that has not been checked against its sources is treated exactly as one known to lag, so nothing passes on it.

The endpoint answers 501 on a deployment with no clearance artefacts configured anywhere, computed before the caller's own visibility is considered so it cannot become an oracle for other customers' data. Feature-detect on it, as with elevation. Full contract, including the datum argument and every refusal: measured clearance on a route.

Validating a dataset's CRS

Geospatial files carry a declared coordinate reference system, and that declaration is often wrong: a default left in place by an export tool, an axis order swapped, a unit assumed. The failure is quiet. The numbers look plausible, every tool accepts them, and the mistake surfaces only once the data is drawn on a map and turns out to be in the wrong country.

POST /geodata/validate is a cheap check to run first. Give it the declared CRS and a sample of the raw coordinates (a few dozen is plenty) and it says whether the two agree.

json
{
  "declared_crs": "EPSG:32610",
  "coordinates": [
    { "x": -13638000, "y": 6116000 },
    { "x": -13636000, "y": 6118000 }
  ]
}

The coordinates are x/y, deliberately not lon/lat, because whether they are degrees is exactly the question being asked.

json
{
  "verdict": "impossible",
  "interpreted_as": "UTM zone 10N",
  "problems": [
    "declared as UTM zone 10N, but x -13638000..-13636000 is outside the valid UTM easting range (100000..900000 m)"
  ],
  "suggestions": [
    "the magnitudes look like Web Mercator metres (EPSG:3857). Read directly that is 48.0603, -122.5030; read with the axes SWAPPED it is …"
  ],
  "extent": { "min_x": -13638000, "max_x": -13636000, "min_y": 6116000, "max_y": 6118000 }
}

verdict is one of:

VerdictMeaning
consistentThe coordinates are compatible with the declared CRS.
suspectThey fit, but only under an assumption worth stating: degrees declared as a projected CRS in metres, for instance.
impossibleThe declared CRS cannot describe these coordinates at all.

It understands geographic degrees (EPSG:4326, 4258, 4269), Web Mercator (3857, 900913, 3785), the WGS84 and ETRS89 UTM zones, and treats any other EPSG code as a generic projected CRS in metres.

This is a sanity check, not a reprojection. It carries no projection database and never transforms coordinates: it reasons about the ranges, spans and signs a CRS family implies. So it can be silent about an exotic CRS, but it is never wrong about a UTM easting of 6.1 million. The same check is available to agents as the validate_geodata MCP tool.

Transforming coordinates between reference systems

GET /coordinates/transform reprojects up to 50 coordinates in one call.

bash
curl -s -H "Authorization: Bearer $API_KEY" \
  "$BASE/coordinates/transform?coords=651409.903,313177.270;530000,180000&s_srs=EPSG:27700&t_srs=EPSG:4326"
json
{
  "s_srs": "EPSG:27700",
  "t_srs": "EPSG:4326",
  "results": [
    { "x": 1.7160520, "y": 52.6579786 },
    { "x": -0.1283539, "y": 51.5039908 }
  ]
}

Results come back in input order. Both s_srs and t_srs default to EPSG:4326, and both accept either EPSG:27700 or a bare 27700. A third component is allowed (x,y,z) and rides through untouched: no vertical datum is applied, so a height that arrives in one frame leaves in the same frame. Where the projection has no solution for a coordinate, a pole projected into Mercator for instance, that entry is null rather than a fabricated number.

Axis order is always x, y. For a geographic CRS that means longitude then latitude, whatever axis order the EPSG registry defines for the code. Per-code axis order is a well known source of silently swapped coordinates, and an API that switched convention halfway through its own response would be the worst of both worlds.

More than 50 coordinates is a 400. There is nothing to gain from a longer call anyway: the endpoint bills one Standard unit per request whatever it carries, so splitting 100 coordinates into two calls costs two units, which is what two calls of five would cost too.

The supported list, and why it is short

GET /coordinates/crs returns every reference system this endpoint will transform between, with an optional ?q= substring filter over the code and the name:

bash
curl -s -H "Authorization: Bearer $API_KEY" "$BASE/coordinates/crs?q=27700"
json
{
  "count": 1,
  "results": [
    {
      "code": "EPSG:27700",
      "name": "OSGB36 / British National Grid",
      "kind": "projected",
      "unit": "metre",
      "axes": "x = easting, y = northing",
      "accuracy": "Datum shift by the seven-parameter Helmert transformation PROJ ships for OSGB36. Expect errors of a few metres across Great Britain …",
      "proj4": "+proj=tmerc +lat_0=49 +lon_0=-2 …"
    }
  ]
}

The table covers EPSG:4326 (WGS 84), 4258 (ETRS89), 4269 (NAD83), 4277 (OSGB36), 3857 (Web Mercator), 3035 (ETRS89-LAEA Europe), 2154 (Lambert-93), 2263 (NY Long Island, US survey feet), 3812 (Belgian Lambert 2008), 27700 (British National Grid), 28992 (Amersfoort / RD New), 31370 (Belgian Lambert 72), the ETRS89 UTM zones (25828 to 25838) and the WGS84 UTM zones (32601 to 32660 north, 32701 to 32760 south). A code outside it is a 400 naming the code, not a silent approximation.

It is short on purpose. Most projected national grids are defined against a local datum, and the honest transformation between that datum and WGS84 is a published grid file, not a rotation. Accepting every EPSG code would mean being quietly wrong about most of them by metres. So every entry carries an accuracy field stating its own datum-shift error, including the entries that have none, and codes whose error we cannot state are refused.

What the accuracy field actually says

Three cases, in decreasing order of comfort:

EntriesDatum shiftWhat to expect
4326, 3857, the WGS84 UTM zonesNoneProjection arithmetic only. No fitted parameters, nothing to be wrong about beyond floating point.
ETRS89-based: 4258, 3035, 2154, 3812, 25828-25838Zero HelmertETRS89 is realised as coincident with WGS84. The frames have diverged by roughly 0.5 m through plate motion since 1989.
27700, 28992, 31370, and NAD83 (4269, 2263)Seven-parameter HelmertMetre-level. See below.

For EPSG:27700, British National Grid, the transformation is a seven-parameter Helmert and it is accurate to a few metres, not to sub-metre. Ordnance Survey's definitive transformation is OSTN15, a grid shift file, and it is not applied here. Nothing this endpoint returns for EPSG:27700 should be read as sub-metre, and if your work needs OSTN15 accuracy this endpoint is the wrong tool.

What is exact is the projection itself. EPSG:27700 and EPSG:4277 share the OSGB36 datum, so converting between those two applies no datum shift at all and is transverse Mercator arithmetic alone. That pair reproduces Ordnance Survey's own worked example (Annex C of A Guide to Coordinate Systems in Great Britain: OSGB36 52° 39' 27.2531" N, 1° 43' 04.5177" E is easting 651409.903, northing 313177.270) to well under a millimetre in both directions. If you are checking this endpoint against published OS figures, that is the pair to check.

EPSG:28992 (Amersfoort / RD New) is sub-metre over the Netherlands but likewise not exact: the definitive transformation there is RDNAPTRANS, also a grid correction. NAD83 is realised as coincident with WGS84, which is what PROJ's +datum=NAD83 does; the two frames actually differ by one to two metres across North America.

Every entry also returns the proj4 definition used, so any number this endpoint produces can be reproduced in any other PROJ-based tool.

This is the reprojection counterpart to validating a dataset's CRS: that endpoint asks whether a declaration is plausible without transforming anything, this one transforms only where it can state how wrong it might be.

Why this route

Every route can explain itself. Add a top-level "rationale": true to a POST /route request in truck, auto, bicycle, pedestrian or motor_scooter costing and the response gains a rationale block naming the declared constraints or preferences that actually changed the route, with the location where the routes part ways and what each one costs in time and distance. The explanation is derived by the engine, not written by a language model: there is nothing to hallucinate.

json
{
  "locations": [
    { "lat": 51.5072, "lon": -0.1276 },
    { "lat": 52.4862, "lon": -1.8904 }
  ],
  "costing": "bicycle",
  "costing_options": { "bicycle": { "use_hills": 0.1 } },
  "rationale": true
}
json
{
  "trip": { "…": "the normal route response, untouched" },
  "rationale": {
    "method": "route_divergence",
    "avoided": [{
      "kind": "hill_avoided",
      "constraint": "use_hills",
      "value": 0.1,
      "basis": "route_divergence",
      "baseline": "engine_default",
      "location": [-1.2431, 51.9911],
      "divergence": { "start": [-1.2431, 51.9911], "end": [-1.4102, 52.1408], "length_m": 23410 },
      "time_saved_s": 380,
      "distance_saved_m": 2900,
      "reason": "the declared routing preference use_hills 0.1 changed the route here: …"
    }],
    "caveats": "…"
  }
}

How it works, honestly

The routing engine does not expose an exclusion set. There is no field that says "this route avoided the 3.9 m bridge on the A40". So the rationale is computed by route divergence: the gateway re-routes with each declared constraint or preference relaxed and compares the geometry. Where the relaxed route takes a different way, that field provably changed the route, and the diverging span says where. One probe relaxes everything at once (if the route does not change, nothing was binding and the explanation honestly says so); when it does change, one probe per declared field attributes the divergence. Fields that only bind in combination are reported as a single kind: "combined" entry rather than guessed apart.

The baseline differs by field type, because honesty demands it:

Field typeRelaxed tobaseline value
Truck constraints (height, weight, hazmat, adr_tunnel_code, …)explicitly non-binding values (a zero-size vehicle, an unrestricted tunnel code); never removed, because removal would re-impose engine defaults that can themselves bindrelaxed_to_non_binding
Preference factors (use_hills, use_tolls, use_lit, …)removed, so the engine default applies; relaxing to an extreme would let the probe's own bias masquerade as divergenceengine_default
Hard caps (max_hiking_difficulty)their explicit maximumcap_lifted

Typed reason kinds by costing:

CostingKinds
truckmax_height, max_width, max_length, max_weight, hazmat, adr_tunnel
autohighway_avoided, toll_avoided, ferry_avoided, living_street_avoided
bicyclehill_avoided, surface_avoided, road_avoided, ferry_avoided, living_street_avoided
pedestrianunlit_street_avoided, hiking_difficulty_limited, ferry_avoided, living_street_avoided, access_profile
motor_scooterhill_avoided, primary_road_avoided, toll_avoided, ferry_avoided
anycombined

What it can and cannot claim

The method proves a declared field was binding and where the routes part ways. It does not identify the physical restriction or feature that caused the divergence: the specific signed bridge, the particular hill, the individual unlit street. The value reported is your declared value, not the infrastructure limit. Only avoidance-side declarations are attributed (a preference that seeks something rather than avoids it cannot honestly be described by an avoidance vocabulary, so it is skipped). Every entry carries basis: "route_divergence" and the block repeats these limits in caveats, so an agent relaying the explanation relays the epistemics too.

There is deliberately no incident_avoided kind: incident records are not a routing input, so no divergence can ever be attributed to one, and no delay figure is ever estimated from one. Live traffic speeds (UK Strategic Road Network) are a routing input when the request carries a date_time (they shape ETAs and route choice) but traffic is not a preference field, so it has no rationale kind either. To see what speed data stood behind an ETA, request the traffic: true annotation, which reports coverage, confidence and sources per leg.

Live incidents as context

Where the hosted gateway has incident data configured, the rationale block also carries a context.incidents array: currently active incidents whose geometry overlaps the route corridor, each with its road, severity, cause and position along the route, plus the source's coverage note and licence. These are context, never causes: the route did not avoid them and no delay estimate is attached, for the reason above. Where a source publishes its own delay figure, /v1/incidents/along passes it through as delay_s; this block deliberately drops it, because a number here would read as our claim about your route rather than the operator's about their network. An empty array means no matched incident within the configured sources' coverage, not a clear road; when no source is configured the field is absent entirely, because an empty list would be a claim.

The rationale is opt-in because it costs up to 1 + N extra engine calls (one combined probe plus one per declared field; the per-field probes run concurrently, so the explanation adds little latency), and it is billed for exactly the computations it performs: one call of its price class for the route itself and one for each probe that runs. At most 8, and typically fewer. A call with nothing to probe costs 1. A call whose declarations turn out not to have changed the route costs 2, because it stops after the first probe. Only a truck declaring all six constraints reaches 8. For truck the weights multiply, so a truck rationale call drawing 3 computations draws 60 included standard calls. Beyond included volume the same count is charged in money, at the class's ordinary per-unit price.

You are never asked to take that on trust. Every rationale block carries a metering object saying what the call was charged and what the charge bought, and the same figures ride on the x-mapmap-rationale-computations, x-mapmap-rationale-reserved and x-mapmap-quota-units response headers:

json
"metering": {
  "engine_computations": 3,
  "route_computations": 1,
  "probe_computations": 2,
  "billed_units": 3,
  "max_billed_units": 8
}

Because the count is only known once the probes have run, the call is reserved against the ceiling its own request implies and the unused part is returned before the response is sent, so nobody pays for a probe that did not run. A call paid with X-PAYMENT is the one exception: an x402 authorisation is signed for the quoted amount in advance and cannot be settled in part, so it pays the ceiling it was quoted. A failed probe degrades to a note on the block; the primary route is never failed by its own explanation. The MCP route tool accepts the same rationale: true flag and returns the same block, so agents can ask for a route and its reasons in one call; it fans out into ordinary metered route calls, so it has always been billed the same way.

The scenic way, and why it is scenic

"scenic": true on an auto route answers a question the fastest route cannot: is there a prettier way, what makes it prettier, and what does it cost?

json
{
  "locations": [{ "lat": 50.2110, "lon": -5.4800 }, { "lat": 50.1186, "lon": -5.5378 }],
  "costing": "auto",
  "units": "miles",
  "scenic": true
}
json
{
  "trip": { "…": "the route you asked for, unchanged" },
  "scenic": {
    "available": true,
    "chosen": "scenic",
    "reason": "Runs through Cornwall National Landscape for nine miles, then follows the coast.",
    "features": [
      { "kind": "coast", "metres": 2400, "longest_run_m": 900, "share_of_route": 0.12, "name": null },
      { "kind": "protected_landscape", "metres": 14800, "longest_run_m": 13800,
        "share_of_route": 0.72, "name": "Cornwall National Landscape" }
    ],
    "comparison": {
      "fastest": { "time_s": 898, "length_m": 14093 },
      "chosen":  { "time_s": 1326, "length_m": 20522 },
      "extra_time_s": 428, "extra_minutes": 7, "extra_length_m": 6429,
      "spoken": "About seven minutes longer than the quick way."
    },
    "alternative": { "trip": { "…": "the scenic route, with its own geometry" } },
    "candidates": [{ "source": "fastest", "scenic_score": 0.146, "…": "…" }],
    "coverage": { "zoom": 12, "tiles_read": 6, "tiles_missing": 0, "route_share_measured": 1.0 },
    "metering": { "route_computations": 1, "probe_computations": 1, "max_probe_computations": 2 },
    "caveats": "…"
  }
}

It never reroutes you. trip is byte for byte the route the same request would have returned without the flag, and the scenic route, when there is one, arrives beside it in scenic.alternative.trip. A client that ignores the block behaves exactly as it did before the flag existed.

RequestResponse
"scenic": truedefault budget: at most 30 extra minutes
"scenic": {"max_extra_minutes": 45}0 to 120 minutes
"scenic": false, or absentno block, no extra engine call, a byte-identical response
any other costing400, naming auto. A silently ignored flag would tell a lorry driver nothing

What scenic is measured from. Every candidate route is sampled every 100 m against the same OpenStreetMap-derived basemap tiles this deployment serves, at zoom 12, and scored on the share of its length within 300 m of the sea (weight 0.30), inside a national park, National Landscape, nature reserve or national forest (0.25), within 150 m of open water (0.20), within 50 m of mapped woodland (0.15) and within 100 m of a named watercourse (0.10), less the share the engine itself flagged as motorway (-0.25). The weights are a stated judgement rather than a fitted model, because there is no labelled corpus of scenic roads to fit one against. A route must also clear an absolute floor of 0.15 before the word scenic is used about it.

It is a re-ranking, not a scenic search. The routing engine has no scenic term in its search. The candidates are routes it already proposes, its own alternates plus one no-motorway variant issued only when the fastest route actually used a motorway, so a beautiful road the engine never proposed was never considered. caveats says exactly that.

Every offer states its reason, or there is no offer. A route that scores well but cannot support a plain spoken sentence is dropped rather than dressed up, and a landscape is named only when the map carries a name short enough to say out loud. A candidate that lost says why in candidates[].rejected: same_route, no_scenic_gain, not_scenic_enough, over_time_budget (with the minutes it would have cost, so raising the budget is an informed choice), or nothing_to_say.

Nothing is invented when the quick way is already the pretty one. Where the fastest road is itself the scenic one, chosen is fastest, alternative is absent, and reason still says what the road is. Where nothing measures above the floor, the block says so with reason: null.

Where the map cannot see, it says so. Route distance whose tile the archive does not hold is reported in coverage and excluded from every share, so a regional deployment answers "I could not see" rather than "there is nothing there". A deployment with no basemap archive at all answers {"scenic": {"available": false, "note": "…"}} and issues no probes.

Multi-stop journeys. The routing engine proposes no alternates for a route with more than two waypoints, so the only candidate compared against the fastest route is the no-motorway variant, the block carries a candidate_note saying so, and the alternates call is neither issued nor charged for.

Cost. At most two extra route computations, reserved before the request runs and handed back if they are not used, reported in scenic.metering and in the x-mapmap-rationale-computations header. See route_scenic in /pricing.json.

Landmark turn instructions

People do not give directions by street name. They say "turn right just after the petrol station", because that is what you can see from the car. Add a top-level "landmarks": true to a route request and every eligible manoeuvre gains a landmark_instruction anchored to a recognisable place from the first-party place index:

json
{
  "locations": [{"lat": 51.5074, "lon": -0.1278}, {"lat": 51.5155, "lon": -0.1410}],
  "costing": "auto",
  "landmarks": true
}
json
{
  "trip": {
    "legs": [{
      "maneuvers": [{
        "type": 10,
        "instruction": "Turn right onto Great Portland Street",
        "landmark_instruction": "Turn right just after the Shell garage"
      }]
    }]
  },
  "landmarks": { "annotated": 3 }
}

It is additive. The engine's own instruction is never replaced, so you choose which to show and nothing breaks on a route where nothing is recognisable. Clients that read instructions aloud should prefer landmark_instruction when it is present: it is the one a passenger would have given you.

What gets named, and what never does

A wrong landmark is worse than no landmark. On an API surface it is worse still, because there is no map beside the text to correct the impression and a voice client will read it out as fact. So a place is only named when it clears every gate:

GateRule
RecognisabilityScored by category. Highest are the things that are unmistakable from a moving car and usually the only one of their kind on the road: petrol stations, supermarkets, stations, churches, pubs and department stores. Then cinemas, town halls and DIY sheds. Cafés, banks, pharmacies and takeaways sit below the bar on their own and need a household name to clear it. Categories outside the vocabulary score zero and can never anchor an instruction.
Household namesA national chain gets a bonus, matched as whole words against the name and the brand tag, so "Tesco Express" counts and "BPX Logistics" is not mistaken for BP.
DistanceBeyond 40 m from the junction the place is set back from it (a supermarket across its own car park) and is never named. Inside that, distance only breaks ties between comparable places: a station across the junction still beats a café on the corner, because the kind of place matters more than a few metres.
FreshnessAny record tagged disused, abandoned or closed is never named, whatever it scores. A landmark that is not there any more is worse than none.
Manoeuvre shapeOnly turns, slight and sharp turns, and forks are annotated. "Take the second exit just after Tesco" is ambiguous about what the landmark qualifies, so roundabouts, merges, ramps and ferry transitions keep their own instruction.

When nothing clears the bar the manoeuvre is simply not annotated. Silence is the safe answer.

On the OSRM route surface too

The same flag works on GET /route/v1/{profile}/{coordinates}: add landmarks=true (alongside steps=true, since the phrasing rides on the steps) and each step gains a landmark_instruction beside its ordinary fields. Same gates, same conservatism, and clients that ignore the field keep exactly the behaviour they had.

bash
GET /route/v1/driving/-0.1278,51.5074;-0.1410,51.5155?steps=true&landmarks=true
json
{
  "routes": [{ "legs": [{ "steps": [{
    "name": "Great Portland Street",
    "maneuver": { "type": "turn", "modifier": "right" },
    "landmark_instruction": "Turn right just after the Shell garage"
  }] }] }]
}

The summary block

The response carries a landmarks block reporting how many manoeuvres were annotated. It also carries a note when the per-route fair-use cap was reached, or when the deployment has no place index configured at all. That distinction matters: "no landmarks" must never be ambiguous between "nothing recognisable on this road" and "this deployment cannot look them up", because only one of those is a fact about the route.

Landmark lookups run in-process against the place index, so the cost is one index query per eligible manoeuvre rather than a network round trip.

What the journey passes: POST /v1/route/observations

A route object is a list of movements. It will tell you to leave the roundabout by the third exit; it will not tell you that the bridge you just crossed is the Exe, that the road you have been on for half an hour is the A303, or that the hedge line ahead is the edge of a national landscape. This endpoint computes those by intersecting the route's own geometry with the basemap archive and the elevation model, and returns each one with the point along the route where it belongs.

bash
curl -s $BASE/v1/route/observations \
  -H "Authorization: Bearer $API_KEY" \
  -H 'content-type: application/json' \
  -d '{ "geometry_polyline6": "…", "units": "miles" }'
FieldRequiredMeaning
geometry_polyline6yesThe route shape, precision-6 encoded, typically straight out of a prior POST /route response
unitsnomiles (default) or kilometers, for spoken distances
min_gap_mnoLeast distance along the route between two observations (default 2000). A floor, not a target
max_observationsnoCeiling on how many come back, 1 to 200 (default 40)

Any other field is a 400 naming it. There is no costing, no truck profile and no adr object, so the call is always Standard class and never touches the routing engine.

json
{
  "route": { "length_m": 287223.4, "shape_points": 5406 },
  "observations": [
    { "at_m": 92251.0, "kind": "road", "subject": "A303",
      "say": "On the A303 for about ninety-five miles.",
      "basis": "transportation_name", "run_m": 150312.0 },
    { "at_m": 106288.6, "kind": "crossing", "subject": "River Test",
      "say": "Crossing the River Test.", "basis": "waterway.name" },
    { "at_m": 155110.2, "kind": "climb", "subject": "the road",
      "say": "The road climbs to 650 feet.",
      "basis": "elevation model", "elevation_m": 199.0 },
    { "at_m": 248000.0, "kind": "settlement", "subject": "Monkton",
      "say": "Passing through Monkton.", "basis": "place.name", "offset_m": 141.7 }
  ],
  "coverage": { "…": "…" },
  "attribution": "Map data © OpenStreetMap contributors, ODbL. Basemap schema © OpenMapTiles, CC-BY 4.0.",
  "caveat": "Observations are computed by intersecting the route's geometry with …"
}

at_m is the whole point of the response. An observation delivered anywhere other than near the thing it describes is not an observation, it is trivia.

KindThe claimWhat it rests on
crossingThe route's line crosses this watercourse's lineA geometric intersection of two mapped lines, plus a name on the second
landscapeThe route is inside this polygon for at least 3 kmPoint in polygon against a named park feature
settlementThe route passed within the class radius of this settlement's mapped centreA distance to a labelled point, published as offset_m
watercourseThe route ran within 60 m of this watercourse for at least 3 kmRepeated nearest-feature tests
roadThe route ran within 25 m of this road, unambiguously, for at least 4 kmRepeated nearest-feature tests with an ambiguity guard
climbThe road rose at least 120 m and topped out at this heightThe elevation model, at the route's own points

A settlement is a labelled point and not a boundary, which is why offset_m is published on every one: "Passing through Monkton" means the route came that measured distance from the point the map labels Monkton, not that a town boundary was crossed.

Silence is the answer when the map has nothing. coverage reports what was looked for and what was found, so an empty answer is a statement about this archive rather than about the ground: sparse hydrography reads as silence and should, city routes go quiet on road names because the basemap carries none below secondary at this zoom, and a watercourse the schema marks as a tunnel is never announced. It also never names a hill and never reads a junction number, because an observation that can be mistaken for an instruction is not safe to speak beside real guidance.

It is a query, and it triggers nothing. The endpoint returns an annotated list. It holds no position, keeps no session and pushes nothing to anybody.

The response carries an attribution string. The obligation attaches to the product rather than to each utterance, so a client reading these sentences aloud satisfies the licence with a visible or linked credit in the app.

Scope. Full-key only. A maps key is refused with the scope-insufficient 403 above.

Heritage lines: POST /v1/route/heritage

Three to five short spoken lines an hour about the ground the car is on right now. Not a tour and not a chatbot: one sentence, at the moment the driver reaches the thing it is about, and then silence.

Nothing is generated during a drive. Every line is a sentence a person wrote from a public-domain plaque inscription and a person reviewed, compiled into the gateway binary. There is no model in this request path.

It covers one corridor, deliberately. The Chelsea riverside, Lots Road to Millbank: 18 reviewed lines over 5 km. Every other route answers 200 with no lines and a census saying every entry was out of reach. The measure of this feature is whether the third line a driver hears is still worth hearing, and only a corpus small enough to read all of can settle that.

bash
curl -s $BASE/v1/route/heritage \
  -H "Authorization: Bearer $API_KEY" \
  -H 'content-type: application/json' \
  -d '{ "geometry_polyline6": "…", "duration_s": 584, "voice_format": "m4a" }'
FieldRequiredMeaning
geometry_polyline6yesThe route shape, precision-6 encoded
duration_snoThat route's own duration. Supply it: without it the budget falls back to a distance, and "four lines an hour" is a claim about time
min_gap_snoLeast time between two lines, seconds (default 900, which is four an hour). Applied only with duration_s
min_gap_mnoLeast distance between two lines, metres (default 400). A floor under the time budget in every case
max_offset_mnoHow near a plaque must come to the route to be spoken, metres (default 60, hard ceiling 80)
max_linesnoCeiling on how many come back, 1 to 50 (default 12)
voice_formatnoopus or m4a. Given, every line gains a clip with its synthesis cache key and URL
json
{
  "route": { "length_m": 4950.4, "shape_points": 332 },
  "corridor": { "id": "chelsea-riverside", "name": "Chelsea riverside",
                "road": "A3220 and A3212, Lots Road to Millbank",
                "reviewed_on": "2026-09-21" },
  "lines": [
    { "at_m": 431.0, "offset_m": 11.0,
      "say": "Sylvia Pankhurst lived along here. She campaigned for women's rights.",
      "subject": "Sylvia Pankhurst", "basis": "inscription",
      "plaque_ids": [473], "id": "pankhurst",
      "clip": { "hash": "04114cb2…", "url": "/v1/voice/clips/04114cb2….m4a", "format": "m4a" } }
  ],
  "census": { "corridor": "chelsea-riverside", "corpus_entries": 18,
              "within_reach": 18, "beyond_reach": 0, "spoken": 1,
              "silenced_by_budget": 17, "gap_m": 7626.0,
              "gap_basis": "time", "mean_speed_kph": 30.5 },
  "attribution": "Plaque inscriptions from the OpenPlaques United Kingdom dataset …",
  "caveat": "Every line is a sentence written and reviewed by a person …"
}

A route with nothing to say publishes the census anyway. census is the honest-refusal shape: how many entries the corpus holds, how many came within reach of this route, how many were silenced by the budget and what the budget worked out to. A route off the corridor gets no lines and a census saying every entry was beyond reach, rather than an empty array a caller has to interpret.

What a line claims, and the two omissions. It claims that this plaque records this fact (plaque_ids names the records, so any claim can be taken back to its source), that you are passing it (offset_m, the measured perpendicular distance from the plaque's own coordinate to the route's line), and that a person judged it worth saying here. No line says anything is visible, still standing or unchanged, and no line says which side of the road anything is on, because nothing in the source data records it. "On your left" is an instruction to look, and a look that finds nothing spends the driver's trust in everything else the car says.

The audio is bought once. voice_format returns each line's cache key under the deployment's voice and never synthesises: the key is a pure function of the sentence. The audio is bought once, ever, at POST /v1/voice/clips, and because the corpus never changes, a deployment that bakes it once serves every driver afterwards from cache.

Scope. Full-key only. A maps key is refused with the scope-insufficient 403 above.

Traffic confidence

Every routing API gives you an ETA. Almost none tells you what that ETA rests on: whether the road ahead was measured this minute, inferred from a typical Tuesday, or simply assumed from its speed limit. Add a top-level "traffic": true to a route request and each leg comes back with a traffic object that says so:

json
{
  "locations": [{"lat": 51.5074, "lon": -0.1278}, {"lat": 52.4862, "lon": -1.8904}],
  "costing": "auto",
  "date_time": { "type": 0, "value": "2026-08-13T09:20" },
  "traffic": true
}
json
{
  "trip": {
    "legs": [{
      "traffic": {
        "covered_pct": 0.62,
        "confidence": 0.78,
        "band": "live",
        "sources": ["live", "profile", "default"],
        "shares": { "live": 0.62, "predicted": 0.0, "profile": 0.2, "default": 0.18 },
        "distance_m": 178432.0
      }
    }]
  }
}

covered_pct is the share of the leg's distance (0–1) that ran over roads carrying a live or near-horizon-forecast record when you asked. confidence is a distance-weighted data-quality score in the bands the industry already publishes: above 0.7 the leg is live-dominated, 0.5 to 0.7 means typical historical speeds carried it, below 0.5 means default speeds did. band spells that out so you never have to hard-code the thresholds, and sources names exactly which layers contributed.

Ask for alternates and each one is annotated too, so "which of these routes has the best data behind it?" becomes a field comparison rather than a guess.

It is free. The annotation is computed from the geometry your response already carries (no second routing call, no extra charge, no quota weight) and it is purely additive, so omitting the flag leaves your payload exactly as it is today.

How coverage is measured, and what it does not claim

Coverage is measured geometrically: your route's shape is walked in 25 m steps and each step matched, within 30 m, against the traffic network's own map-matched link geometry, then credited by distance to whatever that link carried: a current measured speed, a near-horizon forecast, a typical speed, or nothing.

That tells you a record existed for the road you drove. It is not a claim that the router's internal blend used that exact number (it fades live speeds toward typical ones over the first hour of a journey), it matches a link as a whole rather than edge by edge, and it does not distinguish carriageway direction. Every response repeats those limits in a caveats string, so an agent relaying the number relays its boundaries with it.

It never claims more than your request allows

Live and typical speeds only enter costing for time-dependent requests, and speed_types lets you switch layers off. The annotation follows:

Your requestlive claimedprofile claimed
no date_timenono
date_time.type: 0 (now)yesyes
a stated departure or arrival timenoyes
speed_types without currentnounchanged
speed_types without predictedunchangedno

A layer you excluded is downgraded rather than quietly reported anyway: switch current off and no live can appear in sources, however good the coverage is. When a layer is excluded the block carries a note saying which, so a zero is never left looking like a data gap.

An empty result is never an all-clear

Traffic records cover the National Highways strategic road network in England. Local roads, Scotland, Wales and everywhere outside the UK carry no record, and the honest answer for that distance is default. covered_pct: 0 means "no data here", never "the road is clear". A snapshot more than five minutes old stops supporting a live claim at all and the block says stale: true: a frozen feed decays to typical speeds rather than going on asserting a speed nobody is measuring.

On a deployment with no traffic data configured at all, the block says that in as many words instead of reporting zero coverage: "this deployment cannot see traffic" and "these roads have no traffic" are different statements, and only the first is ours to make.

Places: bring your own places

Customer-owned point data (store locations, depots, branches) attached to your API key: upload once, then search it with the same ranking engine that powers territory geocode indexes. The canonical use case is a store finder; the web SDK's PlacesLayer renders the same data client-side (see SDKs).

Datasets are strictly per key: a key can only ever read, search or delete the places it uploaded. All five routes are authenticated and metered at the Standard price class, one call per request.

MethodPathWhat it does
PUT/placesReplace your dataset; body {"places": [{"id", "name", "lat", "lon", …}]}, up to 10,000 places; returns {"count", "updated_at"}
GET/placesFetch your full dataset plus meta; 404 before the first upload
DELETE/placesRemove dataset and index; 204, idempotent
GET/places/searchRanked full-text search: q (required), optional lat/lon proximity focus (both or neither), limit 1–50, category filter
GET/places/nearestStraight-line nearest-N: lat, lon, limit. For drive-time ranking feed your places into POST /matrix as targets

Each place record carries optional alt_names, categories, address parts (street, locality, region, postcode, country_code), an importance weight and a free-form properties object (≤2 KiB) that round-trips untouched: opening hours, phone numbers, URLs. Over 10,000 places is a 413 (places-too-many); per-field validation failures are a 422 (invalid-places) with a problems list naming each offender.

bash
curl -fsS -X PUT "$BASE/places" \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"places": [{"id": "croydon-01", "name": "MyBrand", "lat": 51.3762,
       "lon": -0.0982, "locality": "Croydon", "categories": ["store"]}]}'

curl -fsS "$BASE/places/search?q=MyBrand+Croydon&lat=51.5&lon=-0.12" \
  -H "Authorization: Bearer $API_KEY"

What is over there: GET /v1/places/in-view

Every other places API answers the map-centric question: a point, a radius, a ring of pins. This one answers the egocentric one. Give it where you are and which way you are facing, and it returns the places inside that cone with, for each, how far away it is, its own bearing, how far off dead ahead it sits, and an honest verdict on whether the ground and the buildings in between let it be seen.

bash
curl -s "$BASE/v1/places/in-view?lat=51.5074&lon=-0.1278&bearing_deg=90\
&fov_deg=60&radius_m=800&categories=pub&limit=5" \
  -H "Authorization: Bearer $API_KEY"
ParameterRequiredMeaning
lat, lonyesWhere the observer is
bearing_degyesWhich way they face, degrees clockwise from true north. Magnetic bearings must be converted by the caller; nothing here corrects for declination
categoriesone of these twoComma-separated categories, the vocabulary GET /geocode/reverse takes (pub, cafe, fuel, charging_station, …). categories=building reaches named buildings, which is how to ask what that building over there is
nameone of these twoA place name or brand, matched as GET /geocode/reverse matches it
fov_degnoFull width of the cone, 1 to 360, default 60. The full width, not the half angle
radius_mnoRange, 1 to 5000, default 1000
limitnoResults, 1 to 25, default 5
eye_height_mnoObserver's eye above the ground, default 1.6. A driver's eye is nearer 1.2
target_height_mnoHeight above its own ground at which each place is tested, default 0. Raise it to ask about rooflines, spires and masts
visible_onlynotrue drops everything whose verdict is not clear. Default false
json
{
  "observer": { "lat": 51.5074, "lon": -0.1278, "bearing_deg": 90.0, "fov_deg": 60.0,
                "half_angle_deg": 30.0, "radius_m": 800, "eye_height_m": 1.6,
                "target_height_m": 0.0, "ground_elevation_m": 14.0 },
  "results": [
    { "name": "The Sherlock Holmes", "lat": 51.5069, "lon": -0.1246,
      "categories": ["pub"], "type": "pub",
      "distance_m": 232.4, "bearing_deg": 104.7, "angular_offset_deg": 14.7,
      "visibility": {
        "verdict": "occluded",
        "obstruction": { "kind": "building", "distance_m": 96.0,
                         "height_above_sightline_m": 18.4,
                         "building_height_m": 33.0, "building_height_basis": "tagged" },
        "checks": { "terrain": "clear", "buildings": "occluded" },
        "basis": "terrain-and-buildings" } }
  ],
  "excluded": { "out_of_sector": 7, "beyond_visibility_check": 0 },
  "coverage": { "…": "…" },
  "caveat": "Visibility is modelled, not observed. …"
}

How the verdict is reached. Two checks, reported separately, either able to answer that it could not tell. The sight line from the observer's eye to the place is sampled against the bare-earth elevation model, the same one POST /elevation reads, corrected for earth curvature and standard atmospheric refraction. Then OpenStreetMap building footprints are read from the vector-tile archive at zoom 14, extruded to their tagged height and tested against the same line; the footprint the place itself sits in is skipped, because a building cannot hide the thing it houses. basis summarises the pair as terrain-and-buildings, terrain-only, buildings-only or nothing, and verdict is clear, occluded or unknown.

The two rules about missing data are deliberately asymmetric. A gap never proves a clear line, so a ground profile with a hole in it answers no-elevation-data, because the hole could be the hill. A gap does not un- prove an occlusion, so one covered sample standing above the line is proof on its own. beyond-building-range is a different word from no-building-data on purpose: one means the tiles were not read that far, the other means they were not read at all.

excluded.out_of_sector counts places that matched the filter and the radius but fell outside the cone. A non-zero count is the difference between "there is nothing near you" and "there are things near you, none of them in the direction you are looking", and a client should say the second.

Scope. Full-key only. A maps key is refused with the scope-insufficient 403 above.

Errands against a hard arrival time: POST /v1/errands/plan

Answers the question a route alone cannot: can I do all of this and still be there on time, and if not, what has to go? The worked example is a school run: pick up a prescription, get petrol, be at the school by a quarter past three.

bash
curl -s -X POST "$BASE/v1/errands/plan" \
  -H "Authorization: Bearer $API_KEY" -H "Content-Type: application/json" \
  -d '{"origin": {"lat": 50.8290, "lon": -0.1410},
       "destination": {"lat": 50.8600, "lon": -0.0900, "name": "The school"},
       "arrive_by": "2026-09-21T15:15:00+01:00",
       "errands": [
         {"id": "prescription", "category": "pharmacy", "dwell_minutes": 6},
         {"id": "petrol", "category": "petrol station", "dwell_minutes": 5}
       ]}'
ParamMeaning
origin{lat, lon}; where the driver sets off from. Required
destination{lat, lon, name?}; where they have to be. "The school" is a coordinate you supply with a label, never a name we go looking for
arrive_byThe hard arrival time, RFC 3339 with an offset. Required
depart_atRFC 3339. Default: now, in the offset arrive_by carries
errands1 to 5 entries. Each is either category (an index category token, or a colloquial phrase such as "petrol station") or place ({lat, lon, name?}, somewhere you already know), plus an optional id and dwell_minutes (default 5, at most 240)
max_detour_minutesHow far off the direct route a candidate place may sit. Default 12, at most 45
costing_optionsPassed to the routes and the matrix verbatim. The costing is always auto

Plan it stationary. Stating a request like this is not something a driver can do at the wheel, and handheld phone use while driving has carried six points in Great Britain since March 2022. Every response carries that sentence as usage, in words a client can show. This is a before-you-set-off feature, or a passenger's.

How it is solved. One direct route gives the corridor. Each category errand is filled by browsing the first-party place index around sample points along it, capped at six candidates per errand and 24 in total. Origin, every candidate and the destination go into one engine matrix, and the ordering is then solved exhaustively rather than greedily, so the times are the engine's and the same table can answer what the chain would cost without any one errand. Every stop is a real indexed place carrying the id it was found under, or the coordinate you supplied echoed back as "source": "supplied", and the response says so in verification.

When it does not fit, that is a 200 with feasible: false, never an error, and it carries more than a refusal: reason.over_by_s and reason.would_arrive say how late the whole chain would be, reason.blocking names the errand that costs the most with the seconds dropping it saves, and achievable is the largest set that does fit, planned out in full with dropped naming what came out. reason.code is one of deadline_missed, destination_unreachable_in_time, no_candidates_in_corridor, all_candidates_closed or unroutable.

Opening hours are a minority signal and are used asymmetrically. The only hours data in the map is OpenStreetMap's opening_hours tag. A stop whose tag proves it shut at the time the plan would arrive is dropped and the chain re-solved without it; a stop whose tag covers the arrival time is reported "verdict": "open_on_the_tag", which is evidence and not a promise; a stop with no tag, or a tag outside the accepted subset, is "verdict": "unknown" and is planned anyway, flagged. We do not know is not it is shut, and neither one is it is open.

Stated limitations. Candidates come from the corridor of the direct route, so a pharmacy twenty minutes the other way was never considered and search states the corridor and the caps searched under. Matrix cells are upper bounds on route cost. Dwell times are yours, echoed back, because the map does not know how long a prescription takes.

Scope. Full-key only. A maps key is refused with the scope-insufficient 403 above.

Geocoding

Geocoding has two backends, in precedence order. First-party (set SN_GEOCODE_DIR to an sn-geocode index, a territory index from a signed package, or a whole-world merge of them): forward and reverse geocoding are served in-process by our own sn-geocode engine, so the ranking you get from the API is the ranking a device gets offline from its package: one engine, online and off. Photon proxy (SN_PHOTON_URL) is the fallback for operators who prefer it, and SN_GEOCODE_FORWARD=photon routes forward search to the proxy while reverse stays first-party. That split is what the hosted gateway currently runs: reverse geocoding (and its POI details) from a worldwide first-party index of every named OSM place and POI, forward search via Photon while first-party forward ranking closes the remaining world-scale gaps. With neither backend set, /geocode and /geocode/reverse answer 501 (urn:sn-gateway:problem:geocoding-not-enabled). Both endpoints are authenticated and metered exactly like any other Standard call (one metered call per request).

First-party POI hits also carry a details object: a curated set of display attributes straight from OpenStreetMap: opening_hours, phone, website, email, brand, operator, cuisine, wheelchair, wikidata, wikipedia, EV socket:* connectors and friends. It is what powers "open now, +44 …, tate.org.uk" place cards without a second data provider; keys absent in OSM are simply omitted.

GET /geocode, forward geocoding:

ParamRequiredMeaning
qyesFree-text place query
limitnoMaximum results, 1–10
langnoResult language, e.g. en, de, fr
biasnoLocation bias as lon,lat (WGS84), e.g. -0.1278,51.5074; reorders candidates, never excludes one
bboxnoHard bounding-box filter as minLon,minLat,maxLon,maxLat (WGS84), e.g. -0.489,51.286,0.236,51.686 for Greater London; the same convention as Mapbox/Google/HERE. Unlike bias, this excludes any result outside the box outright. Honoured on both backends; minLon may exceed maxLon to describe a box crossing the antimeridian

Cities, postcodes and the state field

A bare city or town lists its transport hubs. When a query with no comma leads with a city or town of that name, the city's airports and main railway stations come directly behind it, ahead of namesakes elsewhere. Manchester answers Manchester, then Manchester Airport, Manchester Piccadilly and Manchester Oxford Road, then Manchester, New Hampshire. At most three hubs are added, airports first: an airport must have an IATA code and lie within 60 km of the city, a station within 25 km, and each must be named for the city under one of its names. A hub already on the page is not repeated, and limit still caps the page. Both backends do this, provided the deployment also has a first-party index (the hosted API has one).

state is the subdivision that contains the result. On the first-party backend, each result's state is the first-level subdivision whose boundary contains it (England, Bavaria, California), read from administrative boundaries rather than inferred from nearby place names. It needs the administrative-boundary database (SN_ADMIN_BOUNDARIES_DB); a point that database does not cover keeps the index's own value. This is new in the gateway and takes effect with the next API deploy.

A UK postcode in the query anchors the search. On the first-party backend, a free-text query that contains a full UK postcode (Old Market Square, NG1 2DT), or ends in a bare outward code (High Street, BA1), is ranked around that postcode district as if you had sent a bias at the district's centre, so a common street name resolves in the right town. It applies only when you send no bias of your own, and not to structured requests (send postcode as a component there). A query that is nothing but an outward code (EH1) returns the district itself, on both backends.

Structured input

You can name the parts of an address separately instead of running them together into q. Send any of these alongside q, or on their own. q is only required when no component is:

ParamMeaning
streetStreet name, e.g. Downing Street. Matches the street of addresses and POIs (transliterated street names included) and the street itself
housenumberHouse number, e.g. 10 or 221B
cityTown or city, e.g. London. Matches the containing city as well as the immediate locality
postcodePostcode in any spacing or case: SW1A 2AA and sw1a2aa are one query. A bare UK outward code (SW1A) selects the district
countryISO 3166-1 alpha-2 code or country name (GB, gb, United Kingdom and UK all mean the same), or a comma-separated list of them, e.g. country=GB,IE. Either form is a hard filter on free-text search too: with q=springfield&country=GB,IE, every result carries a countrycode of GB or IE, on both backends, and limit counts what survives the filter. A single value is additionally read as the structured component described above. A value naming no country is a 400, never a filter that silently matches nothing; a comma-separated list sent on its own, with no q and no other component, is also a 400, because a filter needs a search to narrow
matchtrue to get the match object (below) on a plain q query. Implied whenever any component above is supplied

Components exclude, they do not merely reorder: city=London means the answer is in London, so a result elsewhere cannot come back at all, which is the difference between this and putting "London" in q. With both, q decides the order of what the components allow.

bash
curl -fsS "$BASE/geocode?housenumber=10&street=Downing+Street&city=London&postcode=SW1A+2AA&country=GB" \
  -H "Authorization: Bearer $API_KEY"

On the first-party backend the components are one query, so q and components combine freely. A Photon-proxy-only deployment answers them from Photon's own /structured endpoint, which takes components and no free text: sending both there is a 501 (urn:sn-gateway:problem:structured-search-not-enabled) rather than a silently half-honoured request.

Match confidence

When you supply structured components (or pass match=true with a plain q), every hit carries a match object saying how far to trust it:

json
"match": {
  "components": {
    "city": "matched",
    "country": "matched",
    "housenumber": "matched",
    "postcode": "matched",
    "street": "matched"
  },
  "score_gap": 0.0,
  "source": "mapmap-index"
}

That is the live match on the one feature (10 Downing Street, osm:r1879842:poi) the structured example above returns; score_gap is 0 because there is no runner-up.

  • components: one entry per component you sent. matched: the result's own field carries what you asked for (case- and accent-insensitive, and by containment, so city=London matches "City of London"). inferred: the result carries a value, but not yours; it got there through ranking, as when a street is found by its transliterated name and displayed under its canonical one. unmatched: the result carries no value for that component at all.
  • score_gap: the top result's score minus the runner-up's, rounded to 3 decimal places. A small gap means the ranking barely chose between them, which is exactly when to look at the alternatives. 0 for a single result, and 0 on the photon source, which publishes no per-result scores.
  • source: mapmap-index or photon, so you always know which backend answered.

The object is additive: nothing else about the response changes, and a client that ignores it is unaffected.

Search as you type

A search bar is not one geocode. It is a dozen partial queries as someone types, then one lookup of the row they picked, and the two want different answers: the partial queries want a small, cheap, immediately renderable list; the pick wants the whole feature. GET /geocode/suggest and GET /geocode/retrieve are that pair.

GET /geocode/suggest:

ParamRequiredMeaning
qyesThe query as typed, 1–256 characters. The trailing token matches as a prefix (Downing Str reaches Downing Street); earlier tokens must be complete words
limitnoMaximum suggestions, 1–10 (default 5)
biasnoLocation bias as lon,lat (the map centre, for a search box on a map)
zoomnoMap zoom (0–20) scaling the bias, exactly as on /geocode
bboxnoHard bounding-box filter, minLon,minLat,maxLon,maxLat
langnoAccepted for symmetry with /geocode; the first-party index does not currently vary its answer by language
bash
curl -fsS "$BASE/geocode/suggest?q=Downing+Str&bias=-0.1278,51.5074&limit=5" \
  -H "Authorization: Bearer $API_KEY"
json
{
  "suggestions": [
    {
      "id": "osm:way/4288891",
      "name": "10 Downing Street",
      "context": "London, SW1A 2AA, United Kingdom",
      "kind": "address",
      "lat": 51.50344,
      "lon": -0.12770
    }
  ]
}

Suggestions carry their coordinates. That is deliberate, and it is the one place this API differs from the shape the market has settled on: Mapbox's suggest response withholds lat/lon so that every pick must be followed by a retrieve call. There is no technical reason for that (the coordinate is right there in the index), so ours are in the response. A map that only needs to drop a pin can plot the pick from the suggestion alone and never call retrieve; a client that needs the full address, categories or POI details calls it. Suggestions are also cacheable on your side, which the withholding shape quietly prevents.

A one-character q is not expanded as a prefix, because the index's minimum edge gram is two characters: it reaches only places whose whole name is that character (live, q=a answers with localities named "Å" in Norway and Denmark and "A" in Slovakia). Wait for a second character before calling if you want prefix suggestions.

GET /geocode/retrieve?id=… takes one suggestion id and returns the full Photon-shaped FeatureCollection for exactly that document, the same feature, from the same shaper, that GET /geocode would have returned for it:

bash
curl -fsS "$BASE/geocode/retrieve?id=osm%3Away%2F4288891" \
  -H "Authorization: Bearer $API_KEY"

Ids are matched verbatim, and they are stable within an index build but not across one. An id that was never issued, or that predates a rebuild, is a 404 (urn:sn-gateway:problem:not-found) whose detail tells you to search again and re-pick, never a plausible wrong document.

Retrieve is self-host only on api.mapmap.ai today. Verified on 17 September 2026: GET /geocode/suggest answers 200 on the hosted gateway, GET /geocode/retrieve answers 501 (urn:sn-gateway:problem:suggest-not-enabled). Its detail names the reason: retrieve needs a first-party index built with the document-id field, which means an index rebuilt with sn-geocode 0.3 or later, and the hosted one has not been. A self-hosted deployment whose SN_GEOCODE_DIR points at such an index has both endpoints. Until the hosted index is rebuilt, plot the pick from the suggestion's own lat/lon (they are in the response for exactly this reason) and fetch the full feature with GET /geocode if you need it.

What a typed search costs. The typed part is free at ordinary volumes: the first 5,000 GET /geocode/suggest requests of each UTC day draw no units at all. The allowance is counted across every key on your account, not per key, so minting a second key does not buy a second allowance; it resets at midnight UTC, and it is an allowance for this one endpoint, so unused suggest calls never become included volume for anything else. Beyond it, a suggest bills one Standard call exactly as it always did.

The pick is always billed: GET /geocode/retrieve is one Standard call and is never discounted, and so is the single-shot GET /geocode. Because our suggestions carry lat/lon, a client that only drops a pin never calls retrieve at all, which takes a search box inside the allowance to zero.

Keys on an unverified (provisional) identity are outside the allowance and bill per call: a provisional key meters against a lifetime pool rather than a month, and a daily allowance inside it would outlive the pool. Verify the email and the allowance applies.

The debounce in your client is still worth having, and it is what keeps a busy box inside the allowance: at the SDK's 250 ms default, a typed word costs a call or two rather than one per keystroke, so 5,000 covers roughly 1,700 to 2,500 searches a day rather than about 625 undebounced ones. Both endpoints are open to maps-scoped browser keys, and both are on the keyless demo lane, so the examples above run without a key.

Both need the first-party index. On a Photon-proxy-only deployment, or one whose index predates the document-id field, they answer 501 (urn:sn-gateway:problem:suggest-not-enabled) and a client should fall back to GET /geocode with the typed query.

The @mapmap/maps SearchBox control wraps all of this: see Web maps.

Batch geocoding

POST /geocode/batch runs up to 1,000 queries in one call (forward and reverse mixed) and returns one result per query, in the order you sent them. Each entry takes q and/or the structured components above, or a lat/lon pair for a reverse lookup, plus an optional limit (1–10, default 1: a batch is usually one row in, one row out).

bash
curl -fsS -X POST "$BASE/geocode/batch" \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"queries": [
        {"q": "10 Downing Street, London"},
        {"street": "Bankside", "city": "London", "postcode": "SE1 9TG"},
        {"lat": 51.50743, "lon": -0.09934}
      ]}'
json
{
  "results": [
    { "index": 0, "features": { "type": "FeatureCollection", "features": [ ... ] } },
    { "index": 1, "features": { "type": "FeatureCollection", "features": [ ... ] } },
    { "index": 2, "features": { "type": "FeatureCollection", "features": [ ... ] } }
  ]
}

A query that fails takes only itself down: its result carries a problem object (the same RFC 9457 shape the single endpoints answer with) instead of features, and the rest of the batch is unaffected. Over 1,000 queries is a 422 (urn:sn-gateway:problem:batch-too-large), refused before any of them runs.

Billing: one Standard call per query in the body. A batch is a delivery mechanism, not a discount: ten geocodes cost ten geocodes whether they arrive in ten calls or one, so you are free to choose whichever shape suits your client.

GET /geocode/reverse, reverse geocoding:

ParamRequiredMeaning
lonyesLongitude (WGS84)
latyesLatitude (WGS84)
kindsnoRestrict hits to these kinds, comma-separated: address, street, locality, poi, postcode. kinds=poi is the "what place did the user tap" lookup; in dense areas the unfiltered nearest-5 is often all addresses and postcodes. First-party backend only

The response is a GeoJSON FeatureCollection in the Photon property shape, so a client written against either backend works against the other. The first-party backend adds a stable id on every hit, distance_m on reverse-geocode hits (distance from the query point, metres), and details on POI hits:

bash
curl "$BASE/geocode/reverse?lon=-0.09934&lat=51.50743&kinds=poi" \
  -H "Authorization: Bearer $API_KEY"
json
{
  "type": "FeatureCollection",
  "features": [
    {
      "type": "Feature",
      "geometry": { "type": "Point", "coordinates": [-0.0998, 51.5075] },
      "properties": {
        "id": "osm:w24642569:poi",
        "name": "Tate Modern",
        "type": "poi",
        "countrycode": "GB",
        "street": "Bankside",
        "city": "London",
        "postcode": "SE1 9TG",
        "categories": ["arts_centre"],
        "distance_m": 31.0,
        "details": {
          "internet_access": "wlan",
          "opening_hours": "Su-Th 10:00-18:00; Fr-Sa 10:00-21:00",
          "phone": "+44 20 7887 8888",
          "website": "https://www.tate.org.uk/visit/tate-modern",
          "wheelchair": "yes",
          "wikidata": "Q193375",
          "wikipedia": "en:Tate Modern"
        }
      }
    }
  ]
}

A missing q (or malformed lon/lat, or an unknown kinds value) is a 400; an upstream backend failure is a 502.

How we measure search quality

Every geocoder vendor publishes an accuracy number and none of them publishes the queries. Ours are in the repository, along with the scorer, the tolerances and every result file: scripts/geocode-bench/. Run it against your own account and you should get our number back, give or take the near-ties.

The corpus. uk-v1, 207 UK queries in eight classes. Six of the classes are derived mechanically from 30 hand-verified UK places whose coordinates come from the same ground-truth set as our 220-query world benchmark, so a verified coordinate is never retyped and this corpus inherits any correction made there. The remaining hand-written parts are the twenty misspellings, whose coordinates still come from those same verified rows, and twelve settlement centroids, which are the only coordinates in the corpus typed by a person and are cross-checked by a unit test against the verified places they claim to contain. Adding or changing queries means a new corpus version and a new file, so an old result always names a corpus you can still read.

The measure. Top-1 is whether the first result landed within that class's tolerance of the verified coordinate; top-3 asks the same of the first three, because a search dropdown is not a single answer. Tolerance varies by class because one number would be dishonest in both directions: 250 m for an address, 2 km for a bare postcode (the truth is a building, a postcode unit centroid is not), 5 km for a town name (the question is which town, not where in it). A catastrophic miss is nothing returned, the wrong country, or further than 5 km.

Measured 3 September 2026, corpus uk-v1, against this API:

ClassnToleranceTop-1
Locality, bare town name125 km12/12
Postcode alone302 km28/30
Full address with postcode30250 m22/30
Place name with street and town30250 m22/30
Structured components29250 m21/29
Street and town, no postcode26250 m18/26
Bare place name, unbiased30250 m18/30
Misspelt place name20250 m12/20
All207153/207 (73.9%)

Top-3: 163/207. Catastrophic miss: 17 (8.2%). p50 90 ms, p90 323 ms.

The caveat that runs in our favour, stated anyway. 19 of the 54 top-1 misses are inside 1 km, and they cluster on features that are not points: airports, universities, large stations. The ground truth for Manchester Airport is its published reference point, so an answer naming Manchester Airport 530 m away is scored wrong. Read at a 1 km tolerance the total is 172/207 rather than 153/207. We report the 250 m number as the headline because a tolerance chosen after seeing the results is not a measurement, but you should know which way that 19 cuts.

Now the parts that do not flatter us. The result file lists every single miss with the distance and what came back instead, and three of them are worth naming here.

Structured search can return nothing where free text returns the answer. Seven of the eight structured misses returned an empty list, and for five of them the postcode component alone is the cause: drop it, re-send the same query, and the right address comes back. Components exclude by design, which is the feature, but it means a postcode our index does not carry rules out the correct address instead of degrading to it. If you send structured components and get nothing, retry without the postcode.

Bare place names with overseas twins go abroad. Unbiased, "Anfield" answers in Canada, "Stonehenge" answers in Canada, and "St Thomas' Hospital" answers in Tamil Nadu. Bare names are our weakest catastrophic class, and constraining the search helps less than you would hope: re-running those same 30 queries with a bbox around Great Britain moved top-1 from 18/30 to 19/30 and catastrophic misses from 5 to 3. So a bounding box removes the answers that were in the wrong country, and does not by itself find the right building. We keep the unbiased queries in the headline number because leaving them out would flatter it.

Misspellings are our weakest class overall at 12/20. One realistic typo per name, plus the town, which is what people actually type.

One number, two engines. The hosted API answers free text through a Photon proxy today while structured search and reverse geocoding come from our first-party index (see the top of this section). So the table above measures this API as you would call it, not either engine on its own. The first-party engine is benchmarked separately against the 220-query world corpus and scored 169/220 top-1 within 250 m on the same day, at a 1.8% catastrophic-miss rate. Point the harness at a gateway with SN_GEOCODE_FORWARD unset and you can reproduce that too.

Reproducing it.

bash
node scripts/geocode-bench/run.mjs --dry-run   # no network, no keys
MAPMAP_API_KEY=… node scripts/geocode-bench/run.mjs

GOOGLE_GEOCODING_API_KEY and NEXTBILLION_API_KEY add those vendors when set. We have not published comparative numbers for either, because we have not run them, and a vendor the harness could not reach is reported "not run" rather than scored zero. That distinction is not pedantry: our own weekly quality gate once published a total failure for a perfectly healthy index because the test rig had lost its API key and every query answered 401.

Boundaries and route primitives

Three endpoints, each cheap and previously unexposed even though the data or engine capability already existed underneath.

GET /boundary?lat=&lon=: point-in-polygon against the same administrative-boundary database Valhalla's own routing already consults, now exposed read-only. Returns the containing country and sub-divisions:

json
{
  "point": { "lat": 51.5074, "lon": -0.1278 },
  "admins": [
    { "admin_level": 2, "iso_code": "GB", "name": "United Kingdom", "drive_on_right": false },
    { "admin_level": 4, "iso_code": "ENG", "name": "England", "drive_on_right": false }
  ]
}

An empty admins array is a normal answer (open sea, or a genuine gap in a deployment's admin-boundary coverage), never an error. 501 when the operator has not set SN_ADMIN_BOUNDARIES_DB.

POST /route/report: a summary over an existing route (locations) or trace (shape/encoded_polyline): total distance and duration broken down by road_class, by admin area, plus toll/bridge/tunnel counts and a surface breakdown. No new engine capability; it aggregates /trace_attributes internally, so it costs one ordinary metered call, not two. Paired with POST /trace_route it is the trip-replay and mileage-report workflow (a day of raw GPS fixes in, distance by road class and administrative area out), worked through end to end in the analysis guide.

POST /route/progress: the live half of the same question. Given the plan (the polyline /route already returned, or an origin and destination) and where the vehicle is now, it returns the snapped position, how far along the plan it sits, distance and duration still to run recomputed through the engine, an off-route flag with its measured distance, and an ETA carrying the same traffic provenance traffic confidence reports. It stores nothing: the plan and the position arrive on each request and are discarded with it, so progress and ETA are available without any vehicle position ever becoming our data. Details and the two snapping methods are in the analysis guide.

POST /locate and POST /centroid: two Valhalla services that had no client bindings until now. /locate snaps a coordinate to the graph and reports the edges (way ids, side of street) found there. /centroid is "meet in the middle": given several locations, it finds the point in the road graph where paths from all of them converge for the least total cost, and returns one path per location to that shared point: a primitive absent from Google, Mapbox, HERE, TomTom and NextBillion alike. Both require SN_ROUTING_ENGINE=valhalla or auto (the default); a GraphHopper-only deployment answers 501, since GraphHopper has no equivalent service.

Verify places: the geo-hallucination firewall

POST /api/verify on the website today; see Gateway path below for the intended first-class endpoint. Checks whether places (and itineraries) an AI mentioned are real, findable and physically possible, using MapMap's own /geocode and /matrix as evidence. Also exposed as the verify_places MCP tool (see MCP).

The three-state verdict

Every claim gets exactly one of three verdicts. Never a boolean.

VerdictMeaning
verifiedMatched a real place in MapMap's index: the matched place (with a stable id), and the source and date of the evidence.
contradictedA specific, dated, sourced fact contradicts the claim; in this release, only an itinerary leg the routing engine proves cannot be driven in the stated time. Comes with the contradicting source and date.
unverifiedNo evidence either way: no confident name match, or the check could not run.

Hard rule, non-negotiable: MapMap never asserts that a named real business does not exist or has closed. That is a pure defamation risk; we can be wrong about a business we have never visited, and "closed" or "never existed" are exactly the claims that damage a real business if wrong. The only negative outputs this API can produce are "contradicted, with a dated source" or "unverified". This is enforced in code, not just convention: every sentence the implementation generates comes from a fixed template switched over the three-member verdict type (a fourth verdict, some "does not exist" state, is impossible to add without a compile error), and the test suite statically scans the implementation source for banned phrases ("does not exist", "has closed", "no longer trading", …) and separately fuzzes the render path with adversarial claim names to prove the guard survives real, unsanitised input. See website/lib/verify-places.ts and verify-places.test.ts in the repo.

Request

Structured claims (reliable; supports itinerary feasibility):

json
POST /api/verify
Content-Type: application/json

{
  "claims": [
    { "id": "breakfast", "name": "The Roman Baths", "locality": "Bath", "sequence": 1, "claimed_time": "2026-08-01T09:00:00+01:00" },
    { "id": "meeting", "name": "Edinburgh Castle", "locality": "Edinburgh", "sequence": 2, "claimed_time": "2026-08-01T10:00:00+01:00" }
  ],
  "costing": "auto"
}

Or free text (best-effort extraction; existence checks only, no itinerary feasibility, since there are no explicit times to anchor legs to):

json
{ "text": "Breakfast at the Roman Baths in Bath, then a 10am meeting at Edinburgh Castle." }
FieldTypeNotes
textstringFree text to extract place claims from. Mutually exclusive with claims. Max 8,000 characters.
claimsarrayStructured claims (below). Mutually exclusive with text. Max 20 per request.
costingstringOptional, default auto. Same values as /matrix. Used for feasibility legs.

One claim:

FieldTypeRequiredNotes
namestringyesThe place name as claimed.
idstringnoCaller-chosen id, echoed back. Auto-assigned (claim-1, …) when absent.
localitystringnoDisambiguating context, e.g. "Bath". Country-level words (UK, England, …) are stripped before querying; appending them to a name search adds noise, not signal (confirmed against the live gateway: "…, Oxford, UK" ranked an unrelated place literally named "… UK" above the correct Oxford result; "…, Oxford" did not).
sequencenumbernoItinerary position. Claims sharing increasing sequence values, both carrying claimed_time, form legs the feasibility pass checks.
claimed_timestring (ISO 8601)noWhen the itinerary claims you are at this place.

Response

json
{
  "results": [
    {
      "id": "breakfast",
      "claim": { "name": "The Roman Baths", "locality": "Bath", "sequence": 1 },
      "verdict": "verified",
      "match": { "id": "osm:w500279537", "name": "The Roman Baths", "lat": 51.3810283, "lon": -2.359675, "category": "attraction" },
      "evidence": [
        { "source": "mapmap-geocode", "checked_at": "2026-07-26T15:53:57.710Z", "detail": "matched \"The Roman Baths\" (attraction) in the MapMap geocode index." }
      ]
    },
    {
      "id": "meeting",
      "claim": { "name": "Edinburgh Castle", "locality": "Edinburgh", "sequence": 2 },
      "verdict": "verified",
      "match": { "id": "osm:w4301292", "name": "Edinburgh Castle", "lat": 55.9486884, "lon": -3.2004184, "category": "attraction" },
      "evidence": [
        { "source": "mapmap-geocode", "checked_at": "2026-07-26T15:53:58.000Z", "detail": "matched \"Edinburgh Castle\" (attraction) in the MapMap geocode index." },
        { "source": "mapmap-matrix", "checked_at": "2026-07-26T15:53:58.098Z", "detail": "the routing engine declined to compute this leg because it exceeds the engine's maximum routable distance, so feasibility could not be confirmed either way. The leg is long, but no travel time was computed, so nothing here contradicts the claimed times." }
      ],
      "feasibility": {
        "from_id": "breakfast", "from_name": "The Roman Baths",
        "to_id": "meeting", "to_name": "Edinburgh Castle",
        "available_minutes": 60, "travel_time_minutes": null, "status": "implausible"
      }
    }
  ],
  "summary": "2 claims checked: 2 verified, 0 contradicted, 0 unverified. breakfast (\"The Roman Baths\"): verified. meeting (\"Edinburgh Castle\"): verified. Leg breakfast -> meeting is implausible: uncomputable needed, 60 minutes claimed."
}

This is a real response captured against the production gateway (https://api.mapmap.ai) on 26 July 2026.

summary is always present as a plain string alongside the structured results; several MCP clients (notably ChatGPT connectors) drop non-text content blocks, so the text summary must stand on its own.

evidence[].checked_at is when MapMap gathered that piece of evidence (the check's own timestamp), not necessarily the underlying map data's edit date; the gateway does not expose OpenStreetMap edit timestamps today.

feasibility.status is feasible, implausible (tight; flagged, not asserted as impossible) or impossible. Only impossible flips the arriving stop's verdict to contradicted; implausible is a flag only, since the evidence does not clear the bar for a contradiction.

Errors

StatusWhen
400Malformed body, unsupported costing, more than 20 claims, text over 8,000 characters, or free text with no extractable claims.
413Request body too large.
429Per-IP rate limit exceeded (Retry-After set).
502The upstream geocode/matrix check failed unexpectedly.
503The deployment has no gateway URL / demo key configured.

Evidence sources

  • Existence and location: GET /geocode (above), matched against the claimed name with a conservative token-overlap threshold. Deliberately strict: a false "unverified" is safe (it just means "no evidence either way"); a false "verified" defeats the product. Live testing found "The Sherlock Holmes Museum Annexe" (a plausible-sounding embellishment of the real, annexe-free Sherlock Holmes Museum) scoring 0.75 token-overlap against the real name, which is why the threshold sits at 0.85.
  • Feasibility: POST /matrix between consecutive itinerary stops, in the request's costing. When the routing engine cannot even compute a leg (beyond its maximum routable distance) that is reported as implausible, not impossible: the engine's distance cap says nothing about the claimed time window, so only a real computed travel time may contradict a claim. The actual computed (or uncomputable) travel time is always included in the evidence.
  • Your own places: if you maintain a proprietary dataset via /places, GET /places/search and GET /places/nearest are the equivalent existence check scoped to your own data; pass your own API key. Not wired into /api/verify in this release; noted here because "is this claimed branch one of ours" is a different, and differently-evidenced, question from "does this place exist anywhere in the world".
  • Closure signals: the gateway's world index carries no explicit "closed" flag (it is a live OpenStreetMap-derived snapshot, not a directory with lifecycle status), so a missing match is unverified, never evidence of closure. Where a matched POI's details carries a website, a future iteration could check that domain's own JSON-LD or microdata for its own opening-hours claims: the business's own site only, respecting robots.txt, never Google/Yelp/aggregators (both a ToS problem and, in the UK specifically, a database-right problem: the UK has no commercial text-and-data-mining exception). Not implemented in this release.

Gateway path (not yet implemented)

This release ships only the website's demo proxy, POST /api/verify (a server-held demo key, rate-limited, the same pattern as /api/route-demo and /api/map-issue). The natural production surface is a first-class POST /verify on the gateway itself, authenticated with the caller's own key, metered at Standard rate (one call per claim's existence check, one per feasibility leg, mirroring how /geocode and /matrix already bill), sharing this exact request/response contract. That sn-gateway addition is tracked but out of scope for this change, and needs a box rebuild to deploy. Until then, call the website route or the verify_places MCP tool.

Transit

Public transport for display. Today that is England's local buses: live positions from the DfT Bus Open Data Service (BODS), one per vehicle, refreshed every minute; bus and coach stops from NaPTAN; and timetabled departures from the BODS timetables. Nothing here computes a journey time, a timetable route or an arrival prediction, and no field may be read as one. Guide: Buses.

Live buses: GET /v1/transit/vehicles

bash
curl -s "$BASE/v1/transit/vehicles?bbox=-0.2,51.45,0.05,51.55" \
  -H "Authorization: Bearer $MAPMAP_KEY"
ParameterRequiredMeaning
bboxyeswest,south,east,north in degrees, at most 4 square degrees (for example 2 by 2 degrees)
operatornoOne operator's vehicles, by National Operator Code (e.g. TFLO)
linenoOne line as riders see it (e.g. 88)
limitno1 to 5,000 features (default 2,000). Past it, truncated is true and the features are spread evenly across the box

The answer is a GeoJSON FeatureCollection. Each feature is a Point with a stable id (<operator>:<vehicle>) and the feed's line, operator, direction (outbound, inbound or null), origin, destination, bearing, recorded_at and age_s. Alongside the features:

  • feed: how fresh the data is. status is ok, stale (the newest fix is over 180 seconds old, or the feed itself has stopped arriving) or unknown. A stale feed still returns its features: draw them as paused, do not blank the map.
  • coverage: "England local bus services (BODS SIRI-VM). Scotland, Wales and rail are not in this feed."
  • attribution: the Open Government Licence v3.0 statement. Show it with the data.

A box over 4 square degrees is a 400 that names the cap, and costs nothing. One Standard call per request, and a browser (maps) key may call it. A map that polls every 30 seconds makes 2,880 calls a day per open page, so poll only what is on screen. The full contract is in the OpenAPI document and docs/API.md.

On a map: TransitVehiclesLayer

The web SDK draws the same data for you, and moves the buses smoothly:

ts
import { MapMapMap, TransitVehiclesLayer } from "@mapmap/maps";

const map = new MapMapMap({ container: "map", apiKey: "snk_…", center: [-0.12, 51.5], zoom: 13 });
const buses = new TransitVehiclesLayer(map, {
  onFeedStatus: ({ label }) => (statusPill.textContent = label),
  popup: (bus) => `${bus.line} to ${bus.destination ?? "unknown"}`,
});
buses.start();
  • It polls the area on screen (500 ms after the map stops moving) every 30 seconds by default (pollMs, never under 15 seconds), through the map's own key, and not at all while the page is hidden. A viewport over the 4 square degree cap is shrunk around its centre (capTransitBbox).
  • It draws one poll behind the data and glides each bus between its two latest reported fixes, at most 10 map updates a second. A jump of over 300 m snaps instead of gliding across a block, and a bus missing from the feed for over 150 seconds is dropped. The rules are one pure function, interpolateFixes(prev, next, atMs), with fixSetFromCollection to read an answer and renderClockMs for the one-poll-behind clock, if you draw vehicles yourself (types FixSet, TransitFix, Rendered, TransitVehicle).
  • When the feed goes stale the buses stay, dimmed, and onFeedStatus hands you the standard label from transitFreshnessLabel: "Live, 42 s ago", "Paused: last data 6 min ago" or "Feed status unknown" (TransitFeedStatus is ok, stale or unknown).
  • TransitVehiclesLayerOptions also takes a fixed bbox, operator, line, limit, an icon (the default "arrow" points along the heading) and a slot (TransitSlot: bottom, middle or the default top, above POI labels and below place names). stop() pauses, remove() takes it off the map, vehicles() lists what is drawn.
  • It adds the short credit BODS_SHORT_CREDIT ("Bus data: DfT Bus Open Data Service, OGL v3.0") to the map's one credit line.

Stops near a point: GET /v1/transit/stops

bash
curl -s "$BASE/v1/transit/stops?lat=51.5080&lon=-0.1281&radius_m=300" \
  -H "Authorization: Bearer $MAPMAP_KEY"
ParameterRequiredMeaning
lat, lonyesThe point, in degrees
radius_mno1 to 2,000 metres (default 500)
limitno1 to 50 stops (default 10), nearest first

England's bus and coach stops from NaPTAN (rail, tram, metro and ferry stops are not included). Each stop has its ATCO code id, name, indicator (e.g. "Stop S"), locality, lat, lon, distance_m and bearing_deg from your point, and lines, the line names the BODS timetables have calling there. Every answer carries coverage and the NaPTAN and BODS attribution (both Open Government Licence v3.0).

Departures at a stop: GET /v1/transit/departures

bash
curl -s "$BASE/v1/transit/departures?stop=490013767T&limit=6" \
  -H "Authorization: Bearer $MAPMAP_KEY"
ParameterRequiredMeaning
stopyesThe stop's ATCO code, from /v1/transit/stops
atnoStart of the window, RFC 3339 (default now). The index holds three service days from today
window_minutesno1 to 180 (default 60)
limitno1 to 30 departures (default 8)

Timetabled departures from the BODS timetables, in order, each with line, destination, operator, scheduled_at (local time) and trip. basis is always timetable, and every answer carries this label: "Timetable times from Bus Open Data timetables. A live entry shows a vehicle on this line observed upstream of the stop. MapMap does not predict arrival times." Show it with the departures.

A departure may carry live (vehicle, distance_m, age_s): a bus on the same line, seen in the last five minutes, within 3 km of the stop and heading towards it. It is an observation of where that bus is, never an arrival time. Refusals cost nothing: a malformed request or an at outside the timetable window is a 400 (the message names the window), an unknown stop a 404, and a deployment without the timetable index a 501. One Standard call per request; a browser (maps) key may call both. Guide: Buses.

Integration feedback

POST /v1/feedback takes one integration retro: the short structured report a developer (or, with the developer's approval, their AI coding agent) sends once a MapMap integration works or is abandoned. It is key-authenticated, quota-free and never charged, the same posture as POST /v1/probe (aggregate probe contribution: also key-authenticated, also free, but gated on the key's probe_opt_in capability and 403 without it; full contract on data collection). Agents usually reach /v1/feedback through the MCP submit_integration_retro tool rather than by hand; see the MCP server page.

bash
curl -X POST "$BASE/v1/feedback" \
  -H "Authorization: Bearer $API_KEY" \
  -H 'Content-Type: application/json' \
  -d '{
    "what_built": "A store finder with restriction-aware truck routing.",
    "problems": [
      { "area": "docs", "description": "Tunnel codes must be URL-encoded on the compatible endpoint.", "workaround_found": true }
    ],
    "gotchas": ["Bare coordinates are lon,lat."],
    "wins": ["check_adr_tunnel answered without a route call."],
    "docs_gaps": ["No worked ADR example for the OSRM-compatible path."],
    "agent_name": "Claude Code",
    "sdk_version": "@mapmap/maps 0.15.0"
  }'
FieldRules
what_builtRequired, non-empty, ≤500 bytes
problems[]≤20 entries of {area, description, workaround_found}; area is one of sdk, api, mcp, docs, billing, self-host, other; description ≤1000 bytes
gotchas[] · wins[] · docs_gaps[]≤20 entries each, ≤500 bytes per entry
agent_nameOptional, ≤100 bytes
sdk_versionOptional, ≤50 bytes

A stored retro answers 202 Accepted:

json
{ "id": "9c1f2a6e-…", "status": "received" }

Each key may submit 5 retros per rolling 24 hours; the sixth is a 429 with problem type urn:sn-gateway:problem:feedback-velocity. A payload that breaks the schema (missing what_built, an unknown area, an over-long field or an over-list) is a 400 (bad-request). Unknown fields are dropped rather than stored.

Retro content is stored verbatim as untrusted text and reviewed by a person; it is never interpreted or executed. What is kept, for how long, and how to have it deleted is set out in the Agent Feedback Programme and the telemetry disclosure.

Static map images

Agents are vision models, not MapLibre runtimes: they can look at a map but cannot render one. GET/POST https://mapmap.ai/api/static-map renders a MapLibre view (a centre and zoom, a bbox, or auto-framed to whatever overlay you pass) to a PNG, WebP or JPEG image. This is a website endpoint, not a gateway one: no $BASE, no API key, mapmap.ai directly.

bash
# A plain city view
curl "https://mapmap.ai/api/static-map?center=-0.1276,51.5072&zoom=13&size=800x500" -o map.png

# A short route's encoded polyline fits in the query string (see the
# precision trap below). URL-encode it: polylines contain ?, @ and \.
# The whole query string is capped at 8,000 bytes; longer is a 414.
ROUTE_GEOMETRY=$(curl -fsS "$BASE/route/v1/driving/-0.1281,51.5080;-0.1246,51.5007?overview=full" \
  -H "Authorization: Bearer $API_KEY" | jq -r '.routes[0].geometry')
curl -G "https://mapmap.ai/api/static-map" \
  --data-urlencode "route=$ROUTE_GEOMETRY" -d precision=5 -d size=800x500 \
  -o route.png

# A real route is usually too long for a URL: London to Birmingham is
# about 11,000 characters of polyline6. Ask /route/v1 for GeoJSON and POST
# the geometry as the body instead (the body cap is 512,000 bytes).
curl -fsS "$BASE/route/v1/truck/-0.1276,51.5072;-1.8904,52.4862?height=4.0&weight=44.0&hazmat=true&tunnel_code=D&geometries=geojson" \
  -H "Authorization: Bearer $API_KEY" \
  | jq -c '.routes[0].geometry' > route.geojson
curl -X POST "https://mapmap.ai/api/static-map?size=800x500" \
  -H "Content-Type: application/json" \
  --data @route.geojson \
  -o route.png

# Render an /isochrone response; POST when the polygon is too big for a
# URL, which a real isochrone usually is
curl -X POST "https://mapmap.ai/api/static-map?size=800x500" \
  -H "Content-Type: application/json" \
  --data @isochrone-response.json \
  -o isochrone.png
ParameterMeaning
center=lon,lat + zoom=Fixed camera (zoom required with center, 0-20)
bbox=west,south,east,northFit to a box (GeoJSON bbox order); omit both center and bbox to auto-fit to whatever overlay you pass instead
size=WxH (or width=/height=)Up to 1280×1280; append @2x (or retina=2) for a retina image at double the pixel density
bearing= · pitch=Degrees; pitch capped at 85
style=A first-party base id for the site's own live-map style: light (default), dark, streets, midnight, navigator-day, navigator-night, fleet, outdoor, dataviz, backdrop or print. fleet gives its palette only, never its vehicle-restriction overlay, which is a runtime layer on mapmap.ai. Or a hosted style id from your own GET /styles listing, or an https:// style URL on an allowlisted MapMap host
format=png (default), webp or jpeg; quality=1-100 for the lossy formats
route= (or polyline=)An encoded polyline overlay; precision=6 (default) for POST /route's native shape, precision=5 for GET /route/v1's default OSRM-compatible shape (see the trap below); route_colour= and route_width= to restyle it. The whole query string is capped at 8,000 bytes (414 over it), which a route of more than a few kilometres exceeds: POST its GeoJSON geometry instead (body cap 512,000 bytes, 413 over it)
geojson= (repeatable) or a JSON POST bodyGeoJSON overlays: an isochrone's FeatureCollection, a shape, or a Feature; simplestyle-spec stroke/fill/fill-opacity properties are honoured
markers=lon,lat[,label[,colour]] (repeatable, ;-separated)Up to 50 pins; label is 1-2 characters
satellite=1Sentinel-2 imagery where the pilot region has coverage
buildings3d=13D building extrusions (pair with a pitch)
lang=local, or an ISO 639 code, for map labels
pois=0Hide POI labels (pois=1 shows them; omitted, the chosen style decides, and the navigator-*, dataviz and backdrop bases draw none)

Precision trap, found the hard way while building this: GET /route/v1/{profile}/{coordinates} (the OSRM-compatible routing endpoint) returns an encoded polyline at precision 5 by default (matching real OSRM), not 6. POST /route and the MCP route tool return precision 6 (the native Valhalla shape), which is also this endpoint's default. Passing a precision-5 string through with no precision=5 decodes silently to the wrong place (no error, just a badly-wrong map) so always add precision=5 for anything from /route/v1 that did not explicitly request geometries=polyline6.

Legal, always on: every image carries OpenStreetMap/OpenMapTiles attribution (plus Sentinel-2/Copernicus wording when satellite=1 renders imagery) and the MapMap mark, bottom-right. No parameter removes either. The credit is always one line beside the mark, never wrapped: 6 px MapMap Sans (CSS px, so 12 image pixels in an @2x image), black at 80 % on a white chip at 65 %. A narrow image first gets the same words smaller, down to 5 px, then the next shorter form, each tried at 6 px and then shrunk ("© OpenStreetMap contributors © OpenMapTiles", then "© OpenStreetMap © OpenMapTiles", then "© OpenStreetMap"). A licence notice from another source is never shortened, so a request too narrow to show it in full on one line is refused with a 400 that names the minimum width: currently 284 px wide with satellite=1, 253 px for style=outdoor (its terrain credit) and 389 px for both.

Caching: responses carry an ETag and a Cache-Control. A render pinned to a first-party base or a versioned style comes back public, max-age=86400, immutable (a day in your cache, longer at the CDN); an unpinned hosted style that could be republished gets five minutes. Send If-None-Match and expect 304s on a hot map.

Limits: dimensions, overlay counts and payload size are capped (see the parameter table); requests are also rate-limited per client and by a concurrent-render ceiling, so this cannot become a free rendering farm. There is no API key on this endpoint; the guard is the limits above, not billing.

Keyed and metered renders

GET/POST $BASE/v1/static-map is the same endpoint behind your API key. It takes every parameter in the table above, unchanged, and returns the same image: the gateway checks the key, meters the call and hands the request to the same renderer, so there is one implementation and two front doors rather than two pictures that drift apart.

bash
curl -H "Authorization: Bearer $API_KEY" \
  "$BASE/v1/static-map?center=-0.1276,51.5072&zoom=13&size=800x500" -o map.png

Price: 15 standard units per image, whatever the image is. A retina 1280 px render with fifty markers costs what a bare city view costs, because the caps above bound the work either way. Fifteen rather than one because a render is not a lookup: it launches a browser, draws a full map view, and pulls the tiles, glyphs and sprites for it, so one image is most of a map view's tile traffic plus the browser that drew it. The standard unit is the one in pricing.json, which is where the money figure lives. A maps-scoped browser key may call this endpoint, exactly as it may fetch tiles.

Which door to use. The keyless mapmap.ai/api/static-map path above is not going anywhere: it is how an agent evaluates the endpoint before it has a key, and it keeps its public per-client rate limit and concurrent-render ceiling. Move to /v1/static-map when you want renders that are not competing with everyone else's evaluation traffic, and that show up on your own usage.

Errors. A parameter the renderer refuses comes back with the status it chose (400, 413 for an overlay body over the cap, 414 for a query string over it) as problem+json, and costs no units: no image was drawn. Anything else that produces no image, a renderer timeout or a failure of its own, is a single 504 problem+json (urn:sn-gateway:problem:static-map-unavailable). The gateway waits at most 30 seconds for the renderer (its upstream timeout, the same as its default whole-request deadline), and the renderer already retries a hung render once inside its own budget (see the POST body contract below), so a 504 here means both attempts failed or ran past that wall.

The POST body contract

Integrators have been discovering these by trial and error, so here they are stated plainly, verified against production:

  • The POST body IS the GeoJSON. Send the value itself: a Feature, a FeatureCollection, a bare geometry, or a JSON array of any of those. Do not wrap it in an envelope object. {"geojson": [...]} (or any other wrapper key) carries no coordinates of its own, so it fails with a 400, "a geojson value carried no usable coordinates", however valid the GeoJSON inside the wrapper is.
  • Styling applies per posted value, not per feature. Each top-level value you post becomes one overlay with one paint, read from its simplestyle properties; a multi-feature FeatureCollection is painted uniformly from its first feature. To draw several colours or styles in one image, post an array of Features, each carrying its own stroke/fill properties.
  • The query string carries everything that is not GeoJSON. size=, style=, format=, the @2x suffix and the rest of the parameter table above all still apply on a POST; only the overlay travels in the body. pois=false (an alias of pois=0) hides POI labels when the image is about your overlay rather than the base map.
  • 1280 pixels is the ceiling on either dimension. For more detail in the same frame, append @2x to size= for double pixel density instead of asking for a larger image.
  • The first render after idle is slower, and the renderer retries for you. The renderer is headless Chromium behind a serverless route with a 60-second function limit. Each attempt has fixed budgets: about 4 s to acquire the browser, 10 s to load the render page and 12 s for the style, tiles and glyphs to settle. A render that hangs is retried once, on a fresh browser, inside the same request, so you do not need a client-side retry loop for a hang. The keyed /v1/static-map sits behind the gateway's 30-second upstream wall; a hang followed by a healthy retry lands around 25 to 30 s, so a 504 at that wall is still possible. The ETag/Cache-Control behaviour above makes repeats cheap.

Two differently coloured routes in one card, with the camera auto-fitted to both:

bash
curl -X POST "https://mapmap.ai/api/static-map?size=1000x640&pois=false" \
  -H "Content-Type: application/json" \
  -d '[
    {
      "type": "Feature",
      "properties": { "stroke": "#1a6bff", "stroke-width": 5 },
      "geometry": { "type": "LineString", "coordinates":
        [[-0.1276, 51.5072], [-0.1426, 51.5014], [-0.1533, 51.5010]] }
    },
    {
      "type": "Feature",
      "properties": { "stroke": "#ff7a1f", "stroke-width": 5 },
      "geometry": { "type": "LineString", "coordinates":
        [[-0.1276, 51.5072], [-0.1195, 51.5033], [-0.1120, 51.5045]] }
    }
  ]' -o compare.png

render_map (agent/MCP surface)

The intended agent entry point is an MCP tool, render_map, wrapping this endpoint for exactly the constraints real MCP clients have today: a top-level image content block with raw base64 (never a data: prefix or a resource.blob, which several clients either mishandle or ignore), paired with a short text summary (some clients, ChatGPT connectors among them, silently drop image blocks and only ever see the text), a mimeType sniffed from the actual bytes, and a small default size (long edge around 1568px, targeting roughly 70kB) so one call doesn't blow a client's image token budget. The full tool schema is documented in this feature's PR description; as of this writing it is not yet live; it ships from the box-side MCP server (crates/sn-mcp) in a separate PR flagged "needs box rebuild", so call the HTTP endpoint above directly until then.

Re-planning mid-shift

A dispatch day drifts. Two drops land, a customer cancels, an urgent job arrives, a van breaks down, and the morning plan is now fiction. POST /optimise/replan (/optimize/replan is an alias) re-plans what is left without disturbing what has already happened.

We store no dispatch state

The obvious way to build this is to keep the plan on the server, give it an id, and let you send deltas against it. We deliberately do not. MapMap holds no dispatch state: no plan, no vehicle position, no completion log, nothing that records where a named driver was at a named time. POST /route/progress set the precedent (a live position is computed and thrown away, never written), and this endpoint keeps it.

So there is no request_id to quote back at us. You send the day back: the original problem, what has been done, and what has changed. That costs you bandwidth. What it buys is that there is no server-side object to go stale, to leak, to be subpoenaed, or to fall out of step with the telematics platform that actually owns the truth, and a re-plan is reproducible, because the same body always reduces to the same problem.

The body

The original POST /optimise body, unchanged, plus two fields. At least one progress entry or one change is required; without either, this is just POST /optimise with more typing, and you get a 400 saying so.

FieldShapeMeaning
progress[]{vehicle, completed_stop_ids[], current_position{lat,lon}, unavailable}One entry per vehicle that has done something. completed_stop_ids are jobs[].id or shipments[].pickup.id / .delivery.id, in the order they were served
changes.cancel_job_ids[]job idsJobs that no longer need doing
changes.add_jobs[]jobs[] objectsNew single-stop work, in the same shape /optimise takes
changes.add_shipments[]shipments[] objectsNew pickup+delivery pairs, likewise

What "locked" means, mechanically

Completed stops keep their vehicle and their order, and no re-plan can move them, because they already happened. The gateway guarantees that by construction, not by asking the solver nicely:

  1. every completed task is removed from the problem: a stop already served is not work the solver may schedule again;
  2. each reporting vehicle's start becomes current_position, or the location of its last completed stop if you did not send one;
  3. what is left is solved through exactly the /optimise pipeline; and
  4. the prefix that was locked is reported back per vehicle.

VROOM does have a per-vehicle steps array, and it is tempting to read that as forced steps. It is not a prefix lock: its own API documentation gives it two meanings: plan mode, where the steps describe the whole route and nothing is optimised, and ordinary solving mode, where they are a search starting point the solver is free to tear up. Neither pins a prefix and optimises the tail, so we do not pretend otherwise. A task that is not in the problem cannot be moved by any solver, and that is a guarantee the sidecar cannot break.

Because of that, routes[] cover the remaining work only. The locked prefix is reported in replan.locked_stops_per_vehicle, not re-emitted as route steps: its arrival times already happened, and inventing them would be worse than omitting them.

Worked example: two drops, one cancellation, one urgent job

The morning plan, optimised at 08:00, was two vans out of a London depot with three drops. By 11:00 van 1 has served jobs 10 and 11 and is sitting in Manchester; job 12 has been cancelled; and job 20 has just come in for York.

bash
curl -sS -X POST "$BASE/optimise/replan" \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "vehicles": [
      {"id": 1, "start": {"lat": 51.5074, "lon": -0.1278},
                "end":   {"lat": 51.5074, "lon": -0.1278}},
      {"id": 2, "start": {"lat": 52.4862, "lon": -1.8904}}
    ],
    "jobs": [
      {"id": 10, "location": {"lat": 52.4862, "lon": -1.8904}, "service_s": 300},
      {"id": 11, "location": {"lat": 53.4808, "lon": -2.2426}, "service_s": 300},
      {"id": 12, "location": {"lat": 53.8008, "lon": -1.5491}, "service_s": 300}
    ],
    "costing": "auto",
    "progress": [
      {"vehicle": 1,
       "completed_stop_ids": [10, 11],
       "current_position": {"lat": 53.4808, "lon": -2.2426}}
    ],
    "changes": {
      "cancel_job_ids": [12],
      "add_jobs": [
        {"id": 20, "location": {"lat": 53.9599, "lon": -1.0873}, "service_s": 600}
      ]
    }
  }'

The answer is an ordinary /optimise response (summary, unassigned, routes[], and a zones block if any remaining location sits in a clean-air zone) with one block added:

json
{
  "code": 0,
  "profile": "auto",
  "summary": { "routes": 1, "unassigned": 0, "cost": 7846, "duration": 7846,
               "distance": 213099, "service": 600, "waiting_time": 0 },
  "unassigned": [],
  "routes": [ { "vehicle": 2, "distance": 213099, "duration": 7846,
                "steps": [ "start", "job 20", "end" ] } ],
  "zones": { "at_locations": [ "…Birmingham CAZ, London LEZ, London ULEZ…" ] },
  "replan": {
    "mechanism": "locked-prefix elision",
    "basis": "MapMap stores no dispatch state. Completed stops are locked by removing them from the problem and starting each vehicle where it actually is…",
    "locked_stops_per_vehicle": [
      { "vehicle": 1, "stops": [10, 11], "unavailable": false,
        "start_basis": "current_position",
        "start_from": { "lat": 53.4808, "lon": -2.2426 } }
    ],
    "released_stops": [],
    "changes_applied": {
      "cancelled_jobs": [12],
      "added_jobs": [20],
      "added_shipments": [],
      "vehicles_unavailable": [],
      "completed_stops": 2
    },
    "notes": []
  }
}

That is the live answer (23 September 2026), with each step abbreviated to its type and job id and the zones block shortened. Van 1's re-plan starts from Manchester, not from the depot (start_from), but the solver gives job 20 to van 2, which drives Birmingham to York (213 km) and has no end depot, rather than to van 1, which must still finish in London. So routes[] holds one route, for vehicle 2. Jobs 10 and 11 are not in routes[] and not in unassigned: they are done, and stops: [10, 11] is the receipt. If every stop is complete, you get a valid answer with an empty routes[] rather than an error, and no engine work is commissioned at all.

Notes, and things we will not pretend to have done

replan.notes[] names every instruction that could not be carried out as asked, rather than silently ignoring it. Cancelling a job that has already been served says so ("a cancellation cannot un-drive a stop"). Cancelling an id that is not in the problem says so. A completed_stop_ids entry that matches nothing says so, and is not counted as locked.

Two rules are hard errors rather than notes, because they are contradictions only you can resolve: two vehicles claiming the same stop is a 400, and an add_jobs id that is already a task in the problem is a 400 (a re-plan addresses stops by id, so duplicates would make every later completed_stop_ids ambiguous).

Unassigned is honest. Anything the solver could not place comes back in unassigned exactly as on /optimise. Anything the re-plan could not hand to the solver comes back there too, with a reason the solver could never have supplied. The case that matters: a shipment whose pickup is done and whose delivery is not is goods physically on a van. If that van is still working, the shipment becomes a delivery-only job pinned to it (you will see a note saying so). If that van is marked unavailable, the delivery appears in unassigned with a reason naming the transfer it needs, because no re-plan can move a parcel between vehicles, and quietly dropping the stop is the one failure a dispatcher cannot spot.

Vehicles that drop out

"unavailable": true removes the vehicle from the fleet for this re-plan. What it had already completed stays completed and stays locked to it. Its outstanding work is simply back in the pool for whoever is left: the request carries the problem, not yesterday's assignment, so uncompleted stops were never pinned to anyone in the first place.

What it costs

The ordinary /optimise formula (ten units of its class per started 2,500-pair block of internal matrix) applied to the remaining problem, not to the morning plan you had to resend. Locked prefixes are not re-solved, so they are not billed. A 200-stop day with 150 stops done is billed as the 50-stop problem it is. The floor is one block, as on /optimise.

relax_if_unassigned works here too, on the remaining problem, and bills the same way it does on /optimise: two solves when the second one runs, one when it does not, with the reserved-and-unused solve handed back. A vehicle carrying reloads is a 400: a completion in progress does not say which trip it belongs to, so re-planning a tipping round would have to guess. Send the outstanding work to /optimise with the trips and shift the fleet has left.

Asynchronously

POST /v1/jobs/replan takes the same body on the asynchronous lane, at SN_OPTIMISE_MAX_LOCATIONS_ASYNC (2,000) remaining unique locations instead of 200, with the same submission-metering and full-refund-on- failure semantics as the other job kinds. See Asynchronous jobs.

Asynchronous jobs

POST /optimise accepts at most 200 unique locations and POST /matrix at most 10,000 elements. Neither number is a limit on what the engine or the solver can do; both are what finishes inside the gateway's 30-second request deadline. A fleet planning tomorrow's rounds does not need an answer in thirty seconds; it needs a bigger answer.

So the same problems have an asynchronous lane, with the same request bodies and much larger caps:

LaneEndpointCapSetting
Optimise, synchronousPOST /optimise200 unique locationsnone
Optimise, asynchronousPOST /v1/jobs/optimise2,000 unique locationsSN_OPTIMISE_MAX_LOCATIONS_ASYNC
Re-plan, synchronousPOST /optimise/replan200 unique locations, counted on the remaining problemnone
Re-plan, asynchronousPOST /v1/jobs/replan2,000 unique locations, likewiseSN_OPTIMISE_MAX_LOCATIONS_ASYNC
Matrix, synchronousPOST /matrix10,000 elementsnone
Matrix, asynchronousPOST /v1/jobs/matrix40,000 elementsSN_MATRIX_MAX_ELEMENTS_ASYNC

The other way to fit a large day inside the synchronous cap is to stop treating it as one problem. POST /v1/cluster groups stops into balanced clusters (by count, by summed load, and never straddling a territory you name), so a 900-stop day becomes six 150-stop optimisations that each fit comfortably. That is the right answer when the rounds are real; the asynchronous lane is the right answer when they are not. See Territories and clustering.

Crossing a synchronous cap now gets a 422 that names the asynchronous lane in its detail and in an async_lane field, rather than telling you to make your problem smaller.

Submitting

The body is exactly the synchronous body, plus an optional webhook_url. Moving a working request onto the lane is a change of URL and nothing else.

bash
curl -sS -X POST "$BASE/v1/jobs/optimise" \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "vehicles": [{"id": 1, "start": {"lat": 51.5074, "lon": -0.1278}}],
    "jobs": [
      {"id": 10, "location": {"lat": 52.4862, "lon": -1.8904}},
      {"id": 11, "location": {"lat": 53.4808, "lon": -2.2426}}
    ],
    "costing": "auto"
  }'

To be notified when it finishes, add "webhook_url": "https://…" naming an HTTPS endpoint of your own that resolves publicly in DNS (see Webhooks). A placeholder host such as hooks.example.com does not resolve, so it is refused at submission with a 400 ("webhook_url names a host this gateway will not call: it must be a publicly resolvable name, never localhost or an internal-only domain").

202 Accepted:

json
{
  "id": "5030bc8f-3e6d-4f98-8b0e-bc6b52d437c2",
  "kind": "optimise",
  "status": "queued",
  "result_url": "https://api.mapmap.ai/v1/jobs/5030bc8f-3e6d-4f98-8b0e-bc6b52d437c2",
  "units_charged": 10
}

Polling

bash
curl -sS "$BASE/v1/jobs/$JOB_ID" -H "Authorization: Bearer $API_KEY"

status moves queued → running → succeeded or failed. While it is either of the first two there is no result and no error field at all, so an unfinished job can never be mistaken for an empty answer. Once it is succeeded, result carries exactly the body the synchronous endpoint would have returned: the two lanes run the same code.

Polling is free. GET /v1/jobs/{id} and GET /v1/jobs draw no quota units, like GET /v1/keys/self and GET /v1/usage. Poll as often as you find useful.

A job belongs to the key that submitted it, or to any key of the same account. Anybody else's job is a 404, never a 403: a 403 would confirm the id exists.

What it costs, and what a failure gives back

Units are charged at submission, on exactly the formula the synchronous endpoint uses: a 200-location problem costs the same either way. Moving work off the request path buys a bigger problem and a job id, never a discount.

If a job fails, every unit its submission drew is returned to your included volume, and any prepaid credit it debited is returned to your ledger. The job still reports units_charged (what was charged) alongside refunded: true (that it came back), so the two together are an audit trail rather than a number that quietly changed. The one exception is a submission paid with an x402 payment: an on-chain settlement cannot be reversed, so those return quota units only.

A submission refused before a worker ever looks at it (a malformed body, an over-cap problem, an unusable webhook_url, or the pending-jobs cap) costs nothing at all.

Backpressure

One account may have 10 jobs queued or running at once (SN_JOBS_MAX_PENDING). Over that, submission answers 429 with urn:sn-gateway:problem:jobs-pending-limit, naming the cap and what is currently pending. It is a capacity limit and not a billing one, so the body points at /v1/jobs rather than at a top-up page: waiting is the fix, not paying.

Retention

A finished job's result is kept for 7 days (SN_JOBS_TTL_DAYS), then the row is deleted. A swept job is a 404, not a job reporting success with nothing in it. Queued and running jobs are never swept by age.

Webhooks

Pass webhook_url and the gateway POSTs a notification when the job finishes. The delivery is deliberately content-free:

json
{
  "job_id": "0f7c2a1e-9f2b-4c31-9a52-8f2a0c5b1d44",
  "kind": "optimise",
  "status": "succeeded",
  "result_url": "https://api.mapmap.ai/v1/jobs/0f7c2a1e-9f2b-4c31-9a52-8f2a0c5b1d44"
}

The result is never pushed. A 2,000-location plan is megabytes, and an endpoint that may be slow, may be down and may be logging request bodies is the wrong place to send one. Fetch it from result_url with your API key when you are ready.

The target must be https and must resolve to a public address. Loopback, private, link-local (including cloud metadata addresses) and non-resolvable hosts are refused at submission with a 400, as are URLs carrying credentials: the gateway will not be used to reach inside a network on a caller's behalf.

Delivery is attempted three times with exponential backoff. After that the job records webhook_status: "delivery_failed" and the gateway stops trying; there is no persistent retry queue. The job itself is unaffected: poll it.

Verifying a webhook

Mint a signing secret first. It is returned once; calling again rotates it and invalidates the previous one.

bash
curl -sS -X POST "$BASE/v1/keys/self/webhook-secret" \
  -H "Authorization: Bearer $API_KEY"
json
{
  "key_id": "5f1c…",
  "webhook_secret": "9d4b…64 hex characters…",
  "signature_header": "X-MapMap-Signature",
  "created_at": "2026-09-03T09:41:12Z"
}

Submitting a webhook_url on a key with no secret is a 409 (urn:sn-gateway:problem:webhook-secret-missing) rather than an unsigned delivery: a notification anybody who learns your URL could forge is not worth sending.

Every delivery carries X-MapMap-Signature: the lowercase hex HMAC-SHA256 of the raw request body under your secret.

js
import { createHmac, timingSafeEqual } from "node:crypto";

// `raw` must be the exact bytes of the request body. Verify BEFORE
// JSON.parse: re-serialising changes the spacing and the digest with it.
export function verify(raw, header, secret) {
  const expected = createHmac("sha256", secret).update(raw).digest("hex");
  const a = Buffer.from(expected, "utf8");
  const b = Buffer.from(header ?? "", "utf8");
  return a.length === b.length && timingSafeEqual(a, b);
}
python
import hashlib
import hmac

def verify(raw: bytes, header: str, secret: str) -> bool:
    """`raw` is the exact request body. Verify before parsing it."""
    expected = hmac.new(secret.encode(), raw, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, header or "")

Two rules make or break this: sign the raw bytes you received, not a re-encoding of the parsed object, and compare in constant time.

Self-hosting note

Raising SN_OPTIMISE_MAX_LOCATIONS_ASYNC above 1,000 needs matching changes on your VROOM sidecar, or large jobs fail before the solver sees them. vroom-express ships maxlocations: 1000 and a 1mb request-size limit; the gateway sends an explicit N × N duration matrix and the matching distance matrix, which at 2,000 locations is tens of megabytes. Raise maxlocations, limit and timeout together.

Errors, RFC 9457 problem+json

Errors are application/problem+json, with two exceptions: the compatible URL endpoint uses OSRM's own error envelope (clients dispatch on {"code": …}), and 402 bodies are plain JSON: either the x402 wire format (HTTP-native machine payments, see machine payments) or, on territory layer downloads, a download-allowance upgrade pointer. The type is a stable URN, urn:sn-gateway:problem:<slug>; match on it, not on human wording. There is no instance field.

Example problem body (a key over its monthly quota):

json
{
  "type": "urn:sn-gateway:problem:quota-exceeded",
  "title": "Monthly quota exceeded",
  "status": 429,
  "detail": "monthly quota exceeded (50000/50000)",
  "quota": 50000,
  "used": 50000,
  "resets_at": "2026-10-01T00:00:00Z",
  "period_ends": "2026-09-29T18:49:01Z",
  "upgrade": "https://mapmap.ai/pricing",
  "upgrade_url": "https://mapmap.ai/pricing",
  "usage_url": "https://mapmap.ai/console",
  "topup": "https://mapmap.ai/account"
}

resets_at is always the calendar-month reset. period_ends is present when the account has its own billing period (a subscription), and is the date that actually answers "when can I call again"; it is omitted rather than guessed at when there is none. upgrade_url and usage_url are the same two pointers the quota-threshold warning emails and quota.threshold webhooks carry, under the same names, so one client can read both. The older upgrade and topup fields are unchanged.

You should not normally meet this wall without warning: at 50%, 80% and 100% of the monthly quota the gateway emails the account and, where a webhook target is registered, delivers a quota.threshold event carrying the units used, the run rate, the projected end-of-period total and the plan that fits it.

StatusProblem slug(s)When
400bad-request · invalid-email · tos-not-accepted · costing-conflictMalformed request, invalid/disposable email, ToS not accepted, costing_options conflicting with the adr profile
400upstream-errorThe routing engine rejected the request (upstream 4xx, relayed; upstream_status in the body)
401unauthorized · admin-unauthorizedMissing/invalid API key or admin token
402n/a (plain JSON, not problem+json)Payment required: x402 body on machine-payment endpoints (the wall a beyond-quota call meets when machine payments are configured); {"code": "download_allowance_exceeded", "upgrade": …} on territory layer downloads
403forbiddenAuthenticated but not permitted, e.g. a provisional (unverified) key attempting a territory layer download
404not-foundUnknown key/identity/path
409key-cap · no-identity-ledgerIdentity key limit reached; crediting a key with no identity ledger
413places-too-manyA PUT /places body over the 10,000-place cap
422invalid-theme · invalid-places · optimisation-too-largeStyle theme or places dataset failed validation (machine-readable problems list in the body); optimisation problem over the cap of 200 unique locations (max_locations/locations in the body)
422matrix-too-large · matrix-span-too-largeMatrix over the 10,000-element cap (max_pairs/max_square_locations/sources/targets/pairs), which splitting fixes; or the furthest source-to-target pair beyond the engine's matrix span, 1,500 km on the hosted deployment and 400 km on a stock self-host (SN_ENGINE_MAX_MATRIX_DISTANCE_M; max_span_km/span_km), which splitting does not fix, because that pair lands in whichever block it falls in
422route-not-splittableMore waypoints than the engine takes in one call, on a route that cannot be split faithfully: a run of more than 20 consecutive through/via/break_through waypoints, a time-dependent route (date_time type 0, 1 or 2), or a request for alternates. detail says which
429quota-exceeded · rate-limited · issuance-velocity · feedback-velocity · map-issue-velocityMonthly quota, per-minute rate limit (Retry-After header), per-IP signup velocity, or a key past its daily cap on integration retros / map-issue reports. The quota-exceeded body carries resets_at, period_ends (when the account has its own billing period), upgrade_url, usage_url and topup
501geocoding-not-enabled/geocode or /geocode/reverse on a deployment with neither SN_GEOCODE_DIR nor SN_PHOTON_URL set (a self-host concern; the hosted gateway serves both)
501structured-search-not-enabledq combined with structured components on a Photon-proxy-only deployment; Photon's structured endpoint takes no free text
422batch-too-largePOST /geocode/batch with more than 1,000 queries; body carries max_queries and queries
422cluster-too-largePOST /v1/cluster with more than 5,000 locations, or a locations x clusters assignment pass over 500,000; body carries max, got and limit (locations or assignment_work) so you can tell which ceiling fired
502upstream-errorThe routing engine (or geocoder backend) failed (upstream 5xx)
503upstream-unavailable · optimisation-not-enabledRouting backend unreachable (retry with backoff); /optimise on a deployment without a VROOM sidecar
504upstream-timeoutRouting backend timed out, retry with backoff
500internalUnexpected failure; detail is logged server-side, never leaked

Gateway-origin 400s and relayed upstream 400s carry different slugs, so callers can tell them apart. The distinction between 402 (buy or upgrade) and 429 (back off) is spelled out on the conventions page.

map_as_text (agent-native map schematics)

POST /api/map-as-text: a website-hosted endpoint (not the gateway, no API key) that renders the street network around a point as a compact, token-budgeted ASCII schematic, built for an LLM to reason over instead of raw GeoJSON:

sh
curl -fsS -X POST "https://mapmap.ai/api/map-as-text" \
  -H "Content-Type: application/json" \
  -d '{"lon": -0.4546, "lat": 51.7526, "radius_m": 350, "token_budget": 2000}'

Returns {schematic, legend, truncated, token_estimate, bbox}: schematic is a plain-ASCII grid (junctions +, road ends ., the query point @, roads as - | / \ by compass direction, an unconnected crossing as X, a lettered/numbered legend of every road and POI shown, and honest degrade-to-budget behaviour (truncated: true plus exactly what was dropped) rather than ever overflowing token_budget. Those five fields are the whole response. Two request fields are not shown above: focus (driving or walking, which network to draw) and include_pois. Being a website route rather than a gateway one, it does not appear in GET /openapi.json.

Quotas

Self-serve tiers (verified in the gateway source; paid plans are on the pricing page):

TierRequestsRate limitNotes
Provisional (unverified)1,000 calls total60/minExpires after 72 h unless you verify; no territory downloads
Verified free50,000/calendar month60/minUp to 5 active keys per email; 8,192 MiB/month offline download allowance; 1,000 included monthly active users

Signup itself is limited to 3 key issuances per IP per UTC day. Self-hosted operators set their own numbers via the admin API.

On exceeding a rate limit or quota you get 429 Too Many Requests with Retry-After; honour it and back off exponentially. Usage is metered per key and visible at GET /v1/keys/self (authenticated, quota-free):

json
{
  "key_id": "1f3c9a2e-…",
  "state": "verified",
  "monthly_quota": 50000,
  "used_this_month": 1240,
  "remaining": 48760,
  "credits_pence": 0,
  "credits_millipence": 0,
  "expires_at": null,
  "first_success_at": "2026-07-19T12:04:31Z",
  "identity_keys": 1,
  "download_allowance_mib": 8192,
  "download_used_mib": 0,
  "mau_this_month": 0,
  "mau_included": 1000,
  "mau_overdrawn": false
}

first_success_at is the RFC 3339 timestamp of this key's first successful (2xx) metered call (its activation moment) or null if it has never had one. It is written once and never moves, so it is safe to treat as the key's birthday; because a buffered task stamps it, it can lag the actual first success by a moment.

Check your usage

GET /v1/keys/self tells you how much of your allowance is left. GET /v1/usage tells you where it went: a day-by-day, endpoint-by-endpoint breakdown, authenticated with your own key and quota-free to read.

bash
curl -s "$BASE/v1/usage?from=2026-09-01&to=2026-09-30" \
  -H "Authorization: Bearer $API_KEY"
json
{
  "key_id": "1f3c9a2e-…",
  "identity_pooled": true,
  "from": "2026-09-01",
  "to": "2026-09-30",
  "days": [
    { "day": "2026-09-01", "endpoints": { "/route": 412, "/geocode": 96 } },
    { "day": "2026-09-02", "endpoints": { "/route": 380, "/matrix": 1200 } }
  ],
  "totals": { "/route": 792, "/geocode": 96, "/matrix": 1200 },
  "total_units": 2088,
  "month_used": 2088,
  "monthly_quota": 50000,
  "balance_millipence": 0
}

Everything here is counted in quota units, not requests. A plain call draws one unit, but a matrix or isochrone draws one per started block of elements and premium work (truck routing with dimensions, hazmat, ADR tunnel codes) is weighted higher again. Your monthly allowance is spent in units, so units are what this endpoint reports. A /matrix total of 1,200 is not 1,200 requests.

FieldMeaning
identity_pooledtrue when your key belongs to an email identity, whose quota is shared by up to five keys; the figures then cover every key on the account, matching how the quota is actually enforced
daysOldest day first. Days with no usage are omitted, never reported as zero
endpoints / totalsKeyed by the endpoint label the meter stores; per-tile, per-style and OSRM paths are collapsed (/tiles, /styles, /route/v1)
month_usedThe live quota figure, identical to used_this_month on GET /v1/keys/self, including units buffered but not yet written, and independent of from/to

from and to are inclusive UTC days in YYYY-MM-DD. They default to the first of the current UTC month and today. A malformed date, a from after its to, or a window longer than 92 days is a 400 problem+json: page through longer histories a quarter at a time.

What this deployment runs: GET /v1/platform

Unauthenticated, unmetered and cached (Cache-Control: public, max-age=300). The machine-readable answer to what is behind this API: gateway build and version, routing engine and version, the routing graph's OSM snapshot date, when it was built and when it was swapped into production, the basemap archives, the geocoder and the declared elevation dataset. It exists so a provenance claim can be checked rather than believed, and it is keyless for the same reason.

json
{
  "generated_at": "2026-09-18T09:12:00Z",
  "gateway": { "build": "40188405", "version": "0.3.0" },
  "routing": {
    "engine": "valhalla",
    "engine_version": "3.8.2",
    "patches": 9,
    "graph": {
      "osm_timestamp": "2026-09-16T20:59:00Z",
      "built_at": "2026-09-17T04:43:00Z",
      "swapped_at": "2026-09-17T22:37:00Z",
      "tile_count": 209085,
      "sources": ["uk", "france"]
    }
  },
  "basemap": {
    "planet": { "archive": "planet-5ed6a0641ea6.pmtiles",
                "osm_timestamp": "2026-09-13T21:00:00Z",
                "built_at": "2026-09-14T23:38:40Z" },
    "world_relief": { "archive": "world-relief-06b43006123a.pmtiles",
                      "built_at": "2026-09-01T09:36:42Z" },
    "generator": "planetiler 0.8.3"
  },
  "geocoding": { "engine": "sn-geocode", "index_built_at": null, "index_version": "2" },
  "terrain": { "dataset": "mapzen-terrain-tiles", "resolution_m": 30 }
}

Rules this endpoint keeps, and that a client may rely on:

  • Every field is nullable, and null means unrecorded, never "current". A page rendering this must say unrecorded rather than fall back to a plausible date.
  • Nothing is fabricated. Each value is read from a manifest a build script wrote, a file's modification time, or a variable the operator declared. A missing, unreadable or malformed manifest yields nulls and still answers 200: this is a public record, and it must not go down because a file is absent.
  • No key material, no key counts, no identities, no usage. Those belong to the keyed status surface. This record carries versions, public archive filenames and build dates, and nothing else.
  • No map renderer version. MapLibre runs in the browser, so the gateway cannot observe it; the website adds it from its own vendor manifest.

The same record, rendered for a human, is on the status page.

Next steps

Truck routing: dimensions that reroute · 1:39 · all videos
ADR dangerous goods: the tunnel block · 1:42 · all videos