EPCIS 1.2 vs 2.0 Migration Checklist

A trading partner announces they are sunsetting their EPCIS 1.2 SOAP query endpoint in favor of a REST/JSON-LD interface, and suddenly your entire ingestion and verification stack has a deadline. Migrating a serialization pipeline from EPCIS 1.2 XML to EPCIS 2.0 JSON-LD is not a drop-in schema swap — it touches every event producer, the repository schema, and the query path your Verification Router Service Architecture depends on. This checklist is a practical companion to GS1 Standards Implementation, the section establishing the identifier and event contract this migration must preserve, inside the broader DSCSA Compliance Architecture & Standards Mapping framework. The goal is narrow and operational: convert every ObjectEvent, AggregationEvent, TransactionEvent, and TransformationEvent you currently emit as EPCIS 1.2 XML into schema-valid EPCIS 2.0 JSON-LD, stand up the REST query interface alongside the old SOAP binding, and cut over without breaking Transaction Information continuity.

Dual-running cutover architecture from EPCIS 1.2 to EPCIS 2.0 Line events feed two parallel write paths: one persists EPCIS 1.2 XML to the legacy SOAP-queried repository, the other converts each event to EPCIS 2.0 JSON-LD and persists it to the REST-queried repository. Both repositories feed a dual-read consistency check, which gates the eventual cutover to REST-only queries. legacy XML path JSON-LD path Line eventscommission / ship EPCIS 1.2 XML repoSOAP query interface convert_epcis_12_event() EPCIS 2.0 JSON-LD repoREST query interface Dual-readconsistency check Cutover gateREST-only, retire SOAP

EPCIS 1.2 vs 2.0 at a Glance

Before touching code, agree on exactly what changes and what stays the same. The event semantics your compliance program depends on — the four event types, the business-step vocabulary, the SGTIN and SSCC identifiers — are unchanged. What changes is serialization, transport, and the vocabulary version those events reference.

Aspect EPCIS 1.2 EPCIS 2.0
Serialization format XML only JSON / JSON-LD (XML still permitted)
Query interface SOAP (poll/query binding) REST (/events, capture/query interfaces)
Core Business Vocabulary CBV 1.x URNs CBV 2.0 URNs, same namespace pattern
Event types ObjectEvent, AggregationEvent, TransactionEvent, TransformationEvent Same four types, plus optional richer context
eventID Optional, rarely populated Effectively required for traceability tooling
Sensor / IoT data Not natively supported Native sensorElementList extension
Master data (lot, expiry) ilmd under CBV-MDA extension namespace ilmd retained, expressed through JSON-LD context
Context declaration N/A (XML namespaces only) Mandatory @context binding
DSCSA relevance Fully compliant today; many partners still send it Forward-looking baseline as partners modernize
Backward compatibility Event semantics unchanged; only encoding and transport differ

The practical read: nothing about what a compliant event means changes, so this is a transport and encoding project, not a re-architecture of your business logic — the checklist below is almost entirely about faithful field-by-field conversion.

Prerequisites

  • Python 3.10+ — the converter uses dict[str, Any] generics and X | None union types throughout.
  • lxml for parsing the existing EPCIS 1.2 XML source, per Parsing EPCIS XML with Python lxml Efficiently — this migration assumes fields are already extracted into plain dicts.
  • httpx (or aiohttp) for the new REST query client, replacing whatever SOAP client (zeep, hand-rolled envelopes) you use today.
  • The official GS1 EPCIS 2.0 JSON schema and JSON-LD context, pinned locally, as described in Step-by-step guide to EPCIS 2.0 event formatting — this checklist assumes that formatting contract and does not repeat its rules.
  • A staging repository able to run the JSON-LD schema alongside your live XML repository, since the dual-run steps below require writing to both.
  • DSCSA data prerequisites — every event converted must already carry a resolved GTIN (01), serial (21), lot (10), and expiration (17); this checklist re-serializes correct identifiers, it does not fix bad ones.

Step-by-Step Solution

Step 1 — Inventory event types and pin the field-rename map

Before writing a single converter, enumerate every EPCIS 1.2 event type your producers actually emit — most pipelines under-count TransformationEvent usage because repackaging is rarer than commissioning, and a migration that silently drops one event type breaks pedigree reconstruction for exactly the units that were reworked. Then fix the field-rename map once: EPCIS 1.2 relies on XML namespaces (urn:epcglobal:epcis:xsd:1, urn:epcglobal:cbv:mda), while EPCIS 2.0 replaces that with a mandatory @context declaration binding compact JSON keys to the same GS1-defined URIs. eventTime/eventTimeZoneOffset, epcList, bizStep, disposition, and readPoint/id keep their names; ilmd’s CBV-MDA children move from qualified XML elements to JSON-LD-scoped keys.

EPCIS_2_0_CONTEXT = "https://ref.gs1.org/standards/epcis/2.0.0/epcis-context.jsonld"

# Event types this migration must cover — an incomplete inventory here
# is the single most common cause of a partner-facing traceability gap.
MIGRATED_EVENT_TYPES = ("ObjectEvent", "AggregationEvent", "TransactionEvent", "TransformationEvent")

