303.525 datasets geïndexeerd1.419 uitgevers
Open Data IndexDe index van open data over Nederland
Open Data Index›Bouwen›Dashboard met treinvertragingen en storingen
DashboardOm te beginnenVerkeer en vervoer

Dashboard met treinvertragingen en storingen

Een pagina die elke minuut laat zien hoeveel treinen er rijden, welke te laat zijn en welke storingen er spelen, gebouwd op het open GTFS-Realtime-bestand van openOV en, met een gratis sleutel, de storingen van NS.

Je AI-assistentOpen Data Index MCP aan
De MCP-server van Open Data Index staat aan in je chat. Zo leest de assistent dit project en de bronnen zelf.
Jij plakt de prompt van dit project
De assistent haalt op bij Open Data Index
  • geef_project("treinvertragingen-dashboard")
  • geef_bron("ns-reisinformatie")
  • geef_bron("ndov-ov-data")
De assistent

Ik heb het project, de geteste aanroepen en de valkuilen. Ik bouw een dashboard met NS reisinformatie en OV-data via het NDOV-loket. Zo kan het eruitzien:

Dit bouw je zelf. Kopieer de prompt naar Claude, ChatGPT of Cursor, met de MCP-server aan, of volg de stappen hieronder.

Volg de stappen
treinen.jouwsite.nl
Treinen nuvr 16 okt, 23:50
226treinen onderweg
18,6%3 min of meer te laat (42)
8ritten geschrapt vandaag

Meeste vertraging

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

Meldingen van NS

  • StoringAmersfoort - Ede-Wageningendefecte trein
  • CalamiteitWerkzaamheden Gouda - Rotterdam C.17 t/m 25 okt
  • Werkzaamheden12 meldingen
Mockup

Dit is een mockup, geen werkende versie, met de cijfers uit onze proefdraai van vrijdag 16 oktober 2026, 23:50. De MCP-server zet je aan met het adres op Voor AI.

Voor wie

Forensen die willen weten of hun lijn vaak hapert, journalisten die een storingsavond willen duiden, en iedereen die een eerste live-dashboard wil bouwen.

Wat je krijgt

Een dashboard met bovenaan drie getallen: treinen onderweg, het deel met drie minuten of meer vertraging, en het aantal geschrapte ritten vandaag. Daaronder de treinen met de meeste vertraging (met het volgende station) en de lijst met storingen en werkzaamheden van NS.

Proefdraai op vrijdag 16 oktober 2026 om 23:50: 226 treinen onderweg, waarvan 42 (18,6%) drie minuten of meer te laat. Vandaag waren 8 ritten geschrapt; 12 ritten voor latere dagen stonden al als geschrapt in het bestand. De grootste vertraging had GV 328 (categorie GV) met 89 minuten bij Arnhem Centraal. NS meldde op dat moment 1 storing (Amersfoort - Ede-Wageningen, defecte trein), 1 calamiteit (een aankondiging van werkzaamheden Gouda-Rotterdam) en 12 werkzaamheden.

