STIB is the main public transport operator in Brussels. It has its own open data platform
which provides different data sets. The GTFS feed is used to provide the schedule of the
vehicles. Contrary to the SNCB and other operators, the STIB does not provide a GTFS-RT feed.
It provides different proprietary APIs to get the real-time data. The MobilityTwin.Brussels
platform provides a vehicle_position endpoint which provides the estimated positions of
the vehicles based on the GTFS feed and the proprietary APIs. It also provides the vehicle_schedule
endpoint which provides the schedule of the vehicles per stop for a given period of time.
import gtfs_kit as gk
import requests
import tempfile
from datetime import datetime, timedelta, timezone
yesterday_noon = (datetime.now(timezone.utc) - timedelta(days=1)).replace(
hour=12, minute=0, second=0, microsecond=0
)
params = {
"timestamp": yesterday_noon.isoformat(), # ISO accepted; epoch also works
}
url = "https://api.mobilitytwin.brussels/stib/gtfs"
data = requests.get(url, headers={
'Authorization': 'Bearer [MY_API_KEY]'
}, params=params).content
with tempfile.NamedTemporaryFile(delete=False, suffix=".zip") as f:
f.write(data)
feed = gk.read_feed(f.name, 'm')
# Explore the STIB schedule
print(f"Routes: {len(feed.routes)}")
print(f"Stops: {len(feed.stops)}")
print(f"Trips: {len(feed.trips)}")
print("\nRoutes:")
print(feed.routes[['route_short_name', 'route_long_name', 'route_type']])
GTFS (Parquet)
/stib/gtfs-parquet
GTFS-PARQUETAPPLICATION/ZIP
The GTFS feed of STIB/MIVB converted to Apache Parquet format (zip archive of .parquet files). Parquet uses columnar storage with zstd compression and strong typing, resulting in 40-75% smaller files compared to the original GTFS zip. This format enables extremely efficient data transfer and near-zero RAM overhead when reading specific columns via Polars or DuckDB, making it ideal for analytical workloads and large-scale processing. Produced using gtfs-parquet v0.4.0.
Refresh: Daily (regenerated from the GTFS feed)
From
2024-04-05 06:20:01
To
2026-08-31 06:20:07
Records
689
# pip install gtfs-parquet>=0.4.0
import requests
import tempfile
from gtfs_parquet import read_parquet
from gtfs_parquet.ops.network import describe
from gtfs_parquet.ops.calendar import get_first_week, compute_busiest_date
from gtfs_parquet.ops.routes import compute_route_stats
from datetime import datetime, timedelta, timezone
yesterday_noon = (datetime.now(timezone.utc) - timedelta(days=1)).replace(
hour=12, minute=0, second=0, microsecond=0
)
params = {
"timestamp": yesterday_noon.isoformat(), # ISO accepted; epoch also works
}
url = "https://api.mobilitytwin.brussels/stib/gtfs-parquet"
data = requests.get(url, headers={
'Authorization': 'Bearer [MY_API_KEY]'
}, params=params).content
with tempfile.NamedTemporaryFile(delete=False, suffix=".zip") as f:
f.write(data)
feed = read_parquet(f.name)
# Quick summary of the feed
print(describe(feed))
# Find the busiest date in the first week
week = get_first_week(feed)
busiest = compute_busiest_date(feed, week)
print(f"Busiest date: {busiest}")
# Compute per-route statistics for that week
route_stats = compute_route_stats(feed, week)
print(route_stats.sort("num_trips", descending=True))
Segments
/stib/segments
GEOJSONAPPLICATION/JSON
The segments of the STIB/MIVB network
Refresh: Daily (derived from the STIB shapefile and stops)
From
2024-08-21 14:48:24
To
2026-08-31 05:20:01
Records
660
Stops
/stib/stops
GEOJSONAPPLICATION/JSON
The stops of STIB/MIVB. The data was enriched and cleaned for easier use.
Refresh: Daily (derived from stops-by-line and stop-details)
From
2024-08-21 14:48:23
To
2026-08-31 02:20:00
Records
218
import requests
import geopandas as gpd
from datetime import datetime, timedelta, timezone
yesterday_noon = (datetime.now(timezone.utc) - timedelta(days=1)).replace(
hour=12, minute=0, second=0, microsecond=0
)
params = {
"timestamp": yesterday_noon.isoformat(), # ISO accepted; epoch also works
}
url = "https://api.mobilitytwin.brussels/stib/stops"
data = requests.get(url, headers={
'Authorization': 'Bearer [MY_API_KEY]'
}, params=params).json()
gdf = gpd.GeoDataFrame.from_features(data["features"])
# List stops for a specific line
line = "1"
line_stops = gdf[gdf['route_short_name'] == line].sort_values('stop_sequence')
print(f"Stops on line {line}:")
for _, stop in line_stops.iterrows():
print(f" {stop['stop_sequence']}. {stop['stop_name']}")
# Plot all stops on a map
gdf.plot(figsize=(10, 8), markersize=3, color="red")
Vehicle distance
/stib/vehicle-distance
JSONAPPLICATION/JSON
This endpoint provides the raw data of the STIB/MIVB proprietary API which returns the distance of each vehicle since the last stop.
Refresh: Every 20 seconds
From
2023-02-24 17:55:46
To
2026-08-31 07:14:42
Records
4,851,023
import requests
import pandas as pd
from datetime import datetime, timedelta, timezone
end = datetime.now(timezone.utc).replace(minute=0, second=0, microsecond=0)
start = end - timedelta(hours=1)
params = {
"start_timestamp": start.isoformat(), # ISO accepted; epoch also works
"end_timestamp": end.isoformat(),
}
url = "https://api.mobilitytwin.brussels/stib/vehicle-distance"
data = requests.get(url, headers={
'Authorization': 'Bearer [MY_API_KEY]'
}, params=params).json()
# Convert to DataFrame for analysis
df = pd.DataFrame(data)
# Group by line and compute average distance from stop
avg_distance = df.groupby('lineId')['distanceFromPoint'].mean()
print("Average distance from stop per line:")
print(avg_distance.sort_values(ascending=False))
Vehicle position
/stib/vehicle-position
GEOJSONAPPLICATION/JSON
The estimated positions of the vehicles based on the GTFS feed and the proprietary APIs. Because the STIB/MIVB proprietary API does not provide the identity of the vehicles, the MobilityTwin.Brussels platform also performs computations to attribute a unique identity to each vehicle along a given trip. These ids do not correspond to the ids of the GTFS feed but are rather generated randomly. The ids are unique for a given trip. To track a vehicle across consecutive queries, use the uuid field which remains stable for the duration of a trip.
Refresh: Every 20 seconds (computed from vehicle-distance)
From
2024-08-21 14:54:58
To
2026-08-31 07:12:58
Records
2,858,163
import requests
import geopandas as gpd
from datetime import datetime, timedelta, timezone
yesterday_noon = (datetime.now(timezone.utc) - timedelta(days=1)).replace(
hour=12, minute=0, second=0, microsecond=0
)
params = {
"timestamp": yesterday_noon.isoformat(), # ISO accepted; epoch also works
}
url = "https://api.mobilitytwin.brussels/stib/vehicle-position"
data = requests.get(url, headers={
'Authorization': 'Bearer [MY_API_KEY]'
}, params=params).json()
gdf = gpd.GeoDataFrame.from_features(data["features"])
# Count vehicles per line
vehicles_per_line = gdf.groupby('lineId').size().sort_values(ascending=False)
print("Active vehicles per line:")
print(vehicles_per_line)
# Plot vehicles colored by line
gdf.plot(figsize=(10, 8), column='color', legend=True, markersize=8)
Punctuality
/stib/punctuality
PARQUETAPPLICATION/OCTET-STREAM
Daily punctuality table for STIB/MIVB, reconstructed from the anonymous vehicle-distance feed. STIB publishes no GTFS-RT trip updates and no vehicle identity: the source is a set of positions every 20 seconds, each carrying only a line, a direction, the stop point last passed and the metres since. This table is therefore *derived*, not reported. Vehicles are recovered by aligning consecutive polls under the constraint that vehicles on a line do not overtake each other; stop calls are read where a vehicle's distance along the line crosses a stop; and GTFS trip ids are assigned by a second order-preserving alignment of the day's journeys against the day's timetable. Columns match the other operators' punctuality tables, with three appended: `journey_id`, `match_deviation_minutes`, and `observed`/`inferred`. Two limits are structural and must be read before use. **`cancelled` is always false and `trip_schedule_relationship` always 0** — the feed carries no cancellation signal at all, and an unmatched trip is equally evidence that the tracker lost the vehicle, so no cancellation is claimed rather than guessed. Do not compute a STIB cancellation rate from this table. **`observed` marks a measured time and `inferred` a derived one**; they are mutually exclusive, and a consumer wanting measurement only should filter on `observed`. Rows with neither carry a `missing_reason`. Typical quality, measured across 146 days spanning April 2024 to August 2026: about 92% of timetabled trips matched on days without a feed outage, 88% of scheduled stop calls carrying a measured time and 94% once inference is included, with a median match deviation of 1.3 minutes. Quality does not degrade with age. The denominator is the timetable, and since no cancellation signal exists, a trip that was short-turned or never ran keeps all of its scheduled calls.
Refresh: Daily (one file per Brussels service day)
From
2024-04-06 00:00:00
To
2024-12-07 00:00:00
Records
243
Speed
/stib/speed
JSONAPPLICATION/JSON
The average speed of the vehicles of STIB/MIVB on a 20 seconds interval, per line, stop and direction.
Refresh: Every 20 seconds (computed from vehicle-distance)
From
2024-08-21 14:54:58
To
2026-08-31 07:14:20
Records
2,709,608
import requests
import pandas as pd
from datetime import datetime, timedelta, timezone
yesterday_noon = (datetime.now(timezone.utc) - timedelta(days=1)).replace(
hour=12, minute=0, second=0, microsecond=0
)
params = {
"timestamp": yesterday_noon.isoformat(), # ISO accepted; epoch also works
}
url = "https://api.mobilitytwin.brussels/stib/speed"
data = requests.get(url, headers={
'Authorization': 'Bearer [MY_API_KEY]'
}, params=params).json()
df = pd.DataFrame(data)
# Average speed per line (km/h)
speed_per_line = df.groupby('lineId')['speed'].mean().sort_values()
print("Average speed per line (km/h):")
print(speed_per_line)
# Find the slowest segments
slowest = df.nsmallest(5, 'speed')[['lineId', 'pointId', 'speed']]
print("\nSlowest segments:")
print(slowest)
Aggregated speed
/stib/aggregated-speed
JSONAPPLICATION/JSON
The average speed of the vehicles of STIB/MIVB on a 10 minutes interval, per line, stop and direction.
Refresh: Every 20 seconds (10-minute rolling average)
From
2024-08-21 15:01:24
To
2026-08-31 07:14:20
Records
2,621,087
import requests
import pandas as pd
from datetime import datetime, timedelta, timezone
yesterday_noon = (datetime.now(timezone.utc) - timedelta(days=1)).replace(
hour=12, minute=0, second=0, microsecond=0
)
params = {
"timestamp": yesterday_noon.isoformat(), # ISO accepted; epoch also works
}
url = "https://api.mobilitytwin.brussels/stib/aggregated-speed"
data = requests.get(url, headers={
'Authorization': 'Bearer [MY_API_KEY]'
}, params=params).json()
df = pd.DataFrame(data)
# Compare average speed across lines over 10-minute intervals
speed_by_line = df.groupby('lineId')['speed'].agg(['mean', 'min', 'max'])
print("Speed statistics per line (km/h):")
print(speed_by_line.sort_values('mean'))
Trips
/stib/trips
MF-JSONAPPLICATION/JSON
All the trips of STIB/MIVB for the specified period of time. This is an aggregate of the GeoJSON files returned by the vehicle-position endpoint of MobilityTwin.Brussels.
Refresh: On request — aggregated from vehicle-position over the queried interval
Availability depends on source data
import requests
import movingpandas as mpd
from datetime import datetime, timedelta, timezone
end = datetime.now(timezone.utc).replace(minute=0, second=0, microsecond=0)
start = end - timedelta(hours=1)
params = {
"start_timestamp": start.isoformat(), # ISO accepted; epoch also works
"end_timestamp": end.isoformat(),
}
url = "https://api.mobilitytwin.brussels/stib/trips"
data = requests.get(url, headers={
'Authorization': 'Bearer [MY_API_KEY]'
}, params=params).json()
# Load as a MovingPandas TrajectoryCollection
tc = mpd.io.read_mf_dict(data, traj_id_property="uuid")
# Compute speed and distance for each vehicle trajectory
for traj in tc.trajectories[:5]:
print(f"Vehicle {traj.id}: {traj.get_length():.0f}m, duration: {traj.get_duration()}")
# Plot all vehicle trajectories
tc.plot(figsize=(12, 8), linewidth=0.5)
Shapefile
/stib/shapefile
GEOJSONAPPLICATION/JSON
The shapefile of STIB/MIVB
Refresh: Daily (derived from the STIB shapefile)
From
2024-08-21 14:48:24
To
2026-08-31 05:20:01
Records
714
import requests
import geopandas as gpd
from datetime import datetime, timedelta, timezone
yesterday_noon = (datetime.now(timezone.utc) - timedelta(days=1)).replace(
hour=12, minute=0, second=0, microsecond=0
)
params = {
"timestamp": yesterday_noon.isoformat(), # ISO accepted; epoch also works
}
url = "https://api.mobilitytwin.brussels/stib/shapefile"
data = requests.get(url, headers={
'Authorization': 'Bearer [MY_API_KEY]'
}, params=params).json()
gdf = gpd.GeoDataFrame.from_features(data["features"])
# Plot the full STIB network colored by line
gdf.plot(figsize=(12, 10), color=gdf['couleur_hex'], linewidth=1)
# List all unique lines
print("STIB lines:", sorted(gdf['ligne'].unique()))
Stop details
/stib/stop-details
JSONAPPLICATION/JSON
Detailed information about each STIB/MIVB stop including GPS coordinates and names in French and Dutch.
Refresh: Daily
From
2026-04-29 11:31:03
To
2026-08-31 02:20:00
Records
107
import requests
import json
from datetime import datetime, timedelta, timezone
yesterday_noon = (datetime.now(timezone.utc) - timedelta(days=1)).replace(
hour=12, minute=0, second=0, microsecond=0
)
params = {
"timestamp": yesterday_noon.isoformat(), # ISO accepted; epoch also works
}
url = "https://api.mobilitytwin.brussels/stib/stop-details"
data = requests.get(url, headers={
'Authorization': 'Bearer [MY_API_KEY]'
}, params=params).json()
# Parse and display stop details
for stop in data['results'][:10]:
name = json.loads(stop['name'])
coords = json.loads(stop['gpscoordinates'])
print(f"Stop {stop['id']}: {name['fr']} / {name['nl']} ({coords['latitude']}, {coords['longitude']})")
Stops by line
/stib/stops-by-line
JSONAPPLICATION/JSON
The ordered list of stops for each STIB/MIVB line, per direction.
Refresh: Daily
From
2026-04-29 11:31:02
To
2026-08-31 02:20:00
Records
107
import requests
import json
from datetime import datetime, timedelta, timezone
yesterday_noon = (datetime.now(timezone.utc) - timedelta(days=1)).replace(
hour=12, minute=0, second=0, microsecond=0
)
params = {
"timestamp": yesterday_noon.isoformat(), # ISO accepted; epoch also works
}
url = "https://api.mobilitytwin.brussels/stib/stops-by-line"
data = requests.get(url, headers={
'Authorization': 'Bearer [MY_API_KEY]'
}, params=params).json()
# Display the route of a specific line
for route in data['results']:
if route['lineid'] == '1':
dest = json.loads(route['destination'])
stops = json.loads(route['points'])
print(f"Line {route['lineid']} → {dest['fr']} ({route['direction']})")
print(f" {len(stops)} stops: {stops[0]['id']} → {stops[-1]['id']}")
Waiting times
/stib/waiting-times
JSONAPPLICATION/JSON
Real-time waiting times at STIB/MIVB stops with expected arrival times per line and destination.
Refresh: Every 60 seconds
From
2026-03-30 11:00:43
To
2026-08-31 07:14:19
Records
281,560
import requests
import json
from datetime import datetime
from datetime import datetime, timedelta, timezone
end = datetime.now(timezone.utc).replace(minute=0, second=0, microsecond=0)
start = end - timedelta(hours=1)
params = {
"start_timestamp": start.isoformat(), # ISO accepted; epoch also works
"end_timestamp": end.isoformat(),
}
url = "https://api.mobilitytwin.brussels/stib/waiting-times"
data = requests.get(url, headers={
'Authorization': 'Bearer [MY_API_KEY]'
}, params=params).json()
# Show next arrivals at each stop
for entry in data['results'][:10]:
times = json.loads(entry['passingtimes'])
for t in times:
dest = t['destination']['fr']
arrival = datetime.fromisoformat(t['expectedArrivalTime'])
print(f"Stop {entry['pointid']} — Line {entry['lineid']} → {dest} at {arrival:%H:%M}")
Travellers information
/stib/travellers-information
JSONAPPLICATION/JSON
Real-time traveller information messages and service alerts for STIB/MIVB lines and stops.
Refresh: Every 15 minutes
From
2026-03-30 11:15:23
To
2026-08-31 07:12:29
Records
10,377
import requests
import json
from datetime import datetime, timedelta, timezone
yesterday_noon = (datetime.now(timezone.utc) - timedelta(days=1)).replace(
hour=12, minute=0, second=0, microsecond=0
)
params = {
"timestamp": yesterday_noon.isoformat(), # ISO accepted; epoch also works
}
url = "https://api.mobilitytwin.brussels/stib/travellers-information"
data = requests.get(url, headers={
'Authorization': 'Bearer [MY_API_KEY]'
}, params=params).json()
# Display active service alerts sorted by priority
for alert in sorted(data['results'], key=lambda x: x['priority'], reverse=True):
content = json.loads(alert['content'])
lines = json.loads(alert['lines'])
text = content[0]['text'][0]['en']
line_ids = ', '.join(l['id'] for l in lines)
print(f"[Priority {alert['priority']}] Lines {line_ids}: {text}")