Sign inTry it

Plan a train trip in the Netherlands (NS).

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.

No reports yetRead-only, no login
Site
NS (Nederlandse Spoorwegen), Netherlands
Version
v1
Runs, last 7 days
No reports yet
sha256
2c55790c704e…

Run it with your agent

after installing Wield

Ask in plain words. Your agent finds this recipe and calls it:

“Plan a train trip in the Netherlands (NS).”

Read-only: it changes nothing on the site. No login needed.

{
  "tool": "run",
  "arguments": {
    "recipe_id": "ns.nl/search-trains",
    "params": {
      "from": "Amsterdam Centraal",
      "to": "Utrecht Centraal"
    }
  }
}

Requests

sent from your browser, in order
  1. 1GETgateway.apiportal.ns.nl/reisinformatie-api/api/v2/stations?limit&qNS'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.no cookiesrepeats, max 2
  2. 2GETgateway.apiportal.ns.nl/reisinformatie-api/api/v3/trips?dateTime&fromStation&toStationThe 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.no cookiesrepeats, max 1

Parameters

from your agent
datestringYYYY-MM-DD; leave out to travel now. NS plans only within the current timetable (the new one is published shortly before mid-December)
fromstringrequiredStation, e.g. 'Amsterdam Centraal', 'Utrecht', 'Schiphol Airport'. The first match of NS's station search is used
timestringDeparture time HH:MM, Dutch local time; used together with date = "08:00"
tostringrequiredStation, e.g. 'Rotterdam Centraal', 'Den Haag Centraal'

Returns

to your agent
fromstring
tostring
tripsobject[]arrival, arrival_actual, crowding, departure, departure_actual, duration_min, fare, legs, price_eur, price_first_class_eur, status, transfers, url

Where it can send

  • www.ns.nl
  • gateway.apiportal.ns.nl

Anything else is blocked by the extension.

Reads from your session

  • key from dom

Used only in requests to the addresses on the left. Never sent to wield.run or your agent.

Checks

a run passes when
  • 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)

Raw recipe

Served as canonical JSON from /api/v1/recipes/ns.nl/search-trains, so its sha256 matches the ETag and the index.

Check it yourself
curl -s https://wield.run/api/v1/recipes/ns.nl/search-trains | shasum -a 256
# 2c55790c704e084916c649a6d3fd72395e82db7b048e1341dafab241115add2a
ns.nl/search-trains.json, v1
{
  "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
}