Skip to content

API overview

APIhttps://api.routing.gleisklar.de — every endpoint is under /v1, except the probes GET /healthz and GET /readyz
Hosted map styleshttps://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 API is described by an OpenAPI 3.1 document, generated from the server’s code:

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).

KeyHow to send itWhere
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.

AreaEndpointsGuide
RoutingPOST /v1/route, POST /v1/route/batch, GET /v1/route/profilesgetting started, travel times, borders, charges
Many routesPOST /v1/matrix, GET /v1/matrix/jobs/{id}, POST /v1/isochrone, POST /v1/optimize, GET /v1/optimize/jobs/{id}matrix, isochrones, optimization
PlacesGET /v1/places, /v1/places/autocomplete, /v1/places/lookup, /v1/places/reverse, /v1/places/{id}autocomplete, platforms
InfrastructureGET /v1/infra/objects, /v1/infra/objects/{id}, /v1/infra/lines/{line}, /v1/infra/search, POST /v1/infra/along-routeinfrastructure search
Line and kmGET /v1/linref/lines, /v1/linref/locate, /v1/linref/position, /v1/linref/segmentline number and km
Map matchingPOST /v1/matchmap matching
ChangesGET /v1/changes, /v1/changes/subscriptions (+ /{id}, /{id}/test)change feed
MapsGET /v1/tiles.json, /v1/tiles/{version}/{z}/{x}/{y}.mvt, /v1/basemap.json, /v1/basemap/…, /styles/u/{id}.jsonmaps, custom styles
DataGET /v1/dataset, /healthz, /readyz, /v1/openapi.jsondata versions

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.

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)
}

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/)StatusTitle
missing-api-key401API key missing
invalid-api-key401Invalid API key
secret-key-in-url400Secret key in URL
missing-scope403Key lacks scope
origin-not-allowed403Origin not allowed
secret-key-required403Secret key required
rate-limited429Rate limit exceeded
monthly-cap-reached429Monthly cap reached
too-many-jobs429Too many jobs
invalid-request400Invalid request
not-found404Not found
unknown-resource404Not found
not-in-data404Not in this data version
job-not-found404No such job
method-not-allowed405Method not allowed
limit-reached409Limit reached
payload-too-large413Request body too large
too-large413Request too large for the plan
unsupported-media-type415Unsupported media type
invalid-style422Invalid style
internal500Internal error
key-store-unavailable503Key store unavailable
data-not-loaded503Data not loaded
timeout503Request timed out
job-queue-full503Job queue full
store-unavailable503Store 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.

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.

ProblemRetry-After
rate-limited, monthly-cap-reacheduntil the bucket has room again, or the next month
too-many-jobs, job-queue-full60
store-unavailable5
key-store-unavailable, data-not-loaded, timeout1

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.

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.

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_url every few seconds. status goes queued → running (progress 0–1) → done with the answer in result, or failed with error; finished_at and expires_at follow.
  • 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 404 job-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 503 job-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).

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:

  1. Split the header at , into t= and one or more v1= values.
  2. Compute the HMAC over the timestamp, a . and the body exactly as received (before parsing).
  3. Compare with each v1 in constant time; accept if one matches.
  4. Reject events whose t is 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.

EndpointWhat
GET /v1/tiles.jsonTileJSON 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}.mvtA 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}.mvtThe 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}.jsonA 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.