Skip to content

Transit Routing

Set mode to transit and Plaza plans a trip the way a rider takes it: walk to a stop, ride, maybe change, walk to the door. Times come from the agency’s published GTFS timetable, so a 07:58 departure means you board the train that leaves at 07:58, not “about 20 minutes by transit”.

Transit mode works on Directions and Isochrones. Both are premium endpoints, so each request counts as 4x.

Transit routing only knows the GTFS feeds Plaza has loaded. Each feed is a Plaza-curated dataset named gtfs-<agency> (for example gtfs-mbta), so you can see which ones exist:

Terminal window
curl "https://plaza.fyi/api/v1/datasets?scope=plaza&include_pending=true" \
-H "x-api-key: $PLAZA_API_KEY"

A feed that is listed but still pending has not been loaded yet, and requests in its area find no stops. Feeds are loaded and refreshed by hand for now, so a schedule change shows up after Plaza re-syncs the feed.

A journey can change between two agencies’ feeds where their stops are a short walk apart: up to 500 m in a straight line and 1 km on foot, measured when a feed is loaded. Plaza searches the feeds with stops near your origin or destination, plus any feed that connects to feeds at both ends over those walks. So a bus from one agency to a regional rail station run by another works, and so does a ride through a third network in the middle. Feeds in different time zones are never combined.

depart_at has to fall inside a feed’s published timetable (most agencies publish a few months ahead). A date outside every nearby feed’s period is a 422 that names the periods.

Feeds that describe some service with frequencies.txt (a departure every N minutes instead of a timetable) are loaded too. Each frequencies row becomes a copy of its template trip every headway_secs, starting at start_time and stopping before end_time. See Headway-based service for how those legs look. Per-stop time zones (stop_timezone) are ignored: every time in a feed is read in the agency’s time zone.

The catalog lists MBTA (gtfs-mbta) and BART (gtfs-bart), but neither is loaded yet. Their license and attribution strings in the dataset metadata are placeholders until checked against each agency’s developer terms, so check those terms yourself before you build on that data.

Terminal window
curl -X POST https://plaza.fyi/api/v1/route \
-H "x-api-key: $PLAZA_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"origin": {"type": "Point", "coordinates": [-71.0620, 42.3567]},
"destination": {"type": "Point", "coordinates": [-71.1210, 42.3745]},
"mode": "transit",
"depart_at": "2026-03-16T08:00:00-04:00"
}'

The response is one GeoJSON Feature. geometry is a MultiLineString with one line per leg, and properties.legs lists the legs in order. Here: walk to Park Street, ride the Red Line to Harvard, walk to the destination.

{
"type": "Feature",
"geometry": {"type": "MultiLineString", "coordinates": [[...], [...], [...]]},
"properties": {
"mode": "transit",
"feed_id": "mbta",
"feed_ids": ["mbta"],
"depart_at": "2026-03-16T08:03:12-04:00",
"arrive_at": "2026-03-16T08:21:30-04:00",
"duration_s": 1098,
"distance_m": 5940.7,
"walk_distance_m": 520.7,
"transfers": 0,
"legs": [
{
"mode": "walk",
"from": {"kind": "origin", "lat": 42.3567, "lng": -71.062},
"to": {"kind": "stop", "stop_id": "place-pktrm", "feed_id": "mbta", "name": "Park Street", "code": null, "lat": 42.3564, "lng": -71.0624},
"departure": "2026-03-16T08:03:12-04:00",
"arrival": "2026-03-16T08:04:00-04:00",
"duration_s": 48,
"distance_m": 62.4,
"geometry": {"type": "LineString", "coordinates": [...]}
},
{
"mode": "subway",
"feed_id": "mbta",
"route": {"id": "Red", "short_name": null, "long_name": "Red Line", "color": "#DA291C", "text_color": "#FFFFFF", "type": 1, "agency_id": "1"},
"trip_id": "...",
"headsign": "Alewife",
"headway_s": null,
"exact_times": null,
"from": {"kind": "stop", "stop_id": "place-pktrm", "name": "Park Street", "departure": "2026-03-16T08:04:00-04:00", "...": "..."},
"to": {"kind": "stop", "stop_id": "place-harsq", "name": "Harvard", "arrival": "2026-03-16T08:16:00-04:00", "...": "..."},
"departure": "2026-03-16T08:04:00-04:00",
"arrival": "2026-03-16T08:16:00-04:00",
"wait_s": 0,
"duration_s": 720,
"distance_m": 5420.0,
"stops": 4,
"intermediate_stops": [
{"kind": "stop", "name": "Charles/MGH", "arrival": "2026-03-16T08:06:00-04:00", "...": "..."},
{"kind": "stop", "name": "Kendall/MIT", "arrival": "2026-03-16T08:09:00-04:00", "...": "..."},
{"kind": "stop", "name": "Central", "arrival": "2026-03-16T08:13:00-04:00", "...": "..."}
],
"geometry": {"type": "LineString", "coordinates": [...]}
},
{
"mode": "walk",
"from": {"kind": "stop", "stop_id": "place-harsq", "feed_id": "mbta", "name": "Harvard", "code": null, "lat": 42.3734, "lng": -71.1189},
"to": {"kind": "destination", "lat": 42.3745, "lng": -71.121},
"departure": "2026-03-16T08:16:00-04:00",
"arrival": "2026-03-16T08:21:30-04:00",
"duration_s": 330,
"distance_m": 458.3,
"geometry": {"type": "LineString", "coordinates": [...]}
}
]
}
}

