Skip to content

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.

The body is the trace itself. GPX, GeoJSON and CSV are understood:

Terminal window
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
FormatContent-TypeWhat is read
GPXapplication/gpx+xmlevery trkpt (all tracks and segments, in order) with its time; rtept if the file has no track
GeoJSONapplication/geo+json or application/jsona 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)
CSVtext/csva 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,time
54.3146,10.1319,2026-11-04T08:00:00Z
54.3122,10.1335,2026-11-04T08:00:20Z

Query parameters:

ParameterDefaultMeaning
gps_accuracy_m15Standard deviation of the GPS error in metres, 1 to 100. Raise it for noisy devices (a phone inside a train: 20–30).
search_radius_m4 × accuracy, at least 25Track farther than this from a point is not considered for it (5 to 500 m).
max_speed_kmh300The 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).
formatdetectedgpx, geojson or csv.

The answer repeats the values used in options.

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.

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

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_m from 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_km and line_km_accuracy: the km on that line (see Line number and km).
  • track: the OSM way the path uses here, with track_ref (railway:track_ref, e.g. “1”, “2”) only where the track can be told apart. ambiguous: true means a parallel track explains the trace about as well; alternatives lists 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).

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.

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.

The matcher is a hidden Markov model, the method of Newson & Krumm (2009) that GraphHopper and OpenRailRouting use, run on the railroute network:

  1. Candidates: each point is projected onto the track segments within the search radius, in both directions of travel.
  2. Emission: how likely the GPS fix is from a candidate — Gaussian in the distance, with gps_accuracy_m as the standard deviation.
  3. 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_kmh in the elapsed time, so gaps (tunnels) of several kilometres are bridged.
  4. Viterbi finds the most likely sequence of candidates; the path between them is the track route. Forward-backward gives each point’s probabilities: its confidence and whether its track is ambiguous.
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 object
const 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.