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.
The API is drop-in compatible with common OSRM/Valhalla-style routing clients, so your existing routing tooling works against it unchanged.
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, rotate by issue-then-revoke. There is no self-serve
revoke endpoint yet: on self-hosted deployments revoke via
DELETE /admin/keys/{id} (admin token); on the hosted gateway, ask us.
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": 190843.0,
"duration": 8611.0,
"weight": 8611.0,
"weight_name": "duration",
"geometry": "u{~vFvyys@fS]...",
"legs": ["..."]
}
],
"waypoints": [
{ "name": "Victoria Embankment", "location": [-0.1276, 51.5072] },
{ "name": "Corporation Street", "location": [-1.8904, 52.4862] }
]
}
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 | /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 |
| 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) |
| 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) |
| 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; 501 when the deployment has no fuel-price file configured (see the analysis guide) |
| POST | /v1/weather/along | Forecast weather per route sample, aligned to each sample's estimated time of arrival rather than the current time; 501 when the deployment has 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 (see the optimisation guide) |
| GET | /tiles/{territory}/{z}/{x}/{y}.mvt (also .pbf) · /tiles/{territory}/style.json · .../tiles.json | Hosted vector tiles, MapLibre style and TileJSON (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 |
| 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 | /territories · /territories/index.sig | Signed index of offline territories (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/keys/verify?token= | Email verification, upgrades provisional keys to the free tier |
| 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 | /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) |
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.
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 $SN_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); the hosted API and every endpoint above behave identically whichever is selected.
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"}.
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.
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.
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.
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"
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 |
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 $SN_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.
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.
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.
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, like /v1/probe. Agents
usually reach it 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 $SN_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": "0.4.1"
}'
| 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
# Render the route you just got back from /route/v1 (see the precision
# trap below) straight onto the map
curl "https://mapmap.ai/api/static-map?route=$ROUTE_GEOMETRY&precision=5&size=800x500" -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= | light (default) or dark for the site's own live-map style, 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 |
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 |
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.
Caching: responses carry an ETag and a long Cache-Control (a year,
immutable, for a pinned render; an hour for an unpinned hosted style that
could be republished); 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.
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 can be slow, or a
504. The renderer is headless Chromium behind a serverless route, and a cold container has to fetch and launch the browser before it draws anything. Treat a504(or an unusually slow first response) as a cold start and retry once; warm renders are quick, and 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.
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
}
| 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; {"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) |
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 |
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) |
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. Full reference:
docs/API.md
in the repo.
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.
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 five minutes
- Playground: run these requests from your browser, no install