{"auth":{"derive":[{"from":"dom","name":"key","regex":"nsAppInternalProductKey:\\s*\"([0-9a-f]{32})\"","selector":"script:not([src])"}]},"description":"NS's journey planner: trains between two Dutch stations (and some abroad) from now or from a date and time, like ns.nl/reisplanner shows them. Each trip has departure and arrival (planned and live), duration, transfers, status (normal, cancelled, disrupted), crowding forecast, the single fare in 2nd and 1st class with an OV-chipkaart, every leg's train, platform and crowding, and a link to the trip on ns.nl. NS returns a few trips around the requested time.","effect":"read","id":"ns.nl/search-trains","keywords":["ns","nederlandse spoorwegen","trein","reisplanner","treinreis","intercity","sprinter","ov-chipkaart","dienstregeling","spoor","train","netherlands","dutch trains","amsterdam","utrecht","rotterdam","den haag","schiphol"],"output":{"schema":{"properties":{"from":{"type":"string"},"to":{"type":"string"},"trips":{"items":{"properties":{"arrival":{"type":["string","null"]},"arrival_actual":{"type":["string","null"]},"crowding":{"description":"LOW, MEDIUM, HIGH or UNKNOWN","type":["string","null"]},"departure":{"description":"Planned, ISO 8601 with offset","type":["string","null"]},"departure_actual":{"type":["string","null"]},"duration_min":{"type":"integer"},"fare":{"type":["string","null"]},"legs":{"items":{"properties":{"arrival":{"type":["string","null"]},"arrival_actual":{"type":["string","null"]},"arrival_platform":{"type":["string","null"]},"cancelled":{"type":"boolean"},"category":{"type":["string","null"]},"crowding":{"type":["string","null"]},"departure":{"type":["string","null"]},"departure_actual":{"type":["string","null"]},"direction":{"type":["string","null"]},"from":{"type":"string"},"operator":{"type":["string","null"]},"platform":{"type":["string","null"]},"to":{"type":"string"},"train":{"description":"e.g. 'IC 3025'","type":["string","null"]}},"type":"object"},"type":"array"},"price_eur":{"description":"Single fare, 2nd class, OV-chipkaart, no discount","type":["number","null"]},"price_first_class_eur":{"type":["number","null"]},"status":{"description":"NORMAL, CANCELLED, DISRUPTION, ALTERNATIVE_TRANSPORT, …","type":"string"},"transfers":{"type":"integer"},"url":{"description":"The trip on ns.nl","type":["string","null"]}},"type":"object"},"type":"array"}},"required":["from","to","trips"],"type":"object"},"value":"{from: steps.trips[0].f.from.name, to: steps.trips[0].f.to.name, trips: map(&(let $t = @ in let $o = $t.legs[0].origin, $d = $t.legs[-1].destination in {departure: ($o.plannedDateTime && join('', [$o.plannedDateTime[0:22], ':', $o.plannedDateTime[22:24]]) || `null`), departure_actual: ($o.actualDateTime && join('', [$o.actualDateTime[0:22], ':', $o.actualDateTime[22:24]]) || `null`), arrival: ($d.plannedDateTime && join('', [$d.plannedDateTime[0:22], ':', $d.plannedDateTime[22:24]]) || `null`), arrival_actual: ($d.actualDateTime && join('', [$d.actualDateTime[0:22], ':', $d.actualDateTime[22:24]]) || `null`), duration_min: $t.actualDurationInMinutes || $t.plannedDurationInMinutes, transfers: $t.transfers, status: $t.status, crowding: $t.crowdForecast || `null`, price_eur: $t.productFare.priceInCents && $t.productFare.priceInCents / `100` || `null`, price_first_class_eur: ($t.fares[?travelClass == 'FIRST_CLASS' && discountType == 'NO_DISCOUNT'] | [0].priceInCents) && ($t.fares[?travelClass == 'FIRST_CLASS' && discountType == 'NO_DISCOUNT'] | [0].priceInCents) / `100` || `null`, fare: $t.productFare.product || `null`, legs: map(&(let $l = @ in {train: $l.name || `null`, category: $l.product.longCategoryName || $l.product.categoryCode || `null`, operator: $l.product.operatorName || `null`, direction: $l.direction || `null`, from: $l.origin.name, departure: ($l.origin.plannedDateTime && join('', [$l.origin.plannedDateTime[0:22], ':', $l.origin.plannedDateTime[22:24]]) || `null`), departure_actual: ($l.origin.actualDateTime && join('', [$l.origin.actualDateTime[0:22], ':', $l.origin.actualDateTime[22:24]]) || `null`), platform: $l.origin.actualTrack || $l.origin.plannedTrack || `null`, to: $l.destination.name, arrival: ($l.destination.plannedDateTime && join('', [$l.destination.plannedDateTime[0:22], ':', $l.destination.plannedDateTime[22:24]]) || `null`), arrival_actual: ($l.destination.actualDateTime && join('', [$l.destination.actualDateTime[0:22], ':', $l.destination.actualDateTime[22:24]]) || `null`), arrival_platform: $l.destination.actualTrack || $l.destination.plannedTrack || `null`, crowding: $l.crowdForecast || `null`, cancelled: $l.cancelled == `true`}), $t.legs), url: $t.shareUrl.uri || `null`}), steps.trips[0].trips || `[]`)}"},"params":{"additionalProperties":false,"properties":{"date":{"description":"YYYY-MM-DD; leave out to travel now. NS plans only within the current timetable (the new one is published shortly before mid-December)","pattern":"^\\d{4}-\\d{2}-\\d{2}$","type":"string"},"from":{"description":"Station, e.g. 'Amsterdam Centraal', 'Utrecht', 'Schiphol Airport'. The first match of NS's station search is used","maxLength":80,"minLength":2,"type":"string"},"time":{"default":"08:00","description":"Departure time HH:MM, Dutch local time; used together with date","pattern":"^([01][0-9]|2[0-3]):[0-5][0-9]$","type":"string"},"to":{"description":"Station, e.g. 'Rotterdam Centraal', 'Den Haag Centraal'","maxLength":80,"minLength":2,"type":"string"}},"required":["from","to"],"type":"object"},"provenance":{"method":"browser-inspect","notes":"ns.nl/reisplanner/ sets window.journeyplannerConfiguration with reisinfoApiUrl https://gateway.apiportal.ns.nl/reisinformatie-api (NS's own API gateway, a subdomain of ns.nl) and nsAppInternalProductKey, the Ocp-Apim-Subscription-Key every visitor's planner sends; the recipe reads it from that page at run time (auth.derive), nothing is written into the recipe. Calls: /api/v2/stations?q= (station code and names) and /api/v3/trips?fromStation&toStation&dateTime (local time without offset is accepted); the gateway allows CORS from any origin. trips[] carry planned/actual times (offset written +0100, normalised to +01:00), durations, transfers, status, crowdForecast, productFare/fares in cents and shareUrl. Dates beyond the current timetable answer 400 NEW_SCHEDULE_NOT_YET_AVAILABLE. searchForArrival only shifts the window, so arrive-by is not offered. Answers servers.","recorded_at":"2026-11-07T22:02:00+01:00","recorded_by":"C4"},"requires_login":false,"schema":"https://wield.run/schema/recipe/v1.json","site":{"country":"NL","id":"ns.nl","language":"nl","name":"NS (Nederlandse Spoorwegen)","origin":"https://www.ns.nl","origins":["https://www.ns.nl","https://gateway.apiportal.ns.nl"],"start_path":"/reisplanner/"},"steps":[{"description":"NS's station search on gateway.apiportal.ns.nl (NS's own API gateway; the planner page calls it with the product key from its window.journeyplannerConfiguration), once per end, up to 10 candidates.","expect":{"assert":"length(body.payload) > `0`","content_type":"json","status":[200]},"extract":{"json":{"cands":"payload[].{code: code, name: namen.lang, syn: synoniemen || `[]`}"}},"foreach":{"as":"q","in":"[params.from, params.to]","max":2},"id":"station","request":{"credentials":"omit","headers":{"Accept":"application/json","Ocp-Apim-Subscription-Key":"{{vars.key}}"},"method":"GET","query":{"limit":"10","q":"{{q}}"},"url":"https://gateway.apiportal.ns.nl/reisinformatie-api/api/v2/stations"}},{"description":"The planner's trips API (reisinformatie-api v3) on the same gateway. The one-item foreach picks each station (exact name, then a synonym such as 'Rotterdam' for Rotterdam Centraal, then '<name> Centraal', then the first match) and builds dateTime; without a date the API plans from now.","expect":{"assert":"body.trips != `null`","content_type":"json","status":[200]},"extract":{"json":{"trips":"trips"}},"foreach":{"as":"f","in":"[{dt: params.date && join('', [params.date, 'T', params.time || '08:00']) || `null`, from: (let $s = steps.station[0], $q = lower(trim(steps.station[0].q)) in ($s.cands[?lower(name) == $q] | [0]) || ($s.cands[?contains(map(&lower(@), syn), $q)] | [0]) || ($s.cands[?lower(name) == join(' ', [$q, 'centraal'])] | [0]) || $s.cands[0]), to: (let $s = steps.station[1], $q = lower(trim(steps.station[1].q)) in ($s.cands[?lower(name) == $q] | [0]) || ($s.cands[?contains(map(&lower(@), syn), $q)] | [0]) || ($s.cands[?lower(name) == join(' ', [$q, 'centraal'])] | [0]) || $s.cands[0])}]","max":1},"id":"trips","request":{"credentials":"omit","headers":{"Accept":"application/json","Ocp-Apim-Subscription-Key":"{{vars.key}}"},"method":"GET","query":{"dateTime":"{{f.dt}}","fromStation":"{{f.from.code}}","toStation":"{{f.to.code}}"},"url":"https://gateway.apiportal.ns.nl/reisinformatie-api/api/v3/trips"}}],"task":"Plan a train trip in the Netherlands (NS)","tos":{"notes":"Terms not reviewed yet for automated access. One person's search: the planner page plus 3 API requests, the same ones ns.nl/reisplanner makes. No tickets: buying stays on ns.nl.","reviewed":false},"verify":{"checks":["length(output.trips) >= `3`","output.from == 'Amsterdam Centraal'","output.to == 'Utrecht Centraal'","length(output.trips[?price_eur > `0` && duration_min > `15` && duration_min < `90` && length(legs) > `0`]) == length(output.trips)","length(output.trips[?starts_with(url, 'https://www.ns.nl/')]) == length(output.trips)","length(output.trips[?ends_with(departure, '+01:00') || ends_with(departure, '+02:00')]) == length(output.trips)"],"example_params":{"from":"Amsterdam Centraal","to":"Utrecht Centraal"},"max_ms":6000},"version":1}