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

Analyse a route you already have (curl)
# 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"}'

Sends a real request, with your key or the keyless allowance.

Request body

FieldTypeDescription
gpxstringA GPX 1.0/1.1 document; each trkseg (else rte) is a part. Exactly one of gpx and geometry.
geometryobjectGeoJSON LineString, MultiLineString, a Feature of either, or a FeatureCollection of them; a third coordinate is a height.
profilestring: hike | run | bike
elevationstring: auto | recorded | terrain

Response

FieldTypeDescription
schemastring: route-analysis/1
engineobject
profilestring
inputobject
matchobject
surfacesobject or null
waysobject or null
difficultyobject or nullTagged sac_scale per length; untagged paths are unknown, never easy.
elevationobject or null
timeobject or nullHike only: trailsplits-hike/2 moving time, its scope and outside_calibration; null for run and bike.
unknownsarray
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.unmatched and 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_default means 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: unmatched first, 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
gpxstringA GPX 1.0/1.1 document. Each <trkseg> is a part; without tracks, each <rte>. Waypoints are ignored. Heights from <ele>
geometryGeoJSONLineString, MultiLineString, a Feature of either, or a FeatureCollection of them. Each line is a part, in order. A third coordinate is a height
profilehike \run \bikeDefault hike (listed in settings_defaulted). Only hike has a time model
callerstringOptional 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.

  1. 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.
  2. Time (hike only): the calibrated trailsplits-hike/2 on 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_calibration with 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: null and the unknown time_not_estimated.

Response

FieldMeaning
schemaroute-analysis/1
profile, settings_defaultedAs asked, and which were defaulted
inputformat, parts, points, length_m (gaps excluded), heights_share, median_spacing_m, gaps_m (straight distance across each gap between parts)
matchstatus 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)
surfacespaved_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)
waysby_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
difficultyscale: sac_scale, graded_m (T1…T6), untagged_path_m, road_m, hardest (grade, from_m, to_m, name, stretches); null when nothing matched
elevationsource 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
timemodel, 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
timingwindows, 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" } }.

HTTPcodeWhen
400invalid_requestNot JSON, not an object, unknown field, both or neither of gpx/geometry, a bad profile or elevation, a geometry that is not a line
400invalid_gpxgpx is not a string or has no <gpx> element
400invalid_coordinatesA point outside lon −180…180, lat −90…90
405method_not_allowedAny method but POST; the answer carries Allow: POST
413body_too_largeBody over 1 MB (answered as JSON with CORS, like every refusal)
413too_many_pointsMore than 5,000 points
422no_lineNo part of two or more points
422too_longMore than 300 km
422corridor_too_largeA window's corridor is too dense to load; send the line in pieces
422budget_exhaustedThe job's 25 s / 12 M-node budget ran out (the job is receipted budget:*)
422output_too_largeThe answer would exceed 5 MB (never truncated)
503overloadedThe worker's heavy-job queue is full; Retry-After: 5
503tiles_unavailableRouting 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)

CasePoints, kmWindowsTimeAnswer
Kungsleden (owned)1,851, 10461.7–2.1 s28 kB
TMB main route (owned)4,726, 16591.7 s109 kB
Straight across the Alps, mostly off path5,000, 293159.1 s178 kB
10 m zig-zag in central Paris5,000, 5235.1 s142 kB
Random walk through Paris4,900, 294155.7 s407 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" } } } }
}