Docs
A read-only JSON API for Sydney public transport. Base URL https://platform.platformtransit.com/v1. Every endpoint is GET.
Quick start
- Sign in with GitHub.
- Create an app, then a live key for it. Copy the key: it’s shown once.
- Ask for the next departures from Central:
curl -H "Authorization: Bearer $PLATFORM_KEY" \
"https://platform.platformtransit.com/v1/departures?stopId=200060&limit=5"
Find a stop id first with /v1/stops/search?query=Town%20Hall. Plan a trip with /v1/plan?originId=200060&destinationId=….
Keys
| Kind | Looks like | Send it as | Use it for |
|---|---|---|---|
| Live | plat_live_ + 32 characters | Authorization: Bearer only | Servers. Never ship it to a browser or app. |
| Publishable | plat_pub_ + 24 characters | Authorization: Bearer or ?key= | Web pages on the origins you list. The Origin (or Referer) must match exactly. |
A key in a URL (?key=) ends up in browser history, logs and Referer headers. Only publishable keys may be sent that way; a live key in a URL is refused with 401 LIVE_KEY_IN_QUERY.
Rotate a key from the Keys page: you get a new key and the old one keeps working for up to 7 days. Revoked keys stop within a few minutes.
Errors and limits
Per key: 20,000 requests and 500 trip plans each Sydney day, and 2 requests a second in bursts. Departures count as a plan only when modes includes bus. Every response carries RateLimit-Limit, RateLimit-Remaining, RateLimit-Reset (seconds) and RateLimit-Policy.
| Status | code | Meaning |
|---|---|---|
| 401 | KEY_REQUIRED, KEY_INVALID, KEY_REVOKED, LIVE_KEY_IN_QUERY | No key, an unknown or revoked key, or a live key in the URL. |
| 403 | ORIGIN_REQUIRED, ORIGIN_NOT_ALLOWED | A publishable key used from an origin it doesn’t list. |
| 404 / 405 | NOT_FOUND, METHOD_NOT_ALLOWED | Not one of the endpoints below, or not a GET. |
| 429 | KEY_RATE_LIMITED | Over a limit. scope says which: burst, daily, plan or global. Wait for Retry-After seconds. |
| 429 | PLANNER_BUDGET_EXHAUSTED | Today’s shared planning budget for outside apps is used up. Cached answers still work. |
| 429 / 503 | TFNSW_THROTTLED, TFNSW_QUOTA_UNAVAILABLE | Transport for NSW is pacing us. Honour Retry-After. |
Responses say where their data came from in source: tfnsw (live), tfnsw-static (timetable), tfnsw-stale with "stale": true (an earlier answer served while live data is paced) or sample (fixtures, never live). Optional liveDataStatus means some realtime data was skipped. Please show these states to your users rather than presenting old data as live.
Attribution
Wherever you show this data, show Data: Transport for NSW, via Platform near it. Every response carries the same text in X-Platform-Attribution. See the terms. A ready-made badge is on the badge builder.
Endpoint reference
Generated from the backend API reference (platform-backend API.md @ e805dfc302b9). Paths are shown as they are on this API.
Modes
Requests that accept a mode/modes parameter use these values: train, nswtrains (NSW TrainLink — folded into train in vehicle output), metro, ferry, lightRail, bus. Endpoints that default an omitted modes parameter use ["train", "metro"].
GET /v1/stops
Station-level stop catalogue for the requested modes.
Query parameters:
| Param | Required | Notes |
|---|---|---|
modes | no | Defaults to train,metro. Case-sensitive and strict: an unrecognised token (e.g. BUS, coach) makes the whole request 400 { "error": "unknown modes: <tokens>", "supportedModes": [...] } rather than silently substituting the default set. |
bbox | no | minLat,minLon,maxLat,maxLon. Only meaningful when modes includes bus. |
Bus stops are ingested and served, but are viewport-scoped: NSW has tens of thousands of them, so bus rows are emitted only when bbox is supplied and are withheld entirely otherwise (this is not an error — a shipped client asking for train,metro,lightRail,ferry,bus with no bounds still gets its rail rows). When bus rows are withheld, the response carries response headers X-Platform-Omitted-Modes: bus and X-Platform-Omitted-Reason: bus stops are viewport-scoped; supply bbox=minLat,minLon,maxLat,maxLon instead of an in-body flag (to stay decodable by strict client JSON decoders, and to keep "no bus stops in this real viewport" distinguishable from "bounds omitted").
Response: { "source": "tfnsw" | "sample", "stops": [ { ...station fields, "mode": "train" } ] }. Train and metro use their GTFS parent stations. Parentless light-rail platforms and ferry wharves are grouped into passenger-facing stops. Bus rows are individual stops and remain bounded by bbox as described above.
A complete non-sample response uses a content-derived weak ETag and Cache-Control: public, max-age=300, stale-while-revalidate=86400. A matching If-None-Match returns 304. If any requested source fails to load, the compatible partial 200 response remains no-store and cannot become a shared stale catalogue.
GET /v1/stops/search
Searches the backend's atomically published TfNSW static GTFS stops.txt catalogue. It does not call the live Trip Planner stop_finder endpoint and therefore does not consume one upstream request per search. Train, metro, light rail, Sydney ferry, and combined bus stops are indexed in memory after their static bundles load. Ranking is unchanged from earlier releases: an exact stop-id query ranks first, then rail/metro stations, then light rail/ferry, then bus stops, then text match quality (exact, prefix, word prefixes, substring), then name. Only when no stop matches at all does a typo fallback run (each token of at least five characters may be one edit from a name word), so centrl finds Central; those rows carry matchQuality: 70000.
Synthetic grouping IDs used only by the map catalogue are never returned. Grouped light-rail stops and ferry wharves use a real child stop ID that the existing /v1/plan endpoint accepts.
Response: { "source": "tfnsw-static" | "sample", "locations": [...] }. Each non-sample location preserves the rapidJSON-compatible fields consumed by existing clients: id, name, disassembledName, type, [lat, lon] coord, numeric TfNSW product-class modes, matchQuality, isBest, and isGlobalId. When the stop/route catalogue is built, rows also carry routes (up to six public codes, in mode order and busiest first), towards (the most frequent headsign), nullable locality/suburb, parentId, platform and routeCoverage: "scheduled-7-day". A cold catalogue never delays search; rows are then returned without these fields.
Errors: 503 with Retry-After: 30 when no complete static catalogue can be loaded. The route has no request-time TfNSW quota/breaker response and no query-result stale-fallback tier; last-known-good static GTFS publication is the fallback.
GET /v1/departures
Train rows may carry serviceMessages. One departure_mon call per station per 60 s (shared cache, errors cached too). Fails soft: no messages, board unchanged. Upstream budget: at most 1,440 extra Trip Planner calls per station per day, only for stations someone is viewing (no background polling), counted by the quota counter, and skipped (no messages) above 85% of the daily limit.
Station departure board: upcoming services from the per-day schedule index merged with realtime updates and live occupancy/predicted-load, for train, metro, light rail, and ferry (via schedule index) plus bus (via TfNSW's departure_mon Trip Planner endpoint, since bus stop_times.txt is deliberately not parsed).
Query parameters:
| Param | Required | Notes |
|---|---|---|
stopId | yes | 400 { "error": "stopId parameter required" }. Parent station ids are expanded to their platform-level children automatically. |
modes | no | Defaults to train,metro. Unrecognised tokens are dropped. |
limit | no | Defaults to 30, clamped to a max of 60. Non-numeric/≤0 falls back to the default. |
routes | no | Comma-separated exact GTFS routeIds and/or public line codes (T1, 333, IWL_1a); at most 20 distinct values of ≤64 characters, else 400. Only matching departures are returned. The filter runs before the limit cap (schedule boards) or on one wider departure_mon window of 200 events shared by every filtered request for that stop (bus boards). An unmatched filter returns an empty board. |
Response: { "source": "tfnsw" | "sample", "stopId": "...", "stopName": "...", "departures": [ ... ] }. Each departure: tripId, serviceDate, mode, line, lineName, headsign, destination, platform, scheduledTime, estimatedTime, delaySeconds, stopSequence, numberOfCars, liveTracking, cancelled, occupancyStatus, occupancySource ("live", "predicted", or "platformHistory" — a network-wide historical-average fallback used only when TfNSW supplied neither live nor predicted load), carriageOccupancies. Non-revenue ("Empty Train") runs are filtered out. Results across all requested modes are merged and re-sorted by effective departure time before the limit cap is applied. Optional top-level liveDataStatus: present when realtime data for this board was skipped because of TfNSW pacing, for example a bus board whose departure_mon call was refused by the daily cap (the board is then empty or holds whatever other modes returned, exactly as before), or a schedule board whose realtime feed or live vehicle read was refused. A missing serviceMessages list does not set it. See “Errors and limits” at the top of this page. A schedule-index row appears only when GTFS marks that exact stop sequence as a regular boarding call. Pass-through, drop-off-only, and conditional timing points remain available to internal schedule and realtime matching, but never appear as passenger departures. A cancellation cannot override this eligibility. Public trip timelines remove only calls where both allowsBoarding and allowsAlighting are explicitly false. They retain pickup-only, drop-off-only, cancelled, skipped, realtime-only, conditional, and unknown calls with their original stop sequences.
Additive route fields (also on sample rows). They come from the shared static stop/route catalogue; while that catalogue is cold, rebuilding or unavailable boards are served immediately with these fields null or derived from the departure itself.
| Field | Meaning |
|---|---|
routeId | Exact GTFS route ID (schedule boards: the trip's static route; bus boards: Trip Planner gtfsRouteId, else the single bus route with that public code serving the stop, else null). |
routeMode | Presentation mode: train, metro, lightRail, ferry, bus, coach (GTFS route types 200–299, NSW TrainLink 4T.C.*) or schoolBus (GTFS 712/713, Trip Planner class 11). mode is unchanged (feed mode), because existing clients rely on it. |
routeColor, routeTextColor | "#RRGGBB" from routes.txt, each independently null when missing/invalid or the route is unknown. No colour is invented from a mode. |
isSchool | true for GTFS 712/713 routes or Trip Planner product class 11. |
plannedPlatform | The timetabled platform/stand only when a different current platform is proven; otherwise null. Schedule boards compare the scheduled stop with the realtime stop (or assigned_stop_id) at the same stop_sequence; both must share a parent station and carry distinct platform_codes, and platform then holds the current one. Bus boards compare plannedPlatformName with platformName. |
When route-ID inference has no public line, line falls back to the exact GTFS route's route_short_name (NSW TrainLink 4T.T.* regional trains map their route_long_name to STH/WST/NRC/NRW).
GET /v1/vehicles
Live vehicle positions across one or more modes, enriched with line, headsign, occupancy, and (for trains) derived bearing/speed.
Supports an additive viewport / tile contract so a live map can poll only the visible region (plus padding) instead of the full basin fleet. Journey-only callers keep using tripIds= (exact trips; never requires a multi-tile pull).
Coordinate order (read this)
| Context | Order |
|---|---|
This API's bbox= query param | minLat,minLon,maxLat,maxLon |
coverage.bounds / coverage.requestedBounds | same: { minLat, minLon, maxLat, maxLon } |
| GeoJSON / many GIS "bbox" strings | minLon,minLat,maxLon,maxLat — not used here |
TfNSW rapidJSON vehicle coord | [lat, lon] array — not a bbox |
Query parameters
| Param | Required | Notes |
|---|---|---|
modes | no | Comma-separated list from the mode set above. Unknown tokens are dropped; if nothing recognisable survives, falls back to train,metro. |
busScope | no | busScope=railReplacement returns only rail-replacement bus services network-wide (bypassing the viewport gate below), even if modes didn't ask for buses. |
bbox | no | minLat,minLon,maxLat,maxLon. Legacy (default): when modes includes bus and neither viewport=1 nor tiles is set, only ordinary (non-rail-replacement) buses are clipped; other modes are unchanged. Malformed legacy boxes are treated as absent. Viewport mode: with viewport=1, the same bbox clips all requested modes (fail closed on invalid / oversized boxes — 400). |
viewport | no | viewport=1 (also true / bbox) opts into all-mode spatial filtering using bbox. Max span 2° on each axis. Empty viewport → empty vehicles (never basin fallback). |
tiles | no | Comma-separated tile ids v1/z/x/y (same zoom per request, max 16). When present, wins over bbox/viewport and clips all modes to the padded union of those tiles. Invalid tiles → 400 (never a silent full dump). |
pad | no | Optional pad fraction 0..0.5 applied to the requested bounds before filtering (default 0.125 = 12.5% per axis). Reduces pan/zoom thrash. |
updatedSince | no | ISO-8601. When every feedTimestamps entry is <= updatedSince, response is 200 with vehicles: [] and coverage.notModified: true (cheap poll). |
tripIds | no | Comma-separated trip ids (capped to first 12). When present, the response is filtered to just those trips — used by journey-tracking screens so they don't download the full fleet. Overrides bbox/tiles (exact trips are returned even if outside the last map viewport). Planner-shaped RealtimeTripId values are fail-closed resolved onto the live GTFS-RT trip id before filtering. |
Tile scheme v1 (WGS84 fixed grid)
- World: longitude
[-180, 180), latitude[-90, 90). - At zoom
z:2^zcolumns ×2^zrows. - Tile
v1/z/x/ycovers:- lon:
[-180 + x*(360/2^z), -180 + (x+1)*(360/2^z)) - lat:
[-90 + y*(180/2^z), -90 + (y+1)*(180/2^z))
- lon:
- Zoom range accepted:
0..16. Sydney CBD (~0.05°) is typically 1–2 tiles atz=12. - Server pads the union of requested tiles by
pad(default 12.5%) so small pans do not miss edge vehicles.
Modes and spatial filtering
When tiles or viewport=1 is present, spatial filtering applies to every fetched mode in the request surface: train, metro, bus, ferry, lightRail, and nswtrains (folded into mode:"train" / network:"nswtrains" as today). A viewport request that includes train also fetches nswtrains so regional services inside the region are not silently omitted while coverage.complete is true.
Old clients that omit tiles / viewport keep current behaviour (basin fleet for rail modes; bus-only legacy bbox when buses are requested).
Response
{
"source": "tfnsw", // or "sample"
"vehicles": [ /* see below */ ],
"failedModes": ["ferry"], // modes whose feed fetch failed and had no cached fallback
"staleModes": ["bus"], // modes whose fetch failed and were served from a last-known-good
// snapshot, PLUS modes the poller has not refreshed within three
// poll intervals. The second case is judged by the poller's own
// fetch clock, not the GTFS-RT header (some feeds legitimately
// leave that minutes behind off-peak), and a mode with several
// physical feeds is judged by its oldest one.
"feedTimestamps": { "train": "2026-08-02T03:14:00.000Z", ... } // per-mode GTFS-RT feed header time (or fetch time)
// Present only when `tripIds` was supplied:
"unresolvedTripIds": ["24-T8.03082026.1.N.8.0851"] // requested ids that did not uniquely resolve
// Present only for tiles / viewport=1 requests (not for tripIds / basin):
"coverage": {
"kind": "bbox" | "tiles",
"scheme": "v1",
"complete": true, // false when any requested mode is in failedModes
"padFraction": 0.125,
"bounds": { "minLat": -33.9, "minLon": 151.1, "maxLat": -33.8, "maxLon": 151.3 },
"requestedBounds": { "minLat": -33.88, "minLon": 151.15, "maxLat": -33.82, "maxLon": 151.25 },
"tileIds": ["v1/12/1501/640"], // tiles kind only
"modes": ["train", "metro", "nswtrains"],
"vehicleCount": 42,
"notModified": false,
"incompleteReasons": ["failedModes: ferry"], // only when complete=false
"staleModes": ["bus"] // echoed when present
}
}
liveDataStatus (optional): present when a requested feed or its live-tracking annotation was skipped because of TfNSW pacing, or when a mode is in failedModes/staleModes while the server is throttled. See “Errors and limits” at the top of this page. When every requested feed was skipped because of pacing and no last-known-good snapshot exists, the response is the throttle response (429/503 with code, retryAfterSeconds and Retry-After) instead of 500. Any other total failure is still 500.
Conditional GET: viewport/tile responses include an ETag derived from feedTimestamps + the spatial key. Matching If-None-Match yields 304. A response carrying liveDataStatus has a different ETag from one without it, so a client never keeps a body that missed the change.
Each vehicle object includes (non-exhaustive; fields vary by mode/data availability): id, tripId, routeId, startDate, startTime, tripHeadsign, vehicleLabel, runNumber, setType, setTypeName, numberOfCars, isNonTimetabled, carriageIds, occupancyByCarriage, latitude, longitude, bearing, speedKmh, stopId, nextStopName, nextStopPlatform, liveOccupancy, predictedOccupancy, currentStopSequence, currentStatus, timestamp, updatedAt, mode, line, lineName, routeColor, routeTextColor, routeLongName, isRailReplacement, isRevenue, operationalHeadsign, revenueSource, serviceDate, scheduledStartTime, scheduledEndTime, notYetStarted (present only when true — a train parked at its origin more than 3 minutes ahead of its scheduled departure), liveTracking. Honesty fields are preserved under viewport filtering — the server never invents vehicles.
Size / rate limits
| Limit | Value |
|---|---|
| Max tiles / request | 16 |
Max viewport=1 bbox span | 2° lat and 2° lon |
tripIds cap | 12 (unchanged) |
| Default pad | 12.5% per axis |
Invalid tiles / viewport=1 bbox / oversized viewport → 400 with { "error": "..." }. Never a silent full-basin dump.
Errors: an all-modes upstream failure surfaces as 500 (Error: All requested vehicle feeds failed: ...) via the top-level catch.
GET /v1/alerts
All current TfNSW service alerts (network-wide, GTFS_MODE feed), enriched with resolved public line codes and filtered to alerts whose active period hasn't already ended. tripIds lists the exact trips named by informed_entity.trip (usually empty; clients fall back to line and stop matching).
Response: { "source": "tfnsw" | "sample", "alerts": [ { id, header, description, cause, effect, severity, routeIds, stopIds, tripIds, activePeriods, lines, affectedRoutes, summary } ] }. lines is the list of app-facing line codes (e.g. T1, L2) the alert's routeIds resolved to, across train/metro/lightRail/ferry, with a narrow bus fallback gated on the real TfNSW bus route-id shape.
affectedRoutes is an additive list of { code, routeId, mode, routeColor, routeTextColor } badges, one per distinct informed route ID in source order, resolved through the same static route catalogue as departures. mode here is the presentation mode (it can be coach or schoolBus). Unknown IDs keep code = the original ID with null mode and colours; public bus codes resolve to mode: "bus". Stop-only alerts have affectedRoutes: []. Existing lines and filtering are unchanged.
GET /v1/shapes
Returns polyline geometry for the requested line codes (e.g. T1, M1, L1), merged from train/metro/light-rail/ferry static-GTFS-derived shapes, demand-loaded bus shapes, and hand-drawn overrides, with AnyTrip-style lane offsets so lines sharing a corridor render as separated ribbons.
Query parameters:
lines(required, comma-separated line codes;400 { "error": "lines parameter required" }if empty).fields(optional, opt-in). The only accepted value ispaths. Withfields=pathsevery line object is exactly{ "mode", "paths" }:basesandlaneSlotsare left out (about half the body).pathsandmodeare identical to the default response. Omitfields(or send it empty) for the full structure; any other value returns400 { "error": "fields must be \"paths\" when present" }. Clients that do not sendfieldsget exactly the structure below, unchanged. Use it only if the client does not draw lane re-spacing frombases/laneSlots(Android). It is a separate URL, so it is a separate edge cache entry and ETag.
Response: { "lines": { "<LINE>": { "mode": "train"|"metro"|"lightRail"|"ferry"|"bus"|"custom", "paths": [[[lat, lon], ...], ...], "bases"?: [...], "laneSlots"?: [...] } }, "unavailable": ["<LINE>", ...] }. paths holds one polyline per drawn piece of the line (trunk, branch tails, and for T1 one set per GTFS route family: North Shore NSN and Western WST, which TfNSW splits at Central). Clients must draw every entry; the count varies by service day (up to 6). bases/laneSlots are present only for lane-offset-eligible (non-override, non-bus) shapes, so the app can re-space ribbons in screen points. They are vertex-aligned with each other.
Every coordinate in paths and bases is rounded to 5 decimal places (about 1 m). Vertex counts and order are not changed by the rounding.
Encoding: the body is served as br when Accept-Encoding allows it (ties go to br, then gzip), otherwise gzip, otherwise identity, with Vary: Accept-Encoding. Compressed bodies are memoised per encoding and ETag, and a busy server queues compression rather than sending the uncompressed body. Cloudflare asks the origin for br, gzip and converts for clients that only accept gzip.
In sample mode, always returns { "lines": {}, "unavailable": <requested lines> }.
GET /v1/plan
Proxies TfNSW's Trip Planner trip (rapidJSON) endpoint and additionally resolves each leg's realtime trip id to a canonical static trip id, adding a T8-branch label where applicable.
Query parameters:
| Param | Required | Notes |
|---|---|---|
originId / destinationId | one location per end | Stop-ID form. Provide exactly one stop ID or coordinate per end; both, neither or two coordinate forms for one end return 400 { "code": "INVALID_LOCATION" }. |
originCoord / destinationCoord | alternative | <lat>,<lon>, latitude first. Must lie inside NSW (lat −37.6…−28.0, lon 140.9…153.7, which includes the ACT) or 400 { "code": "OUTSIDE_NSW" }. Rounded to 4 decimal places (about 11 m) on arrival. destCoord is the original spelling of destinationCoord and still works. |
originLat + originLon / destinationLat + destinationLon | alternative | Both values required. Same bounds and rounding. Do not combine with another location form for the same end. |
date | no | Defaults to current Sydney date. Must be yyyyMMdd, valid calendar date, or 400 { "error": "date must be yyyyMMdd and time must be HHmm" }. |
time | no | Defaults to current Sydney time. Must be HHmm, 00–23 / 00–59, same 400 as above if invalid. |
depArrMacro | no | Normalized to "dep" (default) or "arr"; any other value silently becomes "dep". |
calcNumberOfTrips | no | Defaults to 6. Must be a positive integer; clamped down to a max of 8 (never widened). Non-numeric/non-positive values are 400 { "error": "calcNumberOfTrips must be a positive integer" }. |
Response: { "source": "tfnsw" | "sample", "journeys": [...], "fareResult": ... | null }.
Caching: keyed by (origin, destination, depArrMacro, date, time-bucketed to 2 minutes, calcNumberOfTrips), where a stop-ID end is its ID and a coordinate end is its rounded coordinate (see below) — the trip-count value is part of the cache key, so an 8-result request never reuses a 6-result window. TTL is 2 minutes normally, 10 minutes when the server is throttled.
Errors: 502 { "error": "<upstream error message>" } on a genuine Trip Planner failure. A pacing/backoff condition instead answers 429/503 with code: "TFNSW_THROTTLED" (or "TFNSW_QUOTA_UNAVAILABLE") and a Retry-After header, unless a stale plan for this exact journey is still within its 30min fallback window, in which case that is served instead as a 200 { "source": "tfnsw-stale", "stale": true, ... } — see “Errors and limits” at the top of this page.
Coordinate plans support either or both ends. When both ends are coordinates and they are less than 100 m apart the reply is 400 { "error": "...", "code": "TOO_CLOSE" } and nothing is sent upstream. The backend sends the rounded coordinates upstream as type_origin=coord&name_origin=<lon>:<lat>:EPSG:4326 (longitude first; the same for the destination) and preserves upstream walking detail: the first or last leg is a walk (product class 100) to or from the coordinate.