Analyse a route you already have
POST https://api.trailsplits.com/v1/route-analysis
Checked by a live probe Key optional · 500 credits a day without one Heavy: 1 at a time on Free
Send a GPX or GeoJSON line (at most 1 MB, 5,000 points, 300 km). The answer places it on the mapped network and says on what surface it runs, which OpenStreetMap grades are tagged, how much it climbs (method named), how long a hike takes with the model scope, and every unknown. The line you send is never rewritten, and nothing is drawn across a gap. Untagged paths are unknown, never easy. No geometry is kept after the answer.
Example
# Without a key, leave out the Authorization line (500 credits a day per address).
curl --fail-with-body 'https://api.trailsplits.com/v1/route-analysis' \
-H "Authorization: Bearer $TRAILSPLITS_API_KEY" \
-H 'Content-Type: application/json' \
--data '{"geometry":{"type":"LineString","coordinates":[[12.1352,46.5369],[12.133,46.536]]},"profile":"hike"}' Request body
| Field | Type | Description |
|---|---|---|
gpx | string | A GPX 1.0/1.1 document; each trkseg (else rte) is a part. Exactly one of gpx and geometry. |
geometry | object | GeoJSON LineString, MultiLineString, a Feature of either, or a FeatureCollection of them; a third coordinate is a height. |
profile | string: hike | run | bike | |
elevation | string: auto | recorded | terrain |
Response
| Field | Type | Description |
|---|---|---|
schema | string: route-analysis/1 | |
engine | object | |
profile | string | |
input | object | |
match | object | |
surfaces | object or null | |
ways | object or null | |
difficulty | object or null | Tagged sac_scale per length; untagged paths are unknown, never easy. |
elevation | object or null | |
time | object or null | Hike only: trailsplits-hike/2 moving time, its scope and outside_calibration; null for run and bike. |
unknowns | array |
Full response schema (JSON Schema)
{
"type": "object",
"properties": {
"schema": {
"type": "string",
"enum": [
"route-analysis/1"
]
},
"engine": {
"type": "object"
},
"profile": {
"type": "string"
},
"input": {
"type": "object",
"properties": {
"format": {
"type": "string"
},
"parts": {
"type": "integer"
},
"points": {
"type": "integer"
},
"length_m": {
"type": "number"
}
},
"required": [
"format",
"parts",
"points",
"length_m"
]
},
"match": {
"type": "object",
"properties": {
"status": {
"type": "string",
"enum": [
"matched",
"partial",
"unmatched"
]
},
"coverage": {
"type": "number"
},
"trace_length_m": {
"type": "number"
},
"matched_length_m": {
"type": "number"
},
"network_length_m": {
"type": "number"
},
"segments": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": true
}
},
"unmatched": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": true
}
}
},
"required": [
"status",
"coverage",
"trace_length_m",
"matched_length_m",
"network_length_m",
"segments",
"unmatched"
]
},
"surfaces": {
"type": "object",
"nullable": true
},
"ways": {
"type": "object",
"nullable": true
},
"difficulty": {
"type": "object",
"nullable": true,
"description": "Tagged sac_scale per length; untagged paths are unknown, never easy."
},
"elevation": {
"type": "object",
"nullable": true
},
"time": {
"type": "object",
"nullable": true,
"description": "Hike only: trailsplits-hike/2 moving time, its scope and outside_calibration; null for run and bike."
},
"unknowns": {
"type": "array",
"items": {
"type": "object",
"properties": {
"code": {
"type": "string"
},
"message": {
"type": "string"
}
},
"required": [
"code",
"message"
]
}
}
},
"required": [
"schema",
"engine",
"profile",
"input",
"match",
"unknowns"
]
}Errors
400, 413, 422, plus the gateway's 401, 429 and 503 (error conventions). A refused request costs no credits.
Contract
Send a line you already have: a race course, a recorded walk, a route from another app. The answer says where it runs on the mapped network, on what surface, which grades OpenStreetMap tags, how much it climbs, how long a hike takes, and what is not known.
Rules it keeps
-
The line you send is the authority. It is never simplified, reordered, rerouted or joined. Distances, climb and time are read on it. Positions (
from_m,to_m) are metres along it; indices (from_index,to_index) count its points over all parts in order. -
Nothing is drawn across a gap. Where no mapped path lies near the line, or the network does not join two of its points, the stretch is listed in
match.unmatchedand keeps no surface, way or grade. The space between two parts of a file (two GPX track segments, two lines of a MultiLineString) is not part of the line at all: no distance, match or climb crosses it. -
A surface the map does not give is said to be a default.
source: class_defaultmeans the surface is the default for the kind of way (a track reads gravel), not a mapped surface. -
Every unknown is listed in
unknowns, each with a code and a sentence:unmatchedfirst, then the others that are lengths, longest first, then the rest. - Privacy: the service keeps no geometry beyond the request. Receipts and logs carry no coordinates: the log line says points, kilometres, match status and coverage.
Request
{ "gpx": "<gpx …>…</gpx>", "profile": "hike", "elevation": "auto" }
{ "geometry": { "type": "LineString", "coordinates": [[lon, lat, height?], …] }, "profile": "run" }
| Field | ||||
|---|---|---|---|---|
gpx | string | A GPX 1.0/1.1 document. Each <trkseg> is a part; without tracks, each <rte>. Waypoints are ignored. Heights from <ele> | ||
geometry | GeoJSON | LineString, MultiLineString, a Feature of either, or a FeatureCollection of them. Each line is a part, in order. A third coordinate is a height | ||
profile | hike \ | run \ | bike | Default hike (listed in settings_defaulted). Only hike has a time model |
caller | string | Optional label for logs and receipts (or the X-Caller header) |
Exactly one of gpx and geometry. Unknown fields are refused, not ignored.
Limits: body 1 MB; 5,000 points; 300 km along the line. A part of one point is dropped.
How it is computed
Positions (from_m, to_m, including difficulty.hardest) are where the edges meet the line as sent; lengths are of the path. On a sketch shorter than its path, a stretch's to_m − from_m is less than its length.
-
Heights. Terrain: the tile server's elevation sampler (Copernicus GLO-30, z13, as the Planner), every 20 m along the line (fewer on long lines, at most 6,000 samples). Climb by the elevation contract:
route-smoothed-100m. Recorded: the file's heights, judged against the terrain at the same points; climb with a 5 m hysteresis (recorded-hysteresis-5m). Steepest stretches over about 200 m and 1 km, on heights smoothed by ±25 m. -
Time (hike only): the calibrated
trailsplits-hike/2on terrain heights (its calibration basis) with the rough-ground share of the matched edges (an unmatched stretch counts at the calibration median). Moving time, breaks excluded.outside_calibrationwith reasons: T4 or harder tagged on 50 m or more, less than half on marked trails, less than 90 % matched, or recorded heights because the terrain did not answer. Run and bike:time: nulland the unknowntime_not_estimated.
Response
| Field | Meaning | |||
|---|---|---|---|---|
schema | route-analysis/1 | |||
profile, settings_defaulted | As asked, and which were defaulted | |||
input | format, parts, points, length_m (gaps excluded), heights_share, median_spacing_m, gaps_m (straight distance across each gap between parts) | |||
match | status matched \ | partial \ | unmatched; coverage (share of the line inside matched segments); trace_length_m, matched_length_m (along the line), network_length_m (the mapped path); segments[] (from_index, to_index, from_m, to_m, network_m, shape polyline6 of the matched path); unmatched[] (from_index, to_index, from_m, to_m, length_m, reason no_edge_nearby \ | no_connection) |
surfaces | paved_m, gravel_m, trail_m, unknown_m; mapped_m and class_default_m; stretches[] (from_m, to_m, surface, detail, source mapped \ | class_default \ | unknown) | |
ways | by_type_m (path, track, hiking_path, mountain_hiking_path, footpath, street, road, steps, other), marked_m (on hiking route relations; null when the tiles do not say), max_sac, via_ferrata_m, aid_m, ford_m | |||
hazards[] | Each ford, aided passage (ladders, fixed ropes, rungs) and via ferrata on the matched line: kind ford \ | aid \ | via_ferrata, from_m, to_m (on the line as sent), length_m (of the path), way_id (OpenStreetMap). Touching stretches of one kind are one entry. Added 2 October 2026; ways keeps the totals | |
difficulty | scale: sac_scale, graded_m (T1…T6), untagged_path_m, road_m, hardest (grade, from_m, to_m, name, stretches); null when nothing matched | |||
elevation | source terrain \ | recorded, kind estimated \ | measured, method, ascent_m, descent_m, min_m, max_m, sparse, steepest[] (window_m, direction, grade_pct, from_m, to_m), profile (at most 500 [m, height]), terrain (release, samples, step_m, source) or null, recorded (share, use, reason, and when judged offset_m, noise_m, correlation, ascent_m, terrain_ascent_m), contract. Null when no heights at all | |
time | model, name, minutes, moving_only, basis, rough_share, applies, calibration (held-out legs, median and p80 error, report), outside_calibration, reasons[], models[] (every named model's minutes). Null for run and bike | |||
unknowns[] | code, message, length_m where it is a length. Codes below | |||
timing | windows, match_ms, total_ms, tiles_loaded |
input.length_m and match.trace_length_m measure the same line with two distance formulas and may differ by a few metres on a long line.
Unknown codes: unmatched, line_differs_from_path, surface_class_default, surface_unknown, difficulty_untagged, positions_approximate, recorded_heights_rejected (only with elevation: auto: the check chose the terrain), recorded_heights_doubtful (with elevation: recorded: the heights were used as asked though they fail the check), recorded_heights_missing, sparse_points (recorded heights from points more than 50 m apart), terrain_unavailable, elevation_unknown, time_outside_calibration, time_not_estimated. A length under 50 m is not named.
Errors
{ "error": { "code", "message" } }.
| HTTP | code | When |
|---|---|---|
| 400 | invalid_request | Not JSON, not an object, unknown field, both or neither of gpx/geometry, a bad profile or elevation, a geometry that is not a line |
| 400 | invalid_gpx | gpx is not a string or has no <gpx> element |
| 400 | invalid_coordinates | A point outside lon −180…180, lat −90…90 |
| 405 | method_not_allowed | Any method but POST; the answer carries Allow: POST |
| 413 | body_too_large | Body over 1 MB (answered as JSON with CORS, like every refusal) |
| 413 | too_many_points | More than 5,000 points |
| 422 | no_line | No part of two or more points |
| 422 | too_long | More than 300 km |
| 422 | corridor_too_large | A window's corridor is too dense to load; send the line in pieces |
| 422 | budget_exhausted | The job's 25 s / 12 M-node budget ran out (the job is receipted budget:*) |
| 422 | output_too_large | The answer would exceed 5 MB (never truncated) |
| 503 | overloaded | The worker's heavy-job queue is full; Retry-After: 5 |
| 503 | tiles_unavailable | Routing tiles could not be read |
A line with nothing matched is not an error: it answers 200 with match.status: unmatched, its climb and time, and unmatched first among the unknowns.
Bounds (measured 2 October 2026, local service on public tiles)
| Case | Points, km | Windows | Time | Answer |
|---|---|---|---|---|
| Kungsleden (owned) | 1,851, 104 | 6 | 1.7–2.1 s | 28 kB |
| TMB main route (owned) | 4,726, 165 | 9 | 1.7 s | 109 kB |
| Straight across the Alps, mostly off path | 5,000, 293 | 15 | 9.1 s | 178 kB |
| 10 m zig-zag in central Paris | 5,000, 52 | 3 | 5.1 s | 142 kB |
| Random walk through Paris | 4,900, 294 | 15 | 5.7 s | 407 kB |
Versioning
Additive fields may appear in route-analysis/1. A change of meaning, a removed field or a changed method name is route-analysis/2.
JSON Schema
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://trailsplits.com/schemas/route-analysis-1.request.json",
"type": "object",
"additionalProperties": false,
"oneOf": [{ "required": ["gpx"] }, { "required": ["geometry"] }],
"properties": {
"gpx": { "type": "string" },
"geometry": { "type": "object", "required": ["type"], "properties": { "type": { "enum": ["LineString", "MultiLineString", "Feature", "FeatureCollection"] } } },
"profile": { "enum": ["hike", "run", "bike"] },
"elevation": { "enum": ["auto", "recorded", "terrain"] },
"caller": { "type": "string" }
}
}
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://trailsplits.com/schemas/route-analysis-1.response.json",
"type": "object",
"required": ["schema", "engine", "profile", "settings_defaulted", "input", "match", "surfaces", "hazards", "ways", "difficulty", "elevation", "time", "unknowns", "timing"],
"properties": {
"schema": { "const": "route-analysis/1" },
"profile": { "enum": ["hike", "run", "bike"] },
"settings_defaulted": { "type": "array", "items": { "enum": ["profile", "elevation"] } },
"input": {
"type": "object",
"required": ["format", "parts", "points", "length_m", "heights_share", "median_spacing_m", "gaps_m"],
"properties": { "format": { "enum": ["gpx", "geojson"] }, "parts": { "type": "integer" }, "points": { "type": "integer" }, "length_m": { "type": "number" }, "heights_share": { "type": "number" }, "median_spacing_m": { "type": "number" }, "gaps_m": { "type": "array", "items": { "type": "number" } } }
},
"match": {
"type": "object",
"required": ["status", "coverage", "trace_length_m", "matched_length_m", "network_length_m", "segments", "unmatched"],
"properties": {
"status": { "enum": ["matched", "partial", "unmatched"] },
"coverage": { "type": "number" },
"segments": { "type": "array", "items": { "type": "object", "required": ["from_index", "to_index", "from_m", "to_m", "network_m", "shape"], "properties": { "shape": { "type": "string" } } } },
"unmatched": { "type": "array", "items": { "type": "object", "required": ["from_index", "to_index", "from_m", "to_m", "length_m", "reason"], "properties": { "reason": { "enum": ["no_edge_nearby", "no_connection"] } } } }
}
},
"surfaces": {
"type": "object",
"required": ["paved_m", "gravel_m", "trail_m", "unknown_m", "mapped_m", "class_default_m", "stretches"],
"properties": { "stretches": { "type": "array", "items": { "type": "object", "required": ["from_m", "to_m", "surface", "detail", "source"], "properties": { "surface": { "enum": ["paved", "gravel", "trail", "unknown"] }, "source": { "enum": ["mapped", "class_default", "unknown"] } } } } }
},
"hazards": { "type": "array", "items": { "type": "object", "required": ["kind", "from_m", "to_m", "length_m", "way_id"], "properties": { "kind": { "enum": ["ford", "aid", "via_ferrata"] } } } },
"ways": { "type": "object", "required": ["by_type_m", "marked_m", "max_sac", "via_ferrata_m", "aid_m", "ford_m"] },
"difficulty": {
"type": ["object", "null"],
"required": ["scale", "graded_m", "untagged_path_m", "road_m", "hardest"],
"properties": { "hardest": { "type": ["object", "null"], "required": ["grade", "from_m", "to_m", "name", "stretches"] } }
},
"elevation": {
"type": ["object", "null"],
"required": ["source", "kind", "method", "ascent_m", "descent_m", "min_m", "max_m", "sparse", "steepest", "profile", "terrain", "recorded"],
"properties": {
"source": { "enum": ["terrain", "recorded"] },
"kind": { "enum": ["estimated", "measured"] },
"method": { "enum": ["route-smoothed-100m", "recorded-hysteresis-5m"] },
"steepest": { "type": "array", "items": { "type": "object", "required": ["window_m", "direction", "grade_pct", "from_m", "to_m"] } },
"profile": { "type": "array", "items": { "type": "array" } }
}
},
"time": {
"type": ["object", "null"],
"required": ["model", "name", "minutes", "moving_only", "basis", "rough_share", "applies", "calibration", "outside_calibration", "reasons", "models"],
"properties": { "basis": { "enum": ["terrain", "recorded"] }, "reasons": { "type": "array", "items": { "type": "string" } } }
},
"unknowns": { "type": "array", "items": { "type": "object", "required": ["code", "message"] } },
"timing": { "type": "object", "required": ["windows"] }
}
}
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://trailsplits.com/schemas/route-analysis-1.error.json",
"type": "object",
"required": ["error"],
"properties": { "error": { "type": "object", "required": ["code", "message"], "properties": { "code": { "type": "string" }, "message": { "type": "string" } } } }
}