Atlas · API documentation Map app Route Lens GET /api/v1

Atlas API — the complete data reference

Every dataset and every field the Atlas API serves: what it means, which upstream source it comes from, how it is fetched, cleaned, derived and validated, how often the upstream changes, and how often we refresh it. This page is generated from the same pipeline the data ships from — if a field exists in the API, it is documented here.

Overview & conventions

Base URL
https://atlas.farhan.app/api/v1
Auth
none — open, no key
Methods
GET / HEAD only
CORS
Access-Control-Allow-Origin: *
Format
JSON (GeoJSON where noted)
Discovery
GET /api/v1 → full index

The API has three groups, discoverable from the index:

GroupPathWhat it servesBacking
current/api/v1/<dataset>Today's snapshot of every dataset — the same files the Atlas apps renderCommitted store files, edge-cached 5–10 min
live/api/v1/live/<feed>Real-time bus + road feeds proxied from TfL, plus live bus GPSTfL Unified API / BODS SIRI-VM, edge-cached 10–120 s
history/api/v1/history/<dataset>Time-series that accrues in the warehouse — daily reliability, CDC snapshots, sightings, collision historySelf-hosted Postgres via PostgREST (server-side key, never exposed)

The discovery index — GET /api/v1

Machine-readable self-description; everything on this page is enumerable from it.