The times are illustrative (Plaza’s own tests use a synthetic feed), but the shape and the arithmetic are real. duration_s runs from depart_at to arrive_at, and each leg starts when the one before it ends, plus wait_s before a vehicle.

A few things about the fields:

  • depart_at is when you need to leave the origin to catch the first vehicle, not the time you asked for. The walk to the first stop is timed to arrive just as it departs.
  • wait_s is the time spent at a stop before the vehicle leaves. Transfers often have a wait; the first ride’s is always 0, because the first walk is timed to end as it departs.
  • A vehicle leg’s mode comes from the GTFS route_type: bus, trolleybus, coach, tram, subway, rail, monorail, ferry, cable_tram, aerial_lift or funicular. A route_type Plaza doesn’t recognize shows up as transit. Walking legs are walk.
  • Vehicle geometry follows the feed’s shapes.txt when it has one. Otherwise it is straight lines through the stops served.
  • Walking legs follow the street graph. A transfer between two platforms of one station can be a straight line, because station interiors are often missing from OpenStreetMap.
  • Times are ISO 8601 in the feed’s time zone.
  • feed_ids lists every feed the journey rides, in order. feed_id is the first vehicle’s feed. Each vehicle leg and each stop (from, to, intermediate_stops) has its own feed_id, so a change from one agency to another shows up as a walk between stops with different feed_ids.

If walking the whole way is faster and no longer than max_walk_m, you get a single walk leg, feed_id is null and feed_ids is empty.

Trips from frequencies.txt have two extra values on their vehicle legs. On scheduled trips both are null.

  • headway_s is the number of seconds between vehicles.
  • exact_times is true when the feed publishes exact departures on that headway. When it’s false, vehicles only run about every headway_s seconds, and the departure and arrival times in the leg are an estimate on that grid. Leave yourself some slack.

trip_id on these legs is the feed’s template trip id, so several vehicles in one day share it.

FieldTypeDefaultMeaning
depart_atstringnowWhen you leave. With an offset (Z, -04:00) it is an exact instant. Without one (2026-03-16T08:00:00) it is wall-clock time in the feed’s time zone, which is usually what you want for “8 am in Boston”.
arrive_bystringnoneWhen you need to be there, read like depart_at (now isn’t accepted). Routes only, and not together with depart_at. See Arriving by a time.
wheelchairbooleanfalseRide only wheelchair-accessible trips and use only accessible stops. See Wheelchair access.
max_walk_mnumber1000Longest walk for each walking leg: to the first stop, between stops, and from the last stop. At most 3000.
max_transfersinteger3Most changes between vehicles. 0 means one vehicle only. At most 4.
use_busnumber10 to 1. 0 never rides a bus (bus, trolleybus, coach).
use_railnumber10 to 1. 0 never rides rail (tram, subway, rail, monorail, cable car, funicular).
use_transfersnumber10 to 1. 0 allows no transfer.
pedestrianobjectdefaultsWalker options for the walking legs, the same object mode: "foot" takes (see Cycling and Walking Options). See Walking options.

