EU Directive 2010/40/EU mandates that every Member State operates a National Access Point (NAP) that publishes real-time traffic, safety, and multimodal travel data in DATEX II. On paper, that means one standard, 27 publishers, one integration.
In practice, every NAP serves a different DATEX II profile, over a different transport, behind a different authentication flow, on a different cadence. "DATEX II compliant" is a checkbox each Member State ticks differently.
This is what we ran into building NAPSPAN, a unified API across EU 27 + UK + EFTA. Here's the architecture that lets us treat 30 incompatible DATEX II feeds as if they were one.
The Spec, Then Reality
DATEX II is a mature European standard maintained by datex2.eu and coordinated by NAPCORE. Each delegated regulation references it as the mandatory exchange format:
- (EU) 2022/670 — RTTI: real-time events, speeds, road conditions
- (EU) No 886/2013 — SRTI: free, open access to safety alerts
- (EU) No 885/2013 — SSTP: safe and secure truck parking
- (EU) 2017/1926 — MMTIS: multimodal travel information
- AFIR Article 20 — live EV charging-station data
So the spec is clear. The implementation is anything but.
Where the NAPs Actually Diverge
"DATEX II compliant" hides at least five axes of divergence:
1. Profile
Each Member State publishes a national profile — a constrained subset of the DATEX II model. Germany's profile (German Traffic Data Profile) defines incidents, road works, and traffic flow on Autobahn + Bundesstraßen. France's transport.data.gouv.fr uses different sub-elements. The Netherlands' NDW publishes a tighter event taxonomy. The UK's NAP TDT wraps DATEX into TIH-specific containers. The DATEX II profiles directory lists them all — same root schema, every country a different shape.
2. Version
DATEX II v2.3 and v3.x are both in the wild. Some NAPs publish both. Some are mid-migration. The XML namespaces are different. Some elements were renamed between versions.
3. Transport
DATEX II is a payload format, not a transport. NAPs ship it over:
- HTTPS pull — you GET an XML document on a schedule
- HTTPS push (subscription) — you register an endpoint, the NAP POSTs to you
- SOAP-over-HTTPS — envelope-wrapped pull
- OCIT-C — the German telematics interface used by Mobilithek
- WFS / GeoJSON — some city-level supplements (Hamburg, Düsseldorf)
4. Authentication
The matrix here is wide:
- None — SRTI safety feeds are mandated free and open
- Registered consumer (no key) — NAP whitelists your origin or service endpoint
- Bearer / API key — standard token in a header
- TLS client certificate (mTLS) — Germany's Mobilithek
- OAuth2 — some operator portals
5. Cadence and freshness
Some publications update every 30 seconds (motorway flow). Others refresh nightly (planned road-work calendars). Some sit behind a CDN that lies about freshness via stale Last-Modified headers. Adaptive backoff matters.
The Format Zoo, in DATEX II Form
Here's roughly the same incident from three different NAPs:
Mobilithek (DE) — OCIT-C envelope wrapping a v2.3 SituationPublication:
<d2:payloadPublication
xsi:type="d2:SituationPublication"
xmlns:d2="http://datex2.eu/schema/2/2_0">
<d2:situation id="DE_BAB7_4711">
<d2:situationRecord
xsi:type="d2:Accident"
id="DE_BAB7_4711_R1">
<d2:probabilityOfOccurrence>certain</d2:probabilityOfOccurrence>
<d2:groupOfLocations xsi:type="d2:Point">
<d2:pointByCoordinates>
<d2:pointCoordinates>
<d2:latitude>53.5511</d2:latitude>
<d2:longitude>9.9937</d2:longitude>
</d2:pointCoordinates>
</d2:pointByCoordinates>
</d2:groupOfLocations>
</d2:situationRecord>
</d2:situation>
</d2:payloadPublication>
NDW (NL) — v3.x with a tighter VmsTablePublication-style envelope, JSON-LD permitted:
{
"@context": "https://datex2.eu/schema/3/jsonld",
"publicationCreator": { "country": "nl", "nationalIdentifier": "NDW" },
"situation": [{
"id": "NDW_2026_05_01_42",
"situationRecord": [{
"@type": "Accident",
"severity": "high",
"groupOfLocations": {
"locationContainedInGroup": [{
"pointByCoordinates": { "latitude": 52.0907, "longitude": 5.1214 }
}]
}
}]
}]
}
UK NAP TDT — DATEX wrapped in a TIH container with a per-publisher feed envelope:
<tih:feed xmlns:tih="https://nap.tdt.gov.uk/tih">
<tih:publisher>National Highways</tih:publisher>
<d2:payloadPublication xsi:type="d2:SituationPublication">
<d2:situation id="NH-INC-9001">...</d2:situation>
</d2:payloadPublication>
</tih:feed>
Same regulation. Same standard. Three different parsers required.
The Architecture
The solution is the same one we use on the North American side: an adapter registry with one function signature.
type FetchFunc func(ctx context.Context, sr SourceResource) (*FetchResult, error)
type FetchResult struct {
Events []TrafficEvent
Features []Feature
ResponseBytes int
}
Every NAP adapter — whether it's pulling Mobilithek's OCIT-C broker, polling NDW's v3 JSON-LD, or unwrapping a TIH envelope — implements this one interface. It takes a source-resource config (URL, credentials, country code, profile version) and returns normalized events and features.
Registration happens at init time:
func init() {
Register("mobilithek", "events", fetchMobilithekSituations)
Register("mobilithek", "afir_charging", fetchMobilithekAFIR)
Register("mobilithek", "parking", fetchMobilithekParking)
Register("ndw", "events", fetchNDWSituations)
Register("ndw", "vms", fetchNDWVMS)
Register("uk_tdt", "events", fetchUKTDTSituations)
Register("transport_data_gouv_fr", "events", fetchFRSituations)
// ... 30 NAPs, ~120 (adapter, resource) registrations
}
The scheduler doesn't know or care which DATEX II profile, version, or transport each adapter handles. It just calls Fetch() and gets back normalized events and features.
The Normalized Model
Everything converges into two tables:
traffic_events — time-bounded incidents with lifecycle tracking:
type TrafficEvent struct {
ID string
Source string // "mobilithek", "ndw", "uk_tdt"
Jurisdiction string // "DE", "NL", "GB"
Type EventType // incident, construction, closure, weather
Severity Severity // minor, moderate, major, critical
Status EventStatus // active, archived
Title string
Description string
AffectedRoads []string
Direction string
LanesAffected string
Latitude, Longitude float64
StartTime time.Time
EndTime *time.Time
EstimatedEndTime *time.Time
RoadClass string // motorway, trunk, primary, secondary
Metadata json.RawMessage // DATEX-II-specific fields preserved
}
features — a generic table for everything else. Cameras, VMS signs, weather stations, parking sites, EV charging stations, truck-route segments, bridge clearances — all use the same table with a feature_type discriminator and type-specific fields in a JSONB properties column. Adding a new resource type requires zero schema migrations.
The Hard Parts
1. Profile Field Mapping
The German profile uses locationDescriptor with roadName. The Dutch v3 profile uses a top-level roadInformation structure. The UK feeds bury the road name three levels deep inside tih:routeContext. Each adapter has a small mapping function that knows where its profile keeps each canonical field. Where a profile has nothing to map to, the field is left empty — we don't synthesize data we don't have.
2. Lifecycle Tracking
A DATEX II SituationRecord has a situationRecordVersion and a situationRecordVersionTime. Each new pull may bring a new version of the same record. Some NAPs increment the version on every minor metadata change; others only on substantive updates.
The solution: diff the current state against the previous fetch. Track every change in an event_history table — severity changes, description updates, lane changes, archival. This enables analytics like clearance time percentiles and corridor reliability across borders.
3. Coordinate Reference Systems
Most NAPs publish WGS84 (EPSG:4326). Some German state feeds publish ETRS89 / UTM zones. A few city feeds use the local cadastral system. PostGIS handles transformation, but each adapter has to know what it's receiving.
4. Rate Limiting at Scale
30 NAPs, each with multiple resource types (events, VMS, parking, EV charging, weather), each polling on a 30 sec to 5 min cadence. The scheduler uses per-server semaphores with configurable concurrency limits and request gaps. Circuit breakers back off on repeated failures. Adaptive backoff increases poll intervals when a NAP is slow or returning errors — important when a single NAP can otherwise drag down a whole region's freshness.
5. Schema-Free Feature Types
When we started on the North American side, we had separate tables for cameras, VMS, parking. Every new data type meant a migration. The pivot to a generic features table with JSONB properties was the best architectural decision in the project. On the EU side, that decision pays off again the moment AFIR EV charging data comes online — zero schema changes, just a new feature_type.
The API
After all that normalization work, the API is straightforward:
Get active incidents in Germany:
curl "https://api.napspan.com/api/v1/events?country=DE&type=incident&status=active" \
-H "X-API-Key: your_key"
{
"data": [
{
"id": "de_mobilithek_4711",
"country": "DE",
"type": "incident",
"severity": "major",
"title": "Verkehrsunfall A7 Richtung Hamburg",
"affected_roads": ["A7"],
"direction": "Hamburg",
"lanes_affected": "2 of 3 lanes closed",
"latitude": 53.5511,
"longitude": 9.9937,
"start_time": "2026-05-01T08:15:00Z",
"estimated_end_time": "2026-05-01T12:00:00Z"
}
],
"total": 142,
"limit": 100,
"offset": 0,
"has_more": true
}
Get traffic cameras in the Netherlands as GeoJSON:
curl "https://api.napspan.com/api/v1/features/geojson?type=cameras&country=NL" \
-H "X-API-Key: your_key"
Drop the response directly into Leaflet, Mapbox, or any GeoJSON-compatible tool.
Query a cross-border corridor (Berlin to Amsterdam):
curl "https://api.napspan.com/api/v1/events/corridor?\
from_lat=52.52&from_lng=13.40&\
to_lat=52.37&to_lng=4.90&buffer_km=5" \
-H "X-API-Key: your_key"
One call, two NAPs (Mobilithek + NDW), one consistent response shape.
What's in the Data
Once normalized, the dataset includes:
- Real-time RTTI events — incidents, road works, traffic flow
- SRTI safety alerts (free, open under 886/2013)
- VMS dynamic message-sign content with current displayed text
- SSTP safe and secure truck parking with availability
- AFIR Article 20 EV charging-point status
- Traffic cameras with image URLs (where the NAP exposes them)
- RWIS-style weather-station readings
- TEN-T core network corridor metadata
- Bridge clearances and weight restrictions per Member State
Tech Stack
- Go — chi router, pgx v5, slog, encoding/xml + custom DATEX schema parsers
- PostgreSQL + PostGIS — spatial queries, GeoJSON generation, corridor intersection
- Redis — response caching, feature detail caching (nil-safe, optional)
- Vue 3 + Leaflet — live traffic map
Try It
The API is live with a free tier (no credit card):
- Live map — explore the data visually
- API docs — full endpoint reference
- Developer portal — sign up and get an API key
If you're building anything on European mobility data — logistics, fleet routing, navigation, urban-mobility dashboards — we'd love to hear which NAPs and which DATEX II profiles you most need normalized first. The hardest part isn't the parsing; it's knowing which Member State publishes which data on which transport behind which auth flow. After 30 NAPs, we've built a pretty good map of that.
Ready to try NAPSPAN?
Free 14-day trial. No credit card. EU 27 + UK + EFTA, normalized.
Get Free API Key Explore the Map