DSCSA/GS1 note: the four event types carry distinct DSCSA meaning — commissioning, packaging, ownership change, and repackaging — so the migration scope must cover all four before any partner is cut over, and keeping the rename map explicit lets an auditor be shown exactly how a 1.2 record maps to its 2.0 equivalent even after the source format is retired.

Step 2 — Convert ObjectEvent, AggregationEvent, TransactionEvent, and TransformationEvent payloads

This is the core of the migration: one converter taking a parsed EPCIS 1.2 event (a plain dict, as an lxml-based extractor produces) and emitting a schema-shaped EPCIS 2.0 JSON-LD dict for any of the four event types.

from __future__ import annotations

import uuid
from typing import Any

EPCIS_2_0_CONTEXT = "https://ref.gs1.org/standards/epcis/2.0.0/epcis-context.jsonld"

_EVENT_TYPES = {"ObjectEvent", "AggregationEvent", "TransactionEvent", "TransformationEvent"}


def convert_epcis_12_event(event: dict[str, Any], event_type: str) -> dict[str, Any]:
    """Convert a parsed EPCIS 1.2 XML event into an EPCIS 2.0 JSON-LD event.

    `event` mirrors the field names an XML extractor produces, e.g.
    {"eventTime": "...", "epcList": [...], "bizStep": "...", ...}.
    """
    if event_type not in _EVENT_TYPES:
        raise ValueError(f"unsupported EPCIS event type: {event_type!r}")

    doc: dict[str, Any] = {
        "@context": EPCIS_2_0_CONTEXT,
        "type": event_type,
        "eventID": event.get("eventID") or _synthetic_event_id(event, event_type),
        "eventTime": _normalize_timestamp(event["eventTime"]),
        "eventTimeZoneOffset": event["eventTimeZoneOffset"],
    }

    if event.get("action"):
        doc["action"] = event["action"]

    if event_type in ("ObjectEvent", "TransactionEvent") and event.get("epcList"):
        doc["epcList"] = list(event["epcList"])

    if event_type == "AggregationEvent":
        doc["parentID"] = event["parentID"]
        doc["childEPCs"] = list(event.get("childEPCs", []))

    if event_type == "TransformationEvent":
        doc["inputEPCList"] = list(event.get("inputEPCList", []))
        doc["outputEPCList"] = list(event.get("outputEPCList", []))

    if event.get("bizStep"):
        doc["bizStep"] = event["bizStep"]
    if event.get("disposition"):
        doc["disposition"] = event["disposition"]
    if event.get("readPoint"):
        doc["readPoint"] = {"id": event["readPoint"]}
    if event.get("bizTransactionList"):
        doc["bizTransactionList"] = [
            {"type": btt, "bizTransaction": ref}
            for btt, ref in event["bizTransactionList"]
        ]

    ilmd = _convert_ilmd(event)
    if ilmd:
        doc["ilmd"] = ilmd

    return doc


def _convert_ilmd(event: dict[str, Any]) -> dict[str, str] | None:
    # EPCIS 1.2 carries lot (10) and expiry (17) in an ilmd block under the
    # CBV-MDA extension namespace; 2.0 keeps ilmd but resolves it through
    # the JSON-LD @context instead of an XML namespace prefix.
    lot = event.get("lot_number")
    expiry = event.get("expiration_date")
    if not (lot or expiry):
        return None
    ilmd: dict[str, str] = {}
    if lot:
        ilmd["cbvmda:lotNumber"] = lot
    if expiry:
        ilmd["cbvmda:itemExpirationDate"] = expiry
    return ilmd


def _normalize_timestamp(xml_event_time: str) -> str:
    # EPCIS 1.2 eventTime is already ISO 8601; only the trailing "Z" style
    # needs normalizing so every downstream JSON-LD consumer sees one offset form.
    return xml_event_time[:-1] + "+00:00" if xml_event_time.endswith("Z") else xml_event_time


def _synthetic_event_id(event: dict[str, Any], event_type: str) -> str:
    # EPCIS 1.2 rarely populated eventID; 2.0 tooling expects one. Derive a
    # stable UUID5 from fields that already uniquely identify the event so
    # re-running the converter on the same source is idempotent.
    key = "|".join((event_type, str(event.get("eventTime", "")), str(event.get("epcList", ""))))
    return f"urn:uuid:{uuid.uuid5(uuid.NAMESPACE_URL, key)}"

DSCSA/GS1 note: the parentID/childEPCs and inputEPCList/outputEPCList shapes preserve the exact packaging and repackaging semantics DSCSA pedigree reconstruction relies on — an AggregationEvent converted incorrectly here silently breaks case-to-pallet traceability even though the JSON validates. bizStep and disposition URIs do not need rewriting, since CBV 2.0 kept the same urn:epcglobal:cbv: namespace pattern — but cross-check every distinct value your Step 1 inventory surfaced against the current CBV vocabulary and reject anything deprecated, rather than passing an unrecognized URI through unchanged.

Step 3 — Replace the SOAP query binding with the EPCIS 2.0 REST interface

