# Ecommerce has orders and shipments — and no invoice, anywhere

> transactions.dev · by visibility.cloud — 2026-07-31
> Written for the in-house supply-chain platform team — the platform engineer wiring the DTC channel of a co-manufactured brand.

One thesis: **an ecommerce platform can tell the record what was ordered
and what shipped, and it structurally cannot tell the record what was
billed — there is no invoice object in any major platform's core API — so
an ecommerce-sourced record claims `po` and `desadv` context honestly and
goes to the ERP for `inv`, instead of laundering a checkout total into a
billing document.**

## The compile map, Shopify edition

Shopify's Admin API is GraphQL-canonical (REST has been legacy since late
2024; new public apps have been GraphQL-only since April 2025), webhooks
are HMAC-signed (`X-Shopify-Hmac-Sha256`) and deliverable over HTTPS,
Google Pub/Sub, or Amazon EventBridge, and dev stores are free — the
lowest-friction `po`/`desadv` source in the entire connector matrix. The
objects compile as:

- `Order` → the `po`-analog reference (the demand side of the cycle, in
  the platform's own grain)
- `FulfillmentOrder` + `Fulfillment` (with `trackingInfo` and fulfillment
  `events`) → `desadv`
- `Return` → `ReverseFulfillmentOrder` → `ReverseDelivery` → `rma`

Each lands as a CBV business-transaction reference on the events it
contextualizes — [core fields, not extensions](/blog/biztransaction-is-a-core-field/) —
and the compiled output validates against the pinned official EPCIS 2.0
JSON schema: superset where the platform's objects carry more than the
standard models, and conformant always.

And then the map stops, one row short.

## The invoice absence is a category law

Shopify has no invoice object. The closest thing the API surface offers is
`DraftOrder.invoiceUrl`, and it is worth being precise about what that is:
**a checkout link** — a URL a buyer pays at — not a billing document with
lines, parties, and terms.

This is not a Shopify quirk you route around by picking a different cart.
In our verified matrix the finding holds across the category: BigCommerce
carries invoices only in its separately-sold B2B Edition, not core;
WooCommerce has no invoice in core. The pattern is structural. Ecommerce
platforms are demand-capture systems; billing — the `inv` reference, the
document an auditor reconciles payments against — lives in the ERP or the
accounting system, full stop. Which is exactly where the record should
source it: the [full-cycle systems](/blog/where-the-document-cycle-lives/)
carry real invoice objects, and the compile takes
[the `inv` reference](/blog/invoic-to-inv/) from there.

Say it as a design law: **the connector never mints a reference type the
source system does not actually possess.** An ecommerce-sourced record
carries `po`, `desadv`, and `rma` context and is silent on `inv` — and a
record that is honestly silent is worth more than one that is confidently
wrong, because the silence is machine-legible: a downstream consumer that
needs billing context knows to look at the ERP join, not to trust a
checkout total wearing an invoice costume.

## The barcode field, quoted

The second honesty line item is identity. Shopify's product identity field
is `ProductVariant.barcode`, and its own documentation describes it as the
**"barcode, UPC, or ISBN"** — free text, unvalidated. Whatever the
merchant typed is what the API returns: a real GTIN-13, a UPC-A missing
its check digit, an internal SKU, or an empty string, all indistinguishable
at the schema level. Party identity is thinner still: `MailingAddress` is
free-text lines everywhere, and no GLN field exists anywhere in the
surface.

So the compile treats ecommerce identity as raw material, not as identity:
every `barcode` value is checksum-validated before it is allowed to become
the GTIN component of an EPC class URI, failures fall back to
tenant-scoped URIs (CBV explicitly permits non-GS1 identifiers, so the
record stays valid), and each join carries an identity grade — GTIN-grade
where the checksum held, SKU-grade where it didn't — so downstream
consumers know the join strength instead of assuming it. The full census
behind that posture is [the GTIN-gap post](/blog/the-gtin-gap/); the
resolution machinery is [barcoding.dev](https://barcoding.dev)'s layer.

## The proof artifact: one fulfillment, compiled

One Shopify fulfillment with tracking, compiled to the shipping-side
skeleton:

```json
{
  "type": "ObjectEvent",
  "action": "OBSERVE",
  "bizStep": "shipping",
  "disposition": "in_transit",
  "quantityList": [
    {
      "epcClass": "urn:epc:class:lgtin:0614141.107346.SHOPIFY",
      "quantity": 2,
      "uom": "EA"
    }
  ],
  "bizTransactionList": [
    { "type": "desadv", "bizTransaction": "urn:epc:id:gdti:0614141.00001.F10442" },
    { "type": "po", "bizTransaction": "urn:epc:id:gdti:0614141.00001.O98213" }
  ]
}
```

Two references, no `inv`, identity graded — the record claims exactly what
the platform knows. What closes the loop is not ecommerce at all: the
physical receiving side comes from
[the 3PL's receiving object](/blog/shipbob-wro-is-a-receiving-advice/),
the delivery corroboration from
[the parcel rails as evidence](/blog/delivered-webhook-is-evidence/), and
the reconcile between claim and observation is
[a typed verdict](/blog/reconcile-is-a-verdict/).

## What free-text addresses cost at reconcile time

One consequence deserves its own paragraph, because it surfaces months
after go-live. Reconciliation joins on party and place as well as product,
and an ecommerce record's parties are strings. "ACME Distribution, 100
Main St" and "Acme Dist. — 100 Main Street" are the same counterparty to a
human and two counterparties to a set comparison. The compile's answer is
the same as for products: address normalization against a customer-supplied
party master, GLN out where resolution succeeds, tenant-scoped party URIs
where it doesn't, grade attached. The cost never disappears — it moves to
where it is visible and priced.

The DTC channel is a third of the paper trail. The record wants all three.
[Tell us what your stack looks like](/get-access/) — the access flow's
branching questions exist so the channel you actually run is the branch
that gets wired.

---
Access: https://transactions.dev/get-access/ · All posts: https://transactions.dev/blog/ · Machine face: https://transactions.dev/llms.txt
