Change feed and webhooks
The data is rebuilt regularly from OpenStreetMap and RINF, and every new data version is compared
with the one before it: which tracks, signals, switches, stations, platforms and other objects were
added, removed or changed, and for every change which attribute went from what to what
(a line speed raised from 100 to 120 km/h, a bridge rebuilt, a new signal). You can poll the changes
with GET /v1/changes, or subscribe to an area, lines or a route and get them POSTed to your server
as soon as a server picks up a new version. Both need a key with the route scope; subscriptions
need a secret key.
Changes
Section titled “Changes”A change names the object with the IDs of the infrastructure API
(way/…, node/…, rinf-op/…, stable across versions), so you can fetch the object itself with
GET /v1/infra/objects/{id}:
{ "version": "20261104-0200", "id": "way/119522823", "type": "track", "change": "changed", "name": "", "lines": ["1043"], "point": [10.30768, 53.93408], "bbox": [10.29911, 53.93102, 10.31544, 53.93711], "attributes": { "maxspeed": { "old": "100", "new": "120" }, "rinf:maxspeed": { "old": null, "new": "120" } }}change:added,removedorchanged.type: the object type, as in/v1/infra/objects(for a removed object, its type before).lines: the line numbers of a track way, or of the track through a signal, switch or level crossing; empty for other types.pointandbbox: where the object is (a removed one: where it was);pointis on the object — a line’s middle, an area’s centre.attributes: per attribute itsoldandnewvalue,nullwhen not set. A changed object lists the attributes that differ; an added one all of them (oldnull), a removed one too (newnull). Attributes are the OSM tags (by key) and the RINF attributes (rinf:<name>), as the infrastructure API shows them underosmandrinf, plustype(e.g.bridge→trackwhen a bridge tag is gone) andgeometry(a moved point as[lon, lat], a reshaped line as{"points": 12, "length_m": 523.4}). Derived values such as the effective speed follow from these and are not listed separately.
The first data version has no changes.
Polling the feed
Section titled “Polling the feed”curl -H "Authorization: Bearer $RAIL_API_KEY" \ 'https://api.routing.gleisklar.de/v1/changes?since=20261101-0200&line=1043,1220&types=track,signal'{ "versions": [ { "version": "20261102-0200", "previous": "20261101-0200", "created": "2026-11-02T02:00:00Z", "added": 112, "removed": 31, "changed": 845 }, { "version": "20261103-0200", "previous": "20261102-0200", "created": "2026-11-03T02:00:00Z", "added": 7, "removed": 2, "changed": 64 } ], "changes": [ { "version": "20261102-0200", "id": "way/119522823", … }, … ], "returned": 38, "limit": 500, "next_cursor": null, "data_version": "20261103-0200", "attribution": "© OpenStreetMap contributors (ODbL 1.0, …); RINF © ERA / infrastructure managers"}| Parameter | |
|---|---|
| (none) | The changes of the version being served (data_version, also X-Data-Version) |
version | The changes one version brought |
since | The changes of every version after this one, up to the one being served, oldest first (at most 100 versions) |
bbox | minLon,minLat,maxLon,maxLat: objects whose bbox touches it (any size) |
line | Line numbers, comma-separated (case and spaces ignored) |
types | Object types, comma-separated (track includes tunnels and bridges) |
change | added, removed, changed, comma-separated |
subscription | Only what one of your subscriptions selects |
limit, cursor | Page size (default 500, max 2,000) and the next_cursor of the previous page |
Every filter given must match. versions lists the versions of the range with their total counts
(unfiltered). To keep up, remember the last data_version you processed and poll with
since=<it>; page through next_cursor (null on the last page) with the other parameters
unchanged — a cursor used with another query is refused (400). A cursor keeps its range, also when
a newer version comes out while you page. A version or since that is unknown, more than 100
versions back or from before change feeds answers 404: start again from a recent version.
With the SDK:
import { paginate } from "@quebi/rail-api"
for await (const page of paginate((cursor) => api.changes({ since: lastSeen, line: "1043,1220", cursor }))) { for (const change of page.changes) handle(change) lastSeen = page.data_version}Webhooks
Section titled “Webhooks”Subscribing
Section titled “Subscribing”curl -X POST -H "Authorization: Bearer $RAIL_API_KEY" -H "Content-Type: application/json" \ https://api.routing.gleisklar.de/v1/changes/subscriptions -d '{ "url": "https://example.com/hooks/railroute", "description": "Kiel lines", "lines": ["1043", "1220"], "types": ["track", "signal", "switch"] }'The answer is 201:
{ "id": "sub_4u3k2ab7f3q2abcdefghijkl", "url": "https://example.com/hooks/railroute", "description": "Kiel lines", "filter": { "lines": ["1043", "1220"], "types": ["track", "signal", "switch"] }, "created_at": "2026-11-04T12:00:00Z", "secret": "whsec_k7f3q2abcdefghijklmnopqrstuvwx"}Store the secret now: it is only in this response, and you need it to verify the events.
Filters, every one given must match (none: every change):
-
bbox:[minLon, minLat, maxLon, maxLat]. -
lines: line numbers (at most 100). -
types: object types. -
route: a route as inPOST /v1/route—from,to,viaand the vehicle (profile,vehicle,avoid,reversal_min,no_reversal) — andbuffer_m(10–5,000 m, default 500):{ "url": "https://example.com/hooks/railroute","route": { "from": { "station": "AH" }, "to": { "station": "AK" },"profile": "freight-electric-de", "buffer_m": 300 } }The route is computed once, when you subscribe, on the data version of that moment. A change matches if it is on one of the route’s track ways or its point is within
buffer_mof the route. The subscription shows the route (filter.route:request,geometry,lines,distance_km,data_version,ok). If no route fits the vehicle, the corridor follows the route ignoring the vehicle’s constraints andokisfalse— useful to hear when what blocks it changes. Places that aren’t connected at all, unknown codes and invalid vehicles are a 400. The route is not recomputed for later versions: to follow a new route, delete the subscription and create a new one.
An account can have up to 25 subscriptions (beyond that: 409 limit-reached); a publishable key
gets 403 secret-key-required. GET /v1/changes/subscriptions lists them,
GET /v1/changes/subscriptions/{id} shows one with its last_delivery (status, attempts, last HTTP
status and error), DELETE /v1/changes/subscriptions/{id} removes it, and
POST /v1/changes/subscriptions/{id}/test queues a test event ("type": "test"; answer 202 with
the delivery_id) that is sent within seconds. GET /v1/changes?subscription={id} is the feed
with the subscription’s filter. In the SDK these are api.subscriptions.list(), create(body),
get(id), delete(id) and test(id).
The url must be https:// and reach a public address; redirects are not followed.
Events
Section titled “Events”When a new data version comes out, each subscription with matching changes gets one event:
POST /hooks/railroute HTTP/1.1Content-Type: application/jsonUser-Agent: railroute-webhooks/1Railroute-Event: changesRailroute-Delivery: dlv_4711Railroute-Attempt: 1Railroute-Signature: t=1793800000,v1=5f2b8a…
{ "id": "dlv_4711", "type": "changes", "created": "2026-11-04T02:41:07Z", "subscription_id": "sub_4u3k2ab7f3q2abcdefghijkl", "version": "20261104-0200", "previous": "20261103-0200", "counts": { "added": 2, "removed": 0, "changed": 5, "by_type": { "track": { "added": 0, "removed": 0, "changed": 5 }, "signal": { "added": 2, "removed": 0, "changed": 0 } } }, "changes": [ { "id": "way/119522823", "type": "track", "change": "changed", … }, … ], "feed_url": "https://api.routing.gleisklar.de/v1/changes?version=20261104-0200&subscription=sub_4u3k2ab7f3q2abcdefghijkl"}countsandchangesare the matching changes only. With more than 1,000 of them (or a body over 1 MB),changesis left out andtruncatedistrue: page throughfeed_url(with your secret key) instead.- Answer with any 2xx within 10 seconds; do the work afterwards. Anything else — another status,
a timeout, a connection error — is retried after 1, 2, 4, 8, … minutes, at most 6 hours apart, for
24 hours (12 attempts); then the delivery is marked
failed(seelast_delivery). The changes stay in the feed: catch up withsince. - Retries carry the same
id(and body); deliveries can arrive more than once and, across versions, out of order. Deduplicate byidand order byversion.
Verifying signatures
Section titled “Verifying signatures”Railroute-Signature is t=<unix seconds>,v1=<signature>, where the signature is the hex-encoded
HMAC-SHA256 of <t>.<raw request body> with your subscription’s secret (the whole whsec_… string,
UTF-8) as the key. To verify an event:
- Split the header at
,intot=and one or morev1=values. - Compute HMAC-SHA256 over the timestamp, a
., and the body exactly as received (before any JSON parsing). - Compare with each
v1in constant time; accept if one matches. - Reject events whose
tis more than 5 minutes from your clock (replays).
Node.js (Express):
import crypto from "node:crypto";import express from "express";
const secret = process.env.RAILROUTE_WEBHOOK_SECRET; // whsec_…const app = express();
app.post("/hooks/railroute", express.raw({ type: "application/json" }), (req, res) => { const header = req.get("Railroute-Signature") ?? ""; const parts = Object.groupBy(header.split(","), (p) => p.split("=")[0]); const t = parts.t?.[0]?.slice(2); const sigs = (parts.v1 ?? []).map((p) => p.slice(3)); const expected = crypto.createHmac("sha256", secret).update(`${t}.`).update(req.body).digest("hex"); const ok = sigs.some((s) => s.length === expected.length && crypto.timingSafeEqual(Buffer.from(s), Buffer.from(expected))); if (!t || !ok || Math.abs(Date.now() / 1000 - Number(t)) > 300) return res.sendStatus(400); res.sendStatus(204); // answer first, then process const event = JSON.parse(req.body); // … queue event.changes (dedupe by event.id)});Python:
import hashlib, hmac, time
def verify(secret: str, header: str, body: bytes, tolerance: int = 300) -> bool: items = [p.split("=", 1) for p in header.split(",") if "=" in p] t = next((v for k, v in items if k == "t"), None) sigs = [v for k, v in items if k == "v1"] if t is None or not t.isdigit() or abs(time.time() - int(t)) > tolerance: return False expected = hmac.new(secret.encode(), t.encode() + b"." + body, hashlib.sha256).hexdigest() return any(hmac.compare_digest(s, expected) for s in sigs)Go:
func verify(secret, header string, body []byte) bool { var t string var sigs []string for _, p := range strings.Split(header, ",") { k, v, _ := strings.Cut(p, "=") switch k { case "t": t = v case "v1": sigs = append(sigs, v) } } ts, err := strconv.ParseInt(t, 10, 64) if err != nil || math.Abs(time.Since(time.Unix(ts, 0)).Seconds()) > 300 { return false } mac := hmac.New(sha256.New, []byte(secret)) mac.Write([]byte(t + ".")) mac.Write(body) want := hex.EncodeToString(mac.Sum(nil)) for _, s := range sigs { if hmac.Equal([]byte(s), []byte(want)) { return true } } return false}Use POST /v1/changes/subscriptions/{id}/test to check your endpoint: the test event is signed the
same way ("type": "test", no changes).
Metering
Section titled “Metering”GET /v1/changes and every subscription call count as one request each. Webhook deliveries are not
metered.