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

news / map-matching · raw .md
Routing7 min readMapMap engineering
A jittery trail of noisy position dots drawn onto a clean road centreline by perpendicular correction ticks, raw trace and snapped path shown as two lines.

Map matching: which roads a vehicle actually drove

A GPS trace is a list of points near roads, not on them. POST /trace_route and POST /trace_attributes turn that trace into an answer about the road network: /trace_route snaps the trace and returns a route, in the same response shape as POST /route, while /trace_attributes returns the matched road segments and their attributes instead. Same request body, two different questions. Both are standard class at 0.05p per call, and a trace bills one call however many points it carries.

What is the difference between /trace_route and /trace_attributes?

The request body is identical. The answer is not. /trace_route gives you a journey: manoeuvres, durations, lengths and a snapped geometry you can draw. /trace_attributes gives you the road segments themselves, so you can ask questions about them rather than display them.

EndpointReturnsReach for it when
POST /trace_routeThe standard route response: trip.legs[].maneuvers, summary, leg shape as polyline6You want a clean, displayable route out of a noisy track
POST /trace_attributesAn open JSON document: shape, matched_points, edges[]You want to interrogate the roads that were driven

If you are unsure which one you need, the test is whether the output goes on a map or into a query. Rendering is /trace_route. Auditing is /trace_attributes.

What does a map-matching request look like?

One body, either endpoint. Supply the trace as a shape array of {lat, lon} points or as an encoded_polyline with six digits of precision, pick a costing model, and optionally set shape_match:

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"
}'

/trace_route returns the route response you already parse, with time in seconds, length in the trip's units (kilometres by default) and the 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"
  }
}

Send the same body to /trace_attributes and you get the matched geometry plus the per-edge attributes the engine can supply. This is an open document rather than a fixed schema, so expect shape (polyline6), matched_points and an edges array, and read the rest defensively:

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 with type urn:sn-gateway:problem:bad-request, and the detail names the fix verbatim:

swift
a trace is required: supply a non-empty `shape` array or an `encoded_polyline`

The full parameter table, including filters for narrowing the attribute set on /trace_attributes, is in the analysis API guide.

Which shape_match mode should you pick?

walk_or_snap, unless you know something the default does not. It tries the exact edge sequence first and falls back to snapping when that fails, which is the right behaviour for traces of unknown quality.

shape_matchBehaviourWhen it is right
edge_walkWalks the shape as an exact edge sequence, no snappingThe trace is already known to follow real edges
map_snapSnaps each point to the most likely edge (hidden Markov model)The trace is raw telematics with real noise in it
walk_or_snapTries edge_walk, falls back to map_snap (the default)You do not know which of the two you have

costing is one of auto, truck, bus, motor_scooter, motorcycle, bicycle or pedestrian, and costing_options reaches the engine verbatim, so the match runs against the same costing model your planning does. Note the billing consequence before you reach for it: "costing": "truck" moves the call to the premium class at 1p.

What is map matching actually for?

Three jobs, in the order we get asked about them.

  • Telematics cleanup. A tracker reporting every 30 seconds gives you a track that cuts corners, wanders across dual carriageways and jumps kerbs. /trace_route returns it as a route with manoeuvres and a geometry that sits on the road, which is what a customer-facing journey replay needs.
  • Evidence of roads travelled. /trace_attributes returns the matched edges, so the record you keep is which segments the vehicle was on, not a cloud of coordinates that a dispute can reinterpret. This is a factual record and nothing more. It does not judge the roads, and it makes no dangerous-goods decision: see the limits below.
  • Toll reconciliation. On Valhalla, the default engine, matched edges carry way_id, which you can join to your own schedule of tolled ways to check an invoice against the roads that were actually driven. Self-hosting on GraphHopper does not give you way_id, so this job needs Valhalla: see the limits below. We supply the matching, you supply the toll data. We do not price tolls and we do not pretend to.

What does map matching cost?

Map matching is standard class: one call per request, priced at 0.05p, whatever the length of the trace. There is no per-point or per-edge metering. Unlike /matrix, which bills one call per started block of 25 elements, and /isochrone, which bills one call per contour, a trace request is a single unit.

Monthly volumeStandard price per call
Included free tier50,000 included calls, after email verification
50,001 to 1M0.05p
1M to 10M0.03p
Beyond 10M0.02p

Bands are marginal, so each price applies only to the calls that fall inside it. The class is selected automatically per request, and it is read from the body before the handler runs: a top-level "costing": "truck", a top-level adr object or a top-level "hazmat": true makes the call premium at 1p. Anything else, including every ordinary trace, is standard. Payment is prepaid credit, so an empty balance is a 402 or a 429 rather than an invoice. Prices reconcile with /pricing.json, which wins if this page ever drifts.

What map matching does not do

It does not apply ADR restrictions, and you should not build a dangerous-goods compliance step on it. /trace_route and /trace_attributes ignore a top-level adr object if you send one, because the extension is honoured only on POST /route and POST /optimise. The meter does not ignore it, though, and that asymmetry will cost you: the class is read from the body, so a trace carrying a top-level adr object or "hazmat": true bills premium at 1p and still gets no ADR enforcement, which is 20 times the standard price for nothing. Leave both off a trace request. Map matching tells you which roads a vehicle was on. It does not tell you whether the load was entitled to be there. That is a separate decision, and it has its own endpoint: ADR tunnel compliance, enforced in costing covers POST /adr/check.

Four more limits worth having in writing:

  • The gateway rejects request bodies over 1 MiB. A long trace sent as a shape array of {lat, lon} objects hits that ceiling sooner than you would like. Send encoded_polyline for anything sizeable, or split the trace.
  • /trace_attributes is an open document, not a contract. The attribute set depends on the engine and on your filters. Match on the keys you need and tolerate their absence rather than assuming a fixed shape.
  • Self-hosting on GraphHopper costs you the edge model. With SN_ROUTING_ENGINE=graphhopper, /trace_attributes is best-effort: the match response is a routing path, so we return shape, matched_points and instruction-derived edges, and we do not fabricate way_id, road_class, surface or speed_limit. Valhalla, the default engine, has the full model. We would rather tell you the field is missing than invent it.
  • There is no map-matching MCP tool. Our MCP server exposes route, matrix, geocode, check_adr_tunnel and optimise_routes, among others, but a coding agent that wants map matching calls the HTTP endpoint directly.

Try it

The analysis API guide has the full parameter tables, the error envelope and the shared conventions for /isochrone, /matrix, /trace_route and /trace_attributes. A key comes from one card-free call to POST /v1/keys, and the endpoint surface is listed in the API reference.

Routing derives from OpenStreetMap, so credit "© OpenStreetMap contributors" when you render or republish it.