GTFS Realtime (gtfs_rt)¶
Ingests GTFS-RT feeds (Trip Updates, Vehicle Positions, Service Alerts) and exposes them via REST endpoints for consumption by gtfs_display and other modules.
Dependencies: gtfs.
GtfsRtFeed config entity¶
Each feed is a config entity with:
- feed_type: tripupdates | vehiclepositions | servicealerts
- url: the GTFS-RT Protobuf endpoint URL
- refresh_interval: seconds between ingestion runs (default 60)
- status: enabled/disabled
Admin UI: /admin/gtfs/rt/feeds
The module fetches each active feed on its refresh interval, decodes the Protobuf payload (via gtfs-media/gtfs-realtime-php, installed by Composer), and stores the result as JSON in the database keyed by feed entity ID.
An async cache endpoint at /gtfs/rt/cache-async allows feeds to be refreshed out-of-band (e.g. triggered by a display data request when the feed is stale).
REST API (v2)¶
All endpoints return JSON. Append ?format=json if not using Accept header. Grant "restful get gtfs_rt*" permissions to the Anonymous role for public kiosk access.
Departures¶
GET /gtfs/api/v2/rt/departures/{stop_id} The next departures at a stop, soonest first, in one request.
Merges the three things that can know when a bus leaves: a time the feed
asserted, a time propagation derived from a delay the feed asserted earlier
on the trip, and the published schedule. Every entry says which of the
three it is, because they are not equally certain.
This is the endpoint to build a departure board or a rider app on. The trip
updates resource below returns the feed's own shape and says nothing about
stops no trip update mentions; an agency with no realtime feed gets nothing
from it at all and a full board from this.
Query parameters:
route_id Filter by route.
limit Departures to return. Default 10, maximum 100.
horizon Minutes ahead to look. Defaults to the configured
propagation lookahead.
Response shape:
{
"data": [
{
"trip_id": "12082346_M21",
"route_id": "K",
"stop_id": "15417",
"stop_sequence": "20",
"headsign": "Ocean Beach",
"time": "1787612534",
"delay": "494",
"scheduled_time": "1787612040",
"vehicle_id": "1425",
"is_last_stop": false,
"source": "feed"
}
],
"meta": {
"realtime": true,
"feed_timestamp": 1787612500,
"last_fetched": 1787612498,
"next_expected_update": 1787612558,
"propagated": 2
}
}
Numbers are lexical strings, as everywhere else in the API. `is_last_stop`
is a real boolean.
`meta.realtime` is false when no trip updates feed had anything to say. That
is the normal state for a static-only agency rather than an error, and a
client should present the times as scheduled rather than implying they are
live. `meta.next_expected_update` is when the feed is next due and is the
interval worth polling at.
A departure's identity is `(trip_id, stop_sequence)`. A trip can serve one
stop twice — loop routes do — and both calls appear, because on a loop the
second one is what a rider on the far side is waiting for.
`is_last_stop` means the trip terminates here and offers no onward service.
A departure board should hide those; it is stated rather than left to be
inferred from array position, which is wrong for every propagated entry.
Where a trip goes¶
GET /gtfs/api/v2/rt/trips/{trip_id} One trip's remaining calls, in stop_sequence order.
The departures resource asked from the other side, in the same entry shape:
one trip's many stops rather than one stop's many trips. What a rider
already on board is looking at.
Ordered by stop_sequence, not by time. The sequence is what the bus does; a
time is a claim about it, and a feed publishing an out-of-order time should
not reorder the route. Calls the vehicle has passed drop off the front.
Propagation runs across the whole trip here, not one stop. Without it every
stop past the last one the feed mentioned would show its scheduled time, so
a trip running eight minutes late would look on time from the fourth stop
onward — worse than saying nothing.
Query parameters:
limit Calls to return, counting from the next. Maximum 200.
start_date YYYYMMDD service date, for a caller that knows which service
day it wants and has no realtime to infer it from.
Responds 404 when the trip has no schedule in the serving feed.
`meta.service_date` is the service day the times were resolved onto. It is
worth reading: a GTFS time of 25:03:01 is 1am tomorrow on today's service,
and the resolution order is an explicit start_date, then the feed's own trip
descriptor, then inference from a time the feed asserted, then today.
Trip Updates¶
GET /gtfs/api/v2/rt/tripupdates/{stop_id} Predicted arrivals/departures for a stop. {stop_id} must match the identifier used in the RT feed (which may differ from the static GTFS stop_id — see the note on ID mapping below).
Query parameters:
route_id Filter by route.
Response shape:
{
"data": [
{
"trip": { "tripId": "…", "routeId": "…", "startDate": "…" },
"vehicle": { "id": "…", "label": "…" },
"stopTimeUpdate": [
{
"stopSequence": 12,
"stopId": "17948",
"arrival": { "time": 1234567890 }
}
]
}
],
"meta": { "feedTimestamp": "…", "generatedAt": "…" }
}
Vehicle Positions¶
GET /gtfs/api/v2/rt/vehicles All active vehicles.
Query parameters:
route_id Filter by route.
stop_id Filter by stop (vehicles at or approaching the stop).
label Find one vehicle by the fleet number painted on it. Matched
against both the vehicle's label and its id, since agencies
disagree about which carries it, case-insensitively and
ignoring leading zeros — a rider types what they can see, and
a rider who cannot read the route number can always read
the number on the bus.
Response shape:
{
"data": [
{
"vehicle": { "id": "…", "label": "…" },
"trip": { "tripId": "…", "routeId": "…" },
"position": { "latitude": 37.7, "longitude": -122.4, "bearing": 270 },
"currentStopSequence": 14,
"currentStatus": "IN_TRANSIT_TO"
}
],
"meta": { "feedTimestamp": "…", "generatedAt": "…" }
}
Service Alerts¶
GET /gtfs/api/v2/rt/alerts/stops/{stop_id} Active service alerts affecting a stop.
Response shape:
{
"data": [
{
"id": "…",
"effect": "DETOUR",
"headerText": "…",
"descriptionText": "…",
"activePeriod": [ { "start": 1234567890, "end": 1234567999 } ],
"informedEntity": [ { "stopId": "17948" } ]
}
],
"meta": { "feedTimestamp": "…", "generatedAt": "…" }
}
Predicting at stops the feed does not mention¶
Not every feed publishes a time for every stop. Some publish one delay for the whole trip; some list only the stops ahead of the vehicle right now; some skip stops in the middle of their own list. Where a feed says nothing, this module works a time out by carrying the delay it did state forward along the trip, letting the schedule's own dwell absorb it as the vehicle goes.
Nothing is inferred from vehicle movement. Every predicted time traces back to a delay the feed asserted, and every one is marked, so a display can show the difference between a time the agency reported and a time we derived.
The rules:
- Forward only. Nothing at or before the last stop the feed spoke about is ever touched — including stops the vehicle has already passed, which a feed that lists only what is ahead of the vehicle drops from its list.
- Asserted always wins. A time the feed states is never overwritten, and a statement further along the trip re-anchors the arithmetic rather than being overridden by it. Between two statements the earlier one applies, which is the interpolation GTFS-realtime itself describes.
- Dwell absorbs delay within a stop, not before it. A vehicle arrives carrying the deviation it had; the scheduled dwell is time it can give back while standing there, so only the departure is reduced.
- An early vehicle holds at a timepoint and is back on schedule after it.
- Capped. The walk stops at a horizon in stops or in scheduled minutes, whichever comes first, and as soon as the deviation reaches zero — a vehicle back on schedule is described by the schedule, which is already published.
- A stale trip update predicts nothing. Several minutes of silence from a feed that refreshes every minute is a feed problem, and a confident wrong time is worse than none.
Configure it at /admin/config/gtfs-rt/settings. It is on by default.
What a propagated entry looks like¶
A synthesized stopTimeUpdate carries _source: "propagated", and the trip
update carries a _propagation marker. The underscore says "not GTFS-RT":
consumers that ignore unknown keys are unaffected.
{
"trip": { "tripId": "12082346_M21", "routeId": "K" },
"stopTimeUpdate": [
{ "stopSequence": 20, "stopId": "15417", "arrival": { "delay": 494, "time": "1787612534" } },
{
"stopSequence": 21,
"stopId": "15418",
"arrival": { "delay": 494, "time": "1787612894" },
"departure": { "delay": 494, "time": "1787612894" },
"scheduleRelationship": "SCHEDULED",
"_source": "propagated",
"_last_stop": true
}
],
"_propagation": {
"basis": "stop_time_update",
"from_sequence": 20,
"mode": "extrapolated",
"horizon": "trip_end"
}
}
basis is which statement the deviation came from — stop_time_update,
delay (the trip-level field) or derived (an asserted epoch less its
scheduled one). mode is interpolated when the feed speaks again further
along the trip and extrapolated when this is past everything it said; the two
are different claims. horizon is why the walk stopped: stops, minutes,
recovered, no_data or trip_end.
_last_stop says whether this is the trip's final call. A synthesized entry is
always last in the array, so a consumer inferring "terminates here" from array
position gets it wrong for every one of them — and departure boards hide
terminating trips, so getting it wrong makes the prediction vanish rather than
merely mislabel it.
meta.propagated counts the synthesized entries in a response, and is absent
when there are none.
Scheduled departures¶
The same question — which trips does the schedule put at this stop soon — answers on its own with no realtime at all:
\Drupal::service('gtfs_rt.stop_prediction')->scheduledDepartures($stop_id, time());
This is the path a site with no realtime feed is served by.
Stop and trip ID mapping¶
GTFS-RT feeds may use different identifiers than the static GTFS data. Feeds sourced from the 511 SF Bay aggregator, for example, identify stops by a stop_code prefixed with "1" ("17948") while the static GTFS stop_id is "7948".
For the frontend, gtfs_display maps static IDs to RT IDs on the way out:
- hook_gtfs_display_rt_entity_ids_alter() — maps static IDs to RT IDs for the frontend (drupalSettings.gtfsDisplay.rtEntityIds).
- hook_gtfs_display_stop_rt_id_alter() — maps a single Stop entity to its RT stop identifier (used by the legacy aggregated controller).
Prediction needs the mapping in the other direction, because it starts from the
static schedule. That resolution tries, in order: an identifier that is already
a static one, the GTFS+ realtime_stops / realtime_trips tables when that
module is enabled, a matching stop_code, and then
hook_gtfs_rt_static_stop_id_alter() / hook_gtfs_rt_static_trip_id_alter() for
agencies whose mapping is a rule rather than a table. See gtfs_rt.api.php.
Between them these cover the common feeds without configuration; the core gtfs_rt module still holds no agency-specific ID conventions of its own.
Requirements¶
Prediction queries the schedule by stop, which needs an index on
gtfs_stop_times.stop_id — added by gtfs_update_10008 in the gtfs module.
Run database updates after upgrading. Without it, one stop lookup scans every
stop time in every feed: measured at 22 seconds on a six-million-row table,
against 4.7 milliseconds with the index.
Feed freshness¶
Feeds are ingested on a cron-driven schedule. Additionally, when a display data request arrives and the feed is stale, gtfs_display calls $feed->cacheAsync() to trigger an out-of-band refresh, so kiosk polls drive feed freshness without waiting for the next cron run.
GtfsRtFeed::isStale() returns true when the feed data is older than refresh_interval seconds. GtfsRtFeed::isCaching() prevents duplicate concurrent refresh requests.
Maintainers¶
This project has been sponsored by: