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.
curl --fail --location --get \
--data-urlencode "q=Châteauroux" \
"https://api.pysae.com/api/v4/rail-stations"
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/87271007andGET /api/v4/rail-stations/87271031both return Paris Gare du Nord. - A code belongs to exactly one station. No UIC code is shared, so resolving one is never ambiguous.
segments_drgis a list for the same reason. The DRG frequentation segment (Abusiest toC) 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.
Writing is reserved to the group's admin role:
PUT /api/v4/groups/<group_id>/rail/station-linkscreates the(stop_id, uic)link or, when it already exists, refreshes itsstop_name— the call is idempotent, re-submitting it never creates a duplicate. Thestop_idis not validated (seeis_orphanabove); the UIC code, however, must belong to the referential, otherwise the answer is a 404RAIL_STATION_NOT_FOUND.DELETE /api/v4/groups/<group_id>/rail/station-links/<stop_id>/<uic>removes the link; on a link that does not exist, the answer is a 404RAIL_STATION_LINK_NOT_FOUND.
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 | now → now + 1 h |
from only |
from → from + 1 h |
to only |
to - 1 h → to |
| 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: scheduledmeans "no realtime", not "on time". When no realtime entity covers the train, the board is served on its theoretical times alone:estimated_departureanddelay_secondsstaynullanddisruptionsis 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 TERreplacement coaches (route_type3) included: they are part of the service the traveller has to see. Filter client-side onroute_typeif 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".
By-UIC variant — internal
GET /api/v4/groups/<group_id>/rail/departures/<uic> serves the same board, addressed by station instead of by stop. It is a support and debugging utility, absent from the public OpenAPI specification: no integration should key on a UIC code, which identifies the referential and not your network.
The scope stays that of the calling group: a UIC code another group attached but this one did not answers 200 with an empty list, never the other group's data.
Plugging in a group-specific rail source¶
By default, a group carrying the rail_data feature reads the SNCF national source, ingested once for the whole platform. The rail_data integration overlays a client's own GTFS source: its data is ingested under a group-specific source identifier (<group_id>:rail_data), filtered on the stations this group attached, and takes precedence over the national source on collisions. Without a declared integration the group reads the national source alone; declaring one adds the overlay, it never replaces anything for other groups.
At read time, the departure board consolidates both sources in this order: the national source first, the group's own on top. A collision resolves by replacement, never field by field — the client's data stands for the whole of the homonymous SNCF entity, and a field its feed does not carry means "absent", not "inherited from SNCF". The natural collision keys:
| Entity | Key |
|---|---|
| station | codes_uic |
| route | route_id |
| call — one board row | (trip_id, service_date, uic) |
| stop | stop_id |
| realtime entity (TripUpdate, alert) | entity_id of the FeedEntity |
The call's key carries the station, not just the trip: a train serving two stations attached to the same stop yields one row per station, not a single one. It is the uic that sits in the key rather than the stop_id, because that is the station identity the two sources share — so your overlay does replace the homonymous SNCF call even when the two feeds name the platform differently.
The source_id of each board row says which of the two sources produced it. This is deliberately the opposite of the v5 multi-GTFS merger (merge_trips_to_v4_schemas), which unions the lists and sums the aggregates: here we override, hence a dedicated read path.
Configuration goes through the integration endpoints, reserved to the group's admin role:
PUT /api/v3/groups/<group_id>/integrations/rail_datacreates or replaces the configuration;GET /api/v3/groups/<group_id>/integrations/rail_datareads it back —api_keyis always masked;DELETE /api/v3/groups/<group_id>/integrations/rail_dataremoves it and immediately purges the already-ingested data of this source; the group falls back to the national source alone.
The fields:
| Field | Role |
|---|---|
dataset_url |
The GTFS source: either a transport.data.gouv.fr dataset (page or API URL — the zip is then resolved through the datasets API, like the national source) or a direct GTFS zip URL |
gtfs_rt_trip_updates_url |
Optional — GTFS-RT TripUpdates feed of the source |
gtfs_rt_service_alerts_url |
Optional — GTFS-RT ServiceAlerts feed of the source |
api_key |
Optional — sent as Authorization: Bearer on every request to the source |
enabled |
false suspends the source without losing its configuration; its data is purged on the next refresh |
Every URL must use https: an http URL or any other scheme is rejected at validation time, and the ingestion refuses to contact a non-public IP address.
A purely static source is accepted: both GTFS-RT URLs may stay empty. Data is refreshed by the rail store's daily batch; a direct zip is considered changed based on its ETag / Last-Modified headers, and re-ingested on every pass when the server exposes neither.
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_reftraces 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 withtotal: 0. - Do not cache the departure board the way you cache the referential. The rail realtime feeds refresh roughly every two minutes: polling
departuresmore often than that returns the same estimates.