Map matching GPS traces
POST /v1/match takes a GPS trace from a train and returns the path it took on the track
network. For every point you get the matched position, the line number, the track where it can be
told apart, the km on the line, the speed and how confident the match is. Typical uses: fleet
telematics (where was the train, on which line, at which km), mileage per line, checking speeds
against line speeds, or cleaning up a recorded trip before showing it on a map.
The endpoint needs an API key with the route scope. It is metered per point (stats.points);
billing weighs a point as 0.01 requests, so a trace of 1,000 points bills as 10 requests.
Send a trace
Section titled “Send a trace”The body is the trace itself. GPX, GeoJSON and CSV are understood:
curl -X POST "https://api.routing.gleisklar.de/v1/match" \ -H "Authorization: Bearer $RAIL_API_KEY" \ -H "Content-Type: application/gpx+xml" \ --data-binary @trip.gpx| Format | Content-Type | What is read |
|---|---|---|
| GPX | application/gpx+xml | every trkpt (all tracks and segments, in order) with its time; rtept if the file has no track |
| GeoJSON | application/geo+json or application/json | a LineString or MultiLineString (bare, as a Feature or in a FeatureCollection) with the times in properties.coordTimes (or times), or Point features with properties.time (or timestamp) |
| CSV | text/csv | a header row naming the lat, lon and optionally time (or timestamp) columns; separated by ,, ; or tabs |
The format comes from the format parameter (gpx, geojson, csv), else the Content-Type,
else the first character of the body. Times are ISO 8601 (2026-11-04T08:15:30Z; without a time
zone they are UTC) or unix seconds (milliseconds work too). Timestamps are optional, but they make
the match better (impossible jumps are recognised, gaps are bridged at a plausible speed) and they
are needed for speeds. They must not go backwards: sort the trace by time.
A CSV trace:
lat,lon,time54.3146,10.1319,2026-11-04T08:00:00Z54.3122,10.1335,2026-11-04T08:00:20ZOptions
Section titled “Options”Query parameters:
| Parameter | Default | Meaning |
|---|---|---|
gps_accuracy_m | 15 | Standard deviation of the GPS error in metres, 1 to 100. Raise it for noisy devices (a phone inside a train: 20–30). |
search_radius_m | 4 × accuracy, at least 25 | Track farther than this from a point is not considered for it (5 to 500 m). |
max_speed_kmh | 300 | The fastest the train can go (10 to 500). With timestamps, faster movements are outliers, and gaps are bridged at most this fast. Set it for slower trains (freight: 120). |
format | detected | gpx, geojson or csv. |
The answer repeats the values used in options.
Limits
Section titled “Limits”At most 10,000 points per trace, and request bodies of at most 2 MiB; larger traces get
413 (payload-too-large) with a message: split them (for example by the hour) and match the
parts. A very sparse trace without timestamps over a dense network can also get 413; send
denser points (every 1–30 s) or timestamps.
The answer
Section titled “The answer”{ "ok": true, "format": "gpx", "options": { "gps_accuracy_m": 15, "search_radius_m": 60, "max_speed_kmh": 300 }, "confidence": 0.96, "distance_km": 41.27, "duration_s": 1802, "stats": { "points": 1803, "matched": 1741, "interpolated": 60, "outliers": 2, "unmatched": 0 }, "sections": [ { "from_point": 0, "to_point": 1802, "from_km": 0, "to_km": 41.27, "distance_km": 41.27, "confidence": 0.96, "geometry": { "type": "LineString", "coordinates": [[10.1319, 54.3146], "…"] }, "lines": [{ "line": "1043", "name": "1043 Neumünster - Bad Oldesloe", "from_km": 0, "to_km": 41.3 }], "infra": [{ "from_km": 0, "to_km": 2.1, "max_speed_kmh": 120, "electrified": false, "…": "…" }] } ], "breaks": [], "gps_gaps": [{ "from_point": 911, "to_point": 972, "distance_km": 1.6, "duration_s": 61, "points": 60 }], "points": [ { "index": 0, "lat": 54.31462, "lon": 10.13187, "time": "2026-11-04T08:00:00Z", "state": "matched", "section": 0, "matched_lat": 54.314631, "matched_lon": 10.131902, "distance_m": 2.4, "path_km": 0, "speed_kmh": 71.8, "line": "1043", "line_km": 103.118, "line_km_accuracy": { "class": "interpolated", "nearest_marker_m": 85.5, "estimated_error_m": 2.1 }, "track": { "id": "way/119522823", "track_ref": "1", "ambiguous": false, "alternatives": [] }, "confidence": 0.99 } ], "warnings": []}(The values above show the shape; they are not from one real trip.) When nothing could be
matched, ok is false and reason says why.
Per point
Section titled “Per point”points has one entry per input point, in the same order (index):
state:matched: the point was snapped to a track.interpolated: the point had no usable fix (no track within the search radius — typical in tunnels and station halls); it is placed along the matched path between its neighbours, by time (or by distance along the trace without timestamps).outlier: the point did not fit (a GPS jump, or too far for its timestamps) and was left out of the match. Within a section it still gets a position along the path, like an interpolated point.unmatched: outside every section (e.g. before the train reached the track).
matched_lat/matched_lon: the position on the track;distance_mfrom the GPS fix to it.line: the line number (railway:ref); on track without one (some station tracks), the nearest calibrated line within 50 m.line_kmandline_km_accuracy: the km on that line (see Line number and km).track: the OSM way the path uses here, withtrack_ref(railway:track_ref, e.g. “1”, “2”) only where the track can be told apart.ambiguous: truemeans a parallel track explains the trace about as well;alternativeslists the other plausible ways. Trust the line then, not the track.path_km: km along the matched path from its start.speed_kmh: the speed along the path from the timestamps, averaged over about 20 s.confidence(0–1): the probability that the point is on the reported line. Outliers and unmatched points have 0; interpolated points half the lower of their neighbours’.
The overall confidence is the mean over all points (outliers and unmatched points count as 0).
Sections, breaks and gaps
Section titled “Sections, breaks and gaps”The matched path is a list of sections. Usually there is one. Where no plausible route on the
track network connects two consecutive points (the trace jumps to a track that isn’t connected,
the train reversed where reversing isn’t allowed, or it would have been faster than
max_speed_kmh), the path breaks: a new section starts and breaks says where and why. Each
section has its geometry (GeoJSON LineString), distance_km, the lines it uses (with path km)
and the infrastructure along it (infra: line speed, electrification, line category, …, as in
POST /v1/infra/along-route).
gps_gaps lists stretches the path bridges by routing: points without a usable fix in between
(tunnels), or fixes far apart in time or distance for the trace’s sampling.
Parallel tracks
Section titled “Parallel tracks”GPS is rarely accurate enough to say which of two tracks 4–5 m apart a train used. The matcher
looks at the whole trace: the train can only change tracks at a crossover, so a long stretch
between crossovers gives enough evidence even when single points don’t. Where the evidence is
not enough, track.ambiguous is true and track_ref is left out — the line and the km are still
right, because both tracks belong to the same line. Where two different lines run side by side,
the confidence drops instead.
How it works
Section titled “How it works”The matcher is a hidden Markov model, the method of Newson & Krumm (2009) that GraphHopper and OpenRailRouting use, run on the railroute network:
- Candidates: each point is projected onto the track segments within the search radius, in both directions of travel.
- Emission: how likely the GPS fix is from a candidate — Gaussian in the distance, with
gps_accuracy_mas the standard deviation. - Transition: how likely the train went from one candidate to the next — high when the
distance along the track is close to the straight-line distance between the fixes. The track
distance comes from a shortest path with the routing rules: no turns sharper than a switch
allows, reversing only at stations. With timestamps, the search goes as far as the train can
go at
max_speed_kmhin the elapsed time, so gaps (tunnels) of several kilometres are bridged. - Viterbi finds the most likely sequence of candidates; the path between them is the track
route. Forward-backward gives each point’s probabilities: its
confidenceand whether its track isambiguous.
From the SDK
Section titled “From the SDK”import { createClient } from "@quebi/rail-api"import { readFile } from "node:fs/promises"
const api = createClient({ apiKey: process.env.RAIL_API_KEY })const trace = await readFile("trip.gpx", "utf8") // or CSV text, or a GeoJSON objectconst result = await api.match(trace, { gps_accuracy_m: 20, max_speed_kmh: 160 })for (const p of result.points) { console.log(p.time, p.state, p.line, p.line_km, p.speed_kmh, p.track?.ambiguous ? "?" : p.track?.track_ref)}The SDK detects the format and sets the Content-Type. In the dashboard’s playground, the match tab takes a file or pasted trace (or makes a sample from a route) and shows the matched path coloured by confidence.