303,525 datasets indexed1,419 publishers
Open Data IndexThe index of open data about the Netherlands
Open Data Index›Build›Train delay and disruption dashboard
DashboardGetting startedTraffic and transport

Train delay and disruption dashboard

A page that shows every minute how many trains are running, which ones are late and which disruptions are going on, built on the open GTFS-Realtime file from openOV and, with a free key, the NS disruptions.

Your AI assistantOpen Data Index MCP on
The Open Data Index MCP server is on in your chat, so the assistant reads this project and its sources itself.
You paste this project's prompt
The assistant fetches from Open Data Index
  • geef_project("treinvertragingen-dashboard")
  • geef_bron("ns-reisinformatie")
  • geef_bron("ndov-ov-data")
The assistant

I have the project, the tested calls and the pitfalls. I'll build a dashboard with NS travel information and Public transport data via the NDOV desk. This is how it could look:

You build this yourself. Copy the prompt into Claude, ChatGPT or Cursor, with the MCP server on, or follow the steps below.

Follow the steps
trains.yoursite.com
Trains nowFri 16 Oct, 23:50
226trains running
18.6%3 min or more late (42)
8trips cancelled today

Most delayed

  • GV 328Arnhem C.+89 min
  • ST 20038Zevenaar+60 min
  • SPR 31371Ede-Wageningen+42 min
  • ICE 120Amsterdam C.+37 min
  • EST 9387Schiphol Airport+27 min

NS notices

  • DisruptionAmersfoort - Ede-Wageningenbroken-down train
  • CalamityWorks Gouda - Rotterdam C.17 to 25 Oct
  • Engineering works12 notices
Mockup

This is a mockup, not a working version, with the figures from our test run on Friday 16 October 2026, 23:50. Turn the MCP server on with the address on For AI.

Who it's for

Commuters who want to know whether their line often stalls, journalists who want to explain an evening of disruptions, and anyone building a first live dashboard.

What you get

A dashboard with three numbers at the top: trains running, the share delayed three minutes or more, and the number of cancelled trips today. Below that, the trains with the largest delay (with their next station) and the list of NS disruptions and engineering works.

Test run on Friday 16 October 2026 at 23:50: 226 trains running, 42 of them (18.6%) three minutes or more late. Today 8 trips had been cancelled; 12 trips for later days were already marked as cancelled in the file. The largest delay was on GV 328 (an international train in category GV), 89 minutes at Arnhem Centraal. At that moment NS reported 1 disruption (Amersfoort - Ede-Wageningen, faulty train), 1 calamity (an announcement of engineering works Gouda-Rotterdam) and 12 engineering works.

