Round trips from a start point
POST https://api.trailsplits.com/v1/roundtrips
Checked by a live probe Key optional · 500 credits a day without one Heavy: 1 at a time on Free
Loops that start and end at a point, from the same engine as the TrailSplits Planner. Each loop reports its target error, the distance walked twice and the same facts as route analysis. Status complete means within 10 % of the target with at most 20 % repeated; rough is answered with a reason; when no good loop exists the answer is 422 no_loop, never an invented loop. Limits: 0.5–40 km on foot, 0.5–60 km by bike (422 too_long), up to 3 alternatives.
Example
# Without a key, leave out the Authorization line (500 credits a day per address).
curl --fail-with-body 'https://api.trailsplits.com/v1/roundtrips' \
-H "Authorization: Bearer $TRAILSPLITS_API_KEY" \
-H 'Content-Type: application/json' \
--data '{"start":[7.7491,46.0207],"distance_km":12,"profile":"hike"}' Request body
| Field | Type | Description |
|---|---|---|
startrequired | array | [lon, lat] |
distance_km | number | 0.5–40 km for hike and run, to 60 km for bike. Or distance_m, not both. |
distance_m | number | |
profile | string: hike | run | bike | |
direction | number or null | |
alternatives | integer | |
max_sac | integer | |
avoid_paved | boolean | |
hills | number | |
bicycle_type | string: road | hybrid | mountain |
Response
| Field | Type | Description |
|---|---|---|
schema | string: roundtrips/1 | |
engine | object | |
status | string: complete | rough | |
reason | string or null | |
request | object | |
loops | array |
Full response schema (JSON Schema)
{
"type": "object",
"properties": {
"schema": {
"type": "string",
"enum": [
"roundtrips/1"
]
},
"engine": {
"type": "object"
},
"status": {
"type": "string",
"enum": [
"complete",
"rough"
]
},
"reason": {
"type": "string",
"nullable": true
},
"request": {
"type": "object"
},
"loops": {
"type": "array",
"minItems": 1,
"maxItems": 4,
"items": {
"type": "object",
"properties": {
"rank": {
"type": "integer"
},
"status": {
"type": "string",
"enum": [
"complete",
"rough"
]
},
"distance_m": {
"type": "number"
},
"target_m": {
"type": "number"
},
"target_error_pct": {
"type": "number"
},
"repeated_m": {
"type": "number"
},
"repeated_share": {
"type": "number"
},
"shape": {
"type": "string",
"description": "Polyline6."
},
"ascent_m": {
"type": "number",
"nullable": true
},
"time": {
"type": "object",
"nullable": true,
"additionalProperties": true
}
},
"required": [
"rank",
"status",
"distance_m",
"target_m",
"target_error_pct",
"repeated_share",
"shape"
]
}
}
},
"required": [
"schema",
"engine",
"status",
"loops"
]
}Errors
400, 422, plus the gateway's 401, 429 and 503 (error conventions). A refused request costs no credits.
Contract
Give a start and a length; get back loops that start and end there, as the TrailSplits Planner builds them, each with the facts route analysis gives: surfaces, ways, tagged grades, climb, the hike time with its scope, and what is not known. When no good loop exists, the answer says so.
Request
{ "start": [7.7491, 46.0207], "distance_km": 12, "profile": "hike", "alternatives": 2 }
| Field | ||||
|---|---|---|---|---|
start | [lon, lat] | Where the loop starts and ends | ||
distance_km or distance_m | number | One of them, not both: the length to aim for. Limits: 0.5 to 40 km for hike and run, to 60 km for bike (below) | ||
profile | hike \ | run \ | bike | Default hike |
direction | bearing in degrees, or null | A preference, not a rule: loops heading that way score better, and the best loop may still head elsewhere when the ground there is much better. Default null (any) | ||
alternatives | 0 to 3 | Loops from other directions besides the best; default 0 | ||
max_sac | 1 to 6 | hike and run only: the hardest tagged SAC grade used; default 3 (T3). A tagged grade above it is never used; untagged paths are unknown, never "easy" | ||
avoid_paved | boolean | Default false | ||
hills | −1 to 1 | −1 avoids climbing, 1 seeks it; default 0 | ||
bicycle_type | road \ | hybrid \ | mountain | bike only; default the router's hybrid |
caller | string | Optional label for logs and receipts |
Defaults are listed in request.settings_defaulted. Unknown fields and fields that do not apply (max_sac on a bike) are refused, not ignored.
Response
| Field | Meaning |
|---|---|
schema | roundtrips/1 |
status | complete: the best loop is within 10 % of the target with at most 20 % walked twice. rough: the best found misses one of those (still answered, with reason). none: no loop (HTTP 422, below) |
reason | Why the best loop is rough, or why there is none; else null |
request | start, target_m, profile, direction, alternatives, settings (the router's), settings_defaulted |
loops[] | The best first (rank 0), then the alternatives |
search | The engine's own counters (version, tree nodes, candidates, pairs routed, ms, and stopped when the budget ended the search early) |
timing | Tiles and milliseconds |
Each loop:
| Field | Meaning |
|---|---|
status | complete or rough, by the rule above |
distance_m, target_m, target_error_pct | The length, the target, and the signed error in per cent |
repeated_m, repeated_share | Metres walked twice |
stem_m | Of those, the way out from the start and back along the same path (a start at the end of a spur) |
spur_m | Of those, dead ends walked into and back out of at the turn points |
turn_points | The loop's two turn points, [lon, lat] |
bearing_deg | Mean bearing of the turn points from the start |
quality | Cost per metre over the settings' best ground (1 is all best-case ground) |
shape | The loop, polyline6 |
ascent_m, descent_m, ascent_method | Climb by the elevation contract on the routing graph's heights: route-smoothed-100m, or route-smoothed-100m+gorge40 when the loop runs through a gorge whose heights were bridged (the router core's two names; elevation contract) |
time | hike only. The calibrated trailsplits-hike/2 (moving time), applies, calibration, outside_calibration with reasons (T4 or harder tagged on 50 m or more, less than half on marked trails), and models (every named model's minutes). Null for run and bike |
surfaces | As in route analysis: paved_m, gravel_m, trail_m, unknown_m, mapped_m, class_default_m, stretches[] along the loop |
ways | by_type_m, marked_m, max_sac, ford_m, aid_m, informal_m, steps_m |
hazards[] | As in route analysis: each ford, aided passage and via ferrata, with from_m and to_m in metres along the loop from the start |
difficulty | As in route analysis: graded_m (T1…T6), untagged_path_m, road_m, hardest |
warnings[] | The router's own (code, message): fords, aided passages, informal paths, high or unknown difficulty, pushing a bike, a start moved to a reachable path. Their figures are the router's: its unknown_difficulty counts untagged paths, tracks and bridleways, while unknowns.difficulty_untagged also counts footpaths and steps, so the two can differ |
unknowns[] | time_not_estimated, time_outside_calibration, surface_class_default, surface_unknown, difficulty_untagged (each with code, message, and length_m where it is a length) |
Errors
{ "error": { "code", "message" } }.
| HTTP | code | When |
|---|---|---|
| 400 | invalid_request | Not JSON, unknown field, a field that does not apply, a value out of range, start not [lon, lat] or off the map, no distance, both distance fields, a distance under 0.5 km |
| 405 | method_not_allowed | Any method but POST; the answer carries Allow: POST |
| 409 | capability_unavailable | bike while the served routing tiles carry no verified one-ways or turn restrictions (as the Planner's loops) |
| 422 | too_long | Over 40 km on foot or 60 km by bike, checked on the length the engine will build |
| 422 | no_loop | No loop: the start is not near a path the settings may use, or none was found. The body also carries status: none, reason, request and search |
| 422 | corridor_too_large | The area the loop needs is too dense or too large to load |
| 503 | overloaded | The worker's heavy-job queue is full; Retry-After: 5 |
| 503 | tiles_unavailable | Routing tiles could not be read |
A loop the work budget cut short is still answered when one was found (search.stopped says so); with none, the reason names the limit, never "no loop exists".
Every request is one heavy job: admission, the shared 25 s and 12 M-node budget, receipts with op: roundtrips, a hard kill 10 s past the deadline.
| Case (3 alternatives unless said) | Time | Tiles |
|---|---|---|
| Zermatt hike 12 km (2 alternatives) | 0.7 s | — |
| Interlaken hike 40 km | 2.5 s | 16 |
| Tokyo run 40 km / hike 40 km | 4.6 s / 4.0 s | 16 |
| London hike 40 km, New York run 40 km | 9.5 s, 10.0 s | 20, 16 |
| Paris bike 60 km, London bike 60 km, Oslo bike 60 km | 12.4 s, 12.9 s, 9.8 s | 42, 36, 64 |
| Over the public limits: Tokyo run 60 km | 24.1 s, at the budget | 25 |
| Over the public limits: Tokyo bike 80 km | 21.7 s | 49 |
| Over the public limits: Interlaken hike 100 km, Oslo bike 70 km, Tromsø hike 60 km | refused: corridor over 64 tiles | — |
Hence 40 km on foot and 60 km by bike: inside about half the budget everywhere measured, and inside the 64-tile corridor up to about 60° north (a 60 km loop from Tromsø would still be refused as corridor_too_large).
Consumers
-
The parity script (here) and
npm run test:roundtrip.
Versioning
Additive fields may appear in roundtrips/1. A change of meaning, a removed field or a new default is roundtrips/2.
JSON Schema
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://trailsplits.com/schemas/roundtrips-1.request.json",
"type": "object",
"additionalProperties": false,
"required": ["start"],
"properties": {
"start": { "type": "array", "items": { "type": "number" } },
"distance_km": { "type": "number" },
"distance_m": { "type": "number" },
"profile": { "enum": ["hike", "run", "bike"] },
"direction": { "type": ["number", "null"] },
"alternatives": { "enum": [0, 1, 2, 3] },
"max_sac": { "enum": [1, 2, 3, 4, 5, 6] },
"avoid_paved": { "type": "boolean" },
"hills": { "type": "number" },
"bicycle_type": { "enum": ["road", "hybrid", "mountain"] },
"caller": { "type": "string" }
}
}
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://trailsplits.com/schemas/roundtrips-1.response.json",
"type": "object",
"required": ["schema", "engine", "status", "reason", "request", "loops", "search", "timing"],
"properties": {
"schema": { "const": "roundtrips/1" },
"status": { "enum": ["complete", "rough", "none"] },
"reason": { "type": ["string", "null"] },
"request": { "type": "object", "required": ["start", "target_m", "profile", "direction", "alternatives", "settings", "settings_defaulted"] },
"loops": {
"type": "array",
"items": {
"type": "object",
"required": ["rank", "status", "distance_m", "target_m", "target_error_pct", "repeated_m", "repeated_share", "stem_m", "spur_m", "turn_points", "bearing_deg", "quality", "shape", "ascent_m", "descent_m", "ascent_method", "time", "surfaces", "hazards", "ways", "difficulty", "warnings", "unknowns"],
"properties": {
"status": { "enum": ["complete", "rough"] },
"shape": { "type": "string" },
"time": { "type": ["object", "null"], "required": ["model", "minutes", "moving_only", "applies", "calibration", "outside_calibration", "reasons", "models"] },
"surfaces": { "type": "object", "required": ["paved_m", "gravel_m", "trail_m", "unknown_m", "mapped_m", "class_default_m", "stretches"] },
"ways": { "type": "object", "required": ["by_type_m", "marked_m", "max_sac", "ford_m", "aid_m", "informal_m", "steps_m"] },
"difficulty": { "type": ["object", "null"], "required": ["scale", "graded_m", "untagged_path_m", "road_m", "hardest"] },
"warnings": { "type": "array", "items": { "type": "object", "required": ["code", "message"] } },
"unknowns": { "type": "array", "items": { "type": "object", "required": ["code", "message"] } }
}
}
},
"search": { "type": "object", "required": ["version", "tree_nodes", "candidates", "pairs_routed"] },
"timing": { "type": "object" }
}
}
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://trailsplits.com/schemas/roundtrips-1.error.json",
"type": "object",
"required": ["error"],
"properties": { "error": { "type": "object", "required": ["code", "message"], "properties": { "code": { "type": "string" }, "message": { "type": "string" } } } }
}