Map matching: which roads a vehicle actually drove

# 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.

| Endpoint | Returns | Reach for it when |
|---|---|---|
| `POST /trace_route` | The standard route response: `trip.legs[].maneuvers`, `summary`, leg `shape` as polyline6 | You want a clean, displayable route out of a noisy track |
| `POST /trace_attributes` | An 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:

```
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](/docs/analysis).

## 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_match` | Behaviour | When it is right |
|---|---|---|
| `edge_walk` | Walks the shape as an exact edge sequence, no snapping | The trace is already known to follow real edges |
| `map_snap` | Snaps each point to the most likely edge (hidden Markov model) | The trace is raw telematics with real noise in it |
| `walk_or_snap` | Tries `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 volume | Standard price per call |
|---|---|
| Included free tier | 50,000 included calls, after email verification |
| 50,001 to 1M | 0.05p |
| 1M to 10M | 0.03p |
| Beyond 10M | 0.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](https://mapmap.ai/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](/news/adr-tunnel-compliance) 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](/docs/analysis) 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](/docs/api-reference).

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