Documentation menu
Analysis APIs: isochrones, matrices, along-route search, map matching, trip replay, progress, elevation, safety cameras, incidents, EV charging & weather
These endpoints answer the fleet questions that sit around routing rather
than on it: where can this vehicle get to in 30 minutes?
(POST /isochrone), what are the travel times between every depot and
every stop? (POST /matrix), what can I stop at on the way, and what
does the detour really cost? (POST /route/along), which roads
did this GPS trace actually drive? (POST /trace_route,
POST /trace_attributes), how many miles on which roads, in which
country? (POST /route/report), where is this vehicle against its
plan, and when will it arrive? (POST /route/progress),
how high, and how hilly?
(POST /elevation, POST /elevation/along), which safety cameras sit
on this route? (POST /v1/cameras/along), which closures, accidents
or roadworks sit on this route? (POST /v1/incidents/along), where is
the cheapest tank on this route once the detour is priced?
(POST /v1/fuel/along), and
what will the weather be doing when I actually get there?
(POST /v1/weather/along). The first five
accept the same costing models as POST /route, including truck costing
with dimensional and ADR constraints (ADR is the European agreement on
carriage of dangerous goods by road; tunnel codes B–E restrict which
tunnels a hazmat load may use; see conventions for
the full table). An isochrone for a 44-tonne hazmat artic is the area that
lorry can legally reach, not the area a car could.
Availability
Status, honestly: the hosted gateway at https://api.mapmap.ai is live:
sign up for a key, or run the self-host distro.
Base URL and authentication conventions are on the
conventions page.
Shared conventions
- You need an API key (
Authorization: Bearer snk_…; get one in the quickstart). - Locations everywhere on this page are
{ "lat": …, "lon": … }objects with named keys, so there is no coordinate-order ambiguity. costingis one ofauto,truck,bus,motor_scooter,motorcycle,bicycle,pedestrian.costing_optionsis passed to the engine verbatim, keyed by costing name. For trucks:{"truck": {"height": 4.0, "width": 2.55, "length": 16.5, "weight": 40.0, "axle_load": 9.0, "axle_count": 5, "hazmat": true, "top_speed": 90.0, "adr_tunnel_code": "C"}}; dimensions in metres, weights in tonnes, speed in km/h.adr_tunnel_codeis honoured by our ADR-extended engine; a stock engine ignores unknown keys, so requests stay portable.- A matrix carries at most 10,000 elements (
sources × targets) and spans at most 1,500 km between the furthest source and target on the hosted gateway (a self-host default of 400 km, set bySN_ENGINE_MAX_MATRIX_DISTANCE_Mto match the engine's ownservice_limits.<costing>.max_matrix_distance). The routing engine itself takes only 2,500 source×target pairs per call, so anything larger is split into blocks that fit, computed concurrently and stitched into one grid with row and column order preserved: a 100 × 100 matrix, or 2,000 origins against 4 destinations, is four engine calls behind one API call, and a block that fails fails the whole request rather than returning quietly wrong numbers. The span is the one limit splitting cannot lift (the offending pair is in whichever block it lands in), so over it you get422 matrix-span-too-largewithspan_km,max_span_kmandfurthest_pair: {source_index, target_index}— the offending pair named by index, so you can drop or re-block it without bisecting your own arrays — pluslimit_env,engine_settingandself_host_docsfor raising it on your own deployment. See the OD-matrix guide for the study-area workflow. - Requests bill by size: a matrix bills one call per started block of 25
elements (
sources × targets), elements, not engine calls, so the splitting above is neither a discount nor a loophole; an isochrone bills one call per contour (max 10 per request); an along-route search bills a flat 10 calls (both/route/alongand/v1/fuel/along, whatevermax_resultsyou ask for, and the internal route and matrix calls it fans out to are not metered again); a point-elevation request bills one call per started block of 25 points, and an along-route elevation profile a flat five calls.POST /route/progressbills a flat five calls, andPOST /route/reportone, both including whatever internal route, map-match ortrace_attributescalls they fan out to. The class is Standard normally, Premium when the body carries"costing": "truck". See pricing for the per-class rates. POST /route,POST /isochroneandPOST /matrixacceptexclude_polygonsandexclude_locationsto cut roads out of the search for one request, which is how you model a closure without rebuilding your own tiles. Shapes, caps and the unreachable-target behaviour are in conventions.
Isochrones: POST /isochrone
Reachability contours from an origin: how far can you get within a time or distance budget. Use it for delivery-coverage maps, depot placement, "can we serve this postcode in under 45 minutes?" checks, and driver-hours planning. The response is GeoJSON you can drop straight onto a map.
Parameters
| Field | Type | Required | Description |
|---|---|---|---|
locations | array of {lat, lon} | yes (≥ 1) | Origin(s) the reachability is computed from. |
costing | string | yes | Costing model (see shared conventions). |
contours | array | yes (≥ 1) | Each contour is a time in minutes or a distance in kilometres; a contour with neither is a 400. |
polygons | boolean | no | Return polygons instead of the default linestrings. |
denoise | number | no | 0–1; higher drops smaller contour islands. |
generalize | number | no | Geometry simplification tolerance in metres. |
show_locations | boolean | no | Include the input locations as GeoJSON points in the response. |
costing_options | object | no | Verbatim costing options, e.g. {"truck": {…}}. |
id | string | no | Opaque identifier echoed back. |
Example
Where can a 4-metre-high truck get to from a London depot in 10 and 20 minutes:
export BASE=https://api.mapmap.ai
export API_KEY=snk_...
curl -fsS -X POST "$BASE/isochrone" \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d '{
"locations": [{ "lat": 51.5074, "lon": -0.1278 }],
"costing": "truck",
"contours": [{ "time": 10 }, { "time": 20 }],
"polygons": true,
"costing_options": { "truck": { "height": 4.0 } }
}'
Response (truncated)
A GeoJSON FeatureCollection, one feature per contour, returned from the
routing engine. Each feature's properties carry the contour value (in
the unit you asked for: minutes or km) and a metric of "time" or
"distance":
{
"type": "FeatureCollection",
"features": [
{
"type": "Feature",
"properties": { "contour": 10.0, "metric": "time" },
"geometry": {
"type": "Polygon",
"coordinates": [[[-0.14, 51.50], [-0.11, 51.50], [-0.11, 51.52], [-0.14, 51.50]]]
}
}
]
}
(GeoJSON geometry itself is [lon, lat]; that is the GeoJSON standard,
not ours.)
Deployment note: on self-hosted deployments running the GraphHopper
engine (SN_ROUTING_ENGINE=graphhopper), isochrones are single-origin,
cannot mix time and distance contours in one call, and the rings match
your contours exactly only when they are evenly spaced. The Valhalla
engine (the default) has none of these limits.
Matrix: POST /matrix
A many-to-many travel-time and distance matrix: sources are the rows,
targets the columns. This is the workhorse behind dispatch decisions
("which of my 12 drivers is genuinely closest, by road, in a lorry?") and
the same computation POST /optimise runs
internally. Call it directly when you want the raw numbers for your own
solver or ranking logic.
Parameters
| Field | Type | Required | Description |
|---|---|---|---|
sources | array of {lat, lon} | yes (≥ 1) | Row locations (origins). |
targets | array of {lat, lon} | yes (≥ 1) | Column locations (destinations). |
costing | string | yes | Costing model (see shared conventions). |
costing_options | object | no | Verbatim costing options, e.g. {"truck": {…}}. |
units | string | no | kilometers or miles; accepted, but the gateway normalises response distances to metres regardless. |
id | string | no | Opaque identifier echoed back. |
Example
One depot to two delivery cities, truck costing:
export BASE=https://api.mapmap.ai
export API_KEY=snk_...
curl -fsS -X POST "$BASE/matrix" \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d '{
"sources": [{ "lat": 51.5074, "lon": -0.1278 }],
"targets": [
{ "lat": 52.4862, "lon": -1.8904 },
{ "lat": 53.4808, "lon": -2.2426 }
],
"costing": "truck",
"costing_options": { "truck": { "height": 4.0, "weight": 40.0 } }
}'
Response
Normalised and complete: this is the whole shape. Row-major matrices:
durations in seconds, distances in metres, null where a pair
is unreachable under the chosen costing:
{
"durations": [[9000.0, 12500.0]],
"distances": [[171100.0, 262000.0]]
}
durations[i][j] is from sources[i] to targets[j]. Unlike
/optimise, unreachable pairs here are honest
nulls, not sentinel costs.
Using matrices for area analysis (catchments, access to services, site selection) rather than dispatch? The origin-destination analysis guide covers chunking under the element cap and exporting straight into kepler.gl.
Along-route search: POST /route/along
"Where can my driver get diesel on the way, and what does the stop
really cost?" This endpoint searches for places along a route and ranks
every candidate by honest detour cost: for each candidate the routing
engine measures leave-route to place to rejoin-route against staying on
the route, so detour_minutes is real added driving time under your
costing (a truck's detour, not a car's), never straight-line optimism.
One primitive covers diesel, truck parking and any consumer POI: "coffee,
adds 4 min".
Candidates come from one of two surfaces, chosen with source. The
default, "geocode", searches the POI surface the gateway already fronts:
a category browse of the first-party index when the query is a category
phrase ("diesel", "petrol station"), free text through the geocoder
otherwise. "places" searches your own dataset instead (the one you
uploaded to /places).
Give it either an existing route geometry (geometry_polyline6, the
polyline6 a /route leg emits) or origin + destination, in which case
the gateway routes it for you with your costing. A straight-line
corridor pre-filter runs server-side first, so only the 25 nearest
candidates that could possibly fit the detour budget ever reach the matrix
engine; that cap comes back as candidates.cap.
Parameters
| Field | Type | Required | Description |
|---|---|---|---|
query | string | yes | Free-text POI query, e.g. "diesel" or "truck stop". An empty string is a 400. |
geometry_polyline6 | string | one route input | An existing route geometry as an encoded polyline with six digits of precision (polyline6). |
origin / destination | {lat, lon} | one route input | Route endpoints, when no geometry is given; the gateway computes the route. |
costing | string | no | auto (default), truck, bicycle, pedestrian, motor_scooter, bus or motorcycle. Used for the route and the detour matrices. |
costing_options | object | no | Verbatim Valhalla costing options, e.g. {"truck": {"height": 4.0}}. |
adr | object | no | ADR vehicle profile, same shape as POST /route; requires "costing": "truck". Makes the request Premium. |
max_detour_minutes | number | no | Largest acceptable detour in minutes (default 10, at most 120). |
max_results | integer | no | Results returned after sorting by detour (default 5, at most 25). |
source | string | no | "geocode" (default) or "places". |
category | string | no | Restrict candidates, in the source's own vocabulary: an OSM category token (fuel, cafe, charging_station) or a colloquial phrase for "geocode", the customer taxonomy for "places". |
sort | string | no | "cheapest_diesel" or "cheapest_petrol": order fuel-enriched results by price, then detour. |
There is no corridor parameter. The corridor is derived from
max_detour_minutes and the costing's free-flow speed, which is the only
honest way to say "close enough to be worth the stop".
Example
Diesel within 15 minutes of a Dover to Birmingham truck route:
export BASE=https://api.mapmap.ai
export API_KEY=snk_...
curl -fsS -X POST "$BASE/route/along" \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d '{
"origin": { "lat": 51.1279, "lon": 1.3134 },
"destination": { "lat": 52.4862, "lon": -1.8904 },
"costing": "truck",
"costing_options": { "truck": { "height": 4.0, "weight": 40.0 } },
"query": "diesel",
"max_detour_minutes": 15,
"max_results": 2
}'
Response
Hits ranked by detour, cheapest stop first. along_route_position is how
far along the route the stop sits (0 = origin, 1 = destination);
off_route_m its straight-line distance from the route; candidates
reports how many places were considered, how many were priced through the
engine and the fan-out cap. matched_categories appears only when a
category browse ran, naming the tokens it understood, so you can tell a
browse from a name match without guessing. Where the operator configured
the fuel-price store, fuel results carry live pump prices and the response
a fuel_attribution string.
Real response to the call above (17 September 2026, trimmed only of the second result's price block):
{
"candidates": { "cap": 25, "considered": 50, "costed": 25 },
"costing": "truck",
"fuel_attribution": "Fuel prices: UK retailer scheme (CMA)",
"matched_categories": ["fuel"],
"max_detour_minutes": 15.0,
"query": "diesel",
"results": [
{
"along_route_position": 0.522,
"detour_km": 5.52,
"detour_minutes": 0.0,
"detour_s": 0.0,
"fuel_brand": "SHELL",
"fuel_prices": {
"diesel": { "currency": "GBP", "updated_at": "13/05/2026 00:00:00", "value": 1.939 },
"petrol_95": { "currency": "GBP", "updated_at": "13/05/2026 00:00:00", "value": 1.629 }
},
"fuel_updated_at": "2026-09-17T15:11:28.463645535Z",
"off_route_m": 1640.0,
"place": {
"label": "Chiswell Green, Watford Road, AL2 3EH, St Albans, United Kingdom",
"lat": 51.721142500000006, "lon": -0.3637157,
"name": "Chiswell Green", "type": "fuel"
}
},
{
"along_route_position": 0.477,
"detour_km": 6.17,
"detour_minutes": 0.5,
"detour_s": 29.0,
"fuel_brand": "SHELL",
"off_route_m": 97.0,
"place": {
"label": "Shell, NW7 3ET, London, United Kingdom",
"lat": 51.62217630000001, "lon": -0.2551736,
"name": "Shell", "type": "fuel"
}
}
],
"route": { "distance_m": 328125.0, "duration_s": 16003.0, "length_m": 327336.0 },
"source": "geocode"
}
Nothing inside the corridor is a well-formed empty results array, not an
error; candidates.considered still tells you whether the search found
places at all or the detour budget cut them. source: "places" with no
customer-places storage configured (SN_PLACES_DIR on self-host) is a
404. An explicit category on a deployment without the first-party
geocode index is a 501, deliberately, rather than a name match that
would look plausible and answer a different question.
The detour terms are matrix cells, and the engine caps a matrix's span
(400 km by default, measured between the furthest source and target), so
a long journey comes back as a 400 from the engine rather than a route:
London to Edinburgh cannot be searched this way. Agents get the same
capability as the MCP server's search_along_route tool.
Map matching: POST /trace_route and POST /trace_attributes
Both snap a recorded GPS trace to the road network. /trace_route returns
a turn-by-turn route, the same response shape as POST /route (see the
API reference), for cleaning up noisy telematics
tracks into displayable routes. /trace_attributes returns the matched
road segments and their attributes instead, for questions like "did this
vehicle use the motorway?" or mileage auditing by road actually driven.
Both take the same request body:
Parameters
| Field | Type | Required | Description |
|---|---|---|---|
shape | array of {lat, lon} | one of shape / encoded_polyline | The GPS trace as an ordered list of points. |
encoded_polyline | string | one of shape / encoded_polyline | The trace as an encoded polyline with six digits of precision (polyline6). |
costing | string | yes | Costing model (see shared conventions). |
shape_match | string | no | edge_walk (exact edge sequence, no snapping), map_snap (snap each point to the most likely edge), or walk_or_snap (try the first, fall back to the second; the default). |
filters | object | no | /trace_attributes only: which edge attributes to include or exclude. |
units | string | no | kilometers (default) or miles, for response length fields. |
costing_options | object | no | Verbatim costing options. |
id | string | no | Opaque identifier echoed back. |
Example
export BASE=https://api.mapmap.ai
export API_KEY=snk_...
curl -fsS -X POST "$BASE/trace_route" \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d '{
"shape": [
{ "lat": 51.5074, "lon": -0.1278 },
{ "lat": 51.5090, "lon": -0.1300 }
],
"costing": "auto",
"shape_match": "map_snap"
}'
Response (truncated)
/trace_route returns the standard route response: durations in seconds,
length in the trip's units (kilometres by default), leg shape as
polyline6:
{
"trip": {
"locations": [
{ "lat": 51.5074, "lon": -0.1278 },
{ "lat": 51.5090, "lon": -0.1300 }
],
"legs": [{
"maneuvers": [
{ "type": 1, "instruction": "Drive north on High Street.",
"time": 240.0, "length": 0.4,
"begin_shape_index": 0, "end_shape_index": 2 }
],
"summary": { "time": 240.0, "length": 0.4 },
"shape": "}~ycbBhavgN..."
}],
"summary": { "time": 240.0, "length": 0.4 },
"status": 0,
"status_message": "Found route between points",
"units": "kilometers",
"language": "en-US"
}
}
/trace_attributes with the same body returns an open JSON document from
the engine: the matched geometry plus per-edge attributes. The exact
attribute set depends on the engine and your filters; expect shape
(polyline6), matched_points and an edges array:
{
"shape": "}~ycbBhavgN...",
"matched_points": [
{ "lat": 51.5074, "lon": -0.1278 },
{ "lat": 51.5090, "lon": -0.1300 }
],
"edges": [
{ "way_id": 12345, "speed": 48 }
],
"units": "kilometers"
}
A request with neither a non-empty shape nor an encoded_polyline is a
400. So is a trace whose consecutive fixes are all more than 2,000 m
apart: that is the engine's breakage distance, and it answers
upstream error 400: Exceeded breakage distance for all pairs: 2000 meters rather than guessing at the road between them.
Trip replay and mileage reports: trace_route + POST /route/report
The two map-matching endpoints above and POST /route/report compose into
the workflow every telematics integration eventually needs: take a day of
raw GPS fixes off a vehicle, replay it as the journey that was
actually driven, and report the distance on it — split by road class
and by administrative area, which is the shape a mileage claim, a
road-user charge or a cross-border compliance return wants.
No new endpoint is involved. It is two calls over the same trace:
| Step | Call | What it gives you |
|---|---|---|
| 1. Replay | POST /trace_route | The journey as a route: matched geometry (polyline6) to draw, turn-by-turn manoeuvres, total distance and the engine's duration. This is the display half. |
| 2. Report | POST /route/report (trace mode) | The numbers: total distance and duration, broken down by_road_class, by_admin, plus toll, bridge, tunnel and surface totals. This is the accounting half. |
Both take the raw trace, so neither depends on the other's output — run
them in parallel, or run only the one you need. /route/report calls
/trace_attributes internally and bills as one ordinary metered call,
not two, so a replay plus a report is two calls for a whole day's driving.
Step 1 — replay the day
export BASE=https://api.mapmap.ai
export API_KEY=snk_...
curl -fsS -X POST "$BASE/trace_route" \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d '{
"shape": [
{ "lat": 51.50748, "lon": -0.12796 },
{ "lat": 51.50650, "lon": -0.14335 },
{ "lat": 51.50074, "lon": -0.16209 },
{ "lat": 51.49511, "lon": -0.18124 },
{ "lat": 51.49214, "lon": -0.20101 },
{ "lat": 51.49121, "lon": -0.22201 },
{ "lat": 51.49052, "lon": -0.24375 },
{ "lat": 51.48757, "lon": -0.26428 },
{ "lat": 51.49180, "lon": -0.28295 }
],
"costing": "truck",
"shape_match": "map_snap",
"costing_options": { "truck": { "height": 4.0, "weight": 40.0 } }
}'
You get the standard route response (shown truncated in the
map-matching section
above): trip.legs[].shape is the cleaned geometry to draw, and
trip.summary the matched journey's distance and duration. The nine fixes
above are about 1.5 km apart and match clean; live on 17 September 2026
they came back as an 11.894 km, 1,059 s matched journey in 13 manoeuvres.
Send a dense trace. The matcher will not bridge consecutive fixes more
than 2,000 m apart (the engine's breakage distance): a trace whose
points are all further apart than that fails the whole request with
400 upstream error 400: Exceeded breakage distance for all pairs: 2000 meters, on both /trace_route and /route/report. Telematics fixes at
15 to 30 second intervals are well inside it; a trace thinned for storage
may not be.
Match with the same costing the vehicle actually is. A 40-tonne artic
matched as auto can be snapped onto a road it could not legally have
used, and every number downstream inherits that.
Step 2 — report the mileage
Same trace, different question. Send it to /route/report in trace
mode — shape or encoded_polyline, never locations (that is route
mode, which computes a fresh route rather than reporting on yours):
curl -fsS -X POST "$BASE/route/report" \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d '{
"shape": [
{ "lat": 51.50748, "lon": -0.12796 },
{ "lat": 51.50650, "lon": -0.14335 },
{ "lat": 51.50074, "lon": -0.16209 },
{ "lat": 51.49511, "lon": -0.18124 },
{ "lat": 51.49214, "lon": -0.20101 },
{ "lat": 51.49121, "lon": -0.22201 },
{ "lat": 51.49052, "lon": -0.24375 },
{ "lat": 51.48757, "lon": -0.26428 },
{ "lat": 51.49180, "lon": -0.28295 }
],
"costing": "truck",
"costing_options": { "truck": { "height": 4.0, "weight": 40.0 } }
}'
The nine-fix sample above answers 200 with the real numbers for those
12 km, live on 17 September 2026:
{
"distance_m": 11891.0,
"duration_s": 1059.4,
"edge_count": 219,
"by_road_class": {
"motorway": { "distance_m": 486.0, "duration_s": 35.1, "edge_count": 2 },
"primary": { "distance_m": 1951.0, "duration_s": 231.2, "edge_count": 57 },
"residential": { "distance_m": 446.0, "duration_s": 65.9, "edge_count": 20 },
"trunk": { "distance_m": 9008.0, "duration_s": 727.2, "edge_count": 140 }
},
"by_admin": [
{ "country_code": "GB", "country_text": "United Kingdom",
"state_code": "ENG", "state_text": "England",
"distance_m": 11891.0, "duration_s": 1059.4, "edge_count": 219 }
],
"toll": { "distance_m": 0.0, "edge_count": 0 },
"bridge": { "distance_m": 1149.0, "edge_count": 5 },
"tunnel": { "distance_m": 313.0, "edge_count": 1 },
"by_surface": {
"paved_smooth": { "distance_m": 11891.0, "edge_count": 219 }
}
}
A whole day of driving has the same shape with bigger numbers and more buckets. The rest of this section works through one (numbers illustrative, the shape exact):
{
"distance_m": 187400.0,
"duration_s": 9240.0,
"edge_count": 684,
"by_road_class": {
"motorway": { "distance_m": 121300.0, "duration_s": 4020.0, "edge_count": 214 },
"primary": { "distance_m": 21400.0, "duration_s": 2010.0, "edge_count": 141 },
"residential": { "distance_m": 12100.0, "duration_s": 1520.0, "edge_count": 233 },
"trunk": { "distance_m": 32600.0, "duration_s": 1690.0, "edge_count": 96 }
},
"by_admin": [
{ "country_code": "GB", "country_text": "United Kingdom",
"state_code": "ENG", "state_text": "England",
"distance_m": 150900.0, "duration_s": 7110.0, "edge_count": 559 },
{ "country_code": "GB", "country_text": "United Kingdom",
"state_code": "WLS", "state_text": "Wales",
"distance_m": 34200.0, "duration_s": 1980.0, "edge_count": 118 },
{ "country_code": null, "country_text": null,
"state_code": null, "state_text": null,
"distance_m": 2300.0, "duration_s": 150.0, "edge_count": 7 }
],
"toll": { "distance_m": 0.0, "edge_count": 0 },
"bridge": { "distance_m": 4850.0, "edge_count": 31 },
"tunnel": { "distance_m": 1120.0, "edge_count": 4 },
"by_surface": {
"paved_smooth": { "distance_m": 168900.0, "edge_count": 512 },
"paved": { "distance_m": 17300.0, "edge_count": 165 },
"compacted": { "distance_m": 1200.0, "edge_count": 7 }
}
}
Every bucket sums to the totals — by_road_class, by_admin and
by_surface each partition the same 187,400 m and 684 edges — so a claim
built from any one of them reconciles against the trip total.
Turning that into a mileage claim
Distances are metres, always. The claim is arithmetic on top:
| Claim line | From the report | Miles |
|---|---|---|
| Total distance driven | distance_m = 187,400 | 116.4 |
| Driven in England | by_admin[0].distance_m = 150,900 | 93.8 |
| Driven in Wales | by_admin[1].distance_m = 34,200 | 21.3 |
| Motorway (higher-rate band) | by_road_class.motorway.distance_m = 121,300 | 75.4 |
| Non-motorway | 187,400 − 121,300 = 66,100 | 41.1 |
| Tolled distance | toll.distance_m = 0 | 0.0 |
(Metres ÷ 1,609.344 = miles. Ask for "units": "miles" on the
/trace_route call if you want the replay in miles too; the report is
metres regardless, so nothing downstream has to know which was asked.)
What these numbers are, and what they are not
Four things worth knowing before a report becomes a claim someone signs:
duration_sis the engine's model, not the driver's clock. The gateway's trace surface takes coordinates, not timestamps, so nothing in the request tells us when the vehicle passed each point. Each edge's duration is the difference between successive cumulativeend_node.elapsed_timevalues the engine reports for the matched path — how long those roads take at the engine's speeds, not how long the driver took. It is the right number for "was this route plausible"; it is not a timesheet, and it will not show the two hours spent at a loading bay. Keep the elapsed time from your own telematics for that.- The
null-admin bucket is real, and is never guessed at. Some matched edges' end nodes fall outside every admin polygon in the graph's admin database, and the very last node of a trace has no onward admin index at all. Those metres are counted honestly in a bucket withcountry_code: nullrather than folded into whichever country was nearest. On a cross-border claim, reconcile that bucket before apportioning: 2,300 m of 187,400 is 1.2 %, and where it lands matters if the two sides have different rates. - Matching is a judgement, not a measurement.
map_snappicks the most likely edge sequence for a noisy trace. A sparse trace through a dense network — a fix every 60 seconds in a city — gives the matcher little to go on, and it can pick a parallel road. The fix is a denser trace, not a bigger claim about the answer. - Toll distance is OSM's
tolltag, not a tariff.toll.distance_mis how far the matched path ran on edges tagged as tolled. It is not a charge, and it does not know your account, your class or your discounts.
Two calls, no state kept, and no vendor box on the vehicle: the trace
stays yours, and MapMap never becomes the place your drivers' journeys
live. The same posture as
POST /route/progress
below, which answers the live half of the same question.
Progress against a plan: POST /route/progress
Where is this vehicle against the route it was given — and when will it actually arrive? One call answers all of it: the position snapped onto the plan, how far along it is, distance and duration still to run, an ETA with its traffic provenance attached, and whether the vehicle has left the plan at all.
Nothing is stored. The plan and the position come in on every request and are gone when it is answered: no position ever reaches our database, our logs or our disks. That is the point of the design, not a footnote to it. The usual way to answer "where is my fleet against its plan" is to host the positions — which makes the vendor a processor of location data about identifiable drivers, for as long as the history lives. Here you keep the history and we do the geometry, so the answer is available without the exposure.
Parameters
| Field | Type | Required | Description |
|---|---|---|---|
geometry_polyline6 | string | one of this / origin+destination | The plan as an encoded polyline with six digits of precision — the shape POST /route already returned to you. The cheap path: no plan route is recomputed. |
origin · destination | {lat, lon} | one of this / geometry_polyline6 | Compute the plan here instead. The same route-input contract POST /v1/fuel/along accepts. |
current_position | {lat, lon} | yes | Where the vehicle is now. |
recent_trace | array of {lat, lon} | no | Recent fixes, oldest first, ending at (or just before) current_position. At most 100. Supplying them switches snapping from geometric projection to engine map matching — see below. |
off_route_threshold_m | number | no | How far from the plan counts as off route. Default 50 m, maximum 5,000. |
costing | string | no | auto (default) or any costing model; used for the plan, the match and the remaining leg. |
costing_options | object | no | Verbatim costing options (truck dimensions, and so on). |
adr | object | no | ADR vehicle profile, exactly as on POST /route; requires "costing": "truck". |
Two ways to snap, and which one to trust
The method field in the response always says which you got.
projection— norecent_trace. The fix is projected onto the plan polyline geometrically. Cheap and exact about what it measures: the distance from a point to a line. But a lone fix has no heading and no history, so 30 m of urban-canyon error beside a dual carriageway or a parallel service road looks identical to being on the plan, and a plan that doubles back can project the fix onto the wrong pass.map_matched— withrecent_trace. The trace is map-matched through the engine (the same matcherPOST /trace_routeuses) and the last matched point is what gets projected. "Which road is this vehicle actually on" is a road-network question and the engine answers it against the real graph, so this is the variant to drive an off-route alert from. The projection-only variant will report a deviation for a parked vehicle with a poor fix.
Example
curl -fsS -X POST "$BASE/route/progress" \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d '{
"geometry_polyline6": "}~ycbBhavgN...",
"costing": "truck",
"current_position": { "lat": 51.6000, "lon": -0.5000 },
"recent_trace": [
{ "lat": 51.5820, "lon": -0.4310 },
{ "lat": 51.5910, "lon": -0.4680 }
],
"off_route_threshold_m": 60
}'
Response
{
"costing": "truck",
"stateless": true,
"privacy": "no positions are stored: the plan and the position are read from this request, answered, and discarded",
"plan": { "length_m": 59562.2, "source": "geometry_polyline6" },
"position": {
"input": { "lat": 51.6, "lon": -0.5 },
"snapped": { "lat": 51.6, "lon": -0.5 },
"method": "map_matched",
"along_m": 29809.3
},
"progress": {
"fraction": 0.5005,
"travelled_m": 29809.3,
"plan_remaining_m": 29752.9
},
"off_route": false,
"off_route_m": 0.0,
"off_route_threshold_m": 60.0,
"arrived": false,
"remaining": { "distance_m": 30140.0, "duration_s": 1802.0, "method": "engine" },
"eta": {
"duration_s": 1802.0,
"arrival_estimate_utc": "2026-09-03T14:22:41Z",
"traffic": {
"available": true,
"method": "link_coverage_v1",
"covered_pct": 0.62,
"confidence": 0.78,
"band": "live",
"legs": [ { "covered_pct": 0.62, "band": "live" } ]
}
}
}
Three measurements, deliberately not merged
progress, remaining and eta answer different questions, and the
response keeps them apart so you can see when they disagree:
progressis geometric, against your plan. How far along the polyline the snapped position sits. No engine call, no re-planning.plan_remaining_mis simply the plan's leftover length.remainingis the engine's answer from where the vehicle actually is to the plan's final point — a fresh search with your costing, not a walk of the plan's tail. On an off-route vehicle it already prices getting back, which is why it can exceedplan_remaining_m. That gap is the useful signal: a large one means the deviation is expensive.etais that duration as a wall clock, on the gateway's UTC clock, with the same per-leg traffic provenance the routing surface reports — what fraction of the remaining leg this deployment has traffic data for, from which source, and how stale (see traffic confidence). UnlikePOST /route, where that annotation is opt-in, it is always attached here: on this endpoint the ETA is the product, so its provenance ships with it. On a deployment with no traffic data configured you get"available": falseand a note saying so — a fact about the deployment, never an all-clear about the roads.
Within 25 m of the plan's final point the answer is "arrived": true,
remaining.method: "arrival_radius" and zeroes: a zero-length route is
not a route, and the last few metres of a journey are exactly when a
navigating client polls hardest.
Billing and errors
A flat five calls per request whatever it carries — the same block as
an along-route elevation profile, and half an along-route search. One
progress call fans out to at most three internal engine calls (an optional
plan route, an optional map match, one remaining-leg route), and none of
them is metered again. Premium when the body carries
"costing": "truck" or an adr profile, Standard otherwise.
A 400 covers a missing or doubled plan, a coordinate that is not WGS84,
and a recent_trace over 100 points (refused, never silently truncated —
map-match a whole journey with POST /trace_route, which is priced per
point). A trace the engine cannot match is a 502: there is no silent
fall back to geometric projection, because the two answers mean different
things and you would have no way to tell which you got.
Elevation: POST /elevation and POST /elevation/along
Terrain elevation, sampled from the DEM tile set staged alongside the
routing engine. Two shapes of question: spot heights for a list of
points (POST /elevation, up to 500 points per request), and an
along-route profile (POST /elevation/along): evenly spaced samples
along a route geometry with per-segment percent grades and whole-route
ascent and descent. Use the profile for EV range and fuel modelling,
gradient-aware cycling and walking products, and "how hilly is this
route?" answers.
The honesty contract, which both endpoints share:
elevation_misnullfor any point outside DEM coverage. It is never estimated from neighbouring points.- Coverage on the hosted gateway is worldwide land, 56°S to 71°N:
32,960 tiles across 128 latitude bands, at roughly 30 m ground
resolution. Antarctica is absent from the upstream open-data tile set,
and open water has no tiles because there is no terrain to sample. So a
nullover land is worth reporting to us; anullin the middle of an ocean is the format working. (Self-hosters stage whatever regions they choose, so their coverage is whatever they staged.) sourceandresolution_mare attached only to samples that got a real elevation, and only ever describe what the operator actually declared for the deployment. The hosted gateway declares"mapzen-terrain-tiles"atresolution_m: 30.- On a deployment with no DEM tiles staged, both endpoints answer
501(urn:sn-gateway:problem:elevation-not-enabled) rather than a well-formed response that isnulleverywhere. Feature-detect on the501and hide elevation features. Self-hosters enable the endpoints by staging DEM tiles for the engine and settingSN_ELEVATION_ENABLED=true(declareSN_ELEVATION_DATASETandSN_ELEVATION_RESOLUTION_Mto label the samples honestly).
Point elevation: POST /elevation
| Field | Type | Required | Description |
|---|---|---|---|
points | array of {lat, lon} | yes (1 to 500) | Points to sample. Empty or over 500 is a 400. |
export BASE=https://api.mapmap.ai
export API_KEY=snk_...
curl -fsS -X POST "$BASE/elevation" \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d '{
"points": [
{ "lat": 54.4542, "lon": -3.2116 },
{ "lat": 51.5074, "lon": -0.1278 }
]
}'
One result per input point, in order:
{
"results": [
{ "lat": 54.4542, "lon": -3.2116, "elevation_m": 971.0,
"source": "mapzen-terrain-tiles", "resolution_m": 30.0 },
{ "lat": 51.5074, "lon": -0.1278, "elevation_m": 15.0,
"source": "mapzen-terrain-tiles", "resolution_m": 30.0 }
]
}
Where the deployment has no coverage for a point, that entry is
{ "elevation_m": null, "source": null, "resolution_m": null }: the three
fields are always null together.
Along-route profile: POST /elevation/along
Give it a route as either an encoded polyline or the full response
POST /route returned; it resamples the geometry at an even spacing and
returns the profile.
| Field | Type | Required | Description |
|---|---|---|---|
geometry_polyline6 | string | one of geometry_polyline6 / route | The route geometry as an encoded polyline with six digits of precision (polyline6), e.g. a leg shape from a /route response. |
route | object | one of geometry_polyline6 / route | A full route response (the {"trip": …} shape POST /route returns); every leg's shape is concatenated. Providing both, or neither, is a 400. |
interval_m | number | no | Approximate sample spacing in metres. At most one of this and sample_count; with neither, the default is 100 m. |
sample_count | integer | no | Exact number of samples, 2 to 2,000, evenly spaced by distance including both endpoints. However the count is derived, it is clamped to that range. |
curl -fsS -X POST "$BASE/elevation/along" \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d '{ "geometry_polyline6": "}~ycbBhavgN...", "interval_m": 200 }'
Response
{
"samples": [
{ "distance_m": 0.0, "lat": 54.49, "lon": -3.06, "elevation_m": 124.0,
"source": "mapzen-terrain-tiles", "resolution_m": 30.0,
"grade_percent": null },
{ "distance_m": 200.0, "lat": 54.4914, "lon": -3.0621, "elevation_m": 139.0,
"source": "mapzen-terrain-tiles", "resolution_m": 30.0,
"grade_percent": 7.5 }
],
"total_distance_m": 5200.0,
"total_ascent_m": 212.0,
"total_descent_m": 96.0,
"coverage_fraction": 1.0,
"sample_count": 27
}
grade_percenton sample i is the percent grade of the segment ending at i:nullon sample 0, andnullwhenever either endpoint of the segment lacks a real elevation. A coverage gap never contributes a wrong (zero) grade.total_ascent_mandtotal_descent_mare accumulated only over consecutive sample pairs where both have a real elevation, and arenull(not0) when no such pair exists, so "flat" and "unknown" stay distinguishable.coverage_fractionis the fraction of samples that got a real elevation.
Billing
Both endpoints bill at the Standard class: POST /elevation one call
per started block of 25 points (so up to 25 points is one call, the full
500 is 20), and POST /elevation/along a flat five calls regardless of
sample count.
Safety cameras along a route (POST /v1/cameras/along)
Given a route shape, returns the safety cameras on it: where each camera sits along the route, its type, and the enforced limit where known. Built for navigation clients that warn the driver on approach. The data is baked per territory at build time, and exact positions are served only where that is lawful (see the jurisdiction policy below).
Two shapes of record come back. Point devices are a single camera at
a position. Corridors are a stretch of enforced road, either
average-speed section control or one of Ireland's published mobile-camera
zones; they carry a zone object and the arc offsets of both ends. A
corridor is always one entry, never one per device.
Parameters
| Field | Type | Required | Description |
|---|---|---|---|
shape | string | yes | The route geometry as an encoded polyline with six digits of precision (polyline6), exactly as POST /route responses emit their leg shape. |
buffer_m | number | no | Corridor half-width in metres for matching cameras to the shape. Default 40; values above 100 are clamped to 100; zero, negative or non-numeric is a 400. |
Example
export BASE=https://api.mapmap.ai
export API_KEY=snk_...
curl -fsS -X POST "$BASE/v1/cameras/along" \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d '{
"shape": "}~ycbBhavgN...",
"buffer_m": 40
}'
Response
{
"cameras": [
{ "arc_m": 1234.5, "lat": 51.5074, "lon": -0.1278,
"kind": "fixed", "limit_kph": 48, "bearing": null,
"limit_confidence": "osm",
"source": "osm", "licence": "ODbL-1.0" },
{ "arc_m": 4820.0, "lat": 53.3226, "lon": -6.4235,
"kind": "mobile_site", "limit_kph": null, "bearing": null,
"limit_confidence": "unknown",
"from_arc_m": 4820.0, "to_arc_m": 6320.0,
"zone": {
"start": [53.322565, -6.423538],
"end": [53.329367, -6.42377],
"length_km": 1.5,
"geometry": [[-6.423538, 53.322565], [-6.42377, 53.329367]],
"geometry_parts": [2]
},
"source": "ie-garda-current-zones", "licence": "CC-BY-4.0" }
],
"attribution": [
"Contains Irish Public Sector Data licensed under a Creative Commons Attribution 4.0 International (CC BY 4.0) licence."
],
"policy": { "country": "IE", "mode": "exact" }
}
camerasis ordered byarc_m, the distance in metres along the shape to each camera's on-route projection. For a corridor that is its start, so a client that only understands points needs no new code.kindisfixed,average(a section-control camera),redlight,mobile_site(a published mobile-enforcement zone) orunknown.limit_kphis the enforced limit where the data records one, on the camera itself or its enforcement zone.nullis common and honest rather than an error: Ireland's published zones carry no limit at all. Never substitute a guess.limit_confidencesays how farlimit_kphcan be trusted:official(from the enforcing authority),osm(from map tagging),inferred_from_road, orunknown; alwaysunknownwhenlimit_kphisnull.bearing, when present, is the camera facing in degrees clockwise from north. Cameras facing away from the direction of travel (more than 60 degrees off the local route bearing, typically the opposite carriageway) are filtered out server-side. Corridors carry no bearing: they are enforced in both directions.attributionlists the notices owed by the data actually returned. Display them verbatim. Empty when no cameras were returned.sourceandlicenceidentify where each record came from, so mixed responses stay auditable.
Corridor records
Corridor entries (average section control and mobile_site zones) add:
| Field | Type | Description |
|---|---|---|
zone.start / zone.end | [lat, lon] | The corridor's published ends. The direction carries no enforcement meaning. |
zone.length_km | number | Enforced length. Published where the authority states one, otherwise measured along the geometry. |
zone.geometry | [[lon, lat], …] | The corridor polyline, in GeoJSON coordinate order. |
zone.geometry_parts | [number, …] | Vertex count of each source part. Split geometry on these boundaries when drawing; joining the parts would draw connecting segments that are not in the data. |
from_arc_m / to_arc_m | number | Where the corridor starts and ends along this route, in metres. Use the interval between them, with zone.length_km and limit_kph, to run an average-speed calculation client-side. to_arc_m can be the smaller of the two when the route runs against the corridor's published direction. |
One corridor is one entry. Do not expect one entry per camera on a section-control stretch, and do not alert per device: that would warn the driver several times for a single enforcement event.
Jurisdiction policy
Camera-position warnings are illegal in some countries, so the gateway
applies a per-country policy server-side, before the response is built,
and reports what it did in policy:
mode | Behaviour |
|---|---|
exact | Exact camera positions are returned. |
omitted | The cameras array is empty; only the policy object is returned. |
The default is off: only countries on an explicit, legally reviewed
allowlist (currently the United Kingdom and Ireland) return exact.
Every other jurisdiction returns omitted, whatever the reason. Those
reasons differ and the code records them separately: Switzerland, Cyprus,
North Macedonia, Turkey and Czechia forbid camera alerts outright; France
allows danger zones only; Germany, Denmark and Greece are lawful to
distribute but ban the driver's use, so they are off by default; and a
further set including Spain, the Netherlands, Belgium, Austria, Italy and
Portugal is researched as lawful but is not yet on the allowlist. Anything
unresearched is treated as forbidden. On routes that cross borders, each camera is also gated
on its own country, so a prohibited neighbour's cameras never appear.
Clients must not work around the policy with their own camera data.
Availability and billing
Bills as one Standard call, never Premium: it is a safety feature
and carries no extra charge. On deployments without camera data the
endpoint answers 501 (urn:sn-gateway:problem:cameras-not-enabled);
treat that as "feature not available here" and disable camera alerts,
rather than as an error. Self-hosters enable it by pointing
SN_CAMERAS_DIR at the per-territory cameras.json sidecars the
factory's safety-cameras stage produces.
Data sources and licences
Camera data is either OpenStreetMap-derived or published by the enforcing authority, and within one territory it is always one or the other, never a mixture. That separation is a licence requirement, not a preference: combining OSM and non-OSM records of the same feature type over the same area makes the whole dataset an ODbL derivative with share-alike.
| Territory | Source | Licence |
|---|---|---|
| Ireland | An Garda Síochána mobile safety camera zones (1,916 corridors) plus the published fixed and average-speed cameras (11 devices). OSM camera nodes are excluded. | CC BY 4.0 (Irish public sector information) |
| Everywhere else | OpenStreetMap | ODbL 1.0 |
The attribution array in every response carries the notices the
returned data obliges. Display them verbatim.
Ireland publishes no speed limits. Every Irish record has
limit_kph: null with limit_confidence: "unknown", and the API will
not infer one from the road: Ireland re-based its default rural limit to
60 km/h in February 2025, so an inference tuned to the old defaults would
be wrong across a large part of the network. Clients must not fill the
gap with a guess of their own either.
Traffic incidents along a route (POST /v1/incidents/along)
Given a route shape, returns the incidents on it: closures and lane
restrictions, plus live accidents, obstructions and roadworks, where
each one sits along the route, its cause and severity, and the lane, time
and delay detail the source publishes. Built for the same "warn the
driver on approach" use case as /v1/cameras/along, over two National
Highways sources merged into one result: the Road and Lane Closures
feed and the live NTIS events feed. Each incident says which one it came
from in source_kind (closures or ntis-events), and source names
the entry in sources[] whose coverage note and licence apply to it.
Parameters
| Field | Type | Required | Description |
|---|---|---|---|
shape | string | yes | The route geometry as a polyline6 string, exactly as POST /route responses emit their leg shape. |
buffer_m | number | no | Corridor half-width in metres. Default 60; values above 500 are clamped to 500; zero, negative or non-numeric is a 400. |
Example
curl -fsS -X POST "$BASE/v1/incidents/along" \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d '{ "shape": "}~ycbBhavgN...", "buffer_m": 60 }'
Response
{
"incidents": [
{ "id": "7-1784655805-...-close", "arc_m": 0.0,
"from_arc_m": 0.0, "to_arc_m": 1326.1, "offset_m": 0.0,
"side": "unknown", "cause": "roadOrCarriagewayOrLaneManagement: laneClosures",
"severity": "lane_restriction", "closure_type": "laneClosures",
"road": "M1", "lanes_restricted": 1, "lanes_operational": 3,
"status": "active", "planned": false, "probable": false,
"overall_start": "2026-07-26T10:00:00Z", "overall_end": "2026-07-27T10:00:00Z",
"source": "uk-national-highways", "source_kind": "closures" },
{ "id": "7c1f…-rec-2", "arc_m": 16680.0,
"from_arc_m": 16680.0, "to_arc_m": 27800.4, "offset_m": 3.1,
"side": "left", "cause": "accident", "severity": "other",
"category": "accident", "reported_severity": "medium",
"delay_s": 900, "situation_id": "7c1f…", "road": "M1",
"location_name": "M1 J10 to J11",
"comment": "Lane one closed while a broken-down lorry is recovered",
"status": "active", "planned": false, "probable": false,
"overall_start": "2026-07-26T09:40:00Z",
"source": "uk-ntis-events", "source_kind": "ntis-events" }
],
"sources": [
{ "source_id": "uk-national-highways",
"network": "England strategic road network (motorways and trunk roads managed by National Highways)",
"coverage_note": "National Highways only. Local roads, and the separate Scotland/Wales trunk-road networks, are not covered.",
"licence": "OGL-UK-3.0" },
{ "source_id": "uk-ntis-events",
"network": "England strategic road network (National Highways NTIS live event feed)",
"coverage_note": "National Highways' strategic road network in England only…",
"licence": "National Highways Transport Data Feeds licence (subscription)" }
],
"attribution": [
"Contains public sector information licensed under the Open Government Licence v3.0.",
"Powered by National Highways' Transport Data Feeds"
]
}
-
incidentsis ordered byarc_m.from_arc_m/to_arc_mbound the extent of the closure's geometry that actually fell inside the corridor; for a closure spanning a long stretch of motorway this can be several kilometres; for one that only clips the corridor briefly,from_arc_mandto_arc_msit close together. -
severity(closure|lane_restriction|other) is derived only from the feed's own closure-type and lane-count fields, never a guess when those are absent. The events feed publishes no lane counts, so its rows areclosurewhen the record is closure-classed andotherotherwise, never alane_restrictioninferred from prose. -
side(left|right|unknown) is geometric: which side of the route's own local bearing the incident sits on. The feed carries no carriageway-direction field, so this is not the same question as "does this affect my direction of travel". -
source_kindisclosuresorntis-events. Rows from the events feed carry extra optional fields, all simply absent on a closure row (nothing you already parse changes):Field Meaning categoryWhat the feed says it is: accident,roadworks,obstruction,congestion,closure,other.reported_severityThe feed's own severity word ( lowest,low,medium,high,highest), verbatim; absent when the feed says it does not know.delay_sDelay in seconds as National Highways reports it for that event, their figure about their network, passed through unchanged. It is never a MapMap estimate, and it is not an ETA adjustment: our routing does not yet consume live speeds, so /routedurations are unaffected by it.situation_idThe feed's grouping key. Several rows can describe one situation (one per record); group on this to show them together. location_nameThe name of the matched stretch in the feed's own network model, e.g. "M1 J10 to J11". -
An events row's
causeis the record's own type, and itsstatusis alwaysactive: records whose validity has ended are dropped, and one whose window has not opened yet comes back withplanned: true. An event is only included when the feed's location resolves to known geometry, so a real event on a covered road can still be missing. -
sourcesis always present, even with zero matching incidents. An emptyincidentsarray means "nothing on the covered network near this route", never "the road is clear". Readcoverage_notebefore trusting silence: National Highways covers the English strategic road network only, not local roads and not the separate Scotland/Wales networks.sourcesalso tells you which feeds a given deployment actually has: a deployment without the live events subscription reports the closures source alone, and its rows never carrycategoryordelay_s.
Availability and billing
Bills as one Standard call, never Premium, the same posture as
/v1/cameras/along, one call however many sources answer it. On
deployments without incident data the endpoint answers 501
(urn:sn-gateway:problem:incidents-not-enabled); treat that as "feature
not available here", not an error. Self-hosters enable it by pointing
SN_INCIDENTS_DIR at a directory of per-source sidecar files, one file per
source; the per-source file format ships with the self-host
distro's own operator documentation rather than here,
because it is an operator concern and not part of the wire contract. The
live events source is a National Highways
subscription feed and is not part of the self-host distro. Licences:
OGL v3.0 (UK Open Government Licence) for the closures feed and National
Highways' own Transport Data Feeds terms for the live events feed. The
attribution array carries every configured source's required notice
verbatim. Display them all.
Cheapest fuel along a route (POST /v1/fuel/along)
Ranks fuel stations along a route cheapest first, each one priced with
its real engine-computed detour. Where POST /route/along
searches an arbitrary place query, here the fuel-price dataset supplies
the candidates, so every result carries a price by construction.
The detour is what makes the ranking usable. A forecourt 3p cheaper is
not cheaper if reaching it costs eight minutes, so detour is measured
through the engine as (origin to station) + (station to destination) − (origin to destination), in real driving time, never crow-flies. Pass a
truck costing or an adr profile and every detour respects the same
dimensional and tunnel restrictions the route itself does.
This endpoint is off by default and returns 501 with
urn:sn-gateway:problem:fuel-not-enabled unless the operator has
configured a price file. Feature-detect on the 501, exactly as for
/v1/cameras/along.
Parameters
| Field | Type | Required | Description |
|---|---|---|---|
geometry_polyline6 | string | one of | An existing route's shape, polyline6, as POST /route emits it. |
origin, destination | object | one of | {lat, lon} pair. Mutually exclusive with geometry_polyline6; a route is computed for you. |
costing, costing_options, adr | string, object, object | no | Same contract as /route/along. A truck profile makes every detour truck-legal. |
fuel | string | no | diesel (default), petrol_95, petrol_98, premium_diesel, e85, lpg. Aliases accepted: petrol, unleaded, e10, super_unleaded, e5, b7, sdv. |
max_detour_minutes | number | no | Detour budget. Default 10, maximum 120. |
max_results | number | no | Default 5, maximum 25. |
fill_litres | number | no | Adds saving_total = saving_per_litre × litres to each result. |
Example
export BASE=https://api.mapmap.ai
export API_KEY=snk_...
curl -fsS -X POST "$BASE/v1/fuel/along" \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d '{
"origin": { "lat": 51.75, "lon": -1.26 },
"destination": { "lat": 53.48, "lon": -2.24 },
"fuel": "diesel",
"max_detour_minutes": 8,
"fill_litres": 55
}'
Response
{
"fuel": "diesel",
"costing": "auto",
"route": { "duration_s": 8123.0, "distance_m": 210400.0, "length_m": 210400.0 },
"max_detour_minutes": 8,
"candidates": { "considered": 604, "costed": 25, "cap": 25 },
"baseline": {
"value": 1.459, "station_id": "uk-1234", "brand": "Example",
"name": "Example Services", "updated_at": "2026-08-09T06:12:00Z", "stale": false
},
"results": [
{
"station_id": "uk-5678", "source": "uk-fuelfinder", "brand": "Example",
"name": "Example Filling Station", "lat": 52.41, "lon": -1.78,
"price": { "value": 1.429, "currency": "GBP", "updated_at": "2026-08-09T05:40:00Z" },
"stale": false,
"detour_minutes": 4.2, "detour_s": 252, "detour_km": 2.31,
"along_route_position": 0.48, "off_route_m": 380,
"saving_per_litre": 0.03, "saving_total": 1.65
}
],
"fuel_attribution": "Fuel prices: tankerkoenig.de CC BY 4.0 (DE)"
}
resultsis ordered cheapest first, and only contains stations inside the detour budget. An emptyresultswith a largecandidates.consideredmeans the cheapest candidates all fell outside the budget, not that there is no fuel on the route.candidates.consideredis how many stations selling that fuel were in the corridor;costedis how many were priced through the engine. Only the cheapestcap(25) are costed, so the ranking is exact among those.staleistruewhen the price is not verifiably fresher than 24 hours. An unparseable timestamp is never presented as fresh.baselineis the cheapest station within 150 m of the route geometry, the "just pull in" option, kept per currency so a border-crossing route never subtracts pence from cents. It isnullwhen no station sits on the route itself, andsaving_per_litreis then absent.fuel_attributionnames the open-data sources. Displaying it with the prices is a licence obligation, not a courtesy.
Coverage is the real limit. Prices come from statutory and open feeds,
so the endpoint answers only where such a feed exists and the operator has
loaded it: the UK Fuel Finder scheme, France's prix-carburants and
Germany's Tankerkönig. Everywhere else returns no candidates rather than
an error. Prices are as reported by the retailer and can be wrong or
stale, which is what the stale flag and updated_at are for.
Metering. A flat 10 calls, the same block as /route/along, whatever
max_results you ask for. The internal route and matrix calls it fans out
to are not metered again. Premium when the body carries truck costing or
an adr profile, Standard otherwise.
EV charge points along a route (POST /v1/charging/along)
Coverage first. There is no national UK charge-point registry. The
Department for Transport decommissioned the National Chargepoint Registry
on 28 November 2024 and published no replacement, leaving every operator
to publish its own feed under the Public Charge Point Regulations 2023. A
MapMap deployment therefore covers exactly the operators it has onboarded,
and this endpoint says so on every answer: coverage_note names the
operators and the number of charging positions, and sources carries each
operator's own coverage note and licence verbatim, present even when
results is empty. An empty results means "none from these operators
within your detour budget". It never means "there are no chargers here."
Show coverage_note to your users alongside the results.
Within that coverage, the endpoint ranks charge points most powerful
first, each costed with its real engine-computed detour. Where
POST /route/along searches OSM's
charging-station POIs, here the charge-point dataset supplies the
candidates, so every result carries connectors and power by construction.
Available when the deployment configures a charge-point directory;
otherwise it answers 501 with
urn:sn-gateway:problem:charging-not-enabled, which you can feature-detect
on exactly as for /v1/fuel/along.
Parameters
| Field | Type | Required | Description |
|---|---|---|---|
origin / destination | {lat, lon} | one of | Endpoints; a route is computed with costing |
geometry_polyline6 | string | one of | An existing route geometry; provide this or the pair above, never both |
costing | string | no | auto (default), truck, bicycle, pedestrian, motor_scooter |
costing_options | object | no | Valhalla costing options, passed through verbatim |
adr | object | no | ADR vehicle profile; requires costing: "truck" |
connectors | string[] | no | Keep only sites offering one of type2, type1, ccs, chademo, tesla, domestic, other. An unknown token is a 400, never a silent no-match |
min_kw | number | no | Keep only sites whose best connector is rated at least this many kW (50 for rapid charging) |
available_only | boolean | no | Keep only sites with a bay reported free. Needs a live availability feed; without one the call is refused rather than answering an empty list |
max_detour_minutes | number | no | Default 10, at most 120 |
max_results | number | no | Default 5, at most 25 |
Example
export BASE=https://api.mapmap.ai
export API_KEY=snk_...
curl -fsS -X POST "$BASE/v1/charging/along" \
-H "Authorization: Bearer $API_KEY" -H "Content-Type: application/json" \
-d '{"origin": {"lat": 51.75, "lon": -1.26},
"destination": {"lat": 53.48, "lon": -2.24},
"connectors": ["ccs"], "min_kw": 50, "max_detour_minutes": 8}'
Response
{
"costing": "auto",
"route": { "duration_s": 9600.0, "distance_m": 271000.0, "length_m": 271420.0 },
"max_detour_minutes": 8.0,
"filters": { "connectors": ["ccs"], "min_kw": 50.0, "available_only": false },
"candidates": { "considered": 14, "costed": 14, "cap": 25 },
"coverage": { "chargers": 5401, "evses": 5401, "countries": ["GB"],
"sources": { "chargy": 5401 } },
"coverage_note": "Covers 5401 charge point(s) (5401 charging position(s)) from char.gy. Charge points from any operator not listed are absent from this dataset entirely, so an empty result means \"none from these operators\", never \"no chargers here\".",
"sources": [
{ "source_id": "chargy", "operator": "char.gy", "chargers": 5401,
"coverage_note": "On-street lamppost charging only; no rapid hubs and no forecourts.",
"licence": "Public Charge Point Regulations 2023" }
],
"availability": {
"live": false,
"note": "No live availability feed is configured, so every status is the value captured when the dataset was last ingested, not what the bay is doing now. Supply your operator's OCPI feed to make these live."
},
"results": [
{
"charger_id": "98632dfb-6751-45ec-b3a0-9066b749fdfa",
"source": "chargy", "operator": "char.gy",
"name": "Bicester Park & Ride", "lat": 51.9036, "lon": -1.1520,
"max_power_kw": 150.0, "connector_standards": ["ccs", "type2"],
"evse_count": 4,
"best_connector": { "standard": "ccs", "power_kw": 150.0, "dc": true,
"power_kw_source": "declared" },
"status_live": false,
"detour_minutes": 4.2, "detour_s": 252.0, "detour_km": 2.1,
"along_route_position": 0.12, "off_route_m": 700.0,
"updated_at": "2026-08-29T07:46:41Z"
}
],
"charging_attribution": "Charge points: Charge-point availability data from char.gy (char.gy), published under the Public Charge Point Regulations 2023."
}
power_kw_source distinguishes a rating the operator declared from one
derived from voltage × amperage × phases. A connector with neither
carries no power_kw at all rather than a fabricated zero. Do not present
the three cases as the same thing.
When results is empty a note names which cause applies: an unconfigured
or empty dataset, a route outside the loaded coverage, filters that
excluded everything, or a genuine "nothing within the detour budget".
Availability, and bringing your own feed
Static charge-point data works on its own. What open data cannot give you is whether a bay is free right now: that is per-operator, published to whoever has agreed terms with that operator.
- Without a feed: every result is
status_live: false, carries noavailable_nowat all (absent means unknown, never "occupied"), andavailable_only: trueis refused with a400rather than answering an empty list that would read as "every charger is busy". - With your own feed: results are
status_live: trueand carryavailable_now, andavailable_onlyfilters on it.
Self-hosting customers wire this by dropping an availability.ndjson file
into the charge-point directory from their own OCPI poller; the gateway
picks it up within a minute. The file format and the operator-onboarding
process are documented in the repository's docs/EV-CHARGING-DATA.md.
Availability and billing
Billed as one flat block of 10 calls, like
POST /route/along: it fans out to
at most one route and two internal matrix calls, which are not metered
again. Premium when the body carries truck costing or an adr profile.
Display the returned charging_attribution with the results: it is a
licence obligation, and a charge point whose operator carries no recorded
attribution is dropped before it is ever served.
EV route planning (POST /v1/ev/plan)
Where /v1/charging/along
finds the best charger on a route you have already decided you can drive,
this endpoint answers the prior question: can this car make this journey
at all, and where does it have to stop?
The routing engine has no EV costing (Valhalla has never had one), so the
whole planner is gateway-side, composing the existing /route,
/elevation and /matrix machinery with a published physics model and the
operator charge-point data behind SN_CHARGE_DIR. It is a static planner
over the operator feeds this deployment ingests; bring your own OCPI feed
for live bay availability, exactly as for /v1/charging/along.
Available when the deployment configures a charge-point directory;
otherwise it answers 501 with
urn:sn-gateway:problem:charging-not-enabled, which you can feature-detect
on.
How it plans
- The base route, computed with
autocosting and yourcosting_options. - Leg sampling. Each manoeuvre becomes one leg with its own length and average speed, which is finer than a leg summary and makes the steady-state consumption model better.
- Elevation, or an honest absence. Where the deployment has elevation,
every leg boundary is sampled and each leg gets its real climb and fall.
Where it does not, legs carry no gradient, consumption runs on the
flat (the only thing that can be done) and the response says
gradient_data: "absent". A gradient is never silently assumed to be zero, because "flat" and "we do not know" are different answers. - The direct plan. If the car arrives at or above
min_arrival_socwithout ever dropping belowreserve_soc, that is the plan: no charger lookup and no matrix call happen at all. - Candidates. Otherwise charge points are projected onto the route, kept only where the vehicle can plug in at a rated power inside the corridor, and capped spread along the route rather than ranked by power: a planner needs reach where a search needs kilowatts.
- One matrix per window. Origin, candidates and destination are costed
pairwise through the gateway's own engine, in overlapping windows narrow
enough for the engine's
max_matrix_distance. Every hop's time and distance are engine-computed; none is a straight-line guess. - Greedy with lookahead. Stops are chosen so the state of charge never
breaches
reserve_soc, preferring a stop that finishes the journey, then the furthest one (fewer stops), then the cheapest by driving plus charging time. A charger from which nothing further can be reached even on a full battery is refused rather than taken, and that lookahead is what stops a greedy planner stranding a driver at the last charge point before a gap. - The final geometry is the route recomputed through the chosen stops.
Charge times are integrated over the vehicle's own charging curve, capped by the charge point's rated power. They are not energy divided by peak power: a DC session is a curve that tapers as the pack fills, and treating it as a rate is the single biggest error in naive EV trip planners.
Parameters
| Field | Type | Required | Description |
|---|---|---|---|
origin / destination | {lat, lon} | yes | Journey endpoints |
waypoints | {lat, lon}[] | no | Points the route must pass through, in order (at most 8) |
profile | string or object | no | "small_hatch", "saloon" (default), "suv", "van"; or an object of EvProfile fields (battery_kwh, usable_fraction, mass_kg, drag_area_m2, rolling_resistance_coefficient, drivetrain_efficiency, regen_efficiency, aux_kw, charge_curve, connectors) layered over a named base |
start_soc | number | no | State of charge at the start, 0–1. Default 0.9 |
min_arrival_soc | number | no | Lowest acceptable state of charge on arrival. Default 0.1 |
reserve_soc | number | no | The floor the charge must never fall below mid-route. Default 0.1. Must not exceed start_soc, and min_arrival_soc must not be below it; a contradiction is a 400, never silently reconciled |
connectors | string[] | no | Narrow the vehicle's own connector set to type2, type1, ccs, chademo, tesla, domestic, other. An unknown token is a 400, never a silent no-match |
min_kw | number | no | Only stop at charge points rated at least this many kW |
ambient_temperature_c | number | no | Derates traction energy from a published study. Cabin heating belongs in the profile's aux_kw, not here |
max_detour_minutes | number | no | How far off the route a charge point may sit. Default 15, at most 120 |
costing_options | object | no | Valhalla costing options, passed through verbatim. The costing is always auto |
Example
export BASE=https://api.mapmap.ai
export API_KEY=snk_...
curl -fsS -X POST "$BASE/v1/ev/plan" \
-H "Authorization: Bearer $API_KEY" -H "Content-Type: application/json" \
-d '{"origin": {"lat": 51.50, "lon": -0.12},
"destination": {"lat": 55.95, "lon": -3.19},
"profile": "small_hatch",
"start_soc": 0.9, "reserve_soc": 0.15,
"connectors": ["ccs"], "min_kw": 50}'
Response
{
"feasible": true,
"costing": "auto",
"plan": {
"feasible": true, "stops": 2,
"total_drive_s": 24120.0, "total_charge_s": 3480.0,
"total_duration_s": 27600.0, "total_distance_m": 666800.0,
"start_soc": 0.9, "arrival_soc": 0.21,
"min_arrival_soc": 0.1, "reserve_soc": 0.15,
"energy_kwh": 92.4
},
"stops": [
{
"charger_id": "98632dfb-6751-45ec-b3a0-9066b749fdfa",
"source": "chargy", "operator": "char.gy",
"name": "Bicester Park & Ride", "lat": 51.9036, "lon": -1.1520,
"connector": { "standard": "ccs", "power_kw": 150.0, "dc": true,
"power_kw_source": "declared" },
"charger_kw": 150.0,
"arrive_soc": 0.22, "depart_soc": 0.78,
"charge_s": 1740.0, "detour_s": 240.0,
"along_route_position": 0.31, "off_route_m": 700.0,
"status": ["available"], "status_live": false,
"updated_at": "2026-08-29T07:46:41Z"
}
],
"legs": [
{ "from": "origin", "to": "98632dfb-6751-45ec-b3a0-9066b749fdfa",
"duration_s": 7200.0, "distance_m": 208000.0,
"start_soc": 0.9, "end_soc": 0.22, "min_soc": 0.22,
"consumed_wh": 39400.0, "regen_wh": 1100.0,
"legs_modelled": 214, "legs_with_gradient": 0 }
],
"soc_trace": [
{ "at": "origin", "soc": 0.9, "along_route_position": 0.0 },
{ "at": "98632dfb-6751-45ec-b3a0-9066b749fdfa", "soc": 0.22,
"along_route_position": 0.31, "departing_soc": 0.78 }
],
"gradient_data": "absent",
"gradient": { "legs_total": 214, "legs_with_gradient": 0, "fraction": 0.0 },
"vehicle": { "name": "small hatchback (Volkswagen ID.3 Pro 58 kWh)",
"usable_kwh": 58.0, "connectors": ["type2", "ccs2"] },
"candidates": { "in_corridor": 34, "usable": 12, "costed": 12, "cap": 20 },
"route": { "duration_s": 24120.0, "distance_m": 666800.0, "length_m": 666420.0 },
"geometry_polyline6": "}~ycbBhavgN...",
"coverage_note": "Covers 5401 charge point(s) (5401 charging position(s)) from char.gy. …",
"sources": [ { "source_id": "chargy", "operator": "char.gy", "chargers": 5401 } ],
"availability": { "live": false, "note": "No live availability feed is configured …" },
"provenance": {
"profile": { "source": "default", "profile": "small_hatch" },
"availability": "static_snapshot",
"consumption_model": "sn-ev road-load model (UNECE GTR No. 15 decomposition); ±15 % on constant-speed motorway travel, under-reads in stop-start traffic",
"charge_model": "sn-ev charge-curve integration, capped by the charge point's rated power. Published curves are best case: a cold pack charges more slowly than this.",
"planner": "static planner over the operator feeds this deployment ingests; bring your own OCPI feed for live availability"
},
"charging_attribution": "Charge points: …"
}
power_kw_source distinguishes a rating the operator declared from one
derived from voltage × amperage × phases; the flag travels with the
number so the two are never presented as the same thing. A charge point
with no rated connector cannot be planned against at all (a charge time
cannot be invented for it), so it is reported as unrated rather than
assumed.
When there is no plan
A charger desert, a connector mismatch or a gap wider than the car's range
is answered as a 200 with feasible: false, never a fabricated plan
and never an error to retry:
{
"feasible": false,
"plan": { "feasible": false, "stops": 0, "arrival_soc": null },
"stops": [],
"reason": {
"code": "out_of_range",
"message": "The next usable charge point is further away than the vehicle can travel before breaching the reserve state of charge. This is a gap in the corridor, not a routing failure."
},
"furthest_reachable": {
"along_route_position": 0.889, "distance_m": 395111.1,
"lat": 54.5556, "lon": -0.5
},
"note": "… Charge points from any operator not listed are absent from this dataset entirely, so an empty result means \"none from these operators\", never \"no chargers here\"."
}
reason.code is one of no_chargers_in_corridor, connector_mismatch,
chargers_unrated, below_min_kw, out_of_range, dead_end,
stop_limit, no_charge_curve, dataset_empty or unroutable. The
note always carries the coverage statement with it, so an infeasible plan
can never be read as "there are no chargers here".
What the numbers are worth
The model is documented, cited and bounded rather than tuned: a road-load
decomposition in the form the UNECE WLTP regulation uses for chassis
dynamometer setting, with constants from ISO 2533, the CGPM and the EU
tyre-label classes. Against published motorway consumption for each default
vehicle it reads within ±15 %, and consistently low. Outside constant
speed it is weaker: it does not model acceleration, so it under-reads
stop-start driving; charging curves are published best-case figures
measured on a warm pack; and a hop's energy is sliced from the base route's
legs with the detour surplus charged as one gradient-unknown leg. A plan is
a plan, not a promise, and gradient_data tells you whether even the
terrain was known.
Availability and billing
Billed as one flat block of 10 calls, Standard class. The planner fans out
to at most two internal routes, one height request and one matrix window
set, none of which is metered again. It is auto costing only, so it
carries no truck profile and cannot be priced as Premium.
Display the returned charging_attribution with the plan: it is a licence
obligation, and a charge point whose operator carries no recorded
attribution is dropped before it is ever served.
Weather along a route (POST /v1/weather/along)
Samples a route and returns forecast weather for each point aligned to when you actually get there, not to now. A two-hour route departing at noon reads the forecast for the departure hour at its start and the "two hours from now" hour at its end; that time alignment is the whole point: "you hit the snow band near Shap at 14:00", not "it is snowing somewhere on this route right now".
Parameters
| Field | Type | Required | Description |
|---|---|---|---|
shape | string | yes | The route geometry as a polyline6 string. |
depart_at | string | no | RFC 3339 departure time. Defaults to now. |
duration_s | number | no | Total route duration in seconds, used to compute each sample's ETA. Omit for a generic average-speed estimate (flagged duration_source: "estimated_default_speed" in the response); pass the real value from /route for accurate alignment. |
sample_interval_m | number | no | Sampling interval in metres. Default 25,000; clamped 5,000–100,000; capped at 40 samples total regardless of route length. |
Example
curl -fsS -X POST "$BASE/v1/weather/along" \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d '{ "shape": "}~ycbBhavgN...", "depart_at": "2026-07-26T12:00:00Z", "duration_s": 7200 }'
Response
{
"samples": [
{ "lat": 54.4, "lon": -2.7, "arc_m": 180000.0,
"eta": "2026-07-26T14:00:00Z", "forecast_hour": "2026-07-26T14:00",
"available": true, "temperature_c": -1.2, "precipitation_mm": 0.6,
"rain_mm": 0.0, "snowfall_cm": 0.8, "precipitation_type": "snow",
"precipitation_probability_pct": 80.0,
"wind_speed_kph": 32.0, "wind_gusts_kph": 58.0,
"visibility_m": 3200.0, "weather_code": 71, "severe": false }
],
"depart_at": "2026-07-26T12:00:00Z", "duration_s": 7200.0,
"duration_source": "provided", "sample_interval_m": 25000.0,
"attribution": "Weather data by Open-Meteo.com (CC BY 4.0)"
}
wind_gusts_kphis the figure that matters for high-sided vehicles.severeis derived only from the source's WMO weather code (thunderstorm and heavy-precipitation codes), never a proprietary severe-weather product, and omitted (notfalse) when the sample itself is unavailable.- A sample whose upstream fetch failed, or whose ETA falls beyond the
source's forecast horizon, comes back
"available": falsewith anunavailable_reason; the request never fails outright because one sample couldn't be resolved.
Availability, billing and licence
Bills as one Standard call regardless of sample count. 501
(urn:sn-gateway:problem:weather-not-enabled) unless the operator sets
SN_OPEN_METEO_URL. Read this before enabling commercially:
Open-Meteo's free, keyless endpoint is non-commercial use only; a
commercial deployment needs an Open-Meteo paid plan or a self-hosted
instance (Open-Meteo is open source, AGPLv3). That position is recorded in
the distribution's own THIRD-PARTY-NOTICES.md, shipped in the self-host
distro alongside the CycloneDX SBOM; our wider licence posture is on the
open source page. The weather data itself is CC BY 4.0;
every response carrying data includes an attribution string.
Errors
Errors are application/problem+json (see
conventions for the envelope and the 402-vs-429
distinction). All eleven endpoints share the same table:
| Status | type URN | Meaning | Retry? |
|---|---|---|---|
| 400 | urn:sn-gateway:problem:bad-request | Malformed JSON; empty locations, contours, sources or targets; a contour with neither time nor distance; a trace request with no trace; an along-route request with neither/both of route and shape, or an out-of-range corridor_m/max_detours; a cameras or incidents request with an undecodable shape or a non-positive buffer_m; a weather request with a non-RFC-3339 depart_at or a non-positive duration_s/sample_interval_m; an elevation request with no points or more than 500, both or neither of geometry_polyline6 and route, or both interval_m and sample_count. | No; fix the request. |
| 404 | urn:sn-gateway:problem:not-found | /route/along only: customer places are not configured on this deployment. | No. |
| 501 | urn:sn-gateway:problem:cameras-not-enabled / …:incidents-not-enabled / …:weather-not-enabled / …:elevation-not-enabled | The corresponding endpoint's data source is not configured on this deployment. | No. Feature-detect and disable that feature. |
| 401 | urn:sn-gateway:problem:unauthorized | Missing or invalid API key. | No. |
| 429 | urn:sn-gateway:problem:quota-exceeded or …:rate-limited | Monthly quota or rate limit; rate-limited bodies carry retry_after (seconds). | After retry_after. |
| 502 | urn:sn-gateway:problem:upstream-error | The routing engine failed or returned garbage. | Yes; transient. |
Note that /v1/weather/along never returns a 502 for a per-sample
upstream failure: it degrades that sample to
"available": false inside a normal 200 response instead (see above).
Next steps
- Route optimisation: the solver that consumes these matrices, with the same truck/ADR constraints
- API reference: the full endpoint surface, including
POST /route(whose response/trace_routeshares) and the/placesAPI that feeds along-route search - Conventions: base URL, auth, error envelope, ADR tunnel codes
- MCP server: the
matrixandsearch_along_routetools expose these capabilities to agents