Skip to content

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.

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, removed or changed.
  • 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.
  • point and bbox: where the object is (a removed one: where it was); point is on the object — a line’s middle, an area’s centre.
  • attributes: per attribute its old and new value, null when not set. A changed object lists the attributes that differ; an added one all of them (old null), a removed one too (new null). Attributes are the OSM tags (by key) and the RINF attributes (rinf:<name>), as the infrastructure API shows them under osm and rinf, plus type (e.g. bridge → track when a bridge tag is gone) and geometry (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.

Terminal window
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)
versionThe changes one version brought
sinceThe changes of every version after this one, up to the one being served, oldest first (at most 100 versions)
bboxminLon,minLat,maxLon,maxLat: objects whose bbox touches it (any size)
lineLine numbers, comma-separated (case and spaces ignored)
typesObject types, comma-separated (track includes tunnels and bridges)
changeadded, removed, changed, comma-separated
subscriptionOnly what one of your subscriptions selects
limit, cursorPage 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
}
Terminal window
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 in POST /v1/route — from, to, via and the vehicle (profile, vehicle, avoid, reversal_min, no_reversal) — and buffer_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_m of 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 and ok is false — 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.

When a new data version comes out, each subscription with matching changes gets one event:

POST /hooks/railroute HTTP/1.1
Content-Type: application/json
User-Agent: railroute-webhooks/1
Railroute-Event: changes
Railroute-Delivery: dlv_4711
Railroute-Attempt: 1
Railroute-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"
}
  • counts and changes are the matching changes only. With more than 1,000 of them (or a body over 1 MB), changes is left out and truncated is true: page through feed_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 (see last_delivery). The changes stay in the feed: catch up with since.
  • Retries carry the same id (and body); deliveries can arrive more than once and, across versions, out of order. Deduplicate by id and order by version.

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:

  1. Split the header at , into t= and one or more v1= values.
  2. Compute HMAC-SHA256 over the timestamp, a ., and the body exactly as received (before any JSON 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 (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).

GET /v1/changes and every subscription call count as one request each. Webhook deliveries are not metered.