Zo zou je kunnen starten6 stappen

  1. 1

    Haal de live treinritten op, zonder sleutel

    openOV zet de NDOV-data elke minuut om in GTFS-Realtime. trainUpdates.pb (1,3 MB) bevat alle treinritten van vandaag en de komende dagen met per station de verwachte tijd en de vertraging in seconden. Het is protobuf, geen JSON: lees het met gtfs-realtime-bindings of met de kleine lezer uit het script. Vraag hooguit eens per minuut; bij te veel verzoeken achter elkaar geeft de server 429 Too Many Requests (zelf meegemaakt bij een stuk of acht verzoeken binnen een minuut).

    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

    Lees de OVapi-uitbreiding voor treinnummer en station

    De standaard-GTFS-velden zeggen niet welk treinnummer of station het is. Dat staat in veld 1003, een uitbreiding van OVapi: realtime_trip_id ('IFF:IC:1177' = soort IC, treinnummer 1177) en per stop station_id (de NS-stationscode in kleine letters, 'ut') en het spoor. Let op: haltes met schedule_relationship SKIPPED zijn stations waar de trein doorheen rijdt. Dat is geen uitval. Een geschrapte rit herken je aan schedule_relationship 3 (CANCELED) op de rit zelf, en die kan ook voor morgen gelden.

    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

    Vraag een gratis NS-sleutel aan

    Maak een account op apiportal.ns.nl (Externe bezoeker), ga naar het product met de Reisinformatie-API en klik Subscribe. De sleutel staat daarna op je profielpagina en gaat mee in de kop Ocp-Apim-Subscription-Key. Gratis tegoed: 300 verzoeken per 5 minuten. Voorwaarden van het portaal: geen NS-logo in je toepassing, gegevens niet zo bewerken dat er onjuiste informatie ontstaat, en ga zuinig om met de capaciteit. Zet de sleutel alleen op de server, nooit in JavaScript in de browser. De nieuwe timetable-api/v2 vraagt een ander abonnement (onze sleutel kreeg 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

    Haal de storingen en werkzaamheden op

    isActive=true geeft wat nu speelt. type is DISRUPTION (storing), CALAMITY of MAINTENANCE (werkzaamheden). Een CALAMITY is niet altijd een ramp: om 23:50 was het een aankondiging van werkzaamheden voor volgende week. De oorzaak staat in timespans[0].cause.label, de tekst voor reizigers in timespans[0].situation.label. Het antwoord is 87 kB, omdat elk getroffen station met coördinaten wordt meegestuurd.

    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

    Zet stationscodes om in namen

    trainUpdates.pb noemt stations alleen met een code. De stationslijst van NS (397 stations in Nederland) geeft de namen in drie lengtes en de coördinaten, zodat je ook een kaart kunt maken. Haal hem één keer per dag op en bewaar hem; hij verandert zelden.

    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

    Bereken de getallen voor het dashboard

    Een trein is onderweg als de eerste stop in het verleden ligt en er nog een stop in de toekomst is. Neem de vertraging bij die volgende stop. Tel geschrapte ritten alleen als start_date vandaag is. Ververs het dashboard elke minuut en bewaar de getallen per minuut in een eigen tabel: de open bestanden hebben geen geschiedenis.

Hoe de bronnen samenkomen

  • OV-data via het NDOV-loketNS reisinformatievia stationscode (trainUpdates station_id 'ut' = NS code 'UT')

    trainUpdates.pb geeft per trein de vertraging bij elk station met een code in kleine letters. De stationslijst van NS geeft bij dezelfde code (in hoofdletters) de naam en de coördinaten. Het treinnummer (1177) is in beide gelijk, zodat je ook een vertrek uit /departures aan een rit kunt koppelen.

  • NS reisinformatieKNMI daggegevens van het weervia datum

    Bewaar elke dag het aantal storingen en de gemiddelde vertraging en leg die naast temperatuur en windstoten van het KNMI. Zo zie je of hitte of storm het spoor raakt.

Laat AI het bouwen

Plak deze prompt in Claude, ChatGPT, Cursor of Lovable. Hij noemt de echte endpoints, de koppelsleutels en de valkuilen, zodat de assistent meteen werkende code schrijft. Controleer de uitkomst altijd met de bron zelf.

Bouw een klein dashboard (Node 20 met Express en één HTML-pagina, of Next.js) dat elke minuut laat zien hoe het met de treinen in Nederland gaat.

Data 1, zonder sleutel: https://gtfs.ovapi.nl/nl/trainUpdates.pb (GTFS-Realtime, protobuf, CC0, openOV). Lees het op de server met gtfs-realtime-bindings of met een eigen mini-protobuflezer.
Data 2, met een gratis sleutel (apiportal.ns.nl, kop Ocp-Apim-Subscription-Key, lees hem uit de omgevingsvariabele NS_API_KEY, nooit naar de browser sturen):
- storingen: 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
- vertrekken per station: https://gateway.apiportal.ns.nl/reisinformatie-api/api/v2/departures?station=UT&maxJourneys=40

Wat je moet weten:
- In trainUpdates zit per rit een uitbreiding (veld 1003, zie https://gtfs.ovapi.nl/nl/gtfs-realtime-OVapi.proto): realtime_trip_id 'IFF:IC:1177' (soort en treinnummer) en per stop station_id ('ut', kleine letters) en spoor.
- Vertraging (StopTimeEvent.delay) is in seconden en kan negatief zijn (int32: negatieve getallen komen als 10-byte varint).
- StopTimeUpdate met schedule_relationship 1 (SKIPPED) = de trein rijdt daar door zonder te stoppen. Niet als uitval tellen.
- Rit met schedule_relationship 3 (CANCELED) = geschrapt. Het bestand bevat ook ritten voor morgen en overmorgen: tel alleen start_date van vandaag (Europe/Amsterdam).
- 'Onderweg' = eerste stop in het verleden, nog een stop in de toekomst. Neem de vertraging bij de volgende stop.
- Haal trainUpdates hooguit eens per minuut op, met een eigen User-Agent en If-Modified-Since. Bij 429 een minuut wachten.
- NS-storingen: type DISRUPTION, CALAMITY of MAINTENANCE. Oorzaak in timespans[0].cause.label. Een CALAMITY kan ook een aankondiging zijn.
- NS-voorwaarden: geen NS-logo, gegevens niet zo bewerken dat er onjuiste informatie ontstaat. 300 verzoeken per 5 minuten: cache de storingen 60 seconden en de stationslijst een dag.
- Werkt de app zonder sleutel, laat dan alleen de openOV-getallen zien met stationscodes in plaats van namen.

Uitkomst: bovenaan drie grote getallen (treinen onderweg, percentage met 3 minuten of meer vertraging, geschrapte ritten vandaag) met het tijdstip van de feed. Daaronder een tabel met de tien treinen met de meeste vertraging (soort, nummer, vertraging in minuten, volgend station) en een lijst storingen met oorzaak en verwachte duur. Sla elke minuut de drie getallen op in SQLite en toon een lijn van de laatste 24 uur.

Probeer het zelf

De kern van het project in een klein script. Nodig: Node 20+ (fetch ingebouwd), geen pakketten; voor de NS-regels een eigen sleutel in NS_API_KEY. Getest op 2026-10-16; hieronder de echte uitvoer van die proefdraai.

// 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}`);
Uitvoer
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

Waar het misgaat

  • SKIPPED is geen uitval. In trainUpdates.pb staan ook de stations waar een intercity doorheen rijdt, met schedule_relationship 1. Wie die telt, ziet overal 'geschrapte stops' (11.114 van de 36.238 stops in onze proef).
  • Geschrapte ritten voor andere dagen. Om 23:50 stonden 20 ritten op CANCELED, maar 12 daarvan waren voor latere dagen (onder meer werkzaamheden). Filter op start_date van vandaag in Nederlandse tijd.
  • Te vaak vragen geeft 429. gtfs.ovapi.nl blokkeerde ons na een stuk of acht snelle verzoeken een minuut lang, met een HTML-foutpagina in plaats van protobuf. Controleer de status voor je gaat ontleden; anders krijg je vage fouten in de protobuflezer.
  • De NS-sleutel hoort op de server. Met de sleutel in de browser kan iedereen hem kopiëren en je tegoed van 300 verzoeken per 5 minuten opmaken. Toon ook geen NS-logo: dat verbieden de voorwaarden.
  • Negatieve vertraging. Een trein die te vroeg is, heeft een negatieve delay. In protobuf is dat een int32 die als 64-bit getal binnenkomt; zonder omzetting (BigInt.asIntN(32, …)) lijkt de trein 584 miljard jaar te laat.
  • Late avond is niet representatief. Om 23:50 reden er 226 treinen; overdag zijn het er veel meer en is het aandeel met vertraging anders. Vergelijk alleen dezelfde uren met elkaar.

Verder bouwen

  • Punctualiteit per traject: sla elke minuut de vertraging per trein en station op en bereken na een maand per traject het deel van de treinen dat binnen 3 of 5 minuten op tijd was.
  • Storingen op de kaart: de Spoorkaart-API van NS (/Spoorkaart-API/api/v1/storingen) geeft storingen als GeoJSON-lijnen op het spoornet.
  • Weer erbij: koppel het aantal storingen per dag aan KNMI-daggegevens (maximumtemperatuur, windstoten) en kijk of hitte of storm meer storingen geeft.
  • Melding per push: stuur een bericht (Telegram, Signal, ntfy) zodra jouw trein van morgenochtend in trainUpdates op CANCELED komt te staan.

De bronnen2

NS reisinformatie

NSNederland, station en rit

Actuele vertrektijden, vertragingen en storingen op het spoor. Gratis, met een sleutel uit het API-portaal van NS.

Gratis sleutelAPIRealtimeVerkeer en vervoer
Bereikbaar
elke minuut

OV-data via het NDOV-loket

NDOV-loket (GOVI, Stichting OpenGeo), data van de vervoerdersNederland, halte, rit en voertuig

Het NDOV-loket verzamelt de dienstregelingen en actuele ritinformatie van de Nederlandse vervoerders, van Arriva en Connexxion tot GVB, RET en NS. Stichting OpenGeo draait het loket onder de naam GOVI en zet de data als CC0 open: dienstregelingen in NeTEx, het haltebestand en live posities en vertragingen. Er is ook een kant-en-klare GTFS-versie voor heel Nederland.

OpenDownloadRealtimeVerkeer en vervoer
Actueel
bijgewerkt minder dan een uur geleden

Meer om te bouwen