Transit integrations
A transit integration contributes a provider to OpenMapX's public-transit
chain: a national operator's API, a self-hosted engine, a single GTFS feed, or a
real-time vehicle feed. This page is the contract-level guide to writing one. It
assumes you have read the integration system
reference — the manifest, the IntegrationContext, and the loader lifecycle all
apply here unchanged — and covers the two contracts unique to transit:
TransitProvider (schedules, stops, journey planning) and RealtimeProvider
(live deltas, vehicle positions, alerts).
For the user-facing picture of what these providers add up to — journey planning, departure boards, route options, live vehicles — see Public transit. This page is the other side of that feature: how a provider plugs into the orchestrator that powers it.
The transit domain and the MOTIS-first chain
No single data source covers the world's transit well, so OpenMapX runs a chain of providers. Each one declares the geographic area it covers and a numeric priority, and a transit orchestrator dispatches to them in order. The model is MOTIS-first: a self-hosted MOTIS instance with worldwide coverage sits at the top, regional and agency providers slot in just below it where they add value, and broad catalogs sit underneath as a long-tail fallback. The full chain and its priority tiers are documented on the Public transit feature page; this guide focuses on the contract your provider implements to join it.
Two domains carry transit providers:
transit— theTransitProvidercontract. Stops, departures and arrivals, routes, journey planning, vehicle positions, and alerts. Registered withctx.registerTransitProvider(...)and consumed by the transit orchestrator.live-transit— theRealtimeProvidercontract. Real-time overlays: vehicle positions, service alerts, and per-trip update deltas that enrich scheduled departures. Registered withctx.registerRealtimeProvider(...).
An integration declares which it serves in the manifest's domains array; many
agencies ship both as two integrations (for example transit-entur and
live-transit-entur).
The TransitProvider contract
The contract is defined in
packages/integration-framework/src/contracts/transit-provider.ts. A provider is
a plain object with five required descriptor fields and a set of optional
methods:
interface TransitProvider {
readonly id: string;
readonly prefix: string;
readonly coverage: { bbox: BBox } | { all: true };
readonly priority: number;
readonly capabilities: TransitCapabilities;
readonly attribution: Attribution[];
// …optional methods, all returning Promise<MobilityResult<T>>
}
Descriptor fields
id— a stable provider identifier, conventionally the same as the integration id ("transit-mbta"). It names the provider in health tracking, metrics, and the operator's data-use policy.prefix— a short, colon-terminated namespace stamped onto every id this provider emits ("mb:","myr:"). Stops, routes, and trips come back asmb:70061. The orchestrator routes a by-id lookup straight back to the provider whose prefix the id starts with, so prefixes must be unique to one provider in practice.coverage— either{ bbox: [minLng, minLat, maxLng, maxLat] }or{ all: true }. The bbox is the geographic envelope the provider can answer for;{ all: true }makes it eligible for every request (MOTIS and the global catalogs use this). Keep a regional bbox tight — wide envelopes pull a provider into requests it can't usefully serve and add noise to result de-duplication.priority— a number, lower wins. It positions the provider in the chain. The priority bands (1 for the backbone and tight regional providers, 5 for catalogs, 7 for the always-on soft fallback, and so on) are listed on the feature page; match the band that fits your provider's role.capabilities— aTransitCapabilitiesobject declaring which optional methods you actually implement (below).attribution— anAttribution[]for the feeds this provider redistributes, normally produced from the manifest'sdataSources(see Attribution and freshness).
Capabilities
The orchestrator dispatches on the declared capabilities object rather than
probing methods at runtime, so the capability flags and the methods you ship must
agree. The shape is nested:
interface TransitCapabilities {
stops: {
lookup: boolean; // getStop
nearby: boolean; // getStopsNearby
bbox: boolean; // getStopsInBbox
search: boolean; // searchStopsByName
infrastructure: boolean; // getStopInfrastructure
platforms: boolean; // getStopPlatforms
timetable: boolean; // getStopTimetable
};
departures: boolean; // getDepartures
arrivals: boolean; // getArrivals
routes: {
lookup: boolean; // getRoute
forStop: boolean; // getRoutesForStop
stops: boolean; // getRouteStops
geometry: boolean; // getLegGeometry
};
planning: boolean; // planTrip
vehiclePositions: boolean; // getVehiclePositions / getVehicleRadar
vehicleJourney: boolean; // getVehicleJourney
alerts: {
byStop: boolean; // getAlertsForStop
byRoute: boolean; // getAlertsForRoute
byBbox: boolean; // getAlertsForBbox
};
facilities: boolean; // getFacilities
}
Set a flag true only when you ship the matching method and the upstream can
genuinely answer it for your coverage area. Set it false when you don't ship
the method, or when the upstream answers but returns degenerate data (empty
arrays, "not implemented") — a false flag keeps the orchestrator from spending
a round-trip on a method that can't help.
Methods
Every method is optional, and every one returns its payload wrapped in a
MobilityResult<T> so attribution and freshness travel with the data. The full
surface, grouped by what it answers:
// Stops
getStop?(id): Promise<MobilityResult<TransitStop | null>>;
getStopsNearby?(lat, lng, radiusMeters): Promise<MobilityResult<TransitStop[]>>;
getStopsInBbox?(bbox): Promise<MobilityResult<TransitStop[]>>;
searchStopsByName?(q, limit?): Promise<MobilityResult<TransitStop[]>>;
getStopInfrastructure?(stopId): Promise<MobilityResult<TransitStopInfrastructure | null>>;
getStopPlatforms?(stopId): Promise<MobilityResult<TransitStop[]>>;
getStopTimetable?(stopId, date): Promise<MobilityResult<TimetableEntry[]>>;
// Departures / arrivals (arrivals reuse the Departure shape)
getDepartures?(stopId, minutes): Promise<MobilityResult<Departure[]>>;
getArrivals?(stopId, minutes): Promise<MobilityResult<Departure[]>>;
// Routes / lines
getRoute?(routeId): Promise<MobilityResult<TransitRoute | null>>;
getRouteStops?(routeId, hintStopId?): Promise<MobilityResult<TransitStop[]>>;
getRoutesForStop?(stopId): Promise<MobilityResult<TransitRoute[]>>;
getLegGeometry?(tripId, fromStopId?, toStopId?): Promise<MobilityResult<LineString | null>>;
// Journey planning + vehicles
planTrip?(opts: TripPlanRequest): Promise<MobilityResult<TripPlan[]>>;
getVehicleJourney?(tripId, fallbackIds?): Promise<MobilityResult<VehicleJourney | null>>;
getVehiclePositions?(routeId): Promise<MobilityResult<VehiclePosition[]>>;
getVehicleRadar?(bbox): Promise<MobilityResult<VehiclePosition[]>>;
// Alerts + facilities
getAlertsForStop?(stopId): Promise<MobilityResult<ServiceAlert[]>>;
getAlertsForRoute?(routeId): Promise<MobilityResult<ServiceAlert[]>>;
getAlertsForBbox?(bbox): Promise<MobilityResult<ServiceAlert[]>>;
getFacilities?(stopId): Promise<MobilityResult<Facility[]>>;
The canonical model types — TransitStop, Departure, TransitRoute,
TripPlan, VehiclePosition, ServiceAlert, and friends — come from
@openmapx/mobility-core/transit. Map your upstream's response into these shapes;
don't invent your own. planTrip takes a TripPlanRequest that already mirrors
the routing engine's knobs (mode allow-list, wheelchair routing, first- and
last-mile access modes), so a planning provider can pass them through rather than
re-deriving them.
Two further optional methods sit outside the declared capability set —
getRoutesInBbox and getReachableStops — for providers that want to opt into
bbox route geometry or isochrone-style reachability incrementally.
Per-feed attribution
When one integration fronts many feeds, each with its own license — a registry
wrapping dozens of agency APIs, or the local GTFS provider with one row per
imported feed — implement getFeedAttribution():
getFeedAttribution?(): Promise<Record<string, ProviderAttribution>>;
The keys must match what your results carry on TransitStop.provider,
VehicleJourney.provider, and ServiceAlert.providers[], so the frontend can
resolve the right license chip per result. A single-license provider doesn't need
this — its attribution array is enough.
How the orchestrator picks a provider
The transit orchestrator (integrations/transit/orchestrator.ts) collects every
registered transit provider via ctx.getIntegrationsByDomain("transit") and
selects among them with two strategies, depending on the request:
- By id (prefix routing). For a lookup keyed on a single id —
getStop,getRoute, a trip update — the orchestrator sorts providers by priority and returns the first whoseprefixthe id starts with and that is currently healthy. Because the id already encodes its origin (mb:70061), this dispatches straight back to the provider that minted it. - By area (bbox + priority). For a spatial request — stops near a point, a
journey plan, vehicles in view, alerts for an area — the orchestrator keeps
every provider whose
coverageoverlaps the request bbox ({ all: true }always overlaps), then sorts by priority ascending. For a journey plan it waterfalls: it tries each matching provider in priority order and returns the first that yields a usable itinerary. For fan-out queries (stops in an area, vehicle radar, area alerts) it queries the matching providers in parallel and merges, de-duplicating stops that appear in more than one feed.
Coverage overlap is a plain rectangle-intersection test, so a tight coverage.bbox
genuinely scopes a provider out of unrelated requests. Every provider call is also
timed and recorded into a sliding health window — a provider that starts failing
is taken out of rotation for a short cooldown and recovers on its own, so one
flaky upstream degrades to the next in the chain rather than failing the request.
The RealtimeProvider contract
Real-time providers live in the live-transit domain and implement a smaller
contract from
packages/integration-framework/src/contracts/realtime-provider.ts:
interface RealtimeProvider {
readonly id: string;
readonly coverage: { bbox: BBox } | { all: true };
readonly priority: number;
readonly capabilities: RealtimeCapabilities;
readonly attribution: Attribution[];
getVehiclePositions?(bbox): Promise<MobilityResult<VehiclePosition[]>>;
getAlertsForStop?(stopId): Promise<MobilityResult<ServiceAlert[]>>;
getAlertsForRoute?(routeId): Promise<MobilityResult<ServiceAlert[]>>;
getAlertsForBbox?(bbox): Promise<MobilityResult<ServiceAlert[]>>;
getTripUpdate?(tripId, stopId?): Promise<MobilityResult<TripUpdate | null>>;
healthCheck?(): Promise<HealthCheckResult>;
}
Its capabilities flags are vehiclePositions, tripUpdates, and the same
nested alerts group (byStop / byRoute / byBbox).
The central method is getTripUpdate. The orchestrator enriches scheduled
departures by walking the registered real-time providers (priority ascending,
filtered to those whose coverage overlaps the area) and asking each to resolve a
delta for a (tripId, stopId) pair. A TripUpdate carries the live
expectedAt, a signed delaySeconds, a canceled flag, and an optional
platform override. Return null when you can't resolve the trip — typically
because the id prefix isn't one you recognize — so the orchestrator moves on to
the next provider without a thrown error path or a wasted round-trip.
When the underlying schedule provider already returns real-time-aware times (MOTIS does this natively from GTFS-RT), the orchestrator skips the redundant enrichment pass, so a real-time provider only does work that adds something.
The provider factory
Every transit integration repeats the same attribution-and-wrapping boilerplate.
The framework factors it into defineTransitProvider(), exported from
@openmapx/integration-framework:
const { attribution, wrap, wrapRT, init } = defineTransitProvider();
init(ctx)— call once at the top ofsetup; it loads the attribution store fromctx.manifest.dataSources.attribution.all()— the resolvedAttribution[]to put on the provider.wrap(data)/wrapRT(data)— tag a payload with that attribution and a freshness stamp, producing theMobilityResult<T>every method must return.wrapRTis the same but flips thehasRealtimeDataflag — use it for departures, vehicle positions, and alerts; usewrapfor static stops and routes.
Everything that genuinely differs between providers stays explicit in setup —
the id, prefix, coverage, priority, the capabilities object, any
config wiring, and the method delegations. The factory only removes the parts
that are identical across the board.
Attribution and freshness
Provider code should not hand-roll Attribution objects. Declaring upstream
licenses in the manifest's dataSources and letting defineTransitProvider()
turn them into the attribution shape keeps credit metadata in one place — where
the legal pages and per-result credit chips also read it. Each dataSources entry
needs a globally unique sourceId; results are tagged with that id, and the host
maps it back to the right license. See the
integration system reference for the full
dataSources field list and the completeness gate that enforces it.
Registering a provider
A transit integration's backend entry point is small: configure the upstream
client, then register the provider object. The host calls setup(ctx) once at
load time.
import { defineTransitProvider, type IntegrationContext } from "@openmapx/integration-framework";
import * as mbta from "./provider.js";
const { attribution, wrap, wrapRT, init } = defineTransitProvider();
export function setup(ctx: IntegrationContext): void {
init(ctx);
mbta.setMbtaApiKey(ctx.config.apiKey as string | undefined);
ctx.registerTransitProvider({
id: "transit-mbta",
prefix: "mb:",
coverage: { bbox: [-71.9, 41.3, -69.9, 42.9] },
priority: 1,
attribution: attribution.all(),
capabilities: {
stops: {
lookup: true,
nearby: true,
bbox: false,
search: false,
infrastructure: false,
platforms: false,
timetable: false,
},
departures: true,
arrivals: false,
routes: { lookup: false, forStop: false, stops: false, geometry: false },
planning: false,
vehiclePositions: true,
vehicleJourney: false,
alerts: { byStop: true, byRoute: true, byBbox: false },
facilities: true,
},
async getStopsNearby(lat, lng, radiusMeters) {
return wrap(await mbta.getStops(lat, lng, radiusMeters));
},
async getStop(id) {
return wrap(await mbta.getStop(id));
},
async getDepartures(id, min) {
return wrapRT(await mbta.getDepartures(id, min));
},
async getVehiclePositions(routeId) {
return wrapRT(await mbta.getVehiclePositions(routeId));
},
async getFacilities(stopId) {
return wrap(await mbta.getFacilities(stopId));
},
async getAlertsForStop(stopId) {
return wrapRT(await mbta.getAlerts({ stopId }));
},
async getAlertsForRoute(routeId) {
return wrapRT(await mbta.getAlerts({ routeId }));
},
});
}
This is the real integrations/transit-mbta/index.ts, lightly trimmed. Note the
shape it follows: a tight regional bbox, priority: 1 for a precise agency
provider, vehicleJourney and arrivals left false because the upstream
doesn't model them, and the upstream HTTP itself kept in a separate
provider.ts of pure functions. The wrap / wrapRT split tracks which results
are real-time — departures, positions, and alerts use wrapRT; static stops and
facilities use wrap.
Registering a real-time provider follows the same pattern against
ctx.registerRealtimeProvider(...). A provider that has a meaningful self-check
can implement healthCheck() on the contract; thin pass-throughs can omit it and
declare ctx.registerHealthCheck(...) at setup instead, or rely on the manifest's
healthCheck probe.
The single most common contract bug is a capability flag set true with no
matching method, or a method shipped without flipping its flag. The orchestrator
dispatches on the flags, so a mismatch means either a method that never gets
called or a call that lands on undefined. Make the two agree, and let your
tests assert it.
Manifest essentials
A transit provider's manifest is an ordinary integration manifest with
"domains": ["transit"] (or ["live-transit"]). The fields that matter most
here:
dataSources— one entry per upstream feed, each with a uniquesourceId, the license, and privacy metadata. This is where attribution comes from.configSchema— declare anendpointand anyapiKey(mark a key"x-openmapx-secret": trueso it's handled as a credential).healthCheck— an HTTP probe against the upstream's status endpoint, with${configKey}interpolation when the probe needs a key.
The manifest schema, the config cascade, and the loader lifecycle are shared across all integrations and documented on the integration system page; the service manifest reference covers the separate backend-service side (running your own MOTIS or OpenTripPlanner) that a transit provider points at.