The blog  ·  2026-07-31

Business Central put item.gtin in the standard API — and forgot the webhook

Written for ED-3 — the in-house supply-chain platform team — the platform engineer wiring a mid-market brand's system of record.

One thesis: Business Central is the easiest genuinely GTIN-bearing ERP connector in the landscape — the standard v2.0 API carries the full document cycle and a real item.gtin field — and the one thing it forgets, webhook coverage for purchase documents, is a solved problem the moment the intake design says plainly which entities push and which get polled.

The compile map

Business Central's REST API v2.0 (Entra ID OAuth2) exposes the four reference types as first-class resources, by these exact names:

  • purchaseOrderspo
  • salesShipmentsdesadv (the outbound despatch side)
  • purchaseReceipts, with lines, GET-only → recadv
  • purchaseInvoices / salesInvoicesinv

That is the full podesadvrecadvinv cycle as API objects — a property shared by exactly three ERPs in our verified matrix (NetSuite is the mid-market one), and by almost nothing else anywhere. The compile lands each object as a CBV business-transaction reference on the events it contextualizes: core fields, not extensions, and the output validates against the pinned official EPCIS 2.0 JSON schema — superset where BC's records carry more than the standard models, and conformant always.

Why item.gtin matters more than it sounds

The field is easy to skim past in the resource docs and it is the whole ballgame: item.gtin is in the standard v2.0 API payload. Not a partner extension, not a per-tenant custom field, not a free-text barcode column — a named GTIN property on the item resource.

Here is what that buys, concretely. The join that attaches a PO line or a receipt line to captured events runs on GTIN: the class identifier inside urn:epc:class:lgtin: and urn:epc:id:sgtin: URIs. When the source system names the GTIN itself, the join is a checksum-validated equality test. When it doesn't — and most of the API economy doesn't — the join runs through a maintained SKU↔GTIN crosswalk with an honesty grade attached. BC connectors start at the strong end of that gradient on day one, which is why the matrix scores it the easiest genuinely GTIN-bearing ERP: self-serve trial tenant, sandbox environments, even a Docker image for local development. No other ERP in the matrix lets a platform engineer stand up a real instance before lunch.

One flag, stated as a flag and not inferred past: GLN fields exist on Customer and Vendor in the base application (they are there for e-document and EDI use) but are not exposed in the standard API payload. The compile therefore takes party identity from the customer-supplied party master rather than pretending the API delivers it — the same resolve-don't-demand posture the enrichment layer applies everywhere identity is incomplete.

The webhook gap, and the intake design that respects it

Now the forgetting. BC has native webhooks — subscriptions with a three-day renewal cycle — but the supported entity list covers purchaseInvoices, salesOrders, items, vendors... and not purchaseOrders, and not purchaseReceipts. The two purchase-side documents that open and close the receiving story are exactly the two the eventing surface skips.

The wrong responses to that are (a) pretending it isn't true and shipping an intake that silently misses receipts, or (b) polling everything on a timer and calling it architecture. The connector's intake design is a per-entity table, and the table is the contract:

  • purchaseInvoices — webhook (subscription, renewed inside the 3-day window)
  • salesOrders, items, vendors — webhook
  • purchaseOrders — polled, delta-tracked on the modification timestamp
  • purchaseReceipts — polled, delta-tracked; GET-only is fine because the compile never writes back
  • salesShipments — polled

Webhook-where-possible, poll-where-not, stated per entity — so the latency characteristics of each reference type are a documented property of the record, not a surprise found during an incident. A recadv reference sourced from a polled entity carries the poll interval as its worst-case staleness; the reconcile verdict that consumes it inherits a bound it can print.

The proof artifact: one receipt, one event skeleton

A purchaseReceipts line for a lot-tracked item compiles to the receiving-side skeleton:

{
  "type": "ObjectEvent",
  "action": "OBSERVE",
  "bizStep": "receiving",
  "quantityList": [
    {
      "epcClass": "urn:epc:class:lgtin:0614141.107346.LOT2026B",
      "quantity": 24,
      "uom": "EA"
    }
  ],
  "bizTransactionList": [
    { "type": "recadv", "bizTransaction": "urn:epc:id:gdti:0614141.00001.PR000451" },
    { "type": "po", "bizTransaction": "urn:epc:id:gdti:0614141.00001.PO001204" }
  ]
}

The epcClass GTIN component came from item.gtin, checksum-verified. The recadv and po references came from the receipt and the order it posted against. Nothing in that document required an extension namespace, and it validates against the pinned schema before it leaves the pipeline — the acceptance bar every compile in this family clears or exits nonzero trying.

Where this sits in your stack

If you are the platform team at a mid-market brand, BC is likely either your system of record or your finance system's neighbor. The composition that covers the whole paper trail is BC (or another full-cycle ERP) for the four documents, your storefront for the order-and-fulfillment side, and your 3PL for physical receiving — three partial sources, one conformant record, queryable at epcis.dev grain and answerable at visibility.cloud grain.

Running Business Central? Say so here — the access flow's system-of-record question exists so the BC branch lands for the people who answered it.


859 words. The access flow is at /get-access/; the family's event door is epcis.dev.