Skip to content

Rail stations

The API publishes the SNCF passenger-station referential: 2782 stations, each with its UIC code, its position and its administrative attachment. It is the one part of the API that belongs to nobody — no group, no key, no rights.

What makes this endpoint different

Rest of the API Rail stations
Scope /groups/<group_id>/… no group
Authentication API key or session required none
Origin of the data your network's GTFS and field reports the SNCF gares-de-voyageurs open dataset
Changes when continuously, during service when SNCF revises its dataset, and the referential is reloaded by hand

The consequence to keep in mind: nothing here is real time, and nothing here is specific to your network. It is an index, meant to be read before you have anything else — typically to designate by UIC code the station a stop of your network corresponds to.

The two endpoints

Endpoint Purpose
GET /api/v4/rail-stations Search the referential by name or by proximity
GET /api/v4/rail-stations/<uic> Read one station from one of its UIC codes

Searching by name

q matches anywhere in the station name, ignoring case, accents and separators. chateauroux finds Châteauroux, and saint germain finds Saint-Germain-en-Laye Bel-Air – Fourqueux. There is nothing to normalise on your side.

const url = new URL("https://api.pysae.com/api/v4/rail-stations")
url.searchParams.set("q", "Châteauroux")

const response = await fetch(url)
if (!response.ok) {
  throw new Error(`Search failed: ${response.status}`)
}

const { items } = await response.json()
for (const station of items) {
  console.log(station.uic, station.name)
}

The response carries the page and its total, like every paginated endpoint of the API:

{
  "items": [
    {
      "uic": ["87597005"],
      "name": "Châteauroux",
      "short_label": "CTX",
      "segments_drg": ["A"],
      "lat": 46.809737,
      "lon": 1.699536,
      "insee_code": "36044",
      "source_ref": "0054eed7-a134-4fca-b217-d65e4d2ea153"
    }
  ],
  "total": 1,
  "page": 1,
  "page_size": 50
}

Searching by proximity

near=<latitude>,<longitude> orders the results by increasing distance and adds distance_meters to each one. radius, in metres, bounds the search; it defaults to 10 km and is ignored when near is absent.

GET /api/v4/rail-stations?near=48.880185,2.355151&radius=3000 returns the stations within 3 km of Paris Gare du Nord, the closest first.

Both filters combine: q and near together return the stations whose name matches, within the radius, ordered by distance.

A station, several UIC codes

uic is a list, not a single value, and this is the point that catches integrators out. SNCF attributes several codes to the same station when several networks serve it: Paris Gare du Nord carries three, Avignon TGV two. The consequences:

  • Any of a station's codes resolves to it. GET /api/v4/rail-stations/87271007 and GET /api/v4/rail-stations/87271031 both return Paris Gare du Nord.
  • A code belongs to exactly one station. No UIC code is shared, so resolving one is never ambiguous.
  • segments_drg is a list for the same reason. The DRG frequentation segment (A busiest to C) is given per code, so a station with several codes can carry several segments.

Storing a single code on your side is enough to designate a station, as long as you read uic as a list when you compare.

Attaching a stop of your network to a station

The attachment is what turns the index above into service data: it designates, for a stop of your GTFS, the station whose departures it displays, and it delimits the stations whose rail data is ingested for your network. Unlike the referential, this part is scoped to each group and carried by the paid rail_data feature: without it, the endpoints answer 403 with the code LOCKED_FEATURE.

GET /api/v4/groups/<group_id>/rail/station-links lists the group's attachments. Each link carries the stop (stop_id, with stop_name as its label), the station (uic) and an is_orphan indicator: true when the stop does not — or no longer does — exist in the group's currently published GTFS. An orphan link is never rejected nor deleted on its own: your stops are re-imported with every GTFS, and the link has to survive a re-import that momentarily drops its stop. Surfacing the indicator, then fixing or removing the link, is up to you.

Each link also carries an ingestion_status, which says where the station's rail data stands: ready when its departures are queryable, pending while the station is being ingested, not_found when no train of the current national feed serves it. Attaching a station that is not in the rail store yet answers immediately with pending and prepares its data asynchronously — usually a matter of minutes; poll the list until the link turns ready and show a "preparing" state rather than an empty departure board in the meantime. not_found is not an error: the link is kept, and the daily refresh re-evaluates it against each new SNCF feed.

Reading the departure board of an attached station

This is what the attachment is for: GET /api/v4/groups/<group_id>/rail/departures?stop_id=<stop_id> returns the trains leaving the stations attached to that stop of your network, theoretical times and realtime combined. Same paid rail_data feature as the attachments: without it, 403 LOCKED_FEATURE.

The stop is addressed by its stop_id in your GTFS — not by a UIC code. A stop attached to several stations returns the departures of all of them, in a single list.

The window

from and to are absolute instants, in ISO 8601 or as an epoch in seconds, like everywhere else in the API. Both bounds are included.

What you pass Window applied
nothing nownow + 1 h
from only fromfrom + 1 h
to only to - 1 hto
both as given, 24 h at most

A reversed window, or one wider than 24 h, is refused with a 400 INVALID_RAIL_DEPARTURES_WINDOW: past that you are asking for a timetable catalogue, not for a departure board.

The response

A list sorted by increasing scheduled_departure — the theoretical time, the one the window filters on, so the order does not shift as the estimates move.

Field Role
scheduled_departure The theoretical time. Always present
estimated_departure The time the realtime estimates, null in its absence
delay_seconds The signed difference between the two, negative for a train running early
status scheduled, on_time, delayed, early, canceled, skipped, added
destination What to display: stop_headsign, else trip_headsign, else the name of the train's last stop
route_type The mode, GTFS route_type
platform The platform, when the source publishes it
disruptions The realtime alerts covering this train, its route, the station or the whole network
service_date The service day (YYYYMMDD), which is not the calendar day for a train leaving after midnight

Two things to know:

  • status: scheduled means "no realtime", not "on time". When no realtime entity covers the train, the board is served on its theoretical times alone: estimated_departure and delay_seconds stay null and disruptions is empty. Missing realtime degrades the display, it never fails the call — and it happens train by train, not board by board.
  • No filtering by mode. Every mode calling at the station is returned, Car TER replacement coaches (route_type 3) included: they are part of the service the traveller has to see. Filter client-side on route_type if you only want trains.

platform is optional end to end: it comes from the platform_code of the source's GTFS, which the SNCF national feed does not publish. Expect null on SNCF data, and do not make it a mandatory column of your display.

A stop attached to no station answers 200 with an empty list — a configuration state, not an error. Same for a station whose data is not ingested yet: read ingestion_status on the attachment to tell "being prepared" from "no train at all".

Good practice

  • Cache what you read. The referential is static between two manual reloads; re-querying it on every user action buys nothing.
  • Key on the UIC code, not on the name. Names are edited by SNCF; codes are the stable identifier. source_ref traces a station back to its record in the source dataset, for auditing.
  • An unknown code answers 404, with the code RAIL_STATION_NOT_FOUND — distinguish it from an empty search, which is a 200 with total: 0.
  • Do not cache the departure board the way you cache the referential. The rail realtime feeds refresh roughly every two minutes: polling departures more often than that returns the same estimates.