Skip to content

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

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

API reference

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

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

makefile
Authorization: Bearer snk_…

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

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

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

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

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

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

json
{
  "code": "Ok",
  "routes": [
    {
      "distance": 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

MethodPathPurpose
POST/routeNative routing request (rich JSON body) with the ADR extension
POST/route/alongAlong-route search over your own places, ranked by honest matrix-measured detour cost (see the analysis guide)
GET/route/v1/{profile}/{coordinates}Compatible URL routing with vendor truck/ADR query params
POST/adr/checkADR 8.6.4 tunnel-category compliance check for a load, no routing
POST/geodata/validateDoes a dataset's declared CRS actually describe its own coordinates? Catches swapped lat/lon axes, degrees labelled as metres and a projection mislabelled with the wrong EPSG code, before the data is drawn on a map; see Validating a dataset's CRS
GET/geocode · /geocode/reverseForward and reverse geocoding, when the self-hoster enables it (first-party SN_GEOCODE_DIR, or a SN_PHOTON_URL proxy); see Geocoding
POST/isochroneReachability polygons (GeoJSON) for a time or distance budget (see the analysis guide)
POST/matrixMany-to-many duration and distance matrix (see the analysis guide)
POST/elevationPoint elevation for up to 500 coordinates, honest per-sample source/resolution metadata, null (never a guess) wherever the engine has no terrain-tile coverage. Live on the hosted gateway over worldwide-land DEM tiles, 71°N to 56°S at ~30 m; self-hosters stage tiles and set SN_ELEVATION_ENABLED=true, else 501 (see the analysis guide)
POST/elevation/alongElevation profile along a route: distance, elevation and percent grade per sample, plus whole-route ascent/descent. Live on the hosted gateway; self-hosters stage tiles and set SN_ELEVATION_ENABLED=true, else 501 (see the analysis guide)
GET/timezoneIANA timezone id, UTC offset, DST offset and abbreviation at a coordinate and timestamp; 501 unless the deployment has a timezone boundary database
POST/trace_route · /trace_attributesMap matching: snap a GPS trace to the road network, with per-edge attributes (see the analysis guide)
POST/route/reportDistance/duration breakdown of an existing route or trace by road class, admin area, toll/bridge/tunnel and surface; pure aggregation over /trace_attributes, no extra engine call (see Boundaries and route primitives)
GET/boundaryAdministrative boundary lookup: point-in-polygon against the Valhalla admin database → country and sub-divisions with admin_level, iso_code, name, drive_on_right; 501 when the deployment has no admin-boundaries database (see Boundaries and route primitives)
POST/locate · /centroidSnap-to-graph and "meet in the middle" convergence-point search; Valhalla-only, 501 on a GraphHopper-only deployment (see Boundaries and route primitives)
POST/v1/cameras/alongSafety cameras along a route shape, jurisdiction-gated server-side with a conservative off-by-default policy; 501 when the deployment has no camera data (see the analysis guide)
POST/v1/incidents/alongTraffic incidents and closures along a route shape (National Highways closures feed plus its live events feed, merged), with honest per-source coverage metadata; 501 when the deployment has no incident data (see the analysis guide)
POST/v1/fuel/alongFuel stations along a route ranked cheapest first, each priced with its real engine-computed detour and flagged when the price is over 24 hours old; 501 when the deployment has no fuel-price file configured (see the analysis guide)
POST/v1/weather/alongForecast 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/alongMeasured overhead clearance along a route shape, judged against surveyed point cloud geometry rather than map tags: a verdict per axis, the limiting point with its measured headroom and uncertainty bound, coverage gaps named apart from measured open sky, and a deep link into the survey viewer. Not a signed or legal height and never a certificate for the route; 501 when the deployment has no clearance artefacts (see Measured clearance along a route and the clearance guide)
GET/pointclouds/{dataset}/{file}Hosted point-cloud payload serving: a baked @mapmap/points payload (<cloud>.bin) or its sidecar (<cloud>.json) for one dataset. Range requests are honoured (206 with Content-Range); ETag/If-None-Match gives 304. A dataset is served only to the keys its manifest names, and everything else, including an unknown dataset and a dataset this key may not see, is an identical 404, so the surface is not an existence oracle. Metered in chunk units at the point-cloud discount rather than per request, so a whole-file download costs the same as fetching it in ranged slices; 404 when the deployment hosts no payloads (see the SDK reference)
POST/optimise (/optimize)Multi-vehicle route optimisation with truck and ADR constraints (see the optimisation guide)
GET/tiles/{territory}/{z}/{x}/{y}.mvt (also .pbf) · /tiles/{territory}/style.json · .../tiles.jsonHosted vector tiles, MapLibre style and TileJSON (see the maps guide)
GET · POST/styles · /styles/{spec} · /styles/{spec}/themeHosted and custom map styles. GET /styles/{spec} and .../theme are public (browser map clients fetch them by bare URL); GET /styles lists your own styles and needs a key; POST is metered and only a style's owner may publish over it (see the maps guide)
PUT · GET · DELETE/placesYour own places dataset (store finder), scoped to your key, up to 10,000 places; see Places
GET/places/search · /places/nearestRanked search and straight-line nearest-N over your own places; see Places
POST/verify (intended path, not yet implemented on the gateway; today served at POST /api/verify on the website, see below)Geo-hallucination firewall: checks whether AI-mentioned places and itineraries are real, findable and physically possible; see Verify places
GET/fonts/{fontstack}/{range} · /sprite/{file}Glyph PBFs and sprite sheets for rendering, public
GET · POSTmapmap.ai/api/static-mapStatic Map Images: render a PNG/WebP/JPEG of a view, with a route/GeoJSON/marker overlay; see Static map images. Served from the website, not $BASE; no API key
GET/territories · /territories/index.sigSigned 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/keysSelf-serve key issuance ({email, accept_tos, label?} returns a provisional snk_ key, plus a zero-authority claim_url + display_to_user for the human when the deployment has a console)
GET/v1/keys/selfKey state: quota, usage, prepaid credit, owning identity email (quota-free)
GET/v1/keys/verify?token=Email verification, upgrades provisional keys to the free tier
POST/v1/keys/claimMint a fresh claim pointer for the calling key's identity (key-authenticated, quota-free)
POST/v1/console/signinEmail a 15-minute single-use sign-in link (uniform 202 whether or not the address has an account)
GET/v1/console/claims/{token}Resolve a claim pointer to a masked email (read-only, consumes nothing)
POST/v1/console/claims/{token}/signinThe claim page's "email me a sign-in link" action
POST · DELETE/v1/console/sessionExchange an emailed sign-in token for an identity session (POST) · sign out (DELETE)
GET/v1/console/selfIdentity account view for a session: balance, key metadata, usage, credit history; never key material
POST/map-issuesReport a map error: {lat, lon, category, note?, contact?}, where category is one of road-missing, road-wrong, restriction-wrong, name-wrong, place-closed, place-missing, other. One Standard-class call, capped at 50 reports per key per day. This is the only write path onto the public Map Health board, and the same one the report button on the map uses
POST/v1/feedbackSubmit one integration retro (quota-free, never charged); see Integration feedback
GET/healthLiveness/readiness, no auth
GET/openapi.jsonOpenAPI 3.1 document for this deployment
GET/llms.txtLLM-agent orientation text
POST/admin/keys · /admin/keys/{id}/credit · /admin/identities/creditAdmin (admin token): create keys, credit the prepaid ledger (idempotent by reason)
DELETE/admin/keys/{id}Admin: revoke a key
GET/admin/overview · /admin/keys · /admin/keys/{id}/usage · /admin/identities · /admin/identities/{email}Admin: deployment overview, key listing and usage, identities with balance and credit history
GET/admin/retros · /admin/mcp-map-issuesAdmin: submitted integration retros, and the MCP map-issue NDJSON queue (?limit= 1–500, default 50; ?offset=)
GET/admin/pointclouds · /admin/clearanceAdmin: hosted payload datasets with their declared bytes and each owner's computed monthly hosting fee, and the clearance artefact inventory (what is published, whether it still matches its sources, the bake's own quality statistics, and the reason for any artefact that will not open). Both read-only
POST/admin/identities/{email}/quota · .../download-allowance · .../mau-includedAdmin: set an identity's monthly quota, offline download allowance, or included monthly active users (idempotent by reason)

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

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

Truck and ADR parameters

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

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

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

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

Lane guidance

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

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

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

Route options

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

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

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

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

Measured clearance along a route

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

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

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

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

The response is a report with a verdict per axis:

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

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

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

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

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

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

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

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

Validating a dataset's CRS

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

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

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

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

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

verdict is one of:

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

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

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

Why this route

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

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

How it works, honestly

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

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

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

Typed reason kinds by costing:

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

What it can and cannot claim

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

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

Live incidents as context

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

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

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

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

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

Landmark turn instructions

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

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

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

What gets named, and what never does

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

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

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

On the OSRM route surface too

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

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

The summary block

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

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

Traffic confidence

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

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

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

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

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

How coverage is measured, and what it does not claim

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

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

It never claims more than your request allows

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

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

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

An empty result is never an all-clear

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

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

Places: bring your own places

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

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

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

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

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

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

Geocoding

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

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

GET /geocode, forward geocoding:

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

GET /geocode/reverse, reverse geocoding:

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

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

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

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

Boundaries and route primitives

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

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

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

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

POST /route/report: a summary over an existing route (locations) or trace (shape/encoded_polyline): total distance and duration broken down by road_class, by admin area, plus toll/bridge/tunnel counts and a surface breakdown. No new engine capability; it aggregates /trace_attributes internally, so it costs one ordinary metered call, not two.

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

Verify places: the geo-hallucination firewall

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

The three-state verdict

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

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

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

Request

Structured claims (reliable; supports itinerary feasibility):

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

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

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

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

One claim:

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

Response

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

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

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

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

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

Errors

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

Evidence sources

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

Gateway path (not yet implemented)

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

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.

bash
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"
  }'
FieldRules
what_builtRequired, non-empty, ≤500 bytes
problems[]≤20 entries of {area, description, workaround_found}; area is one of sdk, api, mcp, docs, billing, self-host, other; description ≤1000 bytes
gotchas[] · wins[] · docs_gaps[]≤20 entries each, ≤500 bytes per entry
agent_nameOptional, ≤100 bytes
sdk_versionOptional, ≤50 bytes

A stored retro answers 202 Accepted:

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

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

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

Static map images

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

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

# 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
ParameterMeaning
center=lon,lat + zoom=Fixed camera (zoom required with center, 0-20)
bbox=west,south,east,northFit to a box (GeoJSON bbox order); omit both center and bbox to auto-fit to whatever overlay you pass instead
size=WxH (or width=/height=)Up to 1280×1280; append @2x (or retina=2) for a retina image at double the pixel density
bearing= · pitch=Degrees; pitch capped at 85
style=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 bodyGeoJSON overlays: an isochrone's FeatureCollection, a shape, or a Feature; simplestyle-spec stroke/fill/fill-opacity properties are honoured
markers=lon,lat[,label[,colour]] (repeatable, ;-separated)Up to 50 pins; label is 1-2 characters
satellite=1Sentinel-2 imagery where the pilot region has coverage
buildings3d=13D building extrusions (pair with a pitch)
lang=local, or an ISO 639 code, for map labels
pois=0Hide POI labels

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, a FeatureCollection, a bare geometry, or a JSON array of any of those. Do not wrap it in an envelope object. {"geojson": [...]} (or any other wrapper key) carries no coordinates of its own, so it fails with a 400, "a geojson value carried no usable coordinates", however valid the GeoJSON inside the wrapper is.
  • Styling applies per posted value, not per feature. Each top-level value you post becomes one overlay with one paint, read from its simplestyle properties; a multi-feature FeatureCollection is painted uniformly from its first feature. To draw several colours or styles in one image, post an array of Features, each carrying its own stroke/fill properties.
  • The query string carries everything that is not GeoJSON. size=, style=, format=, the @2x suffix and the rest of the parameter table above all still apply on a POST; only the overlay travels in the body. pois=false (an alias of pois=0) hides POI labels when the image is about your overlay rather than the base map.
  • 1280 pixels is the ceiling on either dimension. For more detail in the same frame, append @2x to size= for double pixel density instead of asking for a larger image.
  • The first render after idle 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 a 504 (or an unusually slow first response) as a cold start and retry once; warm renders are quick, and the ETag/Cache-Control behaviour above makes repeats cheap.

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

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

render_map (agent/MCP surface)

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

Errors, RFC 9457 problem+json

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

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

json
{
  "type": "urn:sn-gateway:problem:quota-exceeded",
  "title": "Monthly quota exceeded",
  "status": 429,
  "detail": "monthly quota exceeded (50000/50000)",
  "quota": 50000,
  "used": 50000
}
StatusProblem slug(s)When
400bad-request · invalid-email · tos-not-accepted · costing-conflictMalformed request, invalid/disposable email, ToS not accepted, costing_options conflicting with the adr profile
400upstream-errorThe routing engine rejected the request (upstream 4xx, relayed; upstream_status in the body)
401unauthorized · admin-unauthorizedMissing/invalid API key or admin token
402n/a (plain JSON, not problem+json)Payment required: x402 body on machine-payment endpoints; {"code": "download_allowance_exceeded", "upgrade": …} on territory layer downloads
403forbiddenAuthenticated but not permitted, e.g. a provisional (unverified) key attempting a territory layer download
404not-foundUnknown key/identity/path
409key-cap · no-identity-ledgerIdentity key limit reached; crediting a key with no identity ledger
413places-too-manyA PUT /places body over the 10,000-place cap
422invalid-theme · invalid-places · optimisation-too-largeStyle theme or places dataset failed validation (machine-readable problems list in the body); optimisation problem over the cap of 200 unique locations (max_locations/locations in the body)
429quota-exceeded · rate-limited · issuance-velocity · feedback-velocity · map-issue-velocityMonthly quota, per-minute rate limit (Retry-After header), per-IP signup velocity, or a key past its daily cap on integration retros / map-issue reports
501geocoding-not-enabled/geocode or /geocode/reverse on a deployment with neither SN_GEOCODE_DIR nor SN_PHOTON_URL set (a self-host concern; the hosted gateway serves both)
502upstream-errorThe routing engine (or geocoder backend) failed (upstream 5xx)
503upstream-unavailable · optimisation-not-enabledRouting backend unreachable (retry with backoff); /optimise on a deployment without a VROOM sidecar
504upstream-timeoutRouting backend timed out, retry with backoff
500internalUnexpected failure; detail is logged server-side, never leaked

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

map_as_text (agent-native map schematics)

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

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

Returns {schematic, legend, truncated, token_estimate, bbox}: schematic is a plain-ASCII grid (junctions +, road ends ., the query point @, roads as - | / \ by compass direction, an unconnected crossing as X, a lettered/numbered legend of every road and POI shown, and honest degrade-to-budget behaviour (truncated: true plus exactly what was dropped) rather than ever overflowing token_budget. Full reference: docs/API.md in the repo.

Quotas

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

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

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

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

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

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

Next steps

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