Ferries are always allowed.

The pedestrian object shapes the walks. walking_speed sets how long every walk takes, transfers between stops included, so a slow walker may miss a connection a fast one makes. wheelchair, max_hiking_difficulty and access rules decide which ways the walks to and from stops can use. The preferences (use_hills, use_lit, walkway_factor, sidewalk_factor, step_penalty) choose which path is drawn. Walks between stops were measured when the feed was loaded, with the default rules. A slower walker takes longer on them, and a transfer with a minimum time set by the feed never gets shorter.

{
"origin": {"type": "Point", "coordinates": [-71.0589, 42.3601]},
"destination": {"type": "Point", "coordinates": [-71.1190, 42.3736]},
"mode": "transit",
"depart_at": "2026-03-16T08:00:00",
"pedestrian": {"walking_speed": 3.5, "use_lit": 1}
}

On transit isochrones walking_speed also sizes the walking circles.

Plaza first finds the fastest journey for each number of vehicles: the fastest with one ride, the fastest with two rides if it arrives earlier, and so on. Then it scores those candidates and returns the cheapest one:

  • arrival time minus depart_at, in seconds,
  • plus bus time multiplied by 2 * (1 - use_bus), and rail time multiplied by 2 * (1 - use_rail),
  • plus 600 * (1 - use_transfers) seconds for each transfer.

So use_transfers: 0.5 costs 5 minutes per change. A direct bus that arrives 4 minutes later than a train-plus-bus trip wins; one that arrives 10 minutes later doesn’t.

The preferences pick between the journeys the search found. They don’t make it look for slower journeys. If the only way to get there involves a bus, use_bus: 0.2 still takes the bus, and use_bus: 0 returns 404.

Send arrive_by instead of depart_at and Plaza returns the latest departure that still gets you there in time:

Terminal window
curl -X POST https://plaza.fyi/api/v1/route \
-H "x-api-key: $PLAZA_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"origin": {"type": "Point", "coordinates": [-71.0620, 42.3567]},
"destination": {"type": "Point", "coordinates": [-71.1210, 42.3745]},
"mode": "transit",
"arrive_by": "2026-03-16T09:00:00-04:00"
}'

The response has the same shape as a depart_at route. Its depart_at is when to leave the origin and arrive_at is when you actually get there, which is at or before arrive_by. If the 08:41 train gets you in at 08:58, that’s the answer, even if a later one would arrive at 09:01.

The search looks back 4 hours from the deadline. A deadline just after midnight can use the previous evening’s late trips.

arrive_by works on routes only. Sending it with depart_at, on an isochrone, or with another mode is a 422 validation_error.

With wheelchair: true, Plaza rides only trips the feed marks wheelchair_accessible 1, and boards and alights only at stops marked wheelchair_boarding 1. A platform with no value takes its parent station’s. Unknown counts as not accessible, so a feed that leaves these fields blank has no accessible service at all.

Terminal window
curl -X POST https://plaza.fyi/api/v1/route \
-H "x-api-key: $PLAZA_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"origin": {"type": "Point", "coordinates": [-71.0620, 42.3567]},
"destination": {"type": "Point", "coordinates": [-71.1210, 42.3745]},
"mode": "transit",
"depart_at": "2026-03-16T08:00:00-04:00",
"wheelchair": true
}'

It works on transit isochrones too. It also sets pedestrian.wheelchair, so the walks to and from stops avoid steps and steep or rough ways. Transfers are different: the walks between stops were measured with the default walking rules when the feed was loaded, so a transfer can include steps. Check the transfer legs before relying on a journey. If no accessible stop is within max_walk_m of an end, the request is a 404.

wheelchair is a transit option. For a wheelchair walking route, use mode: "foot" with pedestrian: {"wheelchair": true} (see Cycling and Walking Options); wheelchair: true with any mode other than transit is a 422 that says so.

waypoints, alternatives, ev, truck (and its fields), exclude, avoid_datasets, datasets, traffic_model, steps, annotations, overview, geometries, lang, units, verbal, voice_instructions, banner_instructions, roundabout_exits, voice_units and waypoint_names are rejected with a 422 when mode is transit. On isochrones, so are denoise and generalize.

