Documentation menu
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=:
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:
- Mint the replacement with
POST /v1/keys(orPOST /admin/keyson a self-hosted deployment). The two keys are independent, so both work while you migrate. - Move your integration onto the new key and confirm the old one has gone
quiet in
GET /v1/usage. - 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 andkey_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 isscheme://hostwith an optional port and no path (https://app.example.com,http://localhost:5173), or a single-level wildcard subdomain (https://*.example.com, which matcheshttps://staging.example.combut never the barehttps://example.comand neverhttps://a.b.example.com).scope:"maps"restricts the key to tiles, style/font/sprite reads, geocoding (including/geocode/suggestand/geocode/retrieve, the/geocode/selectionrelevance ping and the/places/searchand/places/neareststore-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:
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.
{
"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:
{
"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
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):
{
"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
| Method | Path | Purpose |
|---|---|---|
| POST | /route | Native routing request (rich JSON body) with the ADR extension |
| POST | /route/along | Along-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/check | ADR 8.6.4 tunnel-category compliance check for a load, no routing |
| POST | /geodata/validate | Does 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/crs | Reproject 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/reverse | Forward and reverse geocoding, when the self-hoster enables it (first-party SN_GEOCODE_DIR, or a SN_PHOTON_URL proxy); see Geocoding |
| GET | /geocode/suggest | Search-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/retrieve | The full feature for the row the user picked. One Standard call, never discounted; first-party index only, see Search as you type |
| POST | /geocode/batch | Up to 1,000 forward and/or reverse geocodes in one call, order preserved, per-query failures inline; see Batch geocoding |
| POST | /isochrone | Reachability polygons (GeoJSON) for a time or distance budget (see the analysis guide) |
| POST | /matrix | Many-to-many duration and distance matrix (see the analysis guide) |
| POST | /elevation | Point 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/along | Elevation 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 | /timezone | IANA 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_attributes | Map matching: snap a GPS trace to the road network, with per-edge attributes (see the analysis guide) |
| POST | /route/report | Distance/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/progress | Where 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/observations | What 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/heritage | Short 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 | /boundary | Administrative 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 · /centroid | Snap-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/along | Safety 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/along | Traffic 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/vehicles | Live 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/stops | England 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/departures | Timetabled 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/along | Fuel 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/along | EV 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/plan | An 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/plan | A 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/clips | Guidance 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/bank | The 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/zones | The 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/along | Forecast 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/along | Measured 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/matrix | The 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/cluster | Group 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-secret | Mint 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.json | Hosted 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}/theme | Hosted 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 | /places | Your own places dataset (store finder), scoped to your key, up to 10,000 places; see Places |
| GET | /places/search · /places/nearest | Ranked search and straight-line nearest-N over your own places; see Places |
| GET | /v1/places/in-view | Directional 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 · POST | mapmap.ai/api/static-map | Static 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-map | The 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.sig | Signed 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/keys | Self-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/self | Key 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/verify | The page an emailed verification link opens. Shows the request and changes nothing |
| POST | /v1/keys/verify/details | What a verification link's request said about itself (app, time, device). Changes nothing |
| POST | /v1/keys/verify | Confirms 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/claim | Mint a fresh claim pointer for the calling key's identity (key-authenticated, quota-free) |
| POST | /v1/console/signin | Email 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}/signin | The claim page's "email me a sign-in link" action |
| POST · DELETE | /v1/console/session | Exchange an emailed sign-in token for an identity session (POST) · sign out (DELETE) |
| GET | /v1/console/self | Identity account view for a session: balance, key metadata, usage, credit history; never key material |
| POST | /map-issues | Report 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/feedback | Submit one integration retro (quota-free, never charged); see Integration feedback |
| GET | /health | Liveness/readiness, no auth |
| GET | /v1/platform | Public 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.json | OpenAPI 3.1 document for this deployment |
| GET | /llms.txt | LLM-agent orientation text |
| POST | /admin/keys · /admin/keys/{id}/credit · /admin/identities/credit | Admin (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-issues | Admin: submitted integration retros, and the MCP map-issue NDJSON queue (?limit= 1–500, default 50; ?offset=) |
| GET | /admin/pointclouds · /admin/clearance | Admin: 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-included | Admin: set an identity's monthly quota, offline download allowance, or included monthly active users (idempotent by reason) |
| GET | /admin/identities/{email}/quota-outreach | Admin: 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:
| Param | Unit | Example |
|---|---|---|
height | metres | height=4.0 |
width | metres | width=2.55 |
length | metres | length=16.5 |
weight | tonnes | weight=44.0 |
hazmat | boolean | hazmat=true |
tunnel_code | ADR code | tunnel_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:
| Field | Type | Meaning |
|---|---|---|
indications | string[] | 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. |
valid | boolean | The lane can legally be used for the upcoming manoeuvre. |
active | boolean | Guidance 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:
"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."
}
| Field | Meaning |
|---|---|
grid_version | Format version of the baked grid, so a caller knows which grading rules produced the counts |
cells_traversed | Unique 0.1 degree grid cells the returned route geometry passes through |
by_grade | How 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.
| Param | Effect | Profiles |
|---|---|---|
avoid_tolls | Penalise toll roads (use_tolls=0) | motorised |
avoid_motorways | Penalise motorways/highways (use_highways=0) | motorised |
avoid_ferries | Penalise ferries (use_ferry=0) | all |
shortest | Optimise for distance, not time (shortest=true) | all |
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):
{
"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.
{
"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.
| List | What it sets | What it means | What it can do to you |
|---|---|---|---|
avoid | the profile's use_* willingness factor, set to 0 | a strong preference | nothing worse than a longer route, and sometimes nothing at all |
exclude | the profile's exclude_* flag, set to true | a hard exclusion: allowed only at the very start or end of the path | NoRoute / 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
| Value | avoid sets | exclude sets | Profiles |
|---|---|---|---|
tolls | use_tolls: 0 | exclude_tolls: true | avoid: motorised only. exclude: all |
highways | use_highways: 0 | exclude_highways: true | avoid: motorised only. exclude: all |
ferries | use_ferry: 0 | exclude_ferries: true | all |
bridges | n/a | exclude_bridges: true | all |
tunnels | n/a | exclude_tunnels: true | all |
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.
{
"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:
{
"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.
| Value | Meaning |
|---|---|
same (alias curb) | arrive with the location on your side of the road: kerbside |
opposite | arrive 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:
| Field | Default | Effect |
|---|---|---|
street_side_tolerance | 5 m | closer than this to the road centreline, the side is none and the preference is off |
street_side_max_distance | 1000 m | further than this from the centreline, the side is none and the preference is off |
street_side_cutoff | service_other | disables 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:
| Field | Where | Meaning |
|---|---|---|
emissions | request, /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_zones | request, /route and /optimise | Keep 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 |
territories | request, /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 |
reloads | request, 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_unassigned | request, /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[] | response | One 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_charges | response | currency, daily_total, unresolved_zones, banned_zones. Its own line, never added to trip.summary or any routing cost |
zones.avoidance | response | What avoid_zones excluded, and every zone it could not, with the reason |
Four rules the surface will not break:
- No
emissionsobject, 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
bannedwith 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/zonesserves the whole dataset for audit. - The engine's exclusion budget is stated, not hidden.
exclude_polygonsis 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 inavoidance.not_avoidablewith 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.
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:
verdict | Meaning |
|---|---|
pass | Every assessed station clears the vehicle by more than the stated bound, over coverage with no gaps. |
fail | At least one station does not clear the vehicle on the measured figure; limiting names the worst and all_failures lists every one. |
indeterminate | The 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_verdict | Coverage 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.
{
"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.
{
"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:
| Verdict | Meaning |
|---|---|
consistent | The coordinates are compatible with the declared CRS. |
suspect | They fit, but only under an assumption worth stating: degrees declared as a projected CRS in metres, for instance. |
impossible | The 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.
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"
{
"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:
curl -s -H "Authorization: Bearer $API_KEY" "$BASE/coordinates/crs?q=27700"
{
"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:
| Entries | Datum shift | What to expect |
|---|---|---|
4326, 3857, the WGS84 UTM zones | None | Projection arithmetic only. No fitted parameters, nothing to be wrong about beyond floating point. |
ETRS89-based: 4258, 3035, 2154, 3812, 25828-25838 | Zero Helmert | ETRS89 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 Helmert | Metre-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.
{
"locations": [
{ "lat": 51.5072, "lon": -0.1276 },
{ "lat": 52.4862, "lon": -1.8904 }
],
"costing": "bicycle",
"costing_options": { "bicycle": { "use_hills": 0.1 } },
"rationale": true
}
{
"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 type | Relaxed to | baseline 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 bind | relaxed_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 divergence | engine_default |
Hard caps (max_hiking_difficulty) | their explicit maximum | cap_lifted |
Typed reason kinds by costing:
| Costing | Kinds |
|---|---|
truck | max_height, max_width, max_length, max_weight, hazmat, adr_tunnel |
auto | highway_avoided, toll_avoided, ferry_avoided, living_street_avoided |
bicycle | hill_avoided, surface_avoided, road_avoided, ferry_avoided, living_street_avoided |
pedestrian | unlit_street_avoided, hiking_difficulty_limited, ferry_avoided, living_street_avoided, access_profile |
motor_scooter | hill_avoided, primary_road_avoided, toll_avoided, ferry_avoided |
| any | combined |
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:
"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?
{
"locations": [{ "lat": 50.2110, "lon": -5.4800 }, { "lat": 50.1186, "lon": -5.5378 }],
"costing": "auto",
"units": "miles",
"scenic": true
}
{
"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.
| Request | Response |
|---|---|
"scenic": true | default budget: at most 30 extra minutes |
"scenic": {"max_extra_minutes": 45} | 0 to 120 minutes |
"scenic": false, or absent | no block, no extra engine call, a byte-identical response |
| any other costing | 400, 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:
{
"locations": [{"lat": 51.5074, "lon": -0.1278}, {"lat": 51.5155, "lon": -0.1410}],
"costing": "auto",
"landmarks": true
}
{
"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:
| Gate | Rule |
|---|---|
| Recognisability | Scored 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 names | A 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. |
| Distance | Beyond 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. |
| Freshness | Any 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 shape | Only 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.
GET /route/v1/driving/-0.1278,51.5074;-0.1410,51.5155?steps=true&landmarks=true
{
"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.
curl -s $BASE/v1/route/observations \
-H "Authorization: Bearer $API_KEY" \
-H 'content-type: application/json' \
-d '{ "geometry_polyline6": "…", "units": "miles" }'
| Field | Required | Meaning |
|---|---|---|
geometry_polyline6 | yes | The route shape, precision-6 encoded, typically straight out of a prior POST /route response |
units | no | miles (default) or kilometers, for spoken distances |
min_gap_m | no | Least distance along the route between two observations (default 2000). A floor, not a target |
max_observations | no | Ceiling 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.
{
"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.
| Kind | The claim | What it rests on |
|---|---|---|
crossing | The route's line crosses this watercourse's line | A geometric intersection of two mapped lines, plus a name on the second |
landscape | The route is inside this polygon for at least 3 km | Point in polygon against a named park feature |
settlement | The route passed within the class radius of this settlement's mapped centre | A distance to a labelled point, published as offset_m |
watercourse | The route ran within 60 m of this watercourse for at least 3 km | Repeated nearest-feature tests |
road | The route ran within 25 m of this road, unambiguously, for at least 4 km | Repeated nearest-feature tests with an ambiguity guard |
climb | The road rose at least 120 m and topped out at this height | The 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.
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" }'
| Field | Required | Meaning |
|---|---|---|
geometry_polyline6 | yes | The route shape, precision-6 encoded |
duration_s | no | That 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_s | no | Least time between two lines, seconds (default 900, which is four an hour). Applied only with duration_s |
min_gap_m | no | Least distance between two lines, metres (default 400). A floor under the time budget in every case |
max_offset_m | no | How near a plaque must come to the route to be spoken, metres (default 60, hard ceiling 80) |
max_lines | no | Ceiling on how many come back, 1 to 50 (default 12) |
voice_format | no | opus or m4a. Given, every line gains a clip with its synthesis cache key and URL |
{
"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:
{
"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
}
{
"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 request | live claimed | profile claimed |
|---|---|---|
no date_time | no | no |
date_time.type: 0 (now) | yes | yes |
| a stated departure or arrival time | no | yes |
speed_types without current | no | unchanged |
speed_types without predicted | unchanged | no |
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.
| Method | Path | What it does |
|---|---|---|
| PUT | /places | Replace your dataset; body {"places": [{"id", "name", "lat", "lon", …}]}, up to 10,000 places; returns {"count", "updated_at"} |
| GET | /places | Fetch your full dataset plus meta; 404 before the first upload |
| DELETE | /places | Remove dataset and index; 204, idempotent |
| GET | /places/search | Ranked full-text search: q (required), optional lat/lon proximity focus (both or neither), limit 1–50, category filter |
| GET | /places/nearest | Straight-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.
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.
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"
| Parameter | Required | Meaning |
|---|---|---|
lat, lon | yes | Where the observer is |
bearing_deg | yes | Which way they face, degrees clockwise from true north. Magnetic bearings must be converted by the caller; nothing here corrects for declination |
categories | one of these two | Comma-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 |
name | one of these two | A place name or brand, matched as GET /geocode/reverse matches it |
fov_deg | no | Full width of the cone, 1 to 360, default 60. The full width, not the half angle |
radius_m | no | Range, 1 to 5000, default 1000 |
limit | no | Results, 1 to 25, default 5 |
eye_height_m | no | Observer's eye above the ground, default 1.6. A driver's eye is nearer 1.2 |
target_height_m | no | Height above its own ground at which each place is tested, default 0. Raise it to ask about rooflines, spires and masts |
visible_only | no | true drops everything whose verdict is not clear. Default false |
{
"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.
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}
]}'
| Param | Meaning |
|---|---|
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_by | The hard arrival time, RFC 3339 with an offset. Required |
depart_at | RFC 3339. Default: now, in the offset arrive_by carries |
errands | 1 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_minutes | How far off the direct route a candidate place may sit. Default 12, at most 45 |
costing_options | Passed 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:
| Param | Required | Meaning |
|---|---|---|
q | yes | Free-text place query |
limit | no | Maximum results, 1–10 |
lang | no | Result language, e.g. en, de, fr |
bias | no | Location bias as lon,lat (WGS84), e.g. -0.1278,51.5074; reorders candidates, never excludes one |
bbox | no | Hard 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:
| Param | Meaning |
|---|---|
street | Street name, e.g. Downing Street. Matches the street of addresses and POIs (transliterated street names included) and the street itself |
housenumber | House number, e.g. 10 or 221B |
city | Town or city, e.g. London. Matches the containing city as well as the immediate locality |
postcode | Postcode in any spacing or case: SW1A 2AA and sw1a2aa are one query. A bare UK outward code (SW1A) selects the district |
country | ISO 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 |
match | true 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.
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:
"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, socity=Londonmatches "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.0for a single result, and0on thephotonsource, which publishes no per-result scores.source:mapmap-indexorphoton, 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:
| Param | Required | Meaning |
|---|---|---|
q | yes | The query as typed, 1–256 characters. The trailing token matches as a prefix (Downing Str reaches Downing Street); earlier tokens must be complete words |
limit | no | Maximum suggestions, 1–10 (default 5) |
bias | no | Location bias as lon,lat (the map centre, for a search box on a map) |
zoom | no | Map zoom (0–20) scaling the bias, exactly as on /geocode |
bbox | no | Hard bounding-box filter, minLon,minLat,maxLon,maxLat |
lang | no | Accepted for symmetry with /geocode; the first-party index does not currently vary its answer by language |
curl -fsS "$BASE/geocode/suggest?q=Downing+Str&bias=-0.1278,51.5074&limit=5" \
-H "Authorization: Bearer $API_KEY"
{
"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:
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.aitoday. Verified on 17 September 2026:GET /geocode/suggestanswers200on the hosted gateway,GET /geocode/retrieveanswers501(urn:sn-gateway:problem:suggest-not-enabled). Itsdetailnames 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 whoseSN_GEOCODE_DIRpoints at such an index has both endpoints. Until the hosted index is rebuilt, plot the pick from the suggestion's ownlat/lon(they are in the response for exactly this reason) and fetch the full feature withGET /geocodeif 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).
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}
]}'
{
"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:
| Param | Required | Meaning |
|---|---|---|
lon | yes | Longitude (WGS84) |
lat | yes | Latitude (WGS84) |
kinds | no | Restrict 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:
curl "$BASE/geocode/reverse?lon=-0.09934&lat=51.50743&kinds=poi" \
-H "Authorization: Bearer $API_KEY"
{
"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:
| Class | n | Tolerance | Top-1 |
|---|---|---|---|
| Locality, bare town name | 12 | 5 km | 12/12 |
| Postcode alone | 30 | 2 km | 28/30 |
| Full address with postcode | 30 | 250 m | 22/30 |
| Place name with street and town | 30 | 250 m | 22/30 |
| Structured components | 29 | 250 m | 21/29 |
| Street and town, no postcode | 26 | 250 m | 18/26 |
| Bare place name, unbiased | 30 | 250 m | 18/30 |
| Misspelt place name | 20 | 250 m | 12/20 |
| All | 207 | 153/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.
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:
{
"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.
| Verdict | Meaning |
|---|---|
verified | Matched a real place in MapMap's index: the matched place (with a stable id), and the source and date of the evidence. |
contradicted | A 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. |
unverified | No 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):
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):
{ "text": "Breakfast at the Roman Baths in Bath, then a 10am meeting at Edinburgh Castle." }
| Field | Type | Notes |
|---|---|---|
text | string | Free text to extract place claims from. Mutually exclusive with claims. Max 8,000 characters. |
claims | array | Structured claims (below). Mutually exclusive with text. Max 20 per request. |
costing | string | Optional, default auto. Same values as /matrix. Used for feasibility legs. |
One claim:
| Field | Type | Required | Notes |
|---|---|---|---|
name | string | yes | The place name as claimed. |
id | string | no | Caller-chosen id, echoed back. Auto-assigned (claim-1, …) when absent. |
locality | string | no | Disambiguating 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). |
sequence | number | no | Itinerary position. Claims sharing increasing sequence values, both carrying claimed_time, form legs the feasibility pass checks. |
claimed_time | string (ISO 8601) | no | When the itinerary claims you are at this place. |
Response
{
"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
| Status | When |
|---|---|
| 400 | Malformed body, unsupported costing, more than 20 claims, text over 8,000 characters, or free text with no extractable claims. |
| 413 | Request body too large. |
| 429 | Per-IP rate limit exceeded (Retry-After set). |
| 502 | The upstream geocode/matrix check failed unexpectedly. |
| 503 | The 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 /matrixbetween consecutive itinerary stops, in the request'scosting. When the routing engine cannot even compute a leg (beyond its maximum routable distance) that is reported asimplausible, notimpossible: 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/searchandGET /places/nearestare the equivalent existence check scoped to your own data; pass your own API key. Not wired into/api/verifyin 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'sdetailscarries awebsite, 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, respectingrobots.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
curl -s "$BASE/v1/transit/vehicles?bbox=-0.2,51.45,0.05,51.55" \
-H "Authorization: Bearer $MAPMAP_KEY"
| Parameter | Required | Meaning |
|---|---|---|
bbox | yes | west,south,east,north in degrees, at most 4 square degrees (for example 2 by 2 degrees) |
operator | no | One operator's vehicles, by National Operator Code (e.g. TFLO) |
line | no | One line as riders see it (e.g. 88) |
limit | no | 1 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.statusisok,stale(the newest fix is over 180 seconds old, or the feed itself has stopped arriving) orunknown. 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:
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), withfixSetFromCollectionto read an answer andrenderClockMsfor the one-poll-behind clock, if you draw vehicles yourself (typesFixSet,TransitFix,Rendered,TransitVehicle). - When the feed goes stale the buses stay, dimmed, and
onFeedStatushands you the standard label fromtransitFreshnessLabel: "Live, 42 s ago", "Paused: last data 6 min ago" or "Feed status unknown" (TransitFeedStatusisok,staleorunknown). TransitVehiclesLayerOptionsalso takes a fixedbbox,operator,line,limit, anicon(the default"arrow"points along the heading) and aslot(TransitSlot:bottom,middleor the defaulttop, 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
curl -s "$BASE/v1/transit/stops?lat=51.5080&lon=-0.1281&radius_m=300" \
-H "Authorization: Bearer $MAPMAP_KEY"
| Parameter | Required | Meaning |
|---|---|---|
lat, lon | yes | The point, in degrees |
radius_m | no | 1 to 2,000 metres (default 500) |
limit | no | 1 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
curl -s "$BASE/v1/transit/departures?stop=490013767T&limit=6" \
-H "Authorization: Bearer $MAPMAP_KEY"
| Parameter | Required | Meaning |
|---|---|---|
stop | yes | The stop's ATCO code, from /v1/transit/stops |
at | no | Start of the window, RFC 3339 (default now). The index holds three service days from today |
window_minutes | no | 1 to 180 (default 60) |
limit | no | 1 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.
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"
}'
| Field | Rules |
|---|---|
what_built | Required, 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_name | Optional, ≤100 bytes |
sdk_version | Optional, ≤50 bytes |
A stored retro answers 202 Accepted:
{ "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.
# 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
| Parameter | Meaning |
|---|---|
center=lon,lat + zoom= | Fixed camera (zoom required with center, 0-20) |
bbox=west,south,east,north | Fit 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 body | GeoJSON 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=1 | Sentinel-2 imagery where the pilot region has coverage |
buildings3d=1 | 3D building extrusions (pair with a pitch) |
lang= | local, or an ISO 639 code, for map labels |
pois=0 | Hide 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.
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, aFeatureCollection, 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 a400, "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
FeatureCollectionis painted uniformly from its first feature. To draw several colours or styles in one image, post an array of Features, each carrying its ownstroke/fillproperties. - The query string carries everything that is not GeoJSON.
size=,style=,format=, the@2xsuffix 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 ofpois=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
@2xtosize=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-mapsits behind the gateway's 30-second upstream wall; a hang followed by a healthy retry lands around 25 to 30 s, so a504at that wall is still possible. TheETag/Cache-Controlbehaviour above makes repeats cheap.
Two differently coloured routes in one card, with the camera auto-fitted to both:
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.
| Field | Shape | Meaning |
|---|---|---|
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 ids | Jobs that no longer need doing |
changes.add_jobs[] | jobs[] objects | New single-stop work, in the same shape /optimise takes |
changes.add_shipments[] | shipments[] objects | New 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:
- every completed task is removed from the problem: a stop already served is not work the solver may schedule again;
- each reporting vehicle's
startbecomescurrent_position, or the location of its last completed stop if you did not send one; - what is left is solved through exactly the
/optimisepipeline; and - 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.
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:
{
"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:
| Lane | Endpoint | Cap | Setting |
|---|---|---|---|
| Optimise, synchronous | POST /optimise | 200 unique locations | none |
| Optimise, asynchronous | POST /v1/jobs/optimise | 2,000 unique locations | SN_OPTIMISE_MAX_LOCATIONS_ASYNC |
| Re-plan, synchronous | POST /optimise/replan | 200 unique locations, counted on the remaining problem | none |
| Re-plan, asynchronous | POST /v1/jobs/replan | 2,000 unique locations, likewise | SN_OPTIMISE_MAX_LOCATIONS_ASYNC |
| Matrix, synchronous | POST /matrix | 10,000 elements | none |
| Matrix, asynchronous | POST /v1/jobs/matrix | 40,000 elements | SN_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.
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:
{
"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
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:
{
"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.
curl -sS -X POST "$BASE/v1/keys/self/webhook-secret" \
-H "Authorization: Bearer $API_KEY"
{
"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.
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);
}
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):
{
"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.
| Status | Problem slug(s) | When |
|---|---|---|
400 | bad-request · invalid-email · tos-not-accepted · costing-conflict | Malformed request, invalid/disposable email, ToS not accepted, costing_options conflicting with the adr profile |
400 | upstream-error | The routing engine rejected the request (upstream 4xx, relayed; upstream_status in the body) |
401 | unauthorized · admin-unauthorized | Missing/invalid API key or admin token |
402 | n/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 |
403 | forbidden | Authenticated but not permitted, e.g. a provisional (unverified) key attempting a territory layer download |
404 | not-found | Unknown key/identity/path |
409 | key-cap · no-identity-ledger | Identity key limit reached; crediting a key with no identity ledger |
413 | places-too-many | A PUT /places body over the 10,000-place cap |
422 | invalid-theme · invalid-places · optimisation-too-large | Style 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) |
422 | matrix-too-large · matrix-span-too-large | Matrix 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 |
422 | route-not-splittable | More 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 |
429 | quota-exceeded · rate-limited · issuance-velocity · feedback-velocity · map-issue-velocity | Monthly 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 |
501 | geocoding-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) |
501 | structured-search-not-enabled | q combined with structured components on a Photon-proxy-only deployment; Photon's structured endpoint takes no free text |
422 | batch-too-large | POST /geocode/batch with more than 1,000 queries; body carries max_queries and queries |
422 | cluster-too-large | POST /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 |
502 | upstream-error | The routing engine (or geocoder backend) failed (upstream 5xx) |
503 | upstream-unavailable · optimisation-not-enabled | Routing backend unreachable (retry with backoff); /optimise on a deployment without a VROOM sidecar |
504 | upstream-timeout | Routing backend timed out, retry with backoff |
500 | internal | Unexpected 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:
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):
| Tier | Requests | Rate limit | Notes |
|---|---|---|---|
| Provisional (unverified) | 1,000 calls total | 60/min | Expires after 72 h unless you verify; no territory downloads |
| Verified free | 50,000/calendar month | 60/min | Up 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):
{
"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.
curl -s "$BASE/v1/usage?from=2026-09-01&to=2026-09-30" \
-H "Authorization: Bearer $API_KEY"
{
"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.
| Field | Meaning |
|---|---|
identity_pooled | true 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 |
days | Oldest day first. Days with no usage are omitted, never reported as zero |
endpoints / totals | Keyed by the endpoint label the meter stores; per-tile, per-style and OSRM paths are collapsed (/tiles, /styles, /route/v1) |
month_used | The 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.
{
"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
nullmeans 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
- Conventions: base URL, auth, units, error envelope, ADR tunnel codes
- Analysis endpoints: isochrones, matrices, map matching
- Quickstart: from zero to a routed truck in two calls
- Playground: run these requests from your browser, no install