Skip to content

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

Documentation menu
docs / analysis · raw .md

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.
  • costing is one of auto, truck, bus, motor_scooter, motorcycle, bicycle, pedestrian.
  • costing_options is 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_code is 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 by SN_ENGINE_MAX_MATRIX_DISTANCE_M to match the engine's own service_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 get 422 matrix-span-too-large with span_km, max_span_km and furthest_pair: {source_index, target_index} — the offending pair named by index, so you can drop or re-block it without bisecting your own arrays — plus limit_env, engine_setting and self_host_docs for 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/along and /v1/fuel/along, whatever max_results you 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/progress bills a flat five calls, and POST /route/report one, both including whatever internal route, map-match or trace_attributes calls 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 /isochrone and POST /matrix accept exclude_polygons and exclude_locations to 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

FieldTypeRequiredDescription
locationsarray of {lat, lon}yes (≥ 1)Origin(s) the reachability is computed from.
costingstringyesCosting model (see shared conventions).
contoursarrayyes (≥ 1)Each contour is a time in minutes or a distance in kilometres; a contour with neither is a 400.
polygonsbooleannoReturn polygons instead of the default linestrings.
denoisenumberno01; higher drops smaller contour islands.
generalizenumbernoGeometry simplification tolerance in metres.
show_locationsbooleannoInclude the input locations as GeoJSON points in the response.
costing_optionsobjectnoVerbatim costing options, e.g. {"truck": {…}}.
idstringnoOpaque identifier echoed back.

Example

Where can a 4-metre-high truck get to from a London depot in 10 and 20 minutes:

sh
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":