The 1.2 poll/query SOAP binding is replaced by a REST query interface with the same filtering semantics — GTIN match, event-time range, business-step constraint — expressed as query parameters instead of a SOAP envelope. Rewrite each partner-facing query call, keeping the same filter inputs so downstream consumers of your verification layer see no behavior change.

import httpx


async def query_events_rest(base_url: str, gtin: str, since: str) -> list[dict]:
    """Query the EPCIS 2.0 REST interface, replacing the 1.2 SOAP poll/query binding."""
    params = {
        "MATCH_epc": f"urn:epc:id:sgtin:{gtin}",
        "GE_eventTime": since,
        "eventType": "ObjectEvent",
    }
    async with httpx.AsyncClient(base_url=base_url, timeout=10.0) as client:
        response = await client.get("/events", params=params)
        response.raise_for_status()
        return response.json()["epcisBody"]["queryResults"]["resultsBody"]["eventList"]

DSCSA/GS1 note: the Verification Router Service Architecture that answers trading-partner lookups must be updated in lockstep with the repository — a router still speaking SOAP against a repository that now only serves REST breaks verification entirely, not gracefully.

Step 4 — Dual-run both versions and gate the cutover

Never flip a live compliance pipeline in one deployment. Write every converted event to both repositories for a defined overlap window, then reconcile counts before retiring the 1.2 path.

async def dual_write_event(raw_12_event: dict, event_type: str, repo_12, repo_20) -> None:
    """During cutover, persist to both repositories so verification queries
    against either version return an identical answer."""
    await repo_12.commit_xml_event(raw_12_event, event_type)
    event_20 = convert_epcis_12_event(raw_12_event, event_type)
    await repo_20.commit_json_ld_event(event_20)


async def reconcile_repositories(repo_12, repo_20, gtin: str, since: str) -> bool:
    """Return True only if both repositories report the same event count
    for a given identifier and window — the cutover gate condition."""
    count_12 = await repo_12.count_events(gtin=gtin, since=since)
    count_20 = await repo_20.count_events(gtin=gtin, since=since)
    return count_12 == count_20

DSCSA/GS1 note: the six-year DSCSA retention obligation attaches to whichever record a trading partner or the FDA actually queried at the time, so the overlap window must be long enough, and the reconciliation strict enough, that no Transaction Information gap opens during cutover.

Verification

Confirm the converter is lossless before pointing production traffic at it. Feed one known-good event of each type through convert_epcis_12_event and assert the JSON-LD output round-trips the identifiers, business context, and master data:

def test_object_event_converts_cleanly():
    raw = {
        "eventTime": "2026-07-01T10:00:00.000Z",
        "eventTimeZoneOffset": "+00:00",
        "epcList": ["urn:epc:id:sgtin:0312345.011111.SERIAL001"],
        "action": "OBSERVE",
        "bizStep": "urn:epcglobal:cbv:bizstep:shipping",
        "disposition": "urn:epcglobal:cbv:disp:in_transit",
        "readPoint": "urn:epc:id:sgln:0312345.00000.0",
        "lot_number": "LOT42",
        "expiration_date": "2027-12-31",
    }
    converted = convert_epcis_12_event(raw, "ObjectEvent")
    assert converted["type"] == "ObjectEvent"
    assert converted["eventTime"] == "2026-07-01T10:00:00.000+00:00"
    assert converted["epcList"] == raw["epcList"]
    assert converted["ilmd"]["cbvmda:lotNumber"] == "LOT42"
    assert converted["eventID"].startswith("urn:uuid:")

Beyond unit tests, run reconcile_repositories continuously during the dual-run window and alert on any non-zero drift — a growing gap almost always means one event type from Step 1’s inventory was missed. Finally, validate a sample of converted events against the official GS1 EPCIS 2.0 JSON schema exactly as described in Step-by-step guide to EPCIS 2.0 event formatting, so schema conformance is confirmed independently of the converter’s own assumptions.

Gotchas & Edge Cases

  • Leading-zero GTIN/NDC corruption. If any step casts the SGTIN’s numeric portion to an int, a leading zero is silently dropped and the GS1 modulo-10 check digit no longer matches — treat converted epcList values as strings only.
  • UTC vs. local eventTime drift. EPCIS 1.2 events often store eventTime as a bare UTC Z instant while eventTimeZoneOffset still describes the physical read location; _normalize_timestamp must not overwrite that offset with a fabricated +00:00 that contradicts the original read point.
  • Idempotency-key collisions from synthesized eventIDs. Because 1.2 rarely carried a real eventID, any later change to the key composition reissues different UUIDs for previously converted events — freeze the key composition before the dual-run window starts, not during it.
  • AggregationEvent direction ambiguity. Some 1.2 producers encode a disaggregation as an AggregationEvent with action="DELETE" rather than a distinct event; the converter must pass action through unchanged rather than assuming every AggregationEvent is an ADD.
  • SOAP fault codes with no REST equivalent. A legacy SOAP client may key error handling off specific fault strings; the REST interface returns HTTP status codes and a JSON error body instead, so retry and alerting logic built against SOAP faults needs its own migration.