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.
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 andX | Noneunion types throughout. lxmlfor 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(oraiohttp) 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 convertedepcListvalues as strings only. - UTC vs. local
eventTimedrift. EPCIS 1.2 events often storeeventTimeas a bare UTCZinstant whileeventTimeZoneOffsetstill describes the physical read location;_normalize_timestampmust not overwrite that offset with a fabricated+00:00that contradicts the original read point. - Idempotency-key collisions from synthesized
eventIDs. Because 1.2 rarely carried a realeventID, 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. AggregationEventdirection ambiguity. Some 1.2 producers encode a disaggregation as anAggregationEventwithaction="DELETE"rather than a distinct event; the converter must passactionthrough unchanged rather than assuming everyAggregationEventis anADD.- 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.
Related
- Up to the parent section: GS1 Standards Implementation
- Step-by-Step Guide to EPCIS 2.0 Event Formatting — the field-by-field target format this migration converts into
- Parsing EPCIS XML with Python lxml Efficiently — the streaming extractor that supplies this converter’s input dicts
- DSCSA Compliance Architecture & Standards Mapping — the framework this migration checklist operates within