How you could start6 steps

  1. 1

    Fetch the live train trips, without a key

    openOV converts the NDOV data to GTFS-Realtime every minute. trainUpdates.pb (1.3 MB) contains all train trips for today and the coming days, with the expected time and the delay in seconds per station. It is protobuf, not JSON: read it with gtfs-realtime-bindings or with the small reader in the script. Ask at most once a minute; with too many requests in a row the server returns 429 Too Many Requests (we hit this after about eight requests within a minute).

    https://gtfs.ovapi.nl/nl/trainUpdates.pb
    HTTP/2 200
    last-modified: Fri, 16 Oct 2026 21:51:26 GMT
    content-length: 1317408
    (protobuf; uitgepakt: 2.333 ritten, bijvoorbeeld trip_id 369800009,
     start_date 20261017, realtime_trip_id "IFF:IC:1177", stations "gvc", "gv", "dt", "rtd" …)
  2. 2

    Read the OVapi extension for train number and station

    The standard GTFS fields don't say which train number or station it is. That is in field 1003, an OVapi extension: realtime_trip_id ('IFF:IC:1177' = category IC, train number 1177) and per stop station_id (the NS station code in lower case, 'ut') and the platform. Note: stops with schedule_relationship SKIPPED are stations the train passes through. That is not a cancellation. A cancelled trip has schedule_relationship 3 (CANCELED) on the trip itself, and it can apply to tomorrow.

    https://gtfs.ovapi.nl/nl/gtfs-realtime-OVapi.proto
    message OVapiTripDescriptor {
      // Matches with realtime_trip_id field in trips.txt
      optional string realtime_trip_id = 1;
      // The trip_short_name of the (added) trip
      optional string trip_short_name = 2;
    …
    message OVapiStopTimeUpdate {
      optional string stop_headsign = 1;
      // The planned platform-code where the trip is scheduled to depart from
      optional string scheduled_track = 2;
      optional string actual_track = 3;
      // (optional) The (internal)identifier of a trainstation where the stoptimeupdate applies to
      optional string station_id = 4;
  3. 3

    Request a free NS key

    Create an account at apiportal.ns.nl (external visitor), go to the product containing the Reisinformatie API and click Subscribe. The key then appears on your profile page and goes in the Ocp-Apim-Subscription-Key header. Free allowance: 300 requests per 5 minutes. Portal terms: no NS logo in your application, don't alter data so that it becomes incorrect, and be careful with capacity. Keep the key on the server, never in browser JavaScript. The newer timetable-api/v2 needs a different subscription (our key got 401).

    https://gateway.apiportal.ns.nl/reisinformatie-api/api/v2/departures?station=UT&maxJourneys=40
    {"payload":{"source":"DVS","departures":[{"direction":"Amsterdam Centraal",
      "name":"NS Int   120","plannedDateTime":"2026-10-16T23:02:00+0200",
      "actualDateTime":"2026-10-16T23:41:53+0200","plannedTrack":"7","actualTrack":"5",
      "product":{"number":"120","categoryCode":"ICE",…},"trainCategory":"ICE",
      "cancelled":false,"routeStations":[],"messages":[],"departureStatus":"UNKNOWN"}, …
  4. 4

    Fetch disruptions and engineering works

    isActive=true returns what is going on now. type is DISRUPTION, CALAMITY or MAINTENANCE (engineering works). A CALAMITY is not always a disaster: at 23:50 it was an announcement of engineering works for next week. The cause is in timespans[0].cause.label, the traveller text in timespans[0].situation.label. The response is 87 kB, because every affected station comes with coordinates.

    https://gateway.apiportal.ns.nl/reisinformatie-api/api/v3/disruptions?isActive=true
    [{"id":"6068348","isActive":true,"title":"Amersfoort - Ede-Wageningen.","type":"DISRUPTION",
      "start":"2026-10-16T23:00:24+0200","end":"2026-10-16T23:45:00+0200",
      "phase":{"id":"2","label":"Impact bekend"},"impact":{"value":3},
      "expectedDuration":{"description":"Dit duurt tot ongeveer 23:45 uur.",…},
      "timespans":[{"cause":{"label":"defecte trein","type":"OTHER"},
        "situation":{"label":"Door een defecte trein: tussen Barneveld Centrum en Ede-Wageningen rijden er geen treinen."},…}], …},
     …]
  5. 5

    Turn station codes into names

    trainUpdates.pb only names stations by a code. The NS station list (397 stations in the Netherlands) has the names in three lengths and the coordinates, so you can also draw a map. Fetch it once a day and keep it; it rarely changes.

    https://gateway.apiportal.ns.nl/reisinformatie-api/api/v2/stations?countryCodes=NL
    {"payload":[ …
      {"code":"UT","UICCode":"8400621","namen":{"lang":"Utrecht Centraal","middel":"Utrecht C.","kort":"Utrecht C"},
       "lat":52.0888900756836,"lng":5.11027765274048,"stationType":"MEGA_STATION", …}, …]}
  6. 6

    Compute the dashboard numbers

    A train is running when its first stop is in the past and there is still a stop in the future. Use the delay at that next stop. Count cancelled trips only when start_date is today. Refresh the dashboard every minute and store the numbers per minute in your own table: the open files have no history.

How the sources fit together

  • Public transport data via the NDOV deskNS travel informationvia stationscode (trainUpdates station_id 'ut' = NS code 'UT')

    trainUpdates.pb gives each train's delay at every station with a lower-case code. The NS station list gives the name and coordinates for the same code (in upper case). The train number (1177) is the same in both, so you can also link a departure from /departures to a trip.

  • NS travel informationKNMI daily weather datavia datum

    Store the number of disruptions and the average delay each day and put them next to KNMI temperature and wind gusts. That shows whether heat or storms affect the railway.

Let AI build it

Paste this prompt into Claude, ChatGPT, Cursor or Lovable. It names the real endpoints, the join keys and the pitfalls, so the assistant writes working code straight away. Always check the result against the source itself.

Build a small dashboard (Node 20 with Express and one HTML page, or Next.js) that shows every minute how the trains in the Netherlands are doing.

Data 1, no key: https://gtfs.ovapi.nl/nl/trainUpdates.pb (GTFS-Realtime, protobuf, CC0, openOV). Read it on the server with gtfs-realtime-bindings or a small protobuf reader of your own.
Data 2, with a free key (apiportal.ns.nl, header Ocp-Apim-Subscription-Key, read it from the environment variable NS_API_KEY, never send it to the browser):
- disruptions: https://gateway.apiportal.ns.nl/reisinformatie-api/api/v3/disruptions?isActive=true
- stations: https://gateway.apiportal.ns.nl/reisinformatie-api/api/v2/stations?countryCodes=NL
- departures per station: https://gateway.apiportal.ns.nl/reisinformatie-api/api/v2/departures?station=UT&maxJourneys=40

What you need to know:
- Each trip in trainUpdates has an extension (field 1003, see https://gtfs.ovapi.nl/nl/gtfs-realtime-OVapi.proto): realtime_trip_id 'IFF:IC:1177' (category and train number) and per stop station_id ('ut', lower case) and platform.
- Delay (StopTimeEvent.delay) is in seconds and can be negative (int32: negative numbers arrive as a 10-byte varint).
- A StopTimeUpdate with schedule_relationship 1 (SKIPPED) = the train passes through without stopping. Don't count it as a cancellation.
- A trip with schedule_relationship 3 (CANCELED) = cancelled. The file also has trips for tomorrow and the day after: only count start_date of today (Europe/Amsterdam).
- 'Running' = first stop in the past, at least one stop in the future. Use the delay at the next stop.
- Fetch trainUpdates at most once a minute, with your own User-Agent and If-Modified-Since. On 429 wait a minute.
- NS disruptions: type DISRUPTION, CALAMITY or MAINTENANCE. Cause in timespans[0].cause.label. A CALAMITY can also be an announcement.
- NS terms: no NS logo, don't alter data so it becomes incorrect. 300 requests per 5 minutes: cache disruptions for 60 seconds and the station list for a day.
- If the app runs without a key, show only the openOV numbers with station codes instead of names.

Output: three big numbers at the top (trains running, percentage delayed 3 minutes or more, trips cancelled today) with the feed time. Below that, a table of the ten trains with the largest delay (category, number, delay in minutes, next station) and a list of disruptions with cause and expected duration. Store the three numbers every minute in SQLite and show a line for the last 24 hours.

Try it yourself

The core of the project in a small script. Needs: Node 20+ (fetch ingebouwd), geen pakketten; voor de NS-regels een eigen sleutel in NS_API_KEY. Tested on 2026-10-16; below is the real output of that run.

// Treinvertragingen nu: GTFS-Realtime trainUpdates.pb van openOV (open, CC0) plus, als je een NS-sleutel hebt,
// de actuele storingen en stationsnamen uit de NS Reisinformatie-API. Node 20+, geen pakketten nodig.
// Start: node treinen.mjs   of met sleutel: NS_API_KEY=jouwsleutel node treinen.mjs
const UA = { "User-Agent": "mijn-treindashboard/1.0 (+jij@voorbeeld.nl)" };
const NS = "https://gateway.apiportal.ns.nl/reisinformatie-api/api";

// Mini-protobuflezer voor GTFS-Realtime
function varint(b, p) { let r = 0n, s = 0n, x; do { x = b[p++]; r |= BigInt(x & 127) << s; s += 7n; } while (x & 128); return [r, p]; }
function velden(b) { const uit = []; let p = 0; while (p < b.length) { let k, v; [k, p] = varint(b, p); const veld = Number(k >> 3n), type = Number(k & 7n);
  if (type === 0) [v, p] = varint(b, p); else if (type === 1) { v = b.readDoubleLE(p); p += 8; } else if (type === 5) { v = b.readFloatLE(p); p += 4; }
  else if (type === 2) { let l; [l, p] = varint(b, p); v = b.subarray(p, p + Number(l)); p += Number(l); } else throw new Error("type " + type);
  uit.push([veld, v]); } return uit; }
const een = (vs, n) => vs.find((x) => x[0] === n)?.[1], alle = (vs, n) => vs.filter((x) => x[0] === n).map((x) => x[1]);
const tekst = (b) => b?.toString("utf8");

const res = await fetch("https://gtfs.ovapi.nl/nl/trainUpdates.pb", { headers: UA });
if (!res.ok) throw new Error(`trainUpdates.pb: HTTP ${res.status}`);   // 429: te vaak gevraagd, wacht een minuut
const feed = velden(Buffer.from(await res.arrayBuffer())), nu = Number(een(velden(een(feed, 1)), 3));
const vandaag = new Date(nu * 1000).toLocaleDateString("sv-SE", { timeZone: "Europe/Amsterdam" }).replaceAll("-", "");

let rijdend = 0, geschrapt = 0, geschraptLater = 0; const treinen = [];
for (const e of alle(feed, 2)) {
  const tu = een(velden(e), 3); if (!tu) continue;
  const t = velden(tu), trip = velden(een(t, 1)), ovapi = een(trip, 1003) ? velden(een(trip, 1003)) : [];
  const [, soort, nummer] = (tekst(een(ovapi, 1)) ?? "").split(":");          // "IFF:IC:1177"
  const status = Number(een(trip, 4) ?? 0n);                                    // 0 gepland, 1 extra, 3 geschrapt
  if (status === 3) { tekst(een(trip, 3)) === vandaag ? geschrapt++ : geschraptLater++; continue; }
  const haltes = [];
  for (const st of alle(t, 2)) {
    const s = velden(st), x = een(s, 1003) ? velden(een(s, 1003)) : [];
    if (Number(een(s, 5) ?? 0n) === 1) continue;                                // SKIPPED = trein rijdt hier door, geen uitval
    for (const ev of [een(s, 2), een(s, 3)]) if (ev) { const v = velden(ev);
      haltes.push({ tijd: Number(een(v, 2)), vertraging: Number(BigInt.asIntN(32, een(v, 1) ?? 0n)), station: tekst(een(x, 4))?.toUpperCase() }); }
  }
  const volgende = haltes.find((h) => h.tijd >= nu);
  if (!volgende || haltes[0].tijd > nu) continue;                               // nog niet vertrokken of al aangekomen
  rijdend++; treinen.push({ soort, nummer, ...volgende });
}

let namen = {};
if (process.env.NS_API_KEY) {
  const ns = (pad) => fetch(NS + pad, { headers: { ...UA, "Ocp-Apim-Subscription-Key": process.env.NS_API_KEY } }).then((r) => r.json());
  for (const s of (await ns("/v2/stations?countryCodes=NL")).payload) namen[s.code] = s.namen.middel;
  const storingen = await ns("/v3/disruptions?isActive=true");
  const telling = storingen.reduce((t, s) => ((t[s.type] = (t[s.type] ?? 0) + 1), t), {});
  console.log("NS meldt nu:", Object.entries(telling).map(([k, n]) => `${n} ${k}`).join(", "));
  for (const s of storingen.filter((s) => s.type !== "MAINTENANCE"))
    console.log(`  ${s.type}: ${s.title} ${s.timespans?.[0]?.cause?.label ? "(" + s.timespans[0].cause.label + ")" : ""}`);
}
const laat = treinen.filter((t) => t.vertraging >= 180);
console.log(`\nStand ${new Date(nu * 1000).toLocaleString("nl-NL", { timeZone: "Europe/Amsterdam" })}: ${rijdend} treinen onderweg, ${laat.length} met 3 minuten of meer vertraging (${(100 * laat.length / rijdend).toFixed(1)}%).`);
console.log(`Geschrapte ritten vandaag: ${geschrapt}; al geschrapt voor latere dagen: ${geschraptLater}.`);
console.log("Meeste vertraging nu:");
for (const t of laat.sort((a, b) => b.vertraging - a.vertraging).slice(0, 6))
  console.log(`  ${t.soort} ${t.nummer}: +${Math.round(t.vertraging / 60)} min bij ${namen[t.station] ?? t.station}`);
Output
NS meldt nu: 1 DISRUPTION, 1 CALAMITY, 12 MAINTENANCE
  DISRUPTION: Amersfoort - Ede-Wageningen. (defecte trein)
  CALAMITY: Werkzaamheden Gouda-Rotterdam Centraal van zaterdag 17 t/m zondag 25 oktober 

Stand 16-10-2026, 23:50:26: 226 treinen onderweg, 42 met 3 minuten of meer vertraging (18.6%).
Geschrapte ritten vandaag: 8; al geschrapt voor latere dagen: 12.
Meeste vertraging nu:
  GV 328: +89 min bij Arnhem C.
  ST 20038: +60 min bij Zevenaar
  SPR 31371: +42 min bij Ede-Wageningen
  ICE 120: +37 min bij Amsterdam C.
  EST 9387: +27 min bij Schiphol Airport
  ICD 1887: +25 min bij Hilversum

Where it goes wrong

  • SKIPPED is not a cancellation. trainUpdates.pb also lists the stations an intercity passes through, with schedule_relationship 1. Count those and you see 'cancelled stops' everywhere (11,114 of 36,238 stops in our test).
  • Cancelled trips for other days. At 23:50, 20 trips were CANCELED, but 12 of them were for later days (including engineering works). Filter on today's start_date in Dutch time.
  • Asking too often gives 429. gtfs.ovapi.nl blocked us for a minute after about eight quick requests, with an HTML error page instead of protobuf. Check the status before parsing; otherwise the protobuf reader throws vague errors.
  • The NS key belongs on the server. With the key in the browser anyone can copy it and use up your allowance of 300 requests per 5 minutes. Don't show an NS logo either: the terms forbid it.
  • Negative delay. A train that is early has a negative delay. In protobuf that is an int32 arriving as a 64-bit number; without conversion (BigInt.asIntN(32, …)) the train appears 584 billion years late.
  • Late evening is not representative. At 23:50 there were 226 trains running; during the day there are many more and the delayed share is different. Only compare the same hours.

Take it further

  • Punctuality per route: store each train's delay per station every minute and after a month compute, per route, the share of trains that were within 3 or 5 minutes.
  • Disruptions on a map: the NS Spoorkaart API (/Spoorkaart-API/api/v1/storingen) returns disruptions as GeoJSON lines on the rail network.
  • Add the weather: link the number of disruptions per day to KNMI daily data (maximum temperature, gusts) and see whether heat or storms cause more disruptions.
  • Push alert: send a message (Telegram, Signal, ntfy) as soon as your train for tomorrow morning turns CANCELED in trainUpdates.

The sources2

NS travel information

NSNetherlands, station and trip

Live departures, delays and disruptions on the Dutch railways. Free, with a key from the NS API portal.

Free keyAPIReal timeTraffic and transport
Reachable
every minute

Public transport data via the NDOV desk

NDOV-loket (GOVI, Stichting OpenGeo), data van de vervoerdersNetherlands, stop, trip and vehicle

The NDOV desk collects timetables and live trip information from Dutch transport operators, from Arriva and Connexxion to GVB, RET and NS. Stichting OpenGeo runs the desk under the name GOVI and publishes the data under CC0: timetables in NeTEx, the stop register and live positions and delays. There is also a ready-made GTFS version for the whole country.

OpenDownloadReal timeTraffic and transport
Up to date
updated less than an hour ago

More to build