---
title: "Snap raw GPS breadcrumbs to the road network"
method: POST
path: "/v1/map-match"
tags: ["Routing"]
---

# Snap raw GPS breadcrumbs to the road network

`POST /v1/map-match`

## Request

- Base URL: `https://geo.thinair.co`
- URL: `https://geo.thinair.co/v1/map-match`
- Auth: HTTP bearer

Map matching snaps raw GPS breadcrumbs to the road network — turn a noisy trace into the road-accurate route that was actually driven, with distance, time, road names, and a match confidence. `matched: true` only means a route was returned: gate on `match_status` (`full` | `partial` | `none`), and use `coverage.unmatched_ranges` to find the input points that did not match. `road_names` come from maneuver points — use /v1/trace-attributes for per-segment names. Sparsely-reported traces (consecutive fixes farther apart than the default breakage distance) can split into multiple sections — see `breakage_distance_m`. Total matched distance is limited to 200 km per call; split longer sparse traces by distance, not only by the 100-point cap. Costs 1 quota unit per call.

## Request body

- MapMatchRequest: Map-matching request — a raw GPS breadcrumb trail (2–100 points) to snap to the road network, with optional costing / truck profile / snap radius.
  - `shape` object[], required: Raw GPS breadcrumb trail in travel order — 2 to 100 points of { lat, lon, time? }.
    - `lat` number, required
    - `lon` number, required
    - `time` number: Optional epoch seconds for this fix — timestamps improve match quality on noisy traces.
  - `costing` 'auto' | 'truck' | 'bicycle' | 'pedestrian': Transport mode the trace was driven with. Default: auto.
  - `vehicle_profile` string: Truck preset (DRY_VAN_53, FLATBED_48, BOX_TRUCK_26, …) — same 10-profile table as /v1/directions. Implies costing=truck.
  - `search_radius` number: Snap radius in meters around each point (1–100). Increase for noisy GPS.
  - `breakage_distance_m` integer: Maximum gap, in meters, between consecutive fixes before the trace is split into separate sections (100–20000). Increase for sparsely-reported GPS, such as fleet units reporting every several minutes on a highway. Optional — omitted uses the default.

## Response `200`

Matched route. See `geojson` / `geometry` for the snapped line.

- MapMatchResponse: Matched-route result. `geojson` carries the snapped LineString for small traces; longer traces return `geometry.encoded` (polyline6 — decode with @mapbox/polyline at precision 6). `match_status` and `coverage` report how much of the trace the matcher actually placed on the road network — `matched` alone only says a route was returned. Road names/classes and distances are surfaced; internal road-graph identifiers are never exposed.
  - `matched` boolean, required: True when a route was returned for the trace. It does NOT mean the whole trace matched — use `match_status`.
  - `distance_km` number, required
  - `duration_seconds` number, required
  - `confidence` number, required: 0–1: fraction of input GPS points within the snap tolerance of the matched route. A distance heuristic: it ignores point order and which parallel roadway was matched. For the matcher's own per-point result use `match_status` / `coverage`.
  - `road_names` string[], required
  - `legs` object[], required
  - `costing` string, required
  - `geojson` object
  - `geometry` object
  - `meta` object, required
    - `upstream_ms` number, required
    - `point_count` integer, required
    - `breakage_distance_m` integer: Echoes the breakage_distance_m the caller requested, for confirmation — present only when the caller set it. This is the value SENT to the matcher, not a claim about what it did with it.
  - `match_status` 'full' | 'partial' | 'none', nullable, required: Whole-trace match status from the matcher's own per-point results. `full`: every input point was matched or interpolated onto the road network AND the match is one continuous section. `none`: no input point was matched or interpolated. `partial`: anything else — some points unmatched, or the match broke into more than one section. null when the per-point check could not be completed; `coverage.unavailable_reason` says why. Gate on this field, not on `matched`.
  - `coverage` object, required: Per-point coverage of the trace from the matcher's own per-point status — independent of the distance-based `confidence`. Every key is always present.
    - `point_count` integer, required: Number of input GPS points
    - `matched_count` integer, nullable, required: Input points the matcher placed on the road network directly. null when unavailable.
    - `interpolated_count` integer, nullable, required: Input points the matcher placed on the road network by interpolating between matched neighbours; counted as covered. null when unavailable.
    - `unmatched_count` integer, nullable, required: Input points the matcher could not place on the road network. null when unavailable.
    - `ratio` number, nullable, required: 0–1: (matched_count + interpolated_count) / point_count, rounded down to 3 decimals so a partial trace never reads 1. null when unavailable.
    - `unmatched_ranges` object[], nullable, required: Contiguous runs of unmatched input points, as 0-based inclusive index ranges into `shape`, in trace order. Empty when every point was covered; null when unavailable.
      - `start_index` integer, required: 0-based index of the first unmatched input point in this run
      - `end_index` integer, required: 0-based index of the last unmatched input point in this run (inclusive)
      - `point_count` integer, required: Number of input points in this run
    - `route_sections` integer, required: Number of disjoint sections the match broke into (1 = continuous). When greater than 1, the returned geometry, distance and duration cover only the FIRST section — split the trace at the break and match the parts separately.
    - `unavailable_reason` 'upstream_error' | 'timeout' | 'no_point_status' | 'point_count_mismatch' | 'unknown_point_type', nullable, required: Why the per-point counts and `match_status` are null: `upstream_error` (the per-point check failed), `timeout` (it did not answer in time), `no_point_status` (it answered without per-point results), `point_count_mismatch` (its results did not line up one-to-one with the input points), `unknown_point_type` (it reported a status this API does not recognise). null when coverage is available.

## Other responses

- `400`: Invalid request body (fewer than 2 or more than 100 points, bad coordinates).
- `404`: Trace could not be matched to any road (off-road, too sparse, or too noisy).
- `429`: Quota exceeded for this tenant.
- `502`: Upstream matching engine unavailable.

---

[API](https://skmtc.dev/thinair/apis/thinair-geo-api.md) · [All operations](https://skmtc.dev/thinair/apis/thinair-geo-api/llms.txt) · [OpenAPI document](https://skmtc.dev/thinair/apis/thinair-geo-api/revisions/8fac2d5e4b34?raw)
