← Back to all posts
DATEX II Architecture EU

How We Normalize DATEX II Across 30 European National Access Points

May 1, 2026 · 11 min read

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:

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:

4. Authentication

The matrix here is wide:

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:

Tech Stack

Try It

The API is live with a free tier (no credit card):


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