API overview
Base URLs
Section titled “Base URLs”| API | https://api.routing.gleisklar.de — every endpoint is under /v1, except the probes GET /healthz and GET /readyz |
| Hosted map styles | https://tiles.routing.gleisklar.de/styles/u/{id}.json (see custom map styles) |
Request and response bodies are JSON (Content-Type: application/json), except map matching
traces (GPX, GeoJSON or CSV), tiles and errors. Request bodies are limited to 2 MiB.
The OpenAPI document
Section titled “The OpenAPI document”The API is described by an OpenAPI 3.1 document, generated from the server’s code:
- live at
GET /v1/openapi.json(no key needed); - in the repository as
apps/railroute-rs/openapi.json.
It lists every endpoint with its parameters, bodies, responses and problem types, the key scope
(x-railroute.scope) and the usage name it is metered under (x-railroute.usage). The JavaScript
SDK (@quebi/rail-api) is typed from it.
Create keys in the dashboard. Every key has scopes: route (routing, places, infrastructure,
linear referencing, matching, matrices, isochrones, optimization, the change feed) and tiles
(TileJSON, tiles, the basemap, hosted styles).
| Key | How to send it | Where |
|---|---|---|
Secret, rr_sk_… | Authorization: Bearer rr_sk_… | Servers only. In ?key= it is refused (400 secret-key-in-url) |
Publishable, rr_pk_… | ?key=rr_pk_… (or the header) | Browsers: accepted only from the origins registered for the key (Origin, else the origin of Referer) |
Change subscriptions need a secret key (they hold webhook URLs and signing secrets). A revoked key stops working within a minute.
Endpoints
Section titled “Endpoints”| Area | Endpoints | Guide |
|---|---|---|
| Routing | POST /v1/route, POST /v1/route/batch, GET /v1/route/profiles | getting started, travel times, borders, charges |
| Many routes | POST /v1/matrix, GET /v1/matrix/jobs/{id}, POST /v1/isochrone, POST /v1/optimize, GET /v1/optimize/jobs/{id} | matrix, isochrones, optimization |
| Places | GET /v1/places, /v1/places/autocomplete, /v1/places/lookup, /v1/places/reverse, /v1/places/{id} | autocomplete, platforms |
| Infrastructure | GET /v1/infra/objects, /v1/infra/objects/{id}, /v1/infra/lines/{line}, /v1/infra/search, POST /v1/infra/along-route | infrastructure search |
| Line and km | GET /v1/linref/lines, /v1/linref/locate, /v1/linref/position, /v1/linref/segment | line number and km |
| Map matching | POST /v1/match | map matching |
| Changes | GET /v1/changes, /v1/changes/subscriptions (+ /{id}, /{id}/test) | change feed |
| Maps | GET /v1/tiles.json, /v1/tiles/{version}/{z}/{x}/{y}.mvt, /v1/basemap.json, /v1/basemap/…, /styles/u/{id}.json | maps, custom styles |
| Data | GET /v1/dataset, /healthz, /readyz, /v1/openapi.json | data versions |
Routes in short
Section titled “Routes in short”POST /v1/route takes from, to and up to 10 via places. A place is exactly one of
{"station": "AK"} (a DS100/RL100 code, RINF UOPID or UIC code — find codes by name with
GET /v1/places?q=), {"lat": 54.31, "lon": 10.13} (snapped onto track within 1.5 km) or
{"osm_node": 123}; via points may have dwell_min. The vehicle is a profile from
GET /v1/route/profiles with vehicle overrides (gauge_mm, electrification — "any",
"none" or a list of {voltage_v, frequency_hz} —, train_protection, axle_load, gauging,
max_speed_kmh, train_length_m and the running characteristics). avoid takes lines and
GeoJSON areas; reversal_min (default 20) and no_reversal control reversing; alternatives
(0–3) adds noticeably different routes. include adds parts to each route: geometry,
polyline, segments, sections, lines, operational_points, countries,
infrastructure_managers, changes, estimate, speed_profile, charges, capacity or all.
The answer has status (ok or no_route), the resolved stops, the effective vehicle,
routes (best first: distance_km, duration_min, reversals, legs and the parts asked for)
and warnings. Without a route, no_route names the first leg that can’t be completed, a one-line
reason, the blockers (the stretches of the unconstrained route the vehicle may not use, with
their kind and km) and the unconstrained route’s length. A route counts as one request per leg.
POST /v1/route/batch takes routes: [{from, to, via}] (25 per batch, 100 on the invoiced plan)
and the options above (no alternatives) for all of them. Each item has index, status (ok,
no_route or error) and the route as POST /v1/route answers it, or the error as a
problem; summary counts them.
Pagination
Section titled “Pagination”Every list endpoint pages the same way: limit (the page size; default and maximum per endpoint)
and cursor. The answer has the list (results; features for /v1/infra/objects, changes
for /v1/changes) and next_cursor: null (or absent) on the last page, else the value to pass
as cursor for the next page, with the other parameters unchanged.
A cursor is opaque and bound to its query and data version: with another query, or after the data
version changed, it is refused (400) — start again from the first page. (The change feed’s cursor
pins its range of versions instead, so paging through it survives a new version.) Ranked results
page up to 1,000 deep. The SDK’s paginate iterates the pages:
import { createClient, paginate } from "@quebi/rail-api"
const api = createClient({ apiKey: process.env.RAIL_API_KEY })for await (const page of paginate((cursor) => api.places({ q: "hamburg", cursor }))) { for (const place of page.results) console.log(place.name, place.ds100)}Errors
Section titled “Errors”Every error is an RFC 9457 problem details object,
Content-Type: application/problem+json:
{ "type": "https://routing.gleisklar.de/problems/invalid-request", "title": "Invalid request", "status": 400, "detail": "no station or operational point with code \"XYZ\" (DS100, UOPID or UIC; find codes by name with the search API)", "errors": [ { "detail": "no station or operational point with code \"XYZ\" (DS100, UOPID or UIC; find codes by name with the search API)", "pointer": "/from/station" } ]}type is stable, one per kind of error; title is the same for every
occurrence; detail describes this one. For invalid requests, errors lists each problem with
the query or path parameter or the JSON pointer (RFC 6901) into the body it concerns.
type (after https://routing.gleisklar.de/problems/) | Status | Title |
|---|---|---|
missing-api-key | 401 | API key missing |
invalid-api-key | 401 | Invalid API key |
secret-key-in-url | 400 | Secret key in URL |
missing-scope | 403 | Key lacks scope |
origin-not-allowed | 403 | Origin not allowed |
secret-key-required | 403 | Secret key required |
rate-limited | 429 | Rate limit exceeded |
monthly-cap-reached | 429 | Monthly cap reached |
too-many-jobs | 429 | Too many jobs |
invalid-request | 400 | Invalid request |
not-found | 404 | Not found |
unknown-resource | 404 | Not found |
not-in-data | 404 | Not in this data version |
job-not-found | 404 | No such job |
method-not-allowed | 405 | Method not allowed |
limit-reached | 409 | Limit reached |
payload-too-large | 413 | Request body too large |
too-large | 413 | Request too large for the plan |
unsupported-media-type | 415 | Unsupported media type |
invalid-style | 422 | Invalid style |
internal | 500 | Internal error |
key-store-unavailable | 503 | Key store unavailable |
data-not-loaded | 503 | Data not loaded |
timeout | 503 | Request timed out |
job-queue-full | 503 | Job queue full |
store-unavailable | 503 | Store unavailable |
not-in-data is a 404 for something the request names that the data version doesn’t have: a
code, an object, a line, a km, nothing near a point. unknown-resource is a subscription or style
that doesn’t exist (or belongs to another account), a tile of a data version no longer served, or
a basemap that isn’t published.
In the SDK, errors are thrown as RailApiError with status, type, title, detail and
errors, as a subclass by status: BadRequestError, AuthenticationError,
PermissionDeniedError, NotFoundError, ConflictError, PayloadTooLargeError,
UnprocessableEntityError, RateLimitError (with retryAfter) and ServiceUnavailableError;
a request that never got an answer is a NetworkError.
Rate limits and Retry-After
Section titled “Rate limits and Retry-After”Each key has a rate limit per minute (a token bucket, bursts up to a minute’s worth), and each
account can have a monthly cap. Responses of keyed endpoints carry RateLimit-Limit (the
key’s requests per minute) and RateLimit-Remaining. Beyond them the answer is 429
(rate-limited, or monthly-cap-reached).
429 and 503 answers carry Retry-After (seconds): wait that long before trying again.
| Problem | Retry-After |
|---|---|
rate-limited, monthly-cap-reached | until the bucket has room again, or the next month |
too-many-jobs, job-queue-full | 60 |
store-unavailable | 5 |
key-store-unavailable, data-not-loaded, timeout | 1 |
Requests that take longer than 30 seconds answer 503 timeout. Only answers below 400 are
counted as usage. Most calls count as one request; per-unit endpoints are billed by weight:
POST /v1/route, /v1/route/batch and /v1/infra/along-route per leg, /v1/places/autocomplete
0.2 per call, /v1/match 0.01 per point, /v1/matrix and /v1/optimize 0.1 per element,
/v1/isochrone one per band.
Data versions
Section titled “Data versions”The data is rebuilt regularly from OpenStreetMap and RINF; each build is a data version (e.g.
20261104-0200). A request is answered from one version throughout, and every response names it
in the X-Data-Version header (X-Data-Stale: true while the server serves a cached version
because the data store is unreachable). /v1 responses also carry X-Data-Attribution, the
attribution to show with the data.
GET /v1/dataset describes the version being served: version, built_at, stale and the size
of its network. GET /readyz answers 200 with the data_version once one is loaded (503 before);
GET /healthz always answers 200. Neither needs a key.
IDs of infrastructure objects (way/…, node/…, rinf-op/<UOPID>) are OSM and RINF IDs, stable
across versions as long as the object exists. In paths, / may be written as : or escaped.
Async jobs
Section titled “Async jobs”Large matrices and optimizations run as jobs (matrix,
optimization); "async": true asks for a job
for any size. Submitting answers 202 with the job and its URL in Location:
{ "job_id": "mx_4u3k…", "status": "queued", "progress": 0, "elements": 400000, "poll_url": "/v1/matrix/jobs/mx_4u3k…", "data_version": "20261104-0200", "created_at": "2026-11-04T12:00:00Z" }- Poll
poll_urlevery few seconds.statusgoesqueued→running(progress0–1) →donewith the answer inresult, orfailedwitherror;finished_atandexpires_atfollow. - Idempotent: the same request again (same account, data version and body) returns the existing job (200) while it is queued, running or done; a failed job is replaced by a new one.
- A job is billed once, when it is accepted (not refunded if it fails); each poll counts as one request.
- A finished job is kept for one hour (
expires_at), then answers 404job-not-found, as do unknown jobs and other accounts’ jobs. - At most 5 queued or running jobs per account and kind (beyond: 429
too-many-jobs); a busy server answers 503job-queue-full. A job whose server went away fails; submit it again.
The SDK’s matrixResult() and optimizeResult() submit and poll until done
(waitForMatrixJob() and waitForOptimizeJob() poll a job you already have).
Change feed and webhooks
Section titled “Change feed and webhooks”GET /v1/changes lists what was added, removed or changed between data versions, object by
object: one version’s changes (version, default the one being served) or every version after
since, filtered by bbox, line, types, change and subscription, paged with limit
(default 500, at most 2,000) and cursor. Subscriptions (/v1/changes/subscriptions, secret
keys only) POST the matching changes of every new version to your https:// URL. Details and
examples: change feed and webhooks.
Every event is signed. Its Railroute-Signature header is t=<unix seconds>,v1=<hex>, where the
hex value is the HMAC-SHA256 of <t>.<raw request body> keyed with the subscription’s secret (the
whole whsec_… string, shown only when the subscription is created). To verify:
- Split the header at
,intot=and one or morev1=values. - Compute the HMAC over the timestamp, a
.and the body exactly as received (before parsing). - Compare with each
v1in constant time; accept if one matches. - Reject events whose
tis more than 5 minutes from your clock.
Answer with a 2xx within 10 seconds. Anything else is retried after 1 minute, doubling up to
6 hours between attempts, until 24 hours after the delivery was created; then it is marked
failed. Retries keep the event’s id (also in Railroute-Delivery): deduplicate by it. The
other headers are Railroute-Event (changes or test) and Railroute-Attempt.
| Endpoint | What |
|---|---|
GET /v1/tiles.json | TileJSON 3.0 of the rail network of the version being served: absolute tile URLs (carrying the publishable key the TileJSON was fetched with), zooms, bounds and the layers tracks, signals, switches, level_crossings, platforms, sites |
GET /v1/tiles/{version}/{z}/{x}/{y}.mvt | A vector tile (gzip-compressed, Content-Encoding: gzip); 204 for an empty tile. Only the version being served: an older version answers 404 — fetch the TileJSON again |
GET /v1/basemap.json, /v1/basemap/{version}/{z}/{x}/{y}.mvt | The basemap (Protomaps layers), versioned apart from the rail data; 404 while none is published |
GET /v1/basemap/fonts/{fontstack}/{range}.pbf, /v1/basemap/sprites/{file} | The basemap’s glyphs and sprites; no key needed |
GET /styles/u/{id}.json | A style saved in the dashboard, its railroute://tiles, railroute://basemap, railroute://fonts/… and railroute://sprites/… placeholders resolved to these URLs |
They need a key with the tiles scope; in the browser use a publishable key in ?key=. The SDK’s
tileJSON() and basemapTileJSON() fetch the TileJSONs; railStyle() and railHostedStyle()
from @quebi/rail-api/maplibre build MapLibre styles from them.
Data © OpenStreetMap contributors (ODbL); RINF © ERA and the infrastructure managers. Keep the attribution visible on maps.