FieldTypeMeaning
service / version / description / attributionstringIdentity, API version, upstream attribution
livePositionsobject{ path, note } — pointer to the separate keyed live-GPS endpoint /api/live/vehicles
groupsobject{ current, history, live }, each { path, url?, note }
endpointsarrayOne { name, path, url, description } per current-group dataset (a retired live feed additionally carries retired:true in the live group's own index at /api/v1/live)

Response & error conventions

Keys & limits

Callers never need a key. Every endpoint on this page is open. Keys exist only behind the API, server-side, and never reach the browser or the responses:

Key (name only)LivesPowers
BODS_API_KEYCloudflare Pages secretThe BODS SIRI-VM pull behind live/vehicles — all callers share its one 10 s-cached snapshot
TFL_APP_KEYCloudflare secret + pipeline envOptional — TfL works keyless; the key only raises our upstream rate allowance
WAREHOUSE_URL / WAREHOUSE_ANON_KEYCloudflare secretsThe history group's PostgREST connection (anon = read-only role behind row-level security)
DVLA_API_KEYpipeline env onlyVehicle enrichment at build time — never used at request time
LimitValueNotes
Per-caller rate limitnone enforced todayEdge caching absorbs load (a whole-network flood collapses to one upstream pull per TTL). A Cloudflare rate-limit rule (~100 req/min/IP baseline) is planned; poll politely — respect the cache TTLs, there is nothing fresher behind them
History page sizelimit ≤ 1000 · default 200Server-capped twice (the API clamps, and PostgREST enforces max 1000 rows); paginate with order + range filters (from/to), there is no offset param
Live feed freshness floor10–120 s per feedMatches each upstream's real cadence (BODS asks ≥10 s; TfL caches arrivals 30 s upstream) — faster polling returns the same cached bytes
Current-group freshness5–10 min edge cacheData itself changes at most daily (03:17 UTC build) — one fetch per session is usually right; check manifest for staleness
Payload sizes≤ ~8 MBLargest: routes-overview (~3 MB), accidents (~2 MB), route-stops (~2.5 MB), crowding-profile (~1.5 MB) — all gzip-compressed on the wire; fetch once and cache client-side

Every endpoint — the complete inventory

Every URL the API answers. No other paths exist; anything else returns a JSON 404 listing what's available.

PatternGroupParams
GET /api/v1index— (discovery: all groups + datasets)
GET /api/v1/{dataset}current— · dataset ∈ the 18 names in the sidebar
GET /api/v1/liveindex— (live-feed discovery, incl. retired flags)
GET /api/v1/live/statusliveroute (optional, comma-separable)
GET /api/v1/live/disruptionslive
GET /api/v1/live/arrivalslivestop (NaPTAN) or route — one required
GET /api/v1/live/road-disruptionslive
GET /api/v1/live/vehiclesliveline or route (optional, comma-separable) — filters by publishedLine
GET /api/v1/live/national-highwayslive— (always 410 Gone — retired)
GET /api/live/vehicleslegacyline — same data as live/vehicles, envelope {live, cached, capturedAt, vehicles[]}; kept for compatibility
GET /api/v1/historyindex— (history discovery)
GET /api/v1/history/{dataset}historylimit (≤1000) · order=col.asc|col.desc · plus per-dataset filters (exact whitelist in the history table; filter grammar: route/severity/borough/… are equality, from/to are ≥/≤ on the dataset's date column). Unknown params are ignored, never errors
GET /docsthis page · GET /llms.txt — the machine-readable summary for AI agents

How everything joins — the key map

The single most important thing to know: three route-key conventions exist, verbatim from the upstreams. Getting these wrong is the #1 integration bug.

KeyExampleUsed by
route id lowercasew12routes[].id · route-stops.routes keys · routes-overview properties.routeId · route-classifications keys · route-diversions routes[*].id + upcomingFreeze[] · TfL live URLs (/Line/w12/…)
route name as publishedW12routes[].name · keys of route-meta.routes, fleet.byRoute, tenders.byRoute, crowding.routes, crowding-profile.routes, route-diversions.routes · live publishedLine · history route_id columns (yes — the warehouse's route_id holds the name)
route name UPPERCASEDW12 / N136route-performance.routes keys only. For numeric routes all three conventions coincide ("25"); for lettered routes always normalise — safest: match case-insensitively
NaPTAN stop id490011979Nroute-stops stop id ↔ live arrivals naptanId ↔ diversion missedStops/addedStops id ↔ history schedule qsi_point_stop_ids / timing_point_stop_id. Nationally unique — also joins to any NaPTAN/BODS dataset
registrationLK67ENFfleet regs[]vehicles.byReg keys ↔ live GPS reg ↔ live arrivals vehicleId ↔ history vehicle-sightings registration
garage codeAWroute-meta garagegarages code ↔ history garage_code
direction"1" / "2""1" = outbound, "2" = inbound — in routes-overview properties.direction, live vehicle direction, crowding direction. The word forms outbound/inbound key route-stops and the diversion stop/segment maps
boroughE09000001 vs "Barking & Dagenham"Inconsistent upstreams, preserved honestly: accidents.borough is the ONS district code; bridges.borough and garage-snapshots carry names. Map codes↔names with any ONS lookup if you need to join them
tender idbtID "2179"tenders.byId keys ↔ every award object's btID

Vocabularies & formats — every closed value set

Field(s)Complete value set
type / routeTyperegular · night · twentyfour · school. (The apps additionally display a "prefix" class for lettered routes — that's derived client-side from the name, not a value in this field)
severity (accidents)fatal · serious · slight
statusSeverity (live/status)0–20 TfL scale; bus values in practice: 10 Good Service · 0 Special Service (diversions) · 3 Part Suspended · 4 Suspended · 5 Part Closure · 6 Severe Delays · 9 Minor Delays. Treat ≠10 as "not normal", read statusSeverityDescription for the label
band / crowding bandscomfortable (<0.5) · moderate (<0.65) · busy (<0.8) · crowded (≥0.8) — thresholds also shipped in the payload's bands
geometryStatuspublished · unpublished
baselineSourcestore · ibus:YYYYMMDD
serviceClass / service_classhigh-frequency · low-frequency (occasionally null when TfL's PDF omits it)
propulsionelectric · hybrid · hydrogen · diesel (route-meta/fleet buckets); vehicles[].propulsion may additionally yield gas or null from raw DVLA fuel strings
structure_typebridge · tunnel · barrier
postcodeSourcegarage · override · company · null
day / timeBand (accidents)Mon…Sun · AM peak/Inter-peak/PM peak/Evening/Night
dayType (crowding)Weekday · Saturday · Sunday
manifest statusok · failed
jb.isJoint / was_joint_bid basisvehicle.basis: new · existing · null; vehicle.deck: double · single · null
Format ruleDetail
TimestampsISO 8601 UTC everywhere — except the verbatim-preserved upstream strings: route-meta.contractDate (DD/MM/YY), tenders awardDate (16 January 2024), crowding time (HH:MM:SS) and timeOfDay t (HH:MM)
CoordinatesWGS84. GeoJSON and diversion segments are [lng,lat] (GeoJSON order); stop/vehicle/bridge/accident objects use named lat/lng fields (except history garage-snapshots: lon). Live road-disruptions point is a JSON-encoded string "[lng,lat]"
Unitsdistance km (bridges: metres, 2 dp) · wait times minutes · reliability % · crowding V/C ratio (0–2) · money £ (bids annual, costPerMile per live mile) · speedLimit is a display string ("30 mph")
Casingcurrent group = camelCase · history group = snake_case · GeoJSON = GeoJSON
Null semanticsnull always means "not known / not published / not parseable" — never zero. Numeric zero is a real measurement

Worked examples

# discover everything
curl -s https://atlas.farhan.app/api/v1 | jq ".endpoints[].name"

# everything about one route (note the key conventions: name for meta/perf/crowding, id for stops/geometry)
curl -s https://atlas.farhan.app/api/v1/route-meta        | jq ".routes.W12"
curl -s https://atlas.farhan.app/api/v1/route-performance | jq ".routes.W12"
curl -s https://atlas.farhan.app/api/v1/route-stops       | jq ".routes.w12.outbound[:3]"
curl -s https://atlas.farhan.app/api/v1/tenders           | jq ".byRoute.W12[0]"

# which routes are diverted right now, and where does W12 actually go?
curl -s https://atlas.farhan.app/api/v1/route-diversions | jq ".count, (.routes | keys[:10])"
curl -s https://atlas.farhan.app/api/v1/route-diversions | jq ".routes.W12 | {status, until, geometryStatus, missed: [.missedStops[][].name]}"

# live: buses on a route · arrivals at a stop
curl -s "https://atlas.farhan.app/api/v1/live/vehicles?line=W12" | jq ".count, .data[0]"
curl -s "https://atlas.farhan.app/api/v1/live/arrivals?stop=490011979N" | jq ".data[0] | {lineName, destinationName, timeToStation}"

# history: reliability trend · what changed on a route over time
curl -s "https://atlas.farhan.app/api/v1/history/reliability-daily?route=W12&from=2026-08-01" | jq ".rows[] | {day, ewt_minutes, sample_count}"
curl -s "https://atlas.farhan.app/api/v1/history/route-snapshots?route=W12&limit=30" | jq "[.rows[] | {snapshot_date, operator, propulsion, pvr}] | unique_by(.operator, .propulsion, .pvr)"

For AI agents

A condensed, machine-digestible version of this page is served at GET /llms.txt (llms.txt convention). Deterministic rules an agent should hold:

How data flows — Fetch → Clean → Validate → Store → Serve

Nothing is served straight from a scrape or a live pull. Every dataset moves through the same five-stage pipeline, run unattended every night:

03:17 UTC  atlas-refresh (VPS scheduled task) clones main and runs the pipeline:
           for each dataset: fetch upstream → clean/normalise → validate (hard gate) → write store JSON
           diversions builder runs FIRST and freezes the canonical baselines of diverted routes
           validate-atlas.js (50 checks) gates the commit — a failing dataset keeps its last-good file
           bot commits data/*.json → push → Cloudflare Pages redeploys the API + apps automatically
08:37 UTC  daily fleet sample (warehouse)         ingest pipeline, decoupled — can never block the API
09:23 UTC  warehouse refresh: CDC snapshots per route/garage + reference mirror (incl. diversions history)
every 30m  live arrival/headway sampler (service hours) → our own reliability estimates

Refresh cadences at a glance

Polling matches each upstream's real publication rhythm — a quarterly PDF is not fetched hourly, and a 30-second feed is not cached for a day.

DatasetUpstream publishesWe refreshSkip logic (politeness)
routes · route-stops · routes-overview · route-classificationsnetwork revisions land within a daydailyconditional requests; per-route failures don't abort the run
route-diversionslive status ~30 s · sequences on changeevery run (daily)sequence diffs only for flagged routes; iBus fetched only when a recovery is needed
route-meta · garagesdaily (londonbusroutes.net)dailygeocodes cached; overrides pinned
fleet · vehiclescontinuous (live arrivals) · DVLA staticdaily sampleDVLA per-run cap + 429 backoff; hit/miss cache persists
tendersevent-driven awardsdaily incrementalappend-only by tender id — only NEW ids fetched
route-performance~4-weekly (QSI) · quarterly (MPS)daily checkLast-Modified skip makes the daily check ~free
accidents (STATS19)annualmonthly re-checkbulk CSV, conditional
bridges (EPOWR)annualmonthly re-checksingle xlsx
crowding · crowding-profile (BUSTO)annualmonthly re-check~98 MB CSV streamed, skipped when year unchanged
localities (OSM)rarelymonthlysingle Overpass query
line-status~30 s (TfL cache)every runstored snapshot is the offline fallback tier; apps read TfL live

routes reference daily

Endpoint
GET /api/v1/routes
Source
TfL Unified API · /Line/Mode/bus
Shape
array of route objects
Rows
~640–680 (validated band 400–2000)

The route catalogue — every bus line TfL currently registers. Non-public placeholder lines (the ZZ* test/rail-replacement designations) are filtered out so counts reconcile across datasets. Sorted numerically then lexically (2 before 10, letters after).

FieldTypeMeaningSource & processing
idstringTfL line id — lowercase, e.g. w12. The join key for route-stops, routes-overview and live TfL calls.TfL verbatim
namestringPublic route number, e.g. W12. The join key for route-meta, fleet, tenders, crowding, route-diversions.TfL verbatim
typestringregular · night · twentyfour · schoolDerived: N-prefix → night; 6xx/9xx → school. 24-hour isn't in any free feed — set from a curated overrides file. Lettered/express classification is refined app-side (prefix).

Validation: 400–2000 rows; every row must carry id, name, type. Failure keeps last-good.

route-meta reference daily

Endpoint
GET /api/v1/route-meta
Source
londonbusroutes.net (garages.csv + details.htm)
Shape
{ generatedAt, routes: { [name]: {…} } }
Keyed by
route name (e.g. "W12")

Operational metadata per route — who runs it, from which garage, with how many buses, on what vehicle type and contract. Sourced from the long-running londonbusroutes.net reference (the community authority on garage allocations), scraped in the pipeline only — never at runtime — and joined against the TfL route list.

FieldTypeMeaningSource & processing
typestringRoute type (same vocabulary as routes.type)as routes
operatorstringOperating brand, e.g. Stagecoach LondonScraped operator string canonicalised via the operator-alias table (legal suffixes stripped, subsidiary variants rolled up to the parent brand)
companystringThe legal operating company, e.g. East London Bus & Coach Co. Ltd.Scraped verbatim (the raw counterpart to operator)
propulsionstring|nullelectric · hybrid · hydrogen · dieselParsed from the vehicle-type string; then reconciled against the DVLA-enriched live fleet: a fleet sample ≥4 vehicles that is ≥75% zero-emission upgrades a stale diesel/null (DVLA reliably identifies zero-emission but mis-reports hybrids as diesel, so hybrid-vs-diesel stays this source's call). Guarded by a regression check in the validator.
garagestring|nullGarage code(s), e.g. AWgarages.csv allocation
garageNamestring|nullGarage display name, e.g. Walthamstow Avenuejoined from the garages dataset
pvrnumber|nullPeak Vehicle Requirement — buses needed to run the peak timetableScraped; the operationally-current figure (tender awards freeze PVR at award time — see tenders)
fleetstring|nullRostered vehicle spec, e.g. E100EV/Enviro100EV 8.5mScraped verbatim
lengthKmnumber|nullRoute length, kmScraped; cross-checkable against routes-overview.lengthKm (haversine)
contractDatestring|nullContract spec date, DD/MM/YYParsed from details.htm — the last date on the route's row; a lone date on a night-route row that defers to its day route (See …) is a timetable date and deliberately not read as a contract date
contractStart / contractEndstring|nullContract window, YYYY-MMCurrently a curated map covering 8 flagship routes only — an interim measure until the Find-a-Tender OCDS ingester lands; null elsewhere
sourcestringProvenance marker (londonbusroutes.net)constant

Coverage: every current route (676/676 at last audit). TfL-sourced values are never overwritten by scraped ones — scrape fills only what TfL doesn't publish.

route-classifications reference daily

Endpoint
GET /api/v1/route-classifications
Shape
{ [id]: { name, type } } — a flat map, no wrapper

A convenience projection of the route list keyed by lowercase line id — the lookup the map filter layer uses. Note the shape: unlike most keyed datasets there is no { routes: … } envelope — the response is the map. Fields name and type are identical to routes; same derivation, same validation, written by the same builder in the same run (they can never drift apart).

route-stops reference daily diversion-frozen

Endpoint
GET /api/v1/route-stops
Source
TfL · /Line/{id}/Route/Sequence/{dir}
Shape
{ generatedAt, routes: { [id]: { outbound:[…], inbound:[…] } } }
Keyed by
line id · direction

The ordered stop sequence for every route, both directions. Stops are de-duplicated by NaPTAN id across branch variants while preserving order. Canonical-baseline rule: when a route is on (or within 14 days of) an active diversion, its entry is frozen at the last pre-diversion state — TfL temporarily rewrites the sequence during diversions, and absorbing that would silently redefine the route. The temporary state is served separately in route-diversions; the freeze self-heals when the episode ends.

Field (per stop)TypeMeaningSource & processing
idstringNaPTAN stop-point id, e.g. 490011525W — nationally unique, joinable to any NaPTAN dataset and to live arrivalsTfL verbatim
namestringPublic stop nameTfL verbatim
lat / lngnumberWGS84 positionTfL verbatim
linesarray[string]Every route serving this stop (by name)TfL stop-point line list

Validation: a full run must yield stop sequences for ≥400 routes or the write is refused (a wholesale sequence failure can't blank the file).

routes-overview reference daily diversion-frozen

Endpoint
GET /api/v1/routes-overview
Source
TfL · Route/Sequence lineStrings
Format
GeoJSON FeatureCollection
Features
~1,270–1,350 (validated floor 800)

Route line geometry for the whole network — one LineString feature per route per direction. Geometry is simplified (Ramer–Douglas–Peucker, tolerance 0.0005° ≈ ≤55 m) and coordinate-rounded to 4 dp (~11 m) so the entire network renders instantly; the un-simplified length is computed before simplification. The same diversion freeze as route-stops applies. An independent cross-check against TfL's iBus scheduling geometry is part of the source health sweep.

FieldTypeMeaningSource & processing
metadata.generatedAtISO datetimeBuild timestamppipeline
metadata.routeCount / featureCountnumberRoutes attempted / features writtenpipeline; a run where >30% of routes lose geometry is refused as an upstream outage
metadata.partialbooleantrue only for capped dev builds — always false in productionpipeline
metadata.simplificationTolerance / coordinatePrecisionnumberThe exact simplification parameters (0.0005° / 4 dp) so consumers can reason about positional errorpipeline constants
properties.routeId / namestringLine id (lowercase) / public route numberTfL
properties.directionstring"1" outbound · "2" inboundTfL direction encoding, validated to exactly {1,2}
properties.routeTypestringRoute typeas routes.type
properties.lengthKmnumberHaversine length of the un-simplified line, 0.1 km precisioncomputed pre-simplification (unit-tested against an independent implementation)
properties.stopsnumberStop count for this directionsame sequence call
geometry.coordinates[lng,lat][]The simplified lineRDP + rounding as above

line-status snapshot every run

Endpoint
GET /api/v1/line-status
Source
TfL · /Line/Mode/bus/Status
Shape
{ capturedAt, summary, rows[] }
Role
cached/offline fallback tier

The most recent stored network-status snapshot. The apps read live status from TfL directly (or via /api/v1/live/status); this file is the graceful-degrade tier when live is unreachable, and the historical record's source. Two important interpretations are applied:

FieldTypeMeaningSource & processing
capturedAtISO datetimeSnapshot timepipeline
summary.total / good / disruptednumberNetwork tallies (validated: parts sum to total)computed
rows[].routestringRoute nameTfL
rows[].statusstringTfL severity description active now, e.g. Good Service, Special Service, Severe Delayswindow-filtered as above
rows[].reasonstringTfL's prose explanation (empty for Good Service) — names roads, dates and skipped stopsTfL verbatim
rows[].severitynumberTfL statusSeverity (10 = Good Service; 0 = Special Service; lower = worse)TfL verbatim

route-diversions derived every run

Endpoint
GET /api/v1/route-diversions
Sources
TfL live status + Route/Sequence diff + iBus archive
Shape
{ generatedAt, count, upcomingFreeze, routes: { [name]: … } }
History
warehouse route_diversions, append-only per episode

Active diversion episodes with real diverted geometry. TfL publishes no structured diversion data (its affectedStops/affectedRoutes arrays are empty), but for planned diversions it redraws the route's sequence — so the truth is recovered by diffing TfL's current Route/Sequence against our frozen canonical baseline. Detection uses date-window checking (TfL's isNow is unreliable) plus a diversion-language text signal; both the degraded-feed gate and per-route last-good retention apply. Three baseline tiers:

FieldTypeMeaningSource & processing
generatedAt / countISO / numberBuild time · active episode count (validated: count = routes, ≤400)pipeline
upcomingFreezearray[string]Line ids frozen ahead of a window opening within 14 days (not yet active episodes)derived from validity windows + diversion text
routes[name].idstringLowercase line idTfL
routes[name].status / severitystring / numberWorst active TfL status for the routeTfL, window-filtered
routes[name].disruptions[]arrayEach active status: reason (TfL prose), category, since/until (earliest/latest window bounds, ISO, nullable)TfL validity periods, date-parsed
routes[name].since / untilISO|nullOverall episode window (min/max across disruptions)derived
routes[name].detectedAtISO datetimeFirst pipeline run that saw this episode — stable across the episode's life; the warehouse history keycarried over run-to-run
routes[name].geometryStatusstringpublished (segments present) · unpublishedderived; validated vocabulary
routes[name].baselineSourcestringstore or ibus:<YYYYMMDD> — which baseline the diff ran againstsee tiers above
routes[name].missedStops.{outbound,inbound}[]arrayStops on the canonical route not currently served — id, name, lat, lngbaseline stop-set minus current stop-set, by NaPTAN id
routes[name].addedStops.{…}[]arrayTemporary stops served only during the diversion (same shape, in sequence order — they trace the diversion path)current minus baseline
routes[name].diversionSegments.{…}[[lng,lat][]]The diverted path — segments of the current line deviating >75 m from the baseline, extended one point each side to rejoin visually. Noise-filtered: leave→rejoin separation ≥150 m of bypassed roadway (or ≥400 m travelled for loops); ≤12 segments/direction; 5 dp coords; every coordinate validated inside Greater Londongeometric diff (threshold sits above the ≤55 m simplification noise, verified empirically)
routes[name].bypassedSegments.{…}[[lng,lat][]]Baseline sections not currently served (the closed roadway) — same filteringreverse diff

Guarantee: a temporary diversion never redefines the canonical route in route-stops/routes-overview — the freeze holds the last-good baseline for flagged routes and releases it automatically when the episode ends. Ended episodes leave this dataset but remain forever in the warehouse's route_diversions table (one row per route_id, detected_at), which accrues into a permanent diversion history.

garages reference daily

Endpoint
GET /api/v1/garages
Sources
londonbusroutes.net + postcodes.io
Shape
{ generatedAt, garages: [...] }
Rows
~86 (validated floor 50)

Every London bus garage with position, operator, allocation and utilisation. Tramlink depots are excluded (their lines 1–4 would collide with bus routes 1–4). A stale-duplicate rule drops a routeless garage sitting <150 m from an active one (e.g. a depot listed under both a former and current operator's code); garages that fail to geocode are kept without coordinates so route→garage joins still work.

FieldTypeMeaningSource & processing
codestringLBR garage code, e.g. AWgarages.csv
namestringGarage namegarages.csv
operator / companystringOperating brand / legal companygarages.csv, operator normalised to the parent brand
postcodestring|nullPostcode used for geocodingRegex-extracted from the garage address
postcodeSourcestring|nullgarage · override · company — which address wonPrecedence: garage address → curated per-code override → company/HQ address
lat / lngnumber|nullWGS84 positionpostcodes.io bulk geocoder (one batched pass per run); validated not-all-null so a failed geocode run can't blank coordinates
pvrnumber|nullTotal PVR allocated to this garagegarages.csv
capacitynumber|nullApproximate bus capacitycurated per-garage file (community-sourced)
utilisationnumber|nullpvr ÷ capacity, 3 dpcomputed only when both known
routesarray[string]Routes allocated heregarages.csv; first listing wins as a route's primary garage

fleet sampled daily

Endpoint
GET /api/v1/fleet
Sources
TfL Line Arrivals + DVLA VES
Shape
{ generatedAt, enriched, byRoute: {…} }
Keyed by
route name

The fleet actually running each route, observed live. Registrations come from TfL's arrival predictions (each prediction carries the vehicle id); DVLA's Vehicle Enquiry Service then enriches each registration with make, year and fuel. Important honesty note: this is a point-in-time sample of vehicles on the road at build time — a route's count is "seen this run", not its full roster (the warehouse accrues the roster over time). The DVLA lookup set includes every historically-rostered registration, not just today's, so new plates fill in regardless of timing.

FieldTypeMeaningSource & processing
enrichedbooleanWhether DVLA enrichment ran this build (requires the server-side key)pipeline
byRoute[r].regsarray[string]Registrations observed on the route this runTfL /Line/{id}/Arrivals vehicleIds, deduped, uppercased; concurrency-capped fetch
byRoute[r].countnumberDistinct vehicles observedderived
byRoute[r].avgAgeYearsnumber|nullMean vehicle age, 1 dpcurrent year − DVLA yearOfManufacture, years outside 1990–now discarded
byRoute[r].propulsionobject{electric, hydrogen, hybrid, diesel} countsDVLA fuelType bucketed (HEAVY OIL→diesel, FUEL CELL→hydrogen, any *HYBRID*→hybrid). DVLA mis-reports many hybrids as diesel — which is why route-meta.propulsion stays authoritative for hybrid-vs-diesel
byRoute[r].makesarrayTop-5 {make, n} chassis makersDVLA make cleaned to display brands (ALL-CAPS chassis strings → e.g. Alexander Dennis, body-noise stripped)
byRoute[r].enrichednumberHow many regs carried valid DVLA dataderived

DVLA politeness: one lookup per registration ever (hits and misses both cached persistently), ~220 ms between calls, capped per run (currently 5,000), exponential backoff on 429 with a hard stop that resumes next run. Validation: ≥400 routes present; at least one route must have live vehicles or the write is refused.

Field presence: every route record carries route, regs, count; the enrichment fields (avgAgeYears, propulsion, makes, enriched) appear only on routes that had vehicles observed in the sample (currently ~179 of ~640 — night/school routes often sample empty outside their operating hours; the build runs at 03:17 UTC). The propulsion counts cover electric/hydrogen/hybrid/diesel only — gas-fuelled results are not counted (no gas buses operate TfL routes), so the four buckets may sum to slightly less than count when regs lack DVLA data.

vehicles derived daily

Endpoint
GET /api/v1/vehicles
Source
fleet (inverted) + DVLA cache — no API calls
Shape
{ generatedAt, byReg: {…} }
Keyed by
registration

The vehicle register: fleet turned inside-out, keyed by registration. Purely derived — zero network calls. A registration appearing on more than one route in the same snapshot is a cross-route working; day-to-day fleet moves are recovered from the warehouse's daily snapshots, not from this file.

FieldTypeMeaningSource & processing
regstringVehicle registration markfleet
routesarray[string]Routes this vehicle was observed on (deduped, sorted)fleet inversion
operatorstring|nullOperator inherited from its route(s)route-meta join
make / year / fuel / propulsionvariesChassis maker (cleaned) · build year · raw DVLA fuel string · derived bucketDVLA cache; present only for enriched regs

Validation: ≥100 registrations. If the fleet dataset is missing the builder skips without writing — it can never blank the register.

tenders reference daily · incremental

Endpoint
GET /api/v1/tenders
Source
TfL tender award results (13923/13796.aspx)
Shape
{ generatedAt, count, byId, byRoute }
Depth
~2,500 awards · ~25 years

The complete TfL route-tendering award history. Awards are immutable once published, so the store is append-only and incremental — a daily run fetches only award ids it hasn't seen. Multi-route awards (1/N1) are split so each route carries the award. byRoute lists are sorted newest-first; the raw byId cache is never mutated.

Field (per award)TypeMeaningSource & processing
btIDstringTfL's award id — the incremental-fetch keyTfL
routestringRoute(s) as published, e.g. W12 or 1/N1TfL verbatim
operatorstringWinning operator, canonicalised to the parent brandoperatorRaw preserves the published subsidiary name
numberOfTenderersnumber|nullHow many bids were receivedAccepts TfL's English number-words ("three") as well as digits
acceptedBid / lowestBid / highestBidnumber|nullThe bid spread, £/yearParsed from the award table
costPerMilenumber|null£ per live mileTwo comma conventions in the same TfL column are disambiguated (4,205,196 thousands vs 6,25 European decimal); values outside £0–200 are nulled — TfL sometimes pastes the annual bid into this cell
jointBid / jbstring|null / object|nullRaw joint-bid text · derived {isJoint, partners[], total, raw}Partner routes extracted from the prose (token-validated as route numbers, own variants excluded); total = the first £ amount
notes / vehiclestring|null / object|nullAward notes · derived {basis, propulsion, deck, lowFloor, euro, summary}Vehicle spec parsed from the prose: basis new/existing, propulsion with precedence hydrogen→hybrid→electric→diesel (diesel only when neither "electric" nor "hybrid" appears), deck, Euro standard. Everything degrades to null, never a guess
tranchenumber|nullLBSL tendering trancheFirst tranche N found in joint-bid text, then notes
awardDatestring|nullAward date, e.g. 16 January 2024First long-form date on the award page

Validation: the award count may never shrink (append-only guarantee) — a shrunken result refuses to overwrite last-good. The derived fields (jb/vehicle/tranche) are computed identically in the warehouse ingester, kept in deliberate lockstep.

route-performance official stats daily check · ~4-weekly data

Endpoint
GET /api/v1/route-performance
Sources
TfL QSI quarter PDF + per-route MPS PDFs
Shape
{ generatedAt, periodLabel, routes: {…} }
Keyed by
UPPERCASE route id

TfL's published reliability figures — the authoritative Quality of Service Indicators — extracted from TfL's own PDFs (there is no API for these). Position-aware PDF text extraction reconstructs the real table rows; a sticky table-shape detector tells high-frequency pages (EWT/SWT) from low-frequency ones (on-time %). Numbers are sanity-capped (SWT/AWT ≤ 60 min, EWT ≤ 30, percentages ≤ 100) and anything unparseable becomes null, never a guess. Covers ~738 route ids — more than the current network, because TfL publishes for some night/school variants too.

FieldTypeMeaningSource & processing
periodLabelstringQSI period, e.g. Q4 25/26parsed from the quarter PDF
pdfModifiedAtISO datetimeUpstream PDF's Last-Modified — the change-detection keyHTTP HEAD; an unchanged PDF skips the whole download
routes[R].serviceClassstringhigh-frequency (measured by EWT) · low-frequency (measured by on-time %)table-shape detection; falls back to the route's MPS PDF class
routes[R].ewtMinutes / swtMinutes / awtMinutesnumber|nullExcess / Scheduled / Actual Wait Time (high-freq). EWT = AWT − SWT — the headline reliability metricQSI PDF
routes[R].onTimePercentnumber|null% departures on time (low-freq)QSI PDF
routes[R].mileagePercentnumber|null% scheduled mileage operated (latest period, from the 13-period MPS table)MPS PDF; the 13-value row must parse completely, all values 50–100, or it is rejected
routes[R].ewtMps / otpMps / mileageMpsnumber|nullThe contractual Minimum Performance Standard each figure is judged againstper-route MPS PDF, 28-day sticky cache (one TfL period), ≤1,000 PDFs per run rotating oldest-first; genuine 404s (e.g. school routes) cached so they aren't re-tried; parse errors retry next run

Fallbacks: QSI and MPS halves fail independently, each keeping its last-good values. Validation: 400–2000 routes. Atlas's own higher-frequency reliability estimate is a separate, clearly-labelled dataset — see history · reliability-daily.

accidents official stats monthly check · annual data

Endpoint
GET /api/v1/accidents
Source
DfT STATS19 (collision + vehicle tables)
Shape
{ generatedAt, sample, period, count, bbox, accidents[] }
Rows
~7,100 · 2020–2024 (cap 12,000)

Bus/coach-involved road collisions in Greater London from the national STATS19 casualty statistics. Two CSV tables per year are streamed (never buffered — 100k+ rows/year): the vehicle table first, collecting collision ids where a bus, coach or minibus was involved (vehicle types 10/11), then the collision table filtered to those ids, to finite coordinates inside the London bounding box, and to a decodable severity. All coded attributes are decoded to human labels; an unknown code becomes null, never a wrong label (e.g. the historic junction column is preferred because the newer one uses an incompatible code set).

FieldTypeMeaningSource & processing
samplebooleanfalse = real STATS19 data. true only if the live pull and last-good both failed — a clearly-labelled deterministic sample keeps the UI honest instead of blankthree-tier fallback: live (≥200 rows) → last-good live file → labelled sample
period / count / bboxvariesYear range of kept rows · row count · geographic extentcomputed over kept rows
idstringSTATS19 collision index — stable national idDfT verbatim
lat / lngnumberCollision location, 6 dpDfT; bbox-validated
severitystringfatal · serious · slightdecoded 1/2/3; validated vocabulary; severity split must sum to total
date / day / timeBandstringISO date · Mon…Sun · AM peak / Inter-peak / PM peak / Evening / Nightdate reformatted from DD/MM/YYYY; day decoded (STATS19 counts Sunday=1); time bucketed <6/<10/<16/<19/else
boroughstring|nullONS district code, e.g. E09000001DfT local_authority_ons_district; -1 → null
vehicles / casualtiesnumber|nullVehicles involved · people injuredkept only when finite and >0; casualties validated positive-integer
roadType / speedLimit / junction / light / weather / roadSurfacestring|nullDecoded collision context — the "aggregate by" dimensions in the appseach decoded from its STATS19 code table; speed limits accepted only 20–70 → "N mph"; context null-rates monitored by the validator

bridges reference monthly check · annual data

Endpoint
GET /api/v1/bridges
Source
TfL EPOWR via London Datastore (xlsx)
Shape
{ generatedAt, sample, count, bbox, bridges[] }
Rows
877 structures (floor 50)

Every height-restricted structure in London — the low-bridge layer and the double-deck diversion-risk input. The workbook is discovered through the Datastore's dataset API (the landing page is JS-rendered) with a pinned TfL fallback URL, and parsed with a dependency-free xlsx reader. The safety-critical rule: published heights are bands ("Between 4.6 and 5.1"); we always normalise to the band's lower bound — the guaranteed clearance, never an optimistic one. Imperial values (9'10") are converted exactly (ft + in/12 × 0.3048).

FieldTypeMeaningSource & processing
idstringOS grid reference (or a synthetic id when absent)EPOWR
lat / lngnumberWGS84 positionFile's own lat/lng preferred; a full OSGB36→WGS84 Helmert transform runs only when they're missing; rows without usable coords are dropped
height_mnumberGuaranteed clearance in metres (band lower bound, 2 dp). <4.4 m = below double-deck heightsee rule above
height_imperialstring|nullThe published imperial band, verbatimEPOWR
name / roadstring|nullStructure / road nameEPOWR
structure_typestringbridge · tunnel · barrierclassified from road + comments text
boroughstring|nullBorough nameEPOWR

Same three-tier fallback as accidents (live ≥50 → last-good live → labelled sample).

crowding official stats monthly check · annual data

Endpoint
GET /api/v1/crowding
Source
TfL BUSTO — "MAX DEMAND HOUR" CSV (~98 MB, ~800k rows)
Shape
{ …, year, bands, routes: {…} }
Rows
~606 routes (floor 300)

How full each route gets, from TfL's BUSTO demand model. The newest year folder is auto-discovered on TfL's crowding bucket and the ~98 MB CSV is streamed line-by-line, reduced to one record per route: the peak V/C (load ÷ capacity) across every stop × timeband × direction, plus the per-day-type peaks. Stop names can contain commas, so the numeric columns are read right-anchored — a classic CSV trap handled explicitly. Band thresholds ship in the payload so consumers never hardcode them: comfortable <0.5 · moderate <0.65 · busy <0.8 · crowded ≥0.8.

FieldTypeMeaningSource & processing
year / sourceFilestringBUSTO year (e.g. 2025-2026) · the exact S3 object parseddiscovery
bandsarray{key, label, max} banding thresholds (max:null = unbounded)pipeline constants, shared with the apps
routes[r].peakVCnumberPeak volume/capacity at the max-demand hour, 4 dp — the headline numbermax over all cells; validated ∈ (0,2] and ≈ load÷capacity
routes[r].bandstringBand for peakVCvalidated against the thresholds
routes[r].load / capacity / seats / boardingsnumberPassengers · total capacity · seats · boardings/hr at the peak cellBUSTO, 1 dp
routes[r].dayType / time / timeband / directionvariesWhen and which way the peak occursBUSTO
routes[r].stopcode / stopname / stopSeqvariesThe busiest stopBUSTO
routes[r].maxLoad / maxCapacitynumberNetwork-normalisation extremes for the routecomputed
routes[r].byDay.{Weekday,Saturday,Sunday}objectPer-day-type peak: {vc, load, capacity, time, stopname}reduced per day type

School (6xx/9xx) routes are absent from BUSTO — coverage ~606 of ~640 routes is expected, not a defect. Fallback: last-good only (no synthetic sample for crowding).

crowding-profile official stats monthly check · annual data

Endpoint
GET /api/v1/crowding-profile
Shape
{ …, routes: { [name]: { profileDir, loadProfile, timeOfDay } } }

The per-route detail deliberately split out of crowding so the network layer stays light — apps lazy-load this only when a route is selected. Same source, same build, same validation run.

FieldTypeMeaningSource & processing
routes[r].profileDirstringDirection the load profile describes (the busiest one)direction of the route's peak cell
routes[r].loadProfilearray{seq, name, vc} per stop in sequence — V/C along the route; powers the corridor gradient. vc:0 = empty terminusper-stop maxima in the peak direction, sequence-sorted, 3 dp; validated ∈ [0,2]
routes[r].timeOfDayobject{ [dayType]: [{t:"HH:MM", vc}] } — the demand curve through the dayper-timeband maxima, sorted by clock time (BUSTO's timeband index is not time-ordered — validated chronological)

localities reference monthly

Endpoint
GET /api/v1/localities
Source
OpenStreetMap via Overpass (ODbL)
Rows
~528 places (floor 300)

Town and suburb labels for the map's "Place names" layer — one bounded Overpass query for place=town|suburb nodes across Greater London. Nameless nodes and names >60 chars dropped; duplicates collapsed by name within a ~1 km cell; coordinates rounded to 5 dp; alphabetically sorted.

FieldTypeMeaningSource & processing
namestringPlace nameOSM name tag
lat / lngnumberLabel anchorOSM node, 5 dp, bbox-clamped
kindstringtown (labels from z10) · suburb (joins at z12)OSM place tag

manifest meta every run

Endpoint
GET /api/v1/manifest
Shape
{ generatedAt, datasets: { [name]: {…} } }

The pipeline's own ledger — the machine-readable answer to "how fresh is this?". One entry per dataset. A failed refresh is recorded here while the previous data keeps serving, so consumers can detect staleness honestly.

FieldTypeMeaning
datasets[d].sourcestringHuman-readable upstream description
datasets[d].fetchedAtISO datetimeWhen this dataset last refreshed successfully
datasets[d].statusstringok · failed (last-good still serving)
datasets[d].lastError / lastAttemptstringWhat broke, when. Note: these persist after recovery (entries merge run-over-run), so a record can carry status:"ok" alongside an old lastError — always judge health by status + fetchedAt, not by the presence of lastError
datasets[d].rows / files / cadence / notevariesRow count · files written · refresh cadence · builder notes (e.g. enrichment counts)

The live group — /api/v1/live real-time

Live feeds proxied through our API so external callers get the same real-time view the apps do — CORS-open, keyless, edge-cached so a flood of callers collapses to a trickle of upstream pulls. Every response wraps the payload as { feed, capturedAt, data }.

EndpointCacheParamsReturns · source
GET /live/status30 s?route=25 or 25,86; omit = whole networkTfL line status + disruption reason + validity windows, verbatim from /Line/…/Status. Note: interpret windows by date, not TfL's isNow flag (see line-status)
GET /live/disruptions60 sActive bus-line disruptions (description + affected stops), /Line/Mode/bus/Disruption
GET /live/arrivals30 s?stop=<naptanId> or ?route=<id> (one required → else 400)Live arrival predictions; TfL caches this feed 30 s upstream — polling faster gains nothing
GET /live/road-disruptions60 sLondon road incidents/closures from TfL's traffic control centre (~5 min upstream cadence), /Road/all/Disruption
GET /live/national-highwaysRetired — always 410 Gone. National Highways withdrew the keyless RSS this proxied; use road-disruptions
GET /live/vehicles10 s?line=25 / ?line=25,86; omit = whole networkLive bus GPS (BODS SIRI-VM, Greater London bounding box). All callers share ONE 10-second-cached upstream snapshot — the BODS key stays server-side. Also at the legacy /api/live/vehicles

What's inside the TfL passthrough payloads

The status, disruptions, arrivals and road-disruptions feeds carry TfL's payload verbatim inside data (every object also carries a TfL $type marker — ignore it). The fields consumers actually need:

FeedKey fields per record
statusPer line: id, name, modeName, lineStatuses[]. Per status: statusSeverity (10 = Good Service, 0 = Special Service, lower = worse), statusSeverityDescription, reason (prose — names roads, dates, skipped stops), validityPeriods[] { fromDate, toDate, isNow }, disruption { category, categoryDescription, description, created, affectedRoutes, affectedStops }. Caveats we verified: judge windows by their dates, not isNow (unreliable for in-progress works); affectedRoutes/affectedStops are empty in practice — use route-diversions for structured stop/geometry impact; the whole-network call intermittently degrades to all-Good-Service while per-route calls stay correct
disruptionscategory, categoryDescription (PlannedWork / RealTime…), description, created, lastUpdate, affectedRoutes[], affectedStops[] (the latter two empty in practice)
arrivalsid (prediction id), vehicleId (registration — joins to vehicles), naptanId + stationName (the stop), lineId/lineName, destinationName, towards, direction, bearing, currentLocation (prose), timeToStation (seconds), expectedArrival (ISO), timeToLive (prediction expiry), timestamp. TfL caches 30 s upstream
road-disruptionsid (TIMS ref), pointa JSON-encoded string "[lng,lat]", parse before use — severity (Serious/Moderate/Minimal), category (Collisions/Works/Hazards…) + subCategory, location, comments + currentUpdate (prose), startDateTime/endDateTime/lastModifiedTime, status (Active…), hasClosures, isProvisional, plus geometry in geography/roadDisruptionLines/roadDisruptionImpactAreas when TfL publishes it

Live vehicle fields (SIRI-VM, parsed)

FieldTypeMeaning
regstringVehicle registration (VehicleRef) — joins to vehicles
line / publishedLinestringOperator-internal line ref / the public route number — filter on publishedLine; LineRef is not the public number
directionstring1 outbound · 2 inbound
lat / lng / bearingnumberPosition + heading (bearing may be null)
destination / originstringJourney endpoints as published
operatorRef / recordedAtstringOperator code · when the position was recorded (positions update every ~10–30 s)

The history group — /api/v1/history time-series

The static datasets are "today's values"; this group serves the time-series that accrues in our self-hosted warehouse (Postgres + PostgREST — the server-side key never reaches the browser; each endpoint is a strict whitelist of table + filters). Common params: limit (max 1000, default 200), order=col.asc|desc, plus the per-endpoint filters below. Responses: { dataset, table, count, limit, rows }. Returns 503 (never a fake 200) if the store is unconfigured; accidents and crowding transparently fall back to filtering the static snapshot.

EndpointFiltersWhat accrues
/history/reliability-dailyroute, from, toAtlas's own daily reliability estimate — AWT/SWT/EWT (high-freq), OTD (low-freq), scheduled vs operated km. Methodology: EWT = AWT − SWT where each = Σh²/2Σh over observed/scheduled headways; observed side sampled from live arrivals every ~30 min in service hours. EXPERIMENTAL — biased high (sparse sampling under-observes short headways); never comparable to TfL's QSI
/history/performance-historyrouteTfL's quarterly QSI per route, every period we've captured
/history/scheduleroute, from, toScheduled service over time — service class, SWT, trips/km, representative headway (from TfL Timetable)
/history/tender-programmeroute, yearTfL's forward LBSL tendering programme — issue/return/award/start dates, vehicle type
/history/route-snapshotsroute, from, to, operator, propulsion, garageDaily change-data-capture per route — PVR, propulsion, deck, operator, garage, fleet size/age, MPS. The record behind fleet-move / electrification / PVR-change analysis
/history/garage-snapshotsgarage, operator, from, toPer-garage over time — total PVR, route count, allocation
/history/vehicle-sightingsroute, reg, from, toVehicle-on-route observations (reg ↔ route ↔ timestamp), months deep
/history/accidentsfrom, to, severity, borough, road_type, speed_limit, day, time_bandSTATS19 collisions, snake_case row shape; the temporal source behind the snapshot
/history/crowdingroute, band, year, day_typeBUSTO crowding per route per year — trend across annual releases

History row columns — every field, per endpoint

All history rows are snake_case (the warehouse's convention — note it differs from the current group's camelCase). extracted_at on any row is the ingest write time.

EndpointRow columns · semantics
reliability-dailyroute_id, day, service_class, awt_minutes/swt_minutes/ewt_minutes (high-frequency routes; EWT = AWT − SWT, each Σh²/2Σh over observed/scheduled headways — null when the day's samples are insufficient), otd_percent (low-frequency: % departures 2 min early–5 min late), scheduled_km/operated_km/lost_km/mileage_operated_percent (treat with caution — operated km reflects sampling coverage, not service operated; the apps deliberately don't show it), sample_count (how many arrival sweeps informed the day — confidence grows with it)
performance-historyroute_id, period_label (e.g. Q4 25/26), period_start/period_end, service_class, ewt_minutes/swt_minutes/awt_minutes, on_time_percent, early_percent/late_percent/non_arrival_percent (the low-frequency breakdown — not in the current-group snapshot at all), scheduled_mileage_operated_percent, source_url, pdf_modified_at, extracted_at
scheduleroute_id, snapshot_date, service_class, swt_minutes (scheduled wait computed from the timetable), scheduled_trips, scheduled_km, headway_min (representative), scheduled_departures — an object {weekday, saturday, sunday} of departure times in minutes-since-midnight at the timing point — qsi_point_stop_ids[], timing_point_stop_id (the stop SWT/OTD are measured at), source
tender-programmeid, programme_year (e.g. 2019-2020), tranche, route_id, tender_issue_date/tender_return_date, award_estimated, contract_start_date, route_description (termini), vehicle_type, propulsion_type, two_year_extension, previous_operator, source_url/pdf_modified_at/data_as_of/extracted_at — parsed from TfL's annual LBSL tendering-programme PDFs, all years retained
route-snapshotsThe richest table — one row per route per day, ~60 columns in five families: identity route_id, snapshot_date, type, is_prefix, length_band, stop_count, frequency; vehicle & fleet deck, vehicle_type, propulsion, make, vehicle_age_years, fleet_size; operation operator, garage_name/garage_code, pvr; performance service_class, ewt_minutes, on_time_percent, perf_period, ewt_mps_minutes/otp_mps_percent/mileage_mps_percent; tender & contract context previous_operator, last_award_date/last_awarded_operator/last_cost_per_mile, tender_award_count, number_of_tenderers, was_joint_bid, contract_term_years, awarded_propulsion/awarded_deck (+ prev_* equivalents), the current-contract family current_contract_award_date, current_contract_cost_per_mile, current_contract_accepted_bid, current_contracted_annual_miles, current_contract_number_of_tenderers, current_contract_was_joint_bid, current_contract_awarded_propulsion/current_contract_awarded_deck/current_contract_awarded_operator; the previous-award family previous_award_date, previous_cost_per_mile, previous_accepted_bid, previous_contracted_annual_miles, previous_contract_term_years, previous_number_of_tenderers, previous_was_joint_bid; and the forward view next_tender_start/next_tender_year/next_tender_tranche/next_award_propulsion/next_award_deck/extension_eligible. This is the change-data-capture record — diff rows across dates to detect fleet moves, electrification, PVR changes, operator handovers
garage-snapshotsgarage_code, snapshot_date, garage_name, operator, address, postcode, lat, lon (note: lon here, lng in the current group), total_pvr, route_count, routes[], night_routes[], school_routes[], extracted_at
vehicle-sightingsDeliberately minimal — route_id, registration, observed_at. One row per vehicle-on-route observation; months deep; the raw material for fleet-movement analysis
accidentscollision_id, lat/lng, severity, collision_date, borough, vehicles, casualties, road_type, speed_limit, junction, light, weather, road_surface, day, time_band, extracted_at — the same decoded values as the snapshot, snake_cased
crowdingroute_id, busto_year, peak_vc, band, load/capacity/seats/boardings, day_type/peak_time/timeband/direction, stopcode/stopname/stop_sequence, max_load/max_capacity, and the flattened per-day peaks weekday_vc/saturday_vc/sunday_vc, extracted_at — one row per route per BUSTO year, so crowding trends across annual releases

The warehouse also mirrors reference data (stops, geometry, bridges, crowding profiles, localities) and accrues route_diversions — one row per episode keyed (route_id, detected_at), never deleted: a permanent diversion history. These are storage/history tables; their current values are served by the datasets above. Sidecar note: the store also ships two internal cache files (fleet-dvla-cache.json, route-mps-cache.json) and serves raw files under /data/*.json — implementation details, not part of the API contract; consume via /api/v1.

Integrity & validation — how we know the data is right

Licensing & attribution

SourceFeedsLicence / terms
TfL Unified API, iBus static drops, BUSTO, EPOWR, QSI/MPS reportsroutes, stops, geometry, status, diversions, crowding, bridges, performance, live feedsTfL Transport Data Service terms (free with attribution) — Powered by TfL Open Data
DfT — Bus Open Data Service (SIRI-VM), STATS19live bus GPS, collisionsOpen Government Licence v3.0
DVLA Vehicle Enquiry Servicevehicle make/year/fuelOGL; keyed server-side, one lookup per vehicle ever
OpenStreetMap (Overpass)localitiesODbL — © OpenStreetMap contributors
postcodes.iogarage geocodingOpen (ONS/OS open data)
londonbusroutes.netoperators, garages, PVR, vehicle typesCommunity reference — scraped politely in-pipeline only, credited; TfL-sourced values are never overwritten by it

Respect the upstream licences when reusing this API. Data is provided as-is; the manifest tells you exactly how fresh each dataset is.