json
{
  "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

FieldTypeRequiredDescription
sourcesarray of {lat, lon}yes (≥ 1)Row locations (origins).
targetsarray of {lat, lon}yes (≥ 1)Column locations (destinations).
costingstringyesCosting model (see shared conventions).
costing_optionsobjectnoVerbatim costing options, e.g. {"truck": {…}}.
unitsstringnokilometers or miles; accepted, but the gateway normalises response distances to metres regardless.
idstringnoOpaque identifier echoed back.

Example

One depot to two delivery cities, truck costing:

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

json
{
  "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

FieldTypeRequiredDescription
querystringyesFree-text POI query, e.g. "diesel" or "truck stop". An empty string is a 400.
geometry_polyline6stringone route inputAn existing route geometry as an encoded polyline with six digits of precision (polyline6).
origin / destination{lat, lon}one route inputRoute endpoints, when no geometry is given; the gateway computes the route.
costingstringnoauto (default), truck, bicycle, pedestrian, motor_scooter, bus or motorcycle. Used for the route and the detour matrices.
costing_optionsobjectnoVerbatim Valhalla costing options, e.g. {"truck": {"height": 4.0}}.
adrobjectnoADR vehicle profile, same shape as POST /route; requires "costing": "truck". Makes the request Premium.
max_detour_minutesnumbernoLargest acceptable detour in minutes (default 10, at most 120).
max_resultsintegernoResults returned after sorting by detour (default 5, at most 25).
sourcestringno"geocode" (default) or "places".
categorystringnoRestrict 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".
sortstringno"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:

sh
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):

json
{
  "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

FieldTypeRequiredDescription
shapearray of {lat, lon}one of shape / encoded_polylineThe GPS trace as an ordered list of points.
encoded_polylinestringone of shape / encoded_polylineThe trace as an encoded polyline with six digits of precision (polyline6).
costingstringyesCosting model (see shared conventions).
shape_matchstringnoedge_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).
filtersobjectno/trace_attributes only: which edge attributes to include or exclude.
unitsstringnokilometers (default) or miles, for response length fields.
costing_optionsobjectnoVerbatim costing options.
idstringnoOpaque identifier echoed back.

Example

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

json
{
  "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:

json
{
  "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:

StepCallWhat it gives you
1. ReplayPOST /trace_routeThe 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. ReportPOST /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

sh
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 modeshape or encoded_polyline, never locations (that is route mode, which computes a fresh route rather than reporting on yours):

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

json
{
  "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):

json
{
  "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 lineFrom the reportMiles
Total distance drivendistance_m = 187,400116.4
Driven in Englandby_admin[0].distance_m = 150,90093.8
Driven in Walesby_admin[1].distance_m = 34,20021.3
Motorway (higher-rate band)by_road_class.motorway.distance_m = 121,30075.4
Non-motorway187,400 − 121,300 = 66,10041.1
Tolled distancetoll.distance_m = 00.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_s is 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 cumulative end_node.elapsed_time values 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 with country_code: null rather 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_snap picks 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 toll tag, not a tariff. toll.distance_m is 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

FieldTypeRequiredDescription
geometry_polyline6stringone of this / origin+destinationThe 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_polyline6Compute the plan here instead. The same route-input contract POST /v1/fuel/along accepts.
current_position{lat, lon}yesWhere the vehicle is now.
recent_tracearray of {lat, lon}noRecent 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_mnumbernoHow far from the plan counts as off route. Default 50 m, maximum 5,000.
costingstringnoauto (default) or any costing model; used for the plan, the match and the remaining leg.
costing_optionsobjectnoVerbatim costing options (truck dimensions, and so on).
adrobjectnoADR 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 — no recent_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 — with recent_trace. The trace is map-matched through the engine (the same matcher POST /trace_route uses) 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

sh
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

json
{
  "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:

  • progress is geometric, against your plan. How far along the polyline the snapped position sits. No engine call, no re-planning. plan_remaining_m is simply the plan's leftover length.
  • remaining is 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 exceed plan_remaining_m. That gap is the useful signal: a large one means the deviation is expensive.
  • eta is 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). Unlike POST /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": false and 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_m is null for 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 null over land is worth reporting to us; a null in the middle of an ocean is the format working. (Self-hosters stage whatever regions they choose, so their coverage is whatever they staged.)
  • source and resolution_m are 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" at resolution_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 is null everywhere. Feature-detect on the 501 and hide elevation features. Self-hosters enable the endpoints by staging DEM tiles for the engine and setting SN_ELEVATION_ENABLED=true (declare SN_ELEVATION_DATASET and SN_ELEVATION_RESOLUTION_M to label the samples honestly).

Point elevation: POST /elevation

FieldTypeRequiredDescription
pointsarray of {lat, lon}yes (1 to 500)Points to sample. Empty or over 500 is a 400.
sh
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:

json
{
  "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.

FieldTypeRequiredDescription
geometry_polyline6stringone of geometry_polyline6 / routeThe route geometry as an encoded polyline with six digits of precision (polyline6), e.g. a leg shape from a /route response.
routeobjectone of geometry_polyline6 / routeA full route response (the {"trip": …} shape POST /route returns); every leg's shape is concatenated. Providing both, or neither, is a 400.
interval_mnumbernoApproximate sample spacing in metres. At most one of this and sample_count; with neither, the default is 100 m.
sample_countintegernoExact 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.
sh
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

json
{
  "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_percent on sample i is the percent grade of the segment ending at i: null on sample 0, and null whenever either endpoint of the segment lacks a real elevation. A coverage gap never contributes a wrong (zero) grade.
  • total_ascent_m and total_descent_m are accumulated only over consecutive sample pairs where both have a real elevation, and are null (not 0) when no such pair exists, so "flat" and "unknown" stay distinguishable. coverage_fraction is 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

FieldTypeRequiredDescription
shapestringyesThe route geometry as an encoded polyline with six digits of precision (polyline6), exactly as POST /route responses emit their leg shape.
buffer_mnumbernoCorridor 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

sh
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

json
{
  "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" }
}
  • cameras is ordered by arc_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.
  • kind is fixed, average (a section-control camera), redlight, mobile_site (a published mobile-enforcement zone) or unknown.
  • limit_kph is the enforced limit where the data records one, on the camera itself or its enforcement zone. null is common and honest rather than an error: Ireland's published zones carry no limit at all. Never substitute a guess.
  • limit_confidence says how far limit_kph can be trusted: official (from the enforcing authority), osm (from map tagging), inferred_from_road, or unknown; always unknown when limit_kph is null.
  • 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.
  • attribution lists the notices owed by the data actually returned. Display them verbatim. Empty when no cameras were returned.
  • source and licence identify where each record came from, so mixed responses stay auditable.

Corridor records

Corridor entries (average section control and mobile_site zones) add:

FieldTypeDescription
zone.start / zone.end[lat, lon]The corridor's published ends. The direction carries no enforcement meaning.
zone.length_kmnumberEnforced 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_mnumberWhere 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:

modeBehaviour
exactExact camera positions are returned.
omittedThe 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.

TerritorySourceLicence
IrelandAn 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 elseOpenStreetMapODbL 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

FieldTypeRequiredDescription
shapestringyesThe route geometry as a polyline6 string, exactly as POST /route responses emit their leg shape.
buffer_mnumbernoCorridor half-width in metres. Default 60; values above 500 are clamped to 500; zero, negative or non-numeric is a 400.

Example

sh
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

json
{
  "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"
  ]
}
  • incidents is ordered by arc_m. from_arc_m/to_arc_m bound 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_m and to_arc_m sit 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 are closure when the record is closure-classed and other otherwise, never a lane_restriction inferred 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_kind is closures or ntis-events. Rows from the events feed carry extra optional fields, all simply absent on a closure row (nothing you already parse changes):

    FieldMeaning
    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 /route durations 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 cause is the record's own type, and its status is always active: records whose validity has ended are dropped, and one whose window has not opened yet comes back with planned: 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.

  • sources is always present, even with zero matching incidents. An empty incidents array means "nothing on the covered network near this route", never "the road is clear". Read coverage_note before trusting silence: National Highways covers the English strategic road network only, not local roads and not the separate Scotland/Wales networks. sources also 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 carry category or delay_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

FieldTypeRequiredDescription
geometry_polyline6stringone ofAn existing route's shape, polyline6, as POST /route emits it.
origin, destinationobjectone of{lat, lon} pair. Mutually exclusive with geometry_polyline6; a route is computed for you.
costing, costing_options, adrstring, object, objectnoSame contract as /route/along. A truck profile makes every detour truck-legal.
fuelstringnodiesel (default), petrol_95, petrol_98, premium_diesel, e85, lpg. Aliases accepted: petrol, unleaded, e10, super_unleaded, e5, b7, sdv.
max_detour_minutesnumbernoDetour budget. Default 10, maximum 120.
max_resultsnumbernoDefault 5, maximum 25.
fill_litresnumbernoAdds saving_total = saving_per_litre × litres to each result.

Example

sh
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

json
{
  "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)"
}
  • results is ordered cheapest first, and only contains stations inside the detour budget. An empty results with a large candidates.considered means the cheapest candidates all fell outside the budget, not that there is no fuel on the route.
  • candidates.considered is how many stations selling that fuel were in the corridor; costed is how many were priced through the engine. Only the cheapest cap (25) are costed, so the ranking is exact among those.
  • stale is true when the price is not verifiably fresher than 24 hours. An unparseable timestamp is never presented as fresh.
  • baseline is 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 is null when no station sits on the route itself, and saving_per_litre is then absent.
  • fuel_attribution names 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

FieldTypeRequiredDescription
origin / destination{lat, lon}one ofEndpoints; a route is computed with costing
geometry_polyline6stringone ofAn existing route geometry; provide this or the pair above, never both
costingstringnoauto (default), truck, bicycle, pedestrian, motor_scooter
costing_optionsobjectnoValhalla costing options, passed through verbatim
adrobjectnoADR vehicle profile; requires costing: "truck"
connectorsstring[]noKeep only sites offering one of type2, type1, ccs, chademo, tesla, domestic, other. An unknown token is a 400, never a silent no-match
min_kwnumbernoKeep only sites whose best connector is rated at least this many kW (50 for rapid charging)
available_onlybooleannoKeep 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_minutesnumbernoDefault 10, at most 120
max_resultsnumbernoDefault 5, at most 25

Example

sh
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

json
{
  "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 no available_now at all (absent means unknown, never "occupied"), and available_only: true is refused with a 400 rather than answering an empty list that would read as "every charger is busy".
  • With your own feed: results are status_live: true and carry available_now, and available_only filters 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

  1. The base route, computed with auto costing and your costing_options.
  2. 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.
  3. 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.
  4. The direct plan. If the car arrives at or above min_arrival_soc without ever dropping below reserve_soc, that is the plan: no charger lookup and no matrix call happen at all.
  5. 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.
  6. 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.
  7. 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.
  8. 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

FieldTypeRequiredDescription
origin / destination{lat, lon}yesJourney endpoints
waypoints{lat, lon}[]noPoints the route must pass through, in order (at most 8)
profilestring or objectno"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_socnumbernoState of charge at the start, 0–1. Default 0.9
min_arrival_socnumbernoLowest acceptable state of charge on arrival. Default 0.1
reserve_socnumbernoThe 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
connectorsstring[]noNarrow 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_kwnumbernoOnly stop at charge points rated at least this many kW
ambient_temperature_cnumbernoDerates traction energy from a published study. Cabin heating belongs in the profile's aux_kw, not here
max_detour_minutesnumbernoHow far off the route a charge point may sit. Default 15, at most 120
costing_optionsobjectnoValhalla costing options, passed through verbatim. The costing is always auto

Example

sh
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

json
{
  "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:

json
{
  "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

FieldTypeRequiredDescription
shapestringyesThe route geometry as a polyline6 string.
depart_atstringnoRFC 3339 departure time. Defaults to now.
duration_snumbernoTotal 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_mnumbernoSampling interval in metres. Default 25,000; clamped 5,000–100,000; capped at 40 samples total regardless of route length.

Example

sh
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

json
{
  "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_kph is the figure that matters for high-sided vehicles.
  • severe is derived only from the source's WMO weather code (thunderstorm and heavy-precipitation codes), never a proprietary severe-weather product, and omitted (not false) 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": false with an unavailable_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:

Statustype URNMeaningRetry?
400urn:sn-gateway:problem:bad-requestMalformed 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.
404urn:sn-gateway:problem:not-found/route/along only: customer places are not configured on this deployment.No.
501urn:sn-gateway:problem:cameras-not-enabled / …:incidents-not-enabled / …:weather-not-enabled / …:elevation-not-enabledThe corresponding endpoint's data source is not configured on this deployment.No. Feature-detect and disable that feature.
401urn:sn-gateway:problem:unauthorizedMissing or invalid API key.No.
429urn:sn-gateway:problem:quota-exceeded or …:rate-limitedMonthly quota or rate limit; rate-limited bodies carry retry_after (seconds).After retry_after.
502urn:sn-gateway:problem:upstream-errorThe 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_route shares) and the /places API that feeds along-route search
  • Conventions: base URL, auth, error envelope, ADR tunnel codes
  • MCP server: the matrix and search_along_route tools expose these capabilities to agents