Live UK train data, free, as typed JSON. Ask for a station's departures, plan a journey, find where a train is right now, or see today's delays and cancellations. It's all built from Network Rail's open feeds and National Rail's Darwin forecasts.
No sign-up, no API keys. Everything is a GET, and it works straight from a
browser (CORS is open).
For personal, non-commercial projects only. Hobby apps, home dashboards, learning and research are all welcome; anything commercial isn't. See the terms of use.
curl 'https://api.traini.ac/v1/departures/PAD?limit=5' # next trains from Paddington
curl 'https://api.traini.ac/v1/journey/PAD/RDG' # Paddington to Reading
curl 'https://api.traini.ac/v1/train?headcode=1A23' # where is 1A23?Writing TypeScript? Every answer has a published type. Copy
v1.d.ts, or generate your own from
openapi.v1.json (OpenAPI 3.1).
Using it with an AI? The same data is an MCP server.
In Claude Code it's one line:
claude mcp add --transport http trainiac https://api.traini.ac/mcp
Coming from /api? It's legacy and will go away.
MIGRATING.md maps every field to its /v1 home.
Base URL: https://api.traini.ac. GET /v1 lists everything as JSON.
| Ask for | Endpoint |
|---|---|
| The departure board at a station | /v1/departures/{station} |
| Trains from A to B, with changes | /v1/journey/{from}/{to} |
| Where one train is right now | /v1/train?headcode=… |
| A station's code from its name | /v1/stations?q=… |
| What actually ran at a station in the last 3 hours | /v1/stations/{station}/activity |
| The worst delays right now | /v1/delays |
| Today's cancellations, with reasons | /v1/cancellations |
| Today's headline numbers | /v1/summary |
| Whether the data is fresh right now | /v1/status |
- Switch on
type. Every answer, and every value that can be one of several things, says which it is in atypefield. TypeScript narrows on it. - Every key is always there.
nullmeans the thing doesn't exist (a train that hasn't been given an id yet), never "we don't know": not knowing is its owntype, like{"type": "no_information"}. - Stations can be written however you like: a code (
PAD), a name (London Paddington, percent-encoded in a path) or a group (London). Answers always use proper names: London Paddington, not PADDINGTON LONDON. - Times are UK time, with the offset, like
2026-10-05T14:26:00+01:00. When you send a time, useHH:MM. Dates areYYYY-MM-DD. Delays are in minutes, and negative means early.
type Answer<Request, Data> =
| { type: "ok"; request: Request; data: Data; generated_at: string } // HTTP 200
| { type: "error"; error: ApiError }; // HTTP 4xx/5xxrequest is what we took your arguments to mean, with defaults filled in.
Check it: a name becomes one specific station (or a group), and this is
where you see which.
"request": {
"station": { "query": "London", "resolution": { "type": "group", "name": "London Terminals",
"stations": [ { "crs": "PAD", "name": "London Paddington" }, { "crs": "KGX", "name": "London Kings Cross" }, … ] } },
"calling_at": null, "from_time": null, "to_time": null, "limit": 15
}data can be an empty list. Empty means "no trains". A station we couldn't
work out is an error, never an empty list.
These appear all over. The full set, with every endpoint's request and
data, is v1.d.ts.
type Station = { crs: string; name: string };
/** Somewhere a train is or calls. Junctions and sidings have no code. */
type Place =
| { type: "station"; crs: string; name: string }
| { type: "location"; name: string };
type Operator = { code: string; name: string | null }; // { "code": "GW", "name": "Great Western Railway" }
type ServiceClass = "passenger" | "bus" | "freight" | "ship" | "trip" | "empty_stock" | "unknown";
/** A timetabled time and what is known about it. */
type Time = {
scheduled: string;
estimate: Estimate;
delay_minutes: number | null; // when the estimate has a time; negative is early
};
type Estimate =
| { type: "actual"; at: string } // it has happened
| { type: "forecast"; at: string; source: "darwin" | "live_report" } // expected then
| { type: "delayed_no_estimate" } // late, nobody can say how late
| { type: "no_information" } // nothing live yet: not "on time"
| { type: "cancelled" }; // won't happen
type Platform =
| { type: "known"; number: string; source: "actual" | "forecast" | "timetable" } // "4A" is a platform
| { type: "withheld" } // not to be shown yet
| { type: "unknown" };
type Train = {
id: string | null; // TRUST id, e.g. 671L50MK02. null until the train is activated
uid: string | null; // timetable uid, e.g. GW001
headcode: string; // e.g. 1L50. These repeat across the country
operator: Operator | null;
};A forecast from darwin is National Rail's prediction, the best there is
before a train leaves. One from live_report is the delay the train last
reported, carried forward. A timetable platform is planned only, and
often wrong at big stations.
Anywhere you give a station (in a path, or as calling_at) you can use:
- a code:
PAD,KGX,CDU - a name, in any word order:
London Paddington,paddington,Cam & Dursley(in a path:Cam%20%26%20Dursley) - a group:
London,Birmingham,Manchester,Glasgow,Edinburgh
Exact codes win, then exact names, then the busiest match, so Paddington
means the main station rather than the Elizabeth line platforms. If a name
really is ambiguous (bradford), you get a station_ambiguous error with
candidates instead of a guess.
type StationArgument = { query: string; resolution: Resolution };
type Resolution =
| { type: "station"; crs: string; name: string }
| { type: "group"; name: string; stations: Station[] };GET /v1/departures/{station}. The live departure board. 3 units.
| Parameter | Default | Meaning |
|---|---|---|
limit |
15 | 1–100 |
from_time |
now | Only trains leaving at or after this time |
to_time |
— | Only trains leaving at or before this time |
calling_at |
— | Only trains that stop here later on |
Late trains stay on the board until their expected time passes.
type Departure = {
train: Train;
service_class: ServiceClass;
origin: Place;
destination: Place;
departs: Time; // from the station you asked about
platform: Platform;
calling_points: { station: Place; time: Time; platform: Platform }[];
destination_arrival: string | null; // booked arrival at its destination
coaches: number | null; // from Darwin, when it says
formation_changes: ({ type: "divides"; at: Place } | { type: "joins"; at: Place })[];
formed_from: { headcode: string; origin: Place; expected_arrival: string | null } | null;
delay_reason: string | null;
advertised: boolean; // false: Darwin asks for this stop to be kept off boards
};Example
curl 'https://api.traini.ac/v1/departures/Stroud?limit=1'{
"type": "ok",
"request": {
"station": { "query": "Stroud", "resolution": { "type": "station", "crs": "STD", "name": "Stroud" } },
"calling_at": null, "from_time": null, "to_time": null, "limit": 1
},
"data": [
{
"train": { "id": "671L50MK02", "uid": "GW001", "headcode": "1L50",
"operator": { "code": "GW", "name": "Great Western Railway" } },
"service_class": "passenger",
"origin": { "type": "station", "crs": "GCR", "name": "Gloucester" },
"destination": { "type": "station", "crs": "PAD", "name": "London Paddington" },
"departs": {
"scheduled": "2026-10-05T13:00:00+01:00",
"estimate": { "type": "forecast", "at": "2026-10-05T13:05:00+01:00", "source": "darwin" },
"delay_minutes": 5
},
"platform": { "type": "known", "number": "1", "source": "forecast" },
"calling_points": [
{ "station": { "type": "station", "crs": "KEM", "name": "Kemble" },
"time": { "scheduled": "2026-10-05T13:13:00+01:00", "estimate": { "type": "no_information" }, "delay_minutes": null },
"platform": { "type": "unknown" } },
…
],
"destination_arrival": "2026-10-05T14:26:00+01:00",
"coaches": null,
"formation_changes": [],
"formed_from": null,
"delay_reason": null,
"advertised": true
}
],
"generated_at": "2026-10-05T12:00:00+01:00"
}GET /v1/journey/{from}/{to}. Today's trains between two stations,
including ones with changes, in departure order. Connections are checked
against live running. 5 units, or 2 with max_changes=0.
| Parameter | Default | Meaning |
|---|---|---|
limit |
10 | 1–100 options |
max_changes |
1 | 0–8. 0 means direct trains only |
min_interchange_min |
5 | 1–60. The least time to allow for a change |
from_time / to_time |
now / — | When to leave, UK time |
type Journey =
| { type: "direct"; departs: Time; arrives: Time; duration_minutes: number; leg: Leg }
| { type: "with_changes"; departs: Time; arrives: Time; duration_minutes: number;
legs: Leg[]; connections: Connection[] }; // connections[i] joins legs[i] to legs[i + 1]
type Leg = {
train: Train;
service_class: ServiceClass;
from: Place | null; // null only when you asked from a group and we can't say which station
departs: Time;
departure_platform: Platform;
to: Place;
arrives: Time;
arrival_platform: Platform;
coaches: number | null;
};
type Connection = {
at: Place;
minutes: number; // timetabled time to change
outlook: "safe" | "at_risk" | "unknown"; // at_risk: live running eats into the change
};Example
{
"type": "direct",
"departs": { "scheduled": "2026-10-05T13:00:00+01:00",
"estimate": { "type": "forecast", "at": "2026-10-05T13:05:00+01:00", "source": "darwin" },
"delay_minutes": 5 },
"arrives": { "scheduled": "2026-10-05T14:26:00+01:00",
"estimate": { "type": "forecast", "at": "2026-10-05T14:30:00+01:00", "source": "darwin" },
"delay_minutes": 4 },
"duration_minutes": 86,
"leg": {
"train": { "id": "671L50MK02", "uid": "GW001", "headcode": "1L50",
"operator": { "code": "GW", "name": "Great Western Railway" } },
"service_class": "passenger",
"from": { "type": "station", "crs": "STD", "name": "Stroud" },
"departs": { … },
"departure_platform": { "type": "known", "number": "1", "source": "forecast" },
"to": { "type": "station", "crs": "PAD", "name": "London Paddington" },
"arrives": { … },
"arrival_platform": { "type": "unknown" },
"coaches": null
}
}A journey with changes has legs and connections instead of leg:
"connections": [ { "at": { "type": "station", "crs": "SWI", "name": "Swindon" }, "minutes": 5, "outlook": "at_risk" } ]GET /v1/train?headcode=1A23 or GET /v1/train?train_id=171A23MN16.
Where one train is right now. 1 unit.
| Parameter | Meaning |
|---|---|
headcode |
The four-character train number, e.g. 1A23 |
train_id |
The exact id from a departure or journey. Never ambiguous |
operator, origin, destination |
Narrow a headcode down |
date |
YYYY-MM-DD for a past run. Defaults to the last 12 hours |
You need headcode or train_id. Headcodes repeat, so a headcode alone
can return up to ten trains, most recently seen first.
type TrainRun = {
train: Train;
run_date: string;
origin: Place | null; // null when no timetable matched (some freight)
destination: Place | null;
progress:
| { type: "not_started"; activated: boolean; departs: string; arrives: string | null }
| { type: "running"; last_report: Report }
| { type: "finished"; last_report: Report };
};
type Report = {
location: Place | null;
at: string;
event: "arrival" | "departure"; // a train passing through is reported as a departure
delay_minutes: number;
off_route: boolean;
platform: Platform;
};Example
{
"train": { "id": "671L50MK02", "uid": "GW001", "headcode": "1L50",
"operator": { "code": "GW", "name": "Great Western Railway" } },
"run_date": "2026-10-05",
"origin": { "type": "station", "crs": "GCR", "name": "Gloucester" },
"destination": { "type": "station", "crs": "PAD", "name": "London Paddington" },
"progress": {
"type": "running",
"last_report": {
"location": { "type": "station", "crs": "GCR", "name": "Gloucester" },
"at": "2026-10-05T11:55:00+01:00", "event": <
10BC8
span class="pl-pds">"departure", "delay_minutes": 3,
"off_route": false, "platform": { "type": "known", "number": "2", "source": "actual" }
}
}
}Nothing at all (not in today's timetable, not seen in the last 12 hours)
is a train_not_found error.
GET /v1/stations?q=paddington. Find a station's code. Words match in any
order, best match first. 1 unit.
type StationSearch = {
resolves_to: Resolution | null; // the group, alias or landmark your words name, if any
matches: StationMatch[];
near_misses: boolean; // true: nothing matched as written; these are the closest
};
type StationMatch =
| { type: "station"; crs: string; name: string; tiploc: string; stanox: string | null; calls_today: number }
| { type: "location"; kind: "depot" | "infrastructure" | "other"; name: string; crs: string | null;
tiploc: string; stanox: string | null; calls_today: number };Example
{
"resolves_to": { "type": "station", "crs": "PAD", "name": "London Paddington" },
"matches": [
{ "type": "station", "crs": "PAD", "name": "London Paddington", "tiploc": "PADTON", "stanox": "73000", "calls_today": 1804 },
{ "type": "location", "kind": "depot", "name": "Paddington New Yard", "crs": null, "tiploc": "PADTNNY", "stanox": "73104", "calls_today": 0 }
],
"near_misses": false
}stanox and tiploc are Network Rail's location codes, for joining with
other open data.
GET /v1/stations/{station}/activity?limit=15. What actually happened at a
station in the last three hours, newest first. 1 unit.
type Movement = {
train: { id: string; headcode: string };
location: Place;
event: "arrival" | "departure";
time: Time; // estimate is always { type: "actual" }
platform: Platform;
off_route: boolean;
};GET /v1/delays?min_minutes=10&limit=15&passenger_only=true. The most
delayed trains running right now, worst first. 1 unit.
type DelayedTrain = {
train: Train;
service_class: ServiceClass;
origin: Place | null;
destination: Place | null;
delay_minutes: number;
off_route: boolean;
last_seen: { location: Place | null; at: string };
};GET /v1/cancellations?limit=15&passenger_only=false. Today's
cancellations, newest first, with the reason in plain English. 1 unit.
type Cancellation = {
train: Train;
service_class: ServiceClass;
origin: Place | null; // null when it was cancelled before we knew its timetable
destination: Place | null;
cancelled:
| { type: "before_departure"; at: Place } // never left its origin
| { type: "part_way"; at: Place } // ran, then cancelled from here on
| { type: "not_needed"; at: Place } // an "on call" train that wasn't called on
| { type: "off_booked_path"; at: Place }; // cancelled while off its booked route
reason: { code: string; description: string; category: string; planned: boolean };
reported_at: string;
keyed_at: string; // when it went into the system: hours earlier for a planned one
};reason.planned is true for planned reductions (engineering work and the
like), which you may want to leave out of a "what went wrong today" view.
Example
{
"train": { "id": "671L60MK02", "uid": "GW007", "headcode": "1L60",
"operator": { "code": "GW", "name": "Great Western Railway" } },
"service_class": "passenger",
"origin": { "type": "station", "crs": "STD", "name": "Stroud" },
"destination": { "type": "station", "crs": "PAD", "name": "London Paddington" },
"cancelled": { "type": "not_needed", "at": { "type": "station", "crs": "GCR", "name": "Gloucester" } },
"reason": { "code": "YI", "description": "Late arrival of booked inward stock (…)", "category": "Reactionary", "planned": false },
"reported_at": "2026-10-05T11:30:00+01:00",
"keyed_at": "2026-10-05T10:30:00+01:00"
}GET /v1/summary. Today's headline numbers, as one object. 1 unit.
{
"service_date": "2026-10-05",
"scheduled": 38444,
"started": 1078,
"running_now": { "count": 31, "window_minutes": 30 },
"cancelled": { "total": 325, "planned": 186 },
"punctuality": {
"on_time_percent": 58.0,
"definition": "share of public calls today whose actual was at or before the timetabled time, so to the minute, not the PPM 5/10 threshold",
"average_lateness_minutes": 6.18
},
"movement_reports_today": 10131
}on_time_percent counts to the minute, so it looks much worse than the
official figures, which allow 5 or 10 minutes.
GET /v1/status. Whether the API is answering, how fresh each feed is, and
how many trains are running. Answered from a snapshot at most 15 seconds
old: 1 unit, never waits for the database.
type Status = {
health: "ok" | "degraded"; // ok only when every feed is fresh
feeds: { trust: Feed; td: Feed; darwin: Feed };
trains_running: { type: "counted"; count: number } | { type: "unknown" };
measured_at: string;
};
type Feed = {
stale_after_seconds: number;
freshness:
| { type: "fresh"; last_stored_seconds_ago: number }
| { type: "stale"; last_stored_seconds_ago: number }
| { type: "silent" } // nothing for hours
| { type: "unknown" }; // we couldn't check
};Errors are {"type": "error", "error": {…}}, and the HTTP status tells you
whose problem it is. Switch on error.type; message is for logs.
type ApiError = { message: string } & (
| { type: "station_not_found"; parameter: string; query: string; did_you_mean: Station[] } // 404
| { type: "station_ambiguous"; parameter: string; query: string; candidates: Station[] } // 400
| { type: "same_station" } // 400
| { type: "parameter_out_of_range"; parameter: string; value: number; min: number; max: number } // 400
| { type: "invalid_parameter"; parameter: string; value: string;
expected: "integer" | "boolean" | "time" | "date" | "headcode" } // 400
| { type: "missing_parameter"; parameters: string[] } // 400: give at least one of these
| { type: "train_not_found" } // 404
| { type: "not_found" } // 404: no such endpoint
| { type: "method_not_allowed" } // 405
| { type: "rate_limited"; retry_after_seconds: number } // 429
| { type: "database_busy"; retry_after_seconds: number } // 503
| { type: "database_unavailable" } // 503
| { type: "query_failed" } // 503
| { type: "status_unavailable"; retry_after_seconds: number } // 503
| { type: "internal_error" } // 500: our bug
);parameter is the name you used: station, from, to or calling_at
for stations.
GET /v1/departures/bradford → 400
{"type":"error","error":{"type":"station_ambiguous","parameter":"station","query":"bradford",
"candidates":[{"crs":"BDI","name":"Bradford Interchange"},{"crs":"BDQ","name":"Bradford Forster Square"}, …],
"message":"'bradford' (station) matches more than one station. Ask again with one of the candidates' CRS codes."}}
GET /v1/departures/PAD?from_time=25:99 → 400
{"type":"error","error":{"type":"invalid_parameter","parameter":"from_time","value":"25:99","expected":"time",
"message":"from_time must be a time, HH:MM or HHMM; got '25:99'."}}
import type { Answer, Estimate } from "./v1"; // v1.d.ts from this repo
const station = "PAD";
const res = await fetch(`https://api.traini.ac/v1/departures/${station}?limit=10`);
const body = (await res.json()) as Answer<"/v1/departures/{station}">;
if (body.type === "error") {
throw new Error(body.error.message);
}
for (const departure of body.data) {
const time = clock(departure.departs.scheduled);
const status = describe(departure.departs.estimate);
console.log(`${time} ${departure.destination.name} ${status}`);
}
/** "2026-10-05T14:05:00+01:00" → "14:05" */
function clock(iso: string): string {
return iso.slice(11, 16);
}
/** Every kind of estimate, so TypeScript tells you if a new one appears. */
function describe(estimate: Estimate): string {
switch (estimate.type) {
case "actual":
return `left ${clock(estimate.at)}`;
case "forecast":
return `expected ${clock(estimate.at)}`;
case "delayed_no_estimate":
return "delayed";
case "cancelled":
return "cancelled";
case "no_information":
return "";
}
}Percent-encode station names in paths (encodeURIComponent,
urllib.parse.quote). Three small examples you can run are in
examples/:
next-train.sh: curl and jq, the next train from A to Bdepartures.ts: a departure board in your terminal (Bun), typed withv1.d.tsjourney.py: plan a journey and warn about tight changes (Python, no dependencies)
The same data is a Model Context Protocol
server at https://api.traini.ac/mcp, so assistants like Claude can answer
"when's the next train to Reading?" with live data.
-
Claude (web, desktop and mobile): Settings → Connectors → Add custom connector, and paste
https://api.traini.ac/mcp. -
Claude Code:
claude mcp add --transport http trainiac https://api.traini.ac/mcp
-
Any other MCP client: add a remote server over Streamable HTTP at the same URL. There's no key and no sign-in.
The Trainiac API (everything under api.traini.ac, including the MCP
server) is free for personal, non-commercial projects: things you build
for yourself and your friends, hobby apps you don't charge for, home
dashboards, learning, research and open-source tinkering.
It is not for commercial use. Please don't:
- use it in a product or service that is sold, subscribed to, or carries ads, or in a business's own tools;
- resell, redistribute or mirror its answers, or build a public copy of Trainiac (or a bulk dataset) from them;
- try to get around the limits, for example by spreading requests across many addresses.
We can change the API, rate-limit or block any client, or withdraw access at any time, and it comes with no guarantee of being right, complete or up. Don't rely on it for anything safety-critical.
Building something commercial? The data underneath comes from Network Rail (datafeeds.networkrail.co.uk) and National Rail (opendata.nationalrail.co.uk), whose own licences cover commercial use: go to them directly. Not sure whether your project counts? Ask on Discord.
It's free, and the limits are the same for everyone. They count units of
work, not requests, across /v1, /api and /mcp together:
| Request | Units |
|---|---|
| Journey | 5 |
Journey with max_changes=0 (direct trains only) |
2 |
| Departures | 3 |
| Everything else | 1 |
- 120 units a minute per address (about 40 departure boards). Go over
and you get a 429
rate_limitedwithRetry-After. - 20 units running at once across everyone. If the database is busy, a
request waits up to two seconds, then gets a 503
database_busywithRetry-After: 1.
Every answer tells you where you stand, in headers a browser can read too:
X-Request-Units (what this cost), RateLimit-Limit, RateLimit-Remaining,
RateLimit-Reset and RateLimit-Policy (120;w=60).
Please honour Retry-After, cache what you can (a departure board doesn't
change faster than every 30 seconds or so), and send a User-Agent that
says who you are. Requests are logged (endpoint, timing, status, forwarded
IP, user agent) to keep the usage dashboards honest. Nothing else is kept.
The same endpoints were first served under /api, in the MCP tools' own
JSON ({generated_at, resolved, results, result_count}). That is legacy:
it still works, unchanged, but it will be removed once its last users have
moved to /v1. Don't build anything new on it. MIGRATING.md
maps every old field to its /v1 home.
Network Rail's open data feeds (TRUST movements, TD berth steps, VSTP, TSR, the CIF timetable and CORPUS location names) and National Rail's Darwin Push Port; station names from uk-railway-stations. Times are what those feeds said: nothing is smoothed or made up. Data © Network Rail Infrastructure Limited and Rail Delivery Group, used under their open data licences.