Rail — GTFS
A GTFS dataset for South Korean rail, served as an API. Every subway and commuter-rail stop, route and departure in the country — 11 operators normalised into one feed, with English station names. 1 credit per row; the entire timetable, all 655,961 departures, is about $65.
| Endpoint | Returns | Credits |
|---|---|---|
GET /transit/version | Dataset version and row counts | 0 |
GET /transit/operators | The 11 operators and their route counts | 0 |
GET /transit/calendar | Service patterns and dated exceptions | 0 |
GET /transit/stops | Stations, by name or all of them | 1 per row |
GET /transit/stops/{stop_id} | One station | 1 |
GET /transit/stops/{stop_id}/departures | Departures from a station, in time order | 1 per row |
GET /transit/routes | Routes, with their operator | 1 per row |
GET /transit/routes/{route_id}/trips | Trains on a route | 1 per row |
Authentication, paging, limits and error codes are the same everywhere — conventions.
It is GTFS, and that is checked by other people's tools
Entities, field names and conventions are GTFS: stops, routes,
trips, stop_times, calendar and
calendar_dates, with parent_station for interchanges and times that
run past 24:00. The feed was put through two independently written validators and a
routing engine when this dataset was built, in August 2026 — results below.
| Check | Result |
|---|---|
MobilityData gtfs-validator (Java) | 0 errors |
Interline transitland-lib (Go) | 0 errors, 0 warnings |
| OpenTripPlanner | Builds a graph and returns real itineraries, including transfers |
Those three are run by hand, not by the build. Every build does run our own checks — a round-trip against the source and a coverage comparison — but if you need a current validator report for a specific feed version, ask and we will run one.
Two validators rather than one because they are written by different people and
do not check the same things. transitland-lib is what found that interchange stations
were not linked, which would have made transfers impossible.
Korea publishes this data, but each operator publishes it differently — different headers, different separators, different ways of writing "weekday". A nationwide Korean GTFS does exist: the Korea Transport Institute released one in 2023, built from March 2021 timetables as a pilot, obtained by application, and described by its own publisher as internal analysis material whose ids are not standardised. What has not existed is a current feed you can fetch without asking, with stable ids and English names. That is what this is.
Three calls are free
Services that use this data pull it once and serve from their own store. That only works if you can tell whether anything changed — otherwise you either re-pull blindly or go stale. So checking costs nothing.
curl -H "Authorization: Bearer $KCID_API_KEY" "https://notonlystock.com/korea/api/v0/transit/version"
{
"stops": { "version": "2026.08", "rows": 1120 },
"routes": { "version": "2026.08", "rows": 32 },
"trips": { "version": "2026.08", "rows": 23384 },
"stop_times": { "version": "2026.08", "rows": 655961 }
}
GET /transit/operators is also free — it lists the 11 agencies and how many
routes each runs, so you can see what is in here before spending anything.
GET /transit/calendar is free too, and you need it: it returns the
service patterns and every dated exception. A timetable that cannot tell you which days a train
runs is not a timetable, so this is catalogue, not product.
Everything else is 1 credit per row
Same shape as /companies/search: cursor paging and
you are charged for rows returned, never for the call. An empty page costs
nothing. limit defaults to 100 and goes up to 1,000 — note that is
ten times the company-search default, so a call with no limit bills for 100 rows.
curl -H "Authorization: Bearer $KCID_API_KEY" "https://notonlystock.com/korea/api/v0/transit/stops?limit=2"
{
"rows": [
{ "stop_id": "…", "stop_name": "Yongsan", "stop_name_ko": "용산역",
"stop_lat": 37.52989, "stop_lon": 126.964775, "route_name": "경부선",
"location_type": 0, "parent_station": "…" }
],
"next_cursor": "2",
"credits_charged": 2,
"credits_remaining": 4998
}
| Endpoint | Returns |
|---|---|
/transit/stops | Every stop, with WGS84 coordinates |
/transit/stops/{id} | One stop, as a one-row page — same envelope, rows holds one element |
/transit/stops/{id}/departures | Every departure from that stop, in time order |
/transit/routes | Every route, with its operator |
/transit/routes/{id}/trips | Every train on that route |
Pulling 655,961 rows means 656 calls, and that is the intended use. Paid calls are not rate-limited — your credit balance is the limit.
Errors
| Status | Code | Means |
|---|---|---|
| 400 | bad_cursor | Not a value we returned as next_cursor |
| 402 | insufficient_credits | Says how many the page needed. Lower limit |
| 404 | not_found | No such stop, route or trip |
| 503 | loading | A new timetable is mid-load |
While a timetable loads, every paid /transit/* call returns
503 loading and the three free calls keep answering — so a service that polls
/transit/version sees the switch instead of half-loaded rows. Nothing is charged.
Every error code →
Public holidays are real dates, not weekday flags
Korean operators publish separate weekday, Saturday and holiday timetables — and they label them inconsistently. One line writes 공휴일, the line next to it writes 일요일 for the same thing; four operators publish no Saturday timetable at all because their holiday timetable covers the whole weekend. Copied literally, that produces a feed where Seoul's subway does not run on Sundays and Korail does not run on Saturdays.
So the weekday flags are reconciled per operator, and every public holiday is written out as an
actual date in calendar_dates.txt — including the ones that fall midweek, when the
weekday timetable stops and the holiday timetable starts. Lunar New Year and Chuseok are handled
separately again, because at least one operator publishes a distinct timetable for those.
The holiday list is a reviewed table, not a computed one, and it covers 2026. Two published holiday calendars were cross-checked to build it — both of them were wrong: both omitted the 2026 local-election day, which is a statutory holiday, and one included Constitution Day, which has not been one since 2008.
Three name fields, because display and matching are different jobs
The source is not consistent about station names in English. Some carry a parenthesised
subtitle and some do not; the space before the bracket appears in 72 names
and is missing in 26 others, in the same file. Mokdong and
Mok-dong are harmless to a reader and fatal to a join.
| Field | Is |
|---|---|
stop_name | As published |
stop_name_short | Subtitle removed, spacing normalised — for display |
stop_name_norm | Subtitle, spaces, hyphens and case stripped — for joining |
q matches the normalised key too, so you can send a name in whichever form you
already have it and still land on the right station.
GTFS conventions you should know about
Times are HH:MM:SS and may exceed 24 hours: a train arriving at 1am
the next day reads 25:00:00. That is the GTFS rule, and writing it as
01:00:00 would move that train to the start of the day.
A station served by two lines appears once per line, joined by a shared
parent_station — ask a parent station for departures and you get every line's.
What is not in here
Trains whose source timetable is internally contradictory are left out rather than guessed at, and so are stops whose published coordinates are provably wrong. Holiday-only services cannot be expressed without a Korean holiday calendar, which the sources do not publish. The published row counts above are what actually shipped, not what the sources claimed.
Missing an endpoint or a field? Say what you were trying to do — it is read by the person who builds this.