Purpose
Plan a domestic (or international-via-NS) train journey between two stations on the NS journey planner at ns.nl and return the available travel options: per-option departure/arrival times, total travel time, departure track, number of transfers, train type(s) (Intercity / Sprinter / Intercity direct / Eurocity Direct / metro / bus legs), real-time delays, crowding indication, and any fare/supplement notes. Read-only — never books, buys, or checks in.
When to Use
- "What trains run from Amsterdam Centraal to Rotterdam Centraal around 9am tomorrow?"
- "When do I need to leave Eindhoven to arrive at Schiphol Airport by 17:00?"
- Comparing departure options, transfer counts, or travel times between two Dutch stations.
- Surfacing live delays / disruptions on a specific route at a specific time.
- Any flow that needs train travel options without purchasing a ticket.
Workflow
The NS journey planner is an Angular SPA whose state is fully encoded in the URL hash — so the optimal path is to construct a deep-link URL directly and read the rendered results, with zero form interaction. A plain browserless_agent call (no proxy) is sufficient: the homepage and the planner load cleanly and the underlying trips API returns 200 without geo-restriction (verified from a non-NL outbound IP over two iterations). The site sits behind Akamai (ak_bmsc cookie) but did not challenge plain navigation.
1. Build the deep-link URL
https://www.ns.nl/en/journeyplanner/#/?vertrek=<FROM>&vertrektype=treinstation&aankomst=<TO>&aankomsttype=treinstation&type=<vertrek|aankomst>&tijd=<YYYY-MM-DDTHH:mm>&firstMileModality=PUBLIC_TRANSPORT&lastMileModality=PUBLIC_TRANSPORT| Param | Meaning |
|---|---|
vertrek | Origin station name, URL-encoded (e.g. Amsterdam%20Centraal). Use the canonical NS name. |
aankomst | Destination station name, URL-encoded. |
vertrektype / aankomsttype | treinstation for a train station (other values exist for bus stops / addresses — stick to treinstation). |
type | vertrek = depart at tijd; aankomst = arrive by tijd. |
tijd | Local (Europe/Amsterdam) ISO datetime, minute precision, no seconds/zone: 2026-06-18T09:00. |
firstMileModality / lastMileModality | PUBLIC_TRANSPORT (default). |
Use the English planner (/en/journeyplanner/) for English UI labels; the Dutch planner lives at /reisplanner/ and uses the identical param names.
2. Navigate and wait for the SPA to render
Run one browserless_agent call, chaining the nav, wait, and snapshot in its commands array (the session persists across calls, keyed by proxy/profile, so batching keeps its state live):
{ "method": "goto", "params": { "url": "<deep-link>", "waitUntil": "load", "timeout": 45000 } }
{ "method": "waitForTimeout", "params": { "time": 5000 } } // Angular fetches + renders trip cards 2-5s after load3. Read the results
{ "method": "snapshot" }Each travel option appears as a single accessibility-tree StaticText summary with a predictable shape:
departure 17:18 arrival 18:08 travel time 0:50 track 14a You travel with Sprinter Intercity direct
departure 17:19 with 2 minutes delay arrival 18:34 travel time 1:13 track 4b You travel with Sprinter
Shorter train, extra busy and possibly full departure 17:27 with 1 minute delay arrival 18:18 travel time 0:50 with 1 time transfers You travel with metro line 52 Intercity directParse from that string:
departure HH:MM/arrival HH:MM/travel time H:MMtrack <n>→ departure track/platform (may be absent for some itineraries — emitnull)with N minute(s) delay→ real-time delay in minutes (absent ⇒0)with N time transfers→ transfer count (absent ⇒0)You travel with <…>→ the train/modality types in order (e.g.Intercity,Sprinter,Intercity direct,Eurocity Direct,metro line 52)- Crowding prefix such as
Shorter train, extra busy and possibly full→ crowding note - A leg/option marked
Supplement(e.g. Intercity direct, Eurocity Direct) carries a surcharge — expand "Show details" to read the exact amount (e.g.€3.20).
For full per-leg detail (intermediate stops, walk-to-platform transfers, per-leg tracks, fare breakdown like €20.20 full / €12.12 off-peak), click an option's "Show details" button and re-snapshot.
4. Notes on result windowing
Results are anchored on tijd: for type=vertrek the list starts at/just after the requested time; for type=aankomst the list ends at/just before it (last option's arrival ≤ requested time, verified). A few earlier options may be hidden behind an "Earlier travel options" control — click it only if you need departures before the anchor.
Browser fallback (form interaction)
If a deep link ever fails to populate (e.g. an ambiguous station name that doesn't auto-resolve), drive the form. Keep the whole sequence in one browserless_agent call's commands array so the typeahead state persists:
{ "method": "goto", "params": { "url": "https://www.ns.nl/en/journeyplanner/#/", "waitUntil": "load", "timeout": 45000 } }.- Dismiss the cookie dialog —
clickReject (or Accept); it overlays the page on first visit. clickthe from combobox,{ "method": "type", "params": { "selector": "<from combobox>", "text": "<origin>" } },waitForTimeout~2000ms, thenclickthe first suggestion labelled "Railway station" in the suggestions listbox (avoid the look-alike "Bus station" option; confirm the selector viasnapshotif it misses).- Repeat for the to combobox.
- Optionally set the Departure/Arrival radio, date, and time fields.
clickPlan (or Show Travel options). The URL rewrites itself into the canonical deep link from step 1 — capture it for reuse.- Wait and read results as in step 3.
Site-Specific Gotchas
- No anti-bot escalation needed. A plain
browserless_agentcall (no proxy) loaded the planner and returned full results across both validation iterations from a non-Dutch IP. Akamai is present (ak_bmsccookie) but did not challenge. Don't waste budget enabling a proxy unless you actually observe a 403/Access-Denied. - The URL hash IS the state. Everything is after
#/, so changing only the hash on an already-loaded planner may not re-trigger a fetch — do a fullgotoof the new deep link (or a{ "method": "reload", "params": { "waitUntil": "load" } }) to force a fresh search. - Wait after load. The trips XHR fires 2–5s after the
goto(waitUntil: "load") resolves. Snapshotting too early returns the empty form. Always add a{ "method": "waitForTimeout", "params": { "time": 5000 } }(or re-snapshotuntil option rows appear). typeis the depart-vs-arrive switch, not a UI radio you must click — set it in the URL.vertrek= leave-at,aankomst= arrive-by.tijdis local Amsterdam time, minute precision, no timezone suffix.2026-06-18T09:00. Adding seconds or aZ/offset can cause the SPA to fall back to "now".- Typeahead has bus-stop look-alikes. When using the form fallback, the suggestions list shows both a
Railway stationand aBus stationentry for the same city name (e.g. "Amsterdam, Centraal Station" is a bus station). Always pick the one tagged Railway station. - Supplement trains. "Intercity direct" (Amsterdam–Rotterdam–Breda HSL) and "Eurocity Direct" legs require a surcharge (observed
€3.20); the option is taggedSupplement. Surface this — it changes the real price. - Crowding & shorter-train warnings are prepended to the option text ("Shorter train, extra busy and possibly full"). Capture them as advisory metadata.
- Underlying API needs a key — see Expected Output's note. The SPA fetches
https://gateway.apiportal.ns.nl/reisinformatie-api/api/v3/trips(returns200, JSON, ~11 KB). This is NS's official, documented API (apiportal.ns.nl) and requires a registeredOcp-Apim-Subscription-Keyheader. If you have a free NS API key, call it directly (params below) and skip the browser entirely. Without a key, the deep-link browser path is the recommended route — the website's embedded key is not reliably extractable and reusing it would violate NS's API terms. - Cookie dialog only appears in the form flow / first visit. Deep-link navigation renders results behind/around it without needing dismissal, but if a snapshot looks blocked, dismiss the
dialog: Your cookie preferencesfirst.
Expected Output
{
"success": true,
"from": "Amsterdam Centraal",
"to": "Rotterdam Centraal",
"search_type": "departure",
"search_time": "2026-06-18T09:00",
"options": [
{
"departure": "09:18",
"arrival": "10:08",
"duration": "0:50",
"departure_track": "14a",
"transfers": 1,
"train_types": ["Sprinter", "Intercity direct"],
"delay_minutes": 0,
"crowding_note": null,
"supplement": "€3.20"
},
{
"departure": "09:36",
"arrival": "10:48",
"duration": "1:12",
"departure_track": "2a",
"transfers": 0,
"train_types": ["Intercity"],
"delay_minutes": 0,
"crowding_note": null,
"supplement": null
}
],
"error_reasoning": null
}search_type is "departure" (depart-at) or "arrival" (arrive-by). For arrive-by queries the options' arrival values cluster at/just before search_time.
Failure shape (e.g. unresolvable station, no service on the route/date):
{
"success": false,
"from": "…",
"to": "…",
"options": [],
"error_reasoning": "No travel options returned — station name did not resolve / no service for the requested date-time."
}Direct-API alternative (requires a free NS API subscription key):
GET https://gateway.apiportal.ns.nl/reisinformatie-api/api/v3/trips
?fromStation=Amsterdam Centraal
&originUicCode=8400058
&toStation=Rotterdam Centraal
&destinationUicCode=8400530
&dateTime=2026-06-18T09:00:00
&lang=en
&product=OVCHIPKAART_ENKELE_REIS
Header: Ocp-Apim-Subscription-Key: <your NS apiportal.ns.nl key>Returns structured JSON trip objects (legs, stops, fares, real-time delays) — register at apiportal.ns.nl for a key. UIC station codes are discoverable via the same portal's nsapp-stations / places-api endpoints.