Terminal window
curl -X POST https://plaza.fyi/api/v1/isochrone \
-H "x-api-key: $PLAZA_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"geometry": {"type": "Point", "coordinates": [-71.0589, 42.3601]},
"time": [900, 1800],
"mode": "transit",
"depart_at": "2026-03-16T08:00:00"
}'

Each contour is the union of walking circles: one around the starting point, and one around every stop you can reach in time, sized by the time you have left when you get there. Both are capped at max_walk_m. The circles are straight-line distance at 5 km/h, not walks on the street graph, so a contour can cross a river where there’s no bridge.

Each Feature’s properties have time_seconds, mode (transit), stops_reached and area_m2. One time returns a Feature; a list returns a FeatureCollection, like other isochrones.

Isochrones ride every feed with a stop near the starting point, plus feeds one walk away from those (the same between-feed walks routes use). wheelchair works here; arrive_by doesn’t.

route() and isochrone() take the same options as keyword arguments:

route(
origin: point(42.3601, -71.0589),
destination: point(42.3736, -71.1190),
mode: "transit",
depart_at: "2026-03-16T08:00:00",
max_walk_m: 800,
use_bus: 0.5
);
isochrone(
center: point(42.3601, -71.0589),
time: [900, 1800],
mode: "transit",
depart_at: "2026-03-16T08:00:00-04:00"
);
route(
origin: point(42.3567, -71.0620),
destination: point(42.3745, -71.1210),
mode: "transit",
arrive_by: "2026-03-16T09:00:00-04:00",
wheelchair: true
);

The results, errors and billing match REST. The transit options need mode: "transit"; on another mode, or another computation, they are a type error. The exception is wheelchair with mode: "foot", which is the walking option from Cycling and Walking Options. The walking keywords (walking_speed:, use_lit: and the rest) are the pedestrian object, as with mode: "foot".

Each feed’s stops are a dataset you can search like any other. Properties are stop_id, name, code, kind (stop, station or entrance), parent_station, wheelchair_boarding, routes (the names of the routes serving the stop) and modes.

search(dataset("gtfs-mbta"), name: "Harvard");

Plaza runs RAPTOR (Round-Based Public Transit Routing) over the day’s timetable (forward from depart_at, or backward from arrive_by), which it loads into memory the first time a day is asked for and keeps while it’s in use. Each round adds one more vehicle. Walking legs to, between and from stops are measured on the OpenStreetMap street graph at 5 km/h. Walks between nearby stops (up to 500 m apart in a straight line, 1000 m on foot) are measured when the feed is loaded, including walks to stops of other feeds. transfers.txt minimum transfer times replace those walks, and transfer_type 3 removes them. A transfer is one of these walks, never two chained together.

Calendars are honoured: calendar.txt service days and calendar_dates.txt exceptions (holidays, special service). Trips that run past midnight count, so leaving at 00:15 can catch a train scheduled at 24:20:00 on the previous service day.

  • The search covers 4 hours of travel after departure, or before an arrive_by deadline.
  • No real-time data: delays and cancellations in GTFS-Realtime are not used.
  • No fares.
  • Feeds in different time zones aren’t combined in one journey.
  • The first request for a day can get a 503 transit_unavailable while that day’s timetable loads; retry after a few seconds. A day too large to hold in memory gets the same 503 every time.
  • The whole request, including loading a timetable, gets about 15 seconds; past that it’s a 408 request_timeout.
StatusCodeWhen
400bad_requestMissing or malformed origin, destination or geometry
404not_foundNo stop within max_walk_m of the origin or destination (and walking all the way is too far), no accessible stop there with wheelchair: true, or no journey: every option ruled out by use_bus, use_rail or max_transfers, or no arrival within 4 hours
408request_timeoutThe search ran past its time budget or your plan’s request timeout
422validation_errorAn option out of range, a bad depart_at or arrive_by, both of them at once, arrive_by on an isochrone, a time outside every nearby feed’s published period, an option transit doesn’t take, or arrive_by / wheelchair: true with another mode
503transit_unavailableThe day’s timetable is still loading or too many days are loading at once (retry shortly), it failed to load, or it is too large to hold in memory

Through PlazaQL, option errors are a 400 query_error, as for other computations. The other statuses match REST.