A single page that checks a GTFS-Realtime TripModifications feed against the static GTFS it modifies.
https://transitapp.github.io/tripmodifications-validator/
Everything runs in the browser. There is no backend, no upload, and no telemetry — the feeds you load never leave your machine.
Most transit servers do not send an Access-Control-Allow-Origin header, so a browser refuses to read
their response from another origin. A page with no backend cannot work around that, and this one does
not pretend to: it tries the fetch, and when the browser blocks it you get a plain message saying so.
Download the file yourself and drop it on the page. That always works, for any feed, from any server.
If you run your own CORS proxy, put its prefix in Settings and the page will put your URL after it. The box is empty by default and the value stays in your browser's local storage. Nothing is ever routed through a third-party proxy.
Any of the four inputs can be filled in from the query string, so you can bookmark the feeds you check often:
https://transitapp.github.io/tripmodifications-validator/?static=…&mods=…&tripupdates=…&alerts=…
The link only fills the boxes in. Nothing is requested until you press Fetch, so opening a link someone sent you cannot make the page go and get anything.
Findings are grouped into errors (the feed violates the spec or will break a consumer) and warnings (legal but suspect), each with a stable code, and repeats collapse into one expandable row.
- The header carries
gtfs_realtime_version,incrementalityand atimestamp, and that timestamp is close to your clock — publishing local time as if it were UTC is a common producer bug. - Entity ids are unique.
- Each
TripModificationshas at least oneselected_trips, oneservice_datesand onemodifications. service_datesparse asYYYYMMDDand are not in the past.start_timesis only used with a single trip in a singleSelectedTrips.- Every
StopSelectorsetsstop_sequence,stop_id, or both. - Omitting
end_stop_selectoris legal in two ways: a pure insertion, and the spec's shape-only modification, where the path changes but no stop does. Such a modification is only reported when it also has noreplacement_stops, no newshape_idand nopropagated_modification_delay, which is the one case where it genuinely changes nothing. end_stop_selectordoes not come beforestart_stop_selector, and modifications within an entity are in increasing order and do not overlap.- Every
ReplacementStophas astop_id;travel_time_to_stopis present and increases along the list. propagated_modification_delayis set. Omitting it is legal and consumers may infer a value, but each one infers differently, so downstream times stop agreeing between apps.Shape.shape_iddoes not collide withshapes.txt, and the polyline decodes to at least two points with no repeated points.Stop.stop_iddoes not collide withstops.txt, andstop_name,stop_lat,stop_lonare present.
- Selected
trip_ids exist intrips.txt. The spec is explicit that a trip need not run on everyservice_date, so only a trip that runs on none of them is reported, fromcalendar.txtplus thecalendar_dates.txtexceptions. - No
(trip_id, service_date)pair is claimed twice — on a given date a trip must not belong to more than oneTripModifications. SelectedTripssets ashape_id, which the spec marks required.- A selector that names only
stop_idon a trip that visits that stop twice, where the spec requiresstop_sequenceto say which visit is meant. - Modification spans do not overlap, and are not contiguous either — the spec says two touching spans must be merged into one.
ReplacementStop.stop_idresolves to a stop withlocation_type=0; a station or an entrance is not routable.travel_time_to_stopnever decreases, and is only negative when the modification begins at the trip's first stop, which is the only case where the reference stop allows it.StopSelector.stop_idexists instops.txt; when none of them do, that gets its own prominent finding, because producers commonly emit internal scheduling codes here.- A selector that sets both
stop_sequenceandstop_idhas them agree, and thestop_sequenceexists in each selected trip — the finding shows the trip's actual range, which is usually enough to see that the wrong trip was selected. ReplacementStop.stop_idandselected_trips.shape_idresolve, either in the static feed or against aStop/Shapeentity in the same realtime feed.ShapeandStopentities that nothing in the feed references are flagged.- An entity that mixes routes or directions, or a modification that covers a terminus, is flagged.
Distances are planar with a cos(lat) correction on longitude, and every threshold is adjustable in Settings.
- A new shape starts and ends within 200 m of the trip's first and last stop. The spec wants the full trip path, not just the detour segment.
- Every retained stop is within 80 m of the new shape. A retained stop off the path means the trip claims a stop it never reaches.
- Removed stops still within 40 m of the new shape are reported as information, not as an error: a real stop closure looks like this, and so does a detour applied to the wrong range.
- Every
service_alert_idmatches anAlertentity id, with the placeholder"0"called out by name. header_text,description_textand at least oneinformed_entityare present, all three required.- Each
informed_entitysets at least one field, and eachactive_periodsets a start or an end. informed_entity.stop_id/route_idresolve againststops.txt/routes.txt.- Unfinished text: a
---placeholder, the word "test" in the body, or acause_detailwith nocause(causeon its own is optional, and is not reported). - An
active_period.endmore than two years out, which is always a sentinel.
modified_trip.modifications_idis theFeedEntity.idof aTripModificationsentity, not a trip id. Producers get this wrong constantly, so when the value turns out to be a trip id the finding says exactly that.modified_tripsets bothmodifications_idandaffected_trip_id, andaffected_trip_idis intrips.txtand is selected by the entitymodifications_idnames.- None of
trip_id,route_id,direction_id,start_time,start_dateis set alongsidemodified_trip. - No
schedule_relationship=REPLACEMENTTripUpdate already exists for a trip aTripModificationsselects. - Every entity in effect today is named by some TripUpdate, since that is the only way to predict at a replacement stop.
- A modified trip also has a plain TripUpdate on its
trip_id. The spec asks for both, so clients that do not understand TripModifications still get predictions. - Entity ids are unique.
A summary bar, a findings table you can filter by severity and search by entity, trip or stop id, and an entity inspector showing each selected trip stop by stop — kept, removed or inserted, with each stop's distance to the new shape. Findings export as JSON and as Markdown.
Every geographic finding carries a mini-map next to it: the scheduled shape in grey, the new shape in blue, the trip's other stops as small dots, and the stop the finding is about marked with a dashed line to the nearest point on the new shape and the distance in metres. It answers "is this real?" without leaving the list. The pictures are plain inline SVG with no tiles and no network, drawn only once a row scrolls into view, so a group holding hundreds of findings stays responsive; turn them off in Settings if you prefer a dense list. Open in map on any of them jumps to the full Leaflet map, zoomed to that stop.
The Leaflet map draws the same thing over OpenStreetMap tiles: scheduled shape in grey, new shape in blue, retained stops as filled dots, removed as hollow, inserted highlighted.
stop_times.txt is the only file big enough to matter — 26 MB and roughly 700,000 rows for a mid-size
US agency, far more for a large one. The unzip and CSV parsing run in a Web Worker, the CSV scanner works
over the raw bytes so only the four columns the checks need ever become JS strings, ids are interned to
integer indices, and each trip's stop list is stored as typed arrays.
A feed that size — a 3.8 MB zip expanding to 31 MB — parses and validates in well under a second and holds about 14 MB of heap.
There is no build step. Serve the site/ directory with anything:
cd site && python3 -m http.server 8000
Then open http://localhost:8000/. Opening index.html straight from the filesystem does not work,
because ES modules and Web Workers both need a real origin.
Every check traces to a line in
reference.md or
trip-modifications.md
at the pinned commit below. Where the spec permits something, this tool does not report it, and where the
spec lets a consumer infer a missing value the finding says so rather than claiming the feed is broken.
test/spec-rules.test.mjs holds one case per rule, including the cases that must stay silent. Run it
with node test/spec-rules.test.mjs. Those negative cases matter more than the positive ones: the easy
mistake in a validator is to over-read the spec and turn a correct feed into a wall of red.
site/gtfs-realtime.proto is a verbatim copy of gtfs-realtime/proto/gtfs-realtime.proto from
google/transit, taken at commit
474750a
(2026-08-17). It is vendored rather than fetched so the schema this page validates against is pinned
and visible in the diff. It includes the TripModifications, SelectedTrips, Modification,
StopSelector, ReplacementStop, Shape and Stop messages, and TripDescriptor.modified_trip.
To move to a newer spec, replace that one file and check the findings that change.
Loaded from a CDN at runtime, no build step and no lockfile:
protobufjs parses the .proto and decodes the feeds,
fflate unzips the static GTFS, and
Leaflet draws the map — loaded only when you open the map tab, so a blocked
CDN costs you the map and nothing else.
.github/workflows/pages.yml publishes site/ to GitHub Pages on every push to main. Nothing is
compiled; the workflow uploads the directory as it stands. All paths in the page are relative, so it also
works from a project subpath or any other static host.
MIT. See LICENSE.