The Odoo connector, step by step
One warehouse day walked through end to end — goods in, packed onto a pallet, shipped, part of it back, the rest ground into another article, and one report taken back — with the screen and the EPCIS event it produces side by side.
One warehouse day in Odoo: nine screens, twelve events, each event copied from the outbox of the instance in the screenshots.
Sixty kilos of coffee arrive. Forty-eight go into two cases, the cases onto a pallet. One case comes off and ships; two kilos come back a week later. The other case is emptied, ten kilos are ground into forty bags of a different article, and the delivery turns out never to have happened.
The identifiers throughout are in the GS1 952 test range, which GS1 reserves for exactly this, and every host is an example.org placeholder. The instance in the screenshots reaches no repository, which is why the events sit in the first state of the delivery ladder rather than the last. What that ladder is, and what each rung means, is at the end of the page.
Two switches and a prefix
Nothing is reported until you switch it on. Two services are involved: the resolver holds master data, the repository holds events. Different hosts, configured separately, and a token for one may be refused by the other.
The credential is an offline token — a refresh token that mints short-lived access tokens. Issue it in the OpenEPCIS web interface and paste it here; Odoo never sees a password.
The GS1 company prefix is what this installation forms identifiers from. Both a GTIN and an SSCC are built from it: your prefix, a reference you assign, a check digit. Nobody hands them out.
The difference is what happens afterwards. A GTIN can be registered with GS1 and checked against the registry, so the platform can tell you whether a number is licensed to you and to whom. An SSCC cannot, yet: GS1's Ident service answers 501 Validation for IDs of type SSCC is not yet implemented, and a resolution rule for one comes back as 400 No primary Id in database. So an SSCC is computed here and used without external confirmation. When GS1 adds it, the platform picks it up through a property (openepcis.gs1de-sync.supported-primary-ais, today 01,414,417) rather than a release.
A pallet needs its number before it can say what is underneath it, which is why the field exists. It is used for nothing else.
Where the why comes from
Odoo knows a transfer is "incoming". It does not know GS1 calls that receiving with the goods in_progress. Two fields on the operation type carry that, pre-filled from Odoo's codes and the sequence prefix.
Multi-step routes come out already distinguished:
| Operation type | Odoo code | Business step | Disposition |
|---|---|---|---|
| Receipts | incoming | receiving | in_progress |
| Pick | internal | picking | in_progress |
| Pack | internal | packing | in_progress |
| Quality Control | internal | inspecting | in_progress |
| Storage | internal | storing | sellable_accessible |
| Internal Transfers | internal | storing | in_progress |
| Delivery Orders | outgoing | shipping | in_transit |
| Manufacturing | mrp_operation | commissioning | active |
A choice you make is never overwritten by a later pre-fill. The third field, Incoming events, decides what a partner's event may do; it stands at show only until you change it.
The same screen, a different meaning
A point-of-sale terminal looks to the stock module exactly like a delivery: outgoing, shipping, in_transit. It is not a delivery, and a small bridge addon says so.
What makes this operation type a sale is not its name — it is called "PoS Orders" either way — but the fact that a till points at it. Pointing a till at an operation type changes none of the codes the pre-fill is computed from, so the bridge hooks the moment the till is configured, and it hands the plain shipment meaning back when the last till is pointed elsewhere. On the instance behind these screenshots the operation type read shipping / in_transit until a till was created, and retail_selling / retail_sold immediately after.
Goods in
The receipt is validated in the usual way, and there is nothing EPCIS-shaped about doing it.
{
"type": "ObjectEvent",
"eventTime": "2026-09-02T06:43:33.000Z",
"eventTimeZoneOffset": "+00:00",
"action": "OBSERVE",
"bizStep": "receiving",
"disposition": "in_progress",
"quantityList": [
{
"epcClass": "https://id.gs1.org/01/09520001100000/10/ROAST-2609",
"quantity": 60.0
}
],
"readPoint": { "id": "https://id.gs1.org/414/9520001000010" },
"bizLocation": { "id": "https://id.gs1.org/414/9520001000010" },
"bizTransactionList": [
{
"type": "po",
"bizTransaction": "urn:epcglobal:cbv:bt:9520001000010:PO-2026-0917"
}
],
"sourceList": [
{
"type": "owning_party",
"source": "https://id.gs1.org/414/9520001000027"
}
]
}
Four things there are decisions, not transcription:
- Lot-tracked goods go in the
quantityListas anepcClass./01/{gtin}/10/{lot}is an LGTIN — a set, not a thing. Only serial-tracked goods land in theepcList. - The quantity carries no unit. EPCIS reads a missing
uomas a piece count, and the quantity goes into the event hash. - The read point is an SGLN under AI 414, from the location. No GLN on the path means no event, and the reason is posted on the transfer.
- The
Originfield becomes the business transaction, so downstream readers match on what the warehouse actually typed.
An event is not master data
The event names the lot whether or not anything is published about it. Reporting events and publishing master data are two switches in two places.
It matters at scan time. The repository stores and finds the event either way, but a phone pointed at the lot reaches something only if an instance document was published. A lot whose product is not in the catalog yet waits, says so on its form, and follows when the product lands.
Onto a pallet
Two cases and a pallet come into being, each given an SSCC at that moment. A number handed out later would leave the earlier events unable to name it.
Putting the coffee into a case is an aggregation. Its parentID is the case's SSCC, and its children are the goods:
{
"type": "AggregationEvent",
"parentID": "https://id.gs1.org/00/095200010000000058",
"action": "ADD",
"bizStep": "packing",
"disposition": "in_progress",
"childQuantityList": [
{
"epcClass": "https://id.gs1.org/01/09520001100000/10/ROAST-2609",
"quantity": 24.0
}
],
"readPoint": { "id": "https://id.gs1.org/414/9520001000010" },
"bizLocation": { "id": "https://id.gs1.org/414/9520001000010" }
}
The cases then go onto the pallet. This event has no equivalent on Odoo 18, which has no nesting to report. The children are SSCCs, not trade items:
{
"type": "AggregationEvent",
"parentID": "https://id.gs1.org/00/095200010000000041",
"action": "ADD",
"bizStep": "packing",
"disposition": "in_progress",
"childEPCs": [
"https://id.gs1.org/00/095200010000000058",
"https://id.gs1.org/00/095200010000000065"
],
"readPoint": { "id": "https://id.gs1.org/414/9520001000010" },
"bizLocation": { "id": "https://id.gs1.org/414/9520001000010" }
}
A pallet of cases is the oldest hierarchy GS1 describes. What was missing was the fact, and only the newer Odoo records it.
Both are standing statements. A movement is a fact about a moment; an aggregation answers a question — scan the outer label and the repository says what is under it. So it has to be reported at both ends, or it keeps answering after it stopped being true.
One case comes off, and ships
The customer ordered one case, so it leaves the pallet — the other end of that statement:
{
"type": "AggregationEvent",
"parentID": "https://id.gs1.org/00/095200010000000041",
"action": "DELETE",
"bizStep": "unpacking",
"disposition": "in_progress",
"childEPCs": ["https://id.gs1.org/00/095200010000000058"],
"readPoint": { "id": "https://id.gs1.org/414/9520001000010" },
"bizLocation": { "id": "https://id.gs1.org/414/9520001000010" }
}
The case then goes onto a delivery as a whole unit. No second packing event: a unit that merely travels was neither packed nor unpacked.
{
"type": "ObjectEvent",
"action": "OBSERVE",
"bizStep": "shipping",
"disposition": "in_transit",
"quantityList": [
{
"epcClass": "https://id.gs1.org/01/09520001100000/10/ROAST-2609",
"quantity": 24.0
}
],
"readPoint": { "id": "https://id.gs1.org/414/9520001000010" },
"bizLocation": { "id": "https://id.gs1.org/414/9520001000010" },
"bizTransactionList": [
{
"type": "desadv",
"bizTransaction": "urn:epcglobal:cbv:bt:9520001000010:WH%2FOUT%2F00001"
}
],
"destinationList": [
{
"type": "owning_party",
"destination": "https://id.gs1.org/414/9520001000034"
}
]
}
The transfer had no purchase order reference of its own, so the despatch advice is named after the transfer, and the customer's GLN travels as the destination party.
Two kilos come back
A return is the only place this connector emits a TransactionEvent.
Every event above already names its paperwork, so associating goods with an order needs no event of its own. Ending the association does: goods coming back stop belonging to the shipment they went out on, and a receipt only says they arrived.
{
"type": "TransactionEvent",
"action": "DELETE",
"bizStep": "receiving",
"disposition": "returned",
"quantityList": [
{
"epcClass": "https://id.gs1.org/01/09520001100000/10/ROAST-2609",
"quantity": 2.0
}
],
"readPoint": { "id": "https://id.gs1.org/414/9520001000010" },
"bizLocation": { "id": "https://id.gs1.org/414/9520001000010" },
"bizTransactionList": [
{
"type": "desadv",
"bizTransaction": "urn:epcglobal:cbv:bt:9520001000010:WH%2FOUT%2F00001"
}
]
}
The transaction it releases is the one the original transfer named — which is why a return of goods that named no paperwork releases nothing, and reports nothing.
The other case is emptied
A case comes apart two ways. Picking goods off it in a delivery leaves move lines; pressing Unpack leaves none — it clears the package on the quants and is done. Both are reported, or an aggregation nobody withdrew keeps answering with goods that left weeks ago:
{
"type": "AggregationEvent",
"parentID": "https://id.gs1.org/00/095200010000000065",
"action": "DELETE",
"bizStep": "unpacking",
"disposition": "in_progress",
"childQuantityList": [
{
"epcClass": "https://id.gs1.org/01/09520001100000/10/ROAST-2609",
"quantity": 24.0
}
],
"readPoint": { "id": "https://id.gs1.org/414/9520001000010" },
"bizLocation": { "id": "https://id.gs1.org/414/9520001000010" }
}
Everything the event needs is read before the button runs. Afterwards the quants no longer name the unit, so both the contents and the unit's location are gone.
Beans become ground coffee
Grinding is the one movement where the identifiers going in are not the ones coming out — a TransformationEvent. It hangs on the manufacturing order and lives in its own bridge addon, so a database without Manufacturing never loads it.
{
"type": "TransformationEvent",
"inputQuantityList": [
{
"epcClass": "https://id.gs1.org/01/09520001100000/10/ROAST-2609",
"quantity": 10.0
}
],
"outputQuantityList": [
{
"epcClass": "https://id.gs1.org/01/09520001100017/10/GROUND-2609",
"quantity": 40.0
}
],
"bizStep": "commissioning",
"disposition": "active",
"readPoint": { "id": "https://id.gs1.org/414/9520001000010" },
"bizLocation": { "id": "https://id.gs1.org/414/9520001000010" }
}
It carries no action — the one event type that has none. A half transformation is refused rather than sent: inputs with no outputs assert that goods vanished.
Taking a report back
The delivery turns out never to have happened. An event cannot be pulled back out of a repository; EPCIS withdraws it with an error declaration instead.
{
"type": "ObjectEvent",
"action": "OBSERVE",
"bizStep": "shipping",
"disposition": "in_transit",
"quantityList": [
{
"epcClass": "https://id.gs1.org/01/09520001100000/10/ROAST-2609",
"quantity": 24.0
}
],
"readPoint": { "id": "https://id.gs1.org/414/9520001000010" },
"bizLocation": { "id": "https://id.gs1.org/414/9520001000010" },
"bizTransactionList": [
{
"type": "desadv",
"bizTransaction": "urn:epcglobal:cbv:bt:9520001000010:WH%2FOUT%2F00001"
}
],
"destinationList": [
{
"type": "owning_party",
"destination": "https://id.gs1.org/414/9520001000034"
}
],
"errorDeclaration": {
"declarationTime": "2026-09-02T06:46:20.614Z",
"reason": "did_not_occur"
}
}
The withdrawal is the original event with a declaration attached. Declaration fields are excluded from the event hash, so the correction carries the same identity — the repository knows which event is meant without Odoo ever learning its name.
Only did_not_occur is offered. incorrect_data keeps the occurrence and disputes the description, which means naming the corrected events; this connector mints none.
The outbox, and the two identifiers
Reporting never fails a transfer. Pressing Validate writes rows into an outbox; a scheduled job delivers them.
Delivery is two answers, kept apart. Accepted means the repository took the document; Captured means it stored it. A queue that deletes its row on the 202 reports success for events rejected minutes later. After five attempts a row stops asking and says it cannot be confirmed.
Open a row and both identifiers are visible. They do different jobs.
The expected event ID is the canonical CBV event hash, ni:///sha-256;<hex>?ver=CBV2.0, computed over everything the event asserts. Anyone holding the event can recompute it, so two companies describing the same observation arrive at the same identifier without agreeing on anything. Odoo computes it to recognise its own events coming back and sends the document without it; the repository computes it again when it stores the event.
The idempotency key is private bookkeeping — derived from the database, the transfer and the identifiers, unique per company, so the same movement cannot enter the outbox twice. It never leaves Odoo.
Reproducing this
The instance in these screenshots is a throwaway: Odoo 19 with the six connector addons, seeded with exactly the day described above and pointed at hosts that do not exist. The images are not taken by hand — apps/openepcis-home/scripts/odoo-screenshots.mjs drives a headless browser through the same screens in one run, so they can be renewed when the addon changes instead of quietly going stale.
The addons live in openepcis-odoo under LGPL-3, on two branches: 19.0 for the version shown here, and 18.0 for the current Odoo long-term release. The two carry the same six addons and differ only where Odoo itself does; the one event on this page that has no counterpart on 18.0 is the pallet of cases, because Odoo 18 has no nested units to report.
About the screenshots
Odoo 19 with no demo data, so that nothing on screen distracts from the day being walked through. Every identifier is in the GS1 952 test range, check digits included. Hosts are example.org placeholders and the offline token is a literal placeholder, so this instance publishes to nobody and never did. The delivery states were seeded locally: the two rows that read Captured were never captured by any repository, because the error declaration can only be raised against a captured event and the withdrawal is worth showing.