train-api publishes, every day, which trains a rail-pass holder can take: MAX JEUNE and MAX SENIOR on SNCF, and Interrail on nine European operators. It is plain JSON on GitHub Pages, so anyone can use it, from a browser or a server, for free:
- No key, no sign-up, no rate limit of ours. All public data stays public.
- CORS is open. GitHub Pages answers every origin with
Access-Control-Allow-Origin: *, so a web page on any site canfetchit directly. - Stable. Everything under
/v1/follows the promise below. - Machine-readable.
openapi.yaml(OpenAPI 3.1, JSON Schema 2020-12) describes every file. Load it in Swagger UI, Postman or a code generator.
Base URL: https://offware-apps.github.io/train-api/v1
# What is published, how fresh, which passes per operator
curl -s https://offware-apps.github.io/train-api/v1/index.json
# Station ids for one operator
curl -s https://offware-apps.github.io/train-api/v1/sncf/stations.json
# Every MAX JEUNE train leaving Paris in the next ~30 days
curl -s https://offware-apps.github.io/train-api/v1/sncf/max-jeune/from/paris-intramuros.jsonReady-to-run search, no dependencies:
node examples/search.mjs sncf max-jeune paris-intramuros lyon-intramuros 2026-10-20
python3 examples/search.py renfe interrail madrid-puerta-de-atocha-almudena-grandes zaragoza-delicias 2026-10-20| Path | Content |
|---|---|
index.json |
build time (updatedAt), and per operator: source with its licence, dates covered, stationCount, and each pass with seatKnown, bookingUrl, note, trainCount |
<operator>/stations.json |
[{ "id": "paris-intramuros", "name": "PARIS (intramuros)" }], sorted by name |
<operator>/<pass>/from/<stationId>.json |
the trains bookable with the pass that leave one station (compact) |
<operator>/<pass>/all.json |
every train bookable with the pass (compact); several MB for the large feeds |
Operators today: sncf, renfe, nl, de, it, ch, no, fi, ie, with gb and
se added once their feeds' free accounts are set up. Passes:
max-jeune and max-senior (SNCF only), interrail (all). Read both lists from
index.json rather than hard-coding them: operators and passes get added.
A path that doesn't exist returns 404 with an HTML body. For from/<stationId>.json
that just means no train leaving that station is bookable with that pass in the window.
- Look the two stations up in
<operator>/stations.json(byidor byname). - Fetch
<operator>/<pass>/from/<originId>.json. - Expand it and keep the rows whose
destinationequals the destination'snameand whosedateis the day you want.
Each row is one train between two of its stops, so a train Paris → Lyon → Marseille shows up as Paris → Lyon, Paris → Marseille and Lyon → Marseille. Trips that change trains, or change operators, aren't joined.
Timetable files store each service once with a bitmask of the dates it runs on:
{
"v": 1,
"dates": ["2026-10-05", "2026-10-06", "2026-10-07"],
"stations": ["PARIS (intramuros)", "LYON (intramuros)"],
"axes": ["SUD EST"],
"trains": [[0, 1, "08:00", "10:00", "6601", 0, "5"]]
}A train is [originIdx, destinationIdx, depart, arrive, trainNo, axeIdx, dateMask]:
originIdx,destinationIdxpoint into this file'sstations;axeIdxinto itsaxes(the SNCF axe, or a train family such asAVE,ICE,Intercity),-1for none.dateMaskis hex. Hex digitjholds dates4jto4j+3of this file'sdates, low bit first. Above,"5"= binary0101: the train runs ondates[0]anddates[2].dates,stationsandaxesbelong to each file; don't reuse indexes across files.
Expand it to one row per train per date:
function decodeCompact(c) {
const rows = [];
for (const [o, d, depart, arrive, trainNo, axe, mask] of c.trains) {
for (let j = 0; j < mask.length; j++) {
const n = parseInt(mask[j], 16);
for (let b = 0; b < 4; b++) {
const date = c.dates[j * 4 + b];
if (!(n & (1 << b)) || date === undefined) continue;
rows.push({ date, origin: c.stations[o], destination: c.stations[d], depart, arrive, trainNo,
category: axe >= 0 ? c.axes[axe] : undefined });
}
}
}
return rows;
}def decode_compact(c):
rows = []
for o, d, depart, arrive, train_no, axe, mask in (t[:7] for t in c["trains"]):
for j, digit in enumerate(mask):
n = int(digit, 16)
for b in range(4):
i = j * 4 + b
if n & (1 << b) and i < len(c["dates"]):
rows.append({"date": c["dates"][i], "origin": c["stations"][o],
"destination": c["stations"][d], "depart": depart,
"arrive": arrive, "trainNo": train_no,
"category": c["axes"][axe] if axe >= 0 else None})
return rowsIn TypeScript, copy src/compact.ts: it has no imports.
- Times are
HH:MM, local time at the station. Anarriveearlier thandepartis on the next day.dateis the departure date fromorigin. - Station names are exactly as each operator publishes them, so the same city is spelled
differently by different operators (
BRUXELLES MIDI,Bruxelles-Midi…). Station ids are per operator and are made from the name, so a station renamed by its operator gets a new id. Read ids fromstations.jsoninstead of storing them for good. trainNois the train number, or the line name when a feed has none (Germany and Norway:ICE 1,F6).- Seats. For MAX passes (
seatKnown: true), a train is listed only when SNCF showed a free MAX seat when the feed was read, about once a day; seats can go between two builds. For Interrail (seatKnown: false), no open data publishes pass-holder seats: the train runs and takes the pass, but the reservation quota may be sold out. Never tell users a seat is free whenseatKnownis false. notecarries pass rules no timetable can check (MAX SENIOR's peak periods, Interrail's residence rule). Show it next to results.- Window. About 30 days ahead.
index.jsonlists the exactdatesper operator.
The open train format explains how each operator's feed is mapped and which trains are kept.
- The Publish API workflow rebuilds everything every day at 13:30 UTC, after SNCF's
midday refresh, and after every change merged to
main.updatedAtinindex.jsonis the build time. - GitHub Pages sends
ETag,Last-ModifiedandCache-Control: max-age=600, and gzips responses. Conditional requests (If-None-Match) are cheap. - Please cache: re-fetching more than once an hour gains nothing. For heavy use (a busy
backend, a bulk import), fetch
all.jsononce a day and serve your own copy, or build the API yourself (below). GitHub Pages has a soft limit of 100 GB of traffic a month for the whole site, shared by everyone.
The SNCF feed only shows today's MAX seats. A daily snapshot is kept on the public data
branch, for computing seat odds by weekday, time or days before travel:
https://raw.githubusercontent.com/offware-apps/train-api/data/archive/max/2026/2026-10-06.json.gz
Gzipped JSON, one file per day; format in archive.md. The archive is outside
/v1/ and its promise, though its format isn't expected to change.
The code is AGPL-3.0. The data keeps each source's licence. Every source below allows reuse, commercial use included, as long as you credit it where its licence asks. When you show or redistribute an operator's trains, credit it, for example in your app's footer or about page:
| Operator | Source | Licence | Credit line |
|---|---|---|---|
sncf |
SNCF Open Data, tgvmax dataset |
Licence Ouverte 2.0 | "SNCF Open Data", with the date of the data (updatedAt) |
renfe |
Renfe Data | CC BY 4.0 | "Renfe Data" |
nl |
OVapi, NDOV (Dutch national timetable) | CC0 | none required; "OVapi / NDOV" appreciated |
de |
DELFI e.V. via gtfs.de | CC BY 4.0 | "DELFI e.V. / gtfs.de" |
it |
Trenitalia via the Italian National Access Point, GTFS by Clément Desouche | CC BY 4.0 | "Trenitalia, Italian NAP; GTFS by Clément Desouche" |
ch |
opentransportdata.swiss | terms of use: free use, source cited | "opentransportdata.swiss" |
no |
Entur | NLOD 2.0 | "Contains data under the Norwegian licence for Open Government data (NLOD) distributed by Entur" |
fi |
Fintraffic, digitraffic.fi | CC BY 4.0 | "Fintraffic / digitraffic.fi" |
ie |
National Transport Authority | CC BY 4.0 | "National Transport Authority" |
se |
Samtrafiken via Trafiklab, GTFS Sverige 2 | CC0 | none required; "Samtrafiken / Trafiklab" appreciated |
gb |
Network Rail open data, SCHEDULE feed | Network Rail data feeds licence | "Contains information of Network Rail Infrastructure Limited" |
The source field in index.json repeats each source and licence, so an app can build its
credit line from it. A link back to this repository is welcome and never required. This
table is a summary: the source's own licence text is what applies.
Everything under /v1/ keeps working for the clients written against it:
- Additive changes only. New operators, passes, files, object fields and trailing elements in a compact train may appear at any time. Ignore what you don't know: unknown keys, and anything after the seventh element of a train.
- Never in v1: removing or renaming a path or field, changing a field's type or
meaning, or changing how
dateMaskis read. - A breaking change gets a new version at
/v2/./v1/then keeps being published, with data refreshed daily, for at least 6 months, and the change is announced in a GitHub issue labelledapiand in this file. - Not covered: which trains a source publishes, station names and ids (they follow the operator), an operator dropped because its source stops publishing or changes its licence, and a day's build failing (the previous day's files stay up).
The whole API is a build step, so you can run your own copy, on your own schedule:
git clone https://github.com/offware-apps/train-api && cd train-api
npm ci && npm run fetch && npm run build:api # writes public/v1/The national feeds need about 12 GB of memory (NODE_OPTIONS=--max-old-space-size=12288).
Serve public/ from any static host.
Open an issue on offware-apps/train-api for a bug, a wrong train, an operator to add, or a field you need.