At some point every e-invoicing integration lands on the same ticket: "customer requires Peppol BIS 3.0." You open the spec, find a document model with a hundred-plus business terms, three layers of specifications stacked on top of each other, and example files that are 300 lines long. Meanwhile all you actually need to ship is one valid invoice.

This article is the tour I wish existed when I first read that spec: a minimal Peppol BIS Billing 3.0 UBL invoice, walked through element by element — what each field is, which standard demands it, and which validation rule fires when you get it wrong.

First, get the layers straight

"Peppol" is two things that get conflated constantly, and the confusion causes real architectural mistakes.

The network layer is the transport: access points, the SMP/SML lookup infrastructure, AS4 messaging. It moves documents from sender to receiver. Your access point provider lives here.

The document layer is what's inside the envelope. That's a stack of three specs:

  1. EN 16931 — the European semantic model. Defines business terms (BT-1 = invoice number, BT-2 = issue date, …) independent of any XML syntax.
  2. Peppol BIS Billing 3.0 — a CIUS (restriction) of EN 16931. It narrows options and adds Peppol-specific rules, the PEPPOL-EN16931-R* series.
  3. UBL 2.1 — the XML syntax the business terms are serialized into. (BIS Billing also permits CII, but in practice the Peppol billing world is UBL.)

The reason this matters: your access point validates and transports, but it does not author documents. Building a correct BIS 3.0 document is your problem — a document-layer problem — and it's solvable in your own codebase and CI, long before anything touches the network. Everything below lives at the document layer.

The two magic strings: CustomizationID and ProfileID

Every BIS 3.0 invoice starts with two identifiers that developers routinely mangle, because they look like boilerplate and are actually load-bearing:

<cbc:CustomizationID>urn:cen.eu:en16931:2017#compliant#urn:fdc:peppol.eu:2017:poacc:billing:3.0</cbc:CustomizationID>
<cbc:ProfileID>urn:fdc:peppol.eu:2017:poacc:billing:01:1.0</cbc:ProfileID>

CustomizationID (BT-24) declares which specification the document claims to conform to — "EN 16931, as restricted by Peppol BIS Billing 3.0." Validators dispatch on this string: send the wrong one and your document gets checked against the wrong ruleset, or rejected outright. It is compared as an exact string. No trailing whitespace, no case changes.

ProfileID (BT-23) declares the business process — for ordinary billing it's the fixed value above. Together these two strings are how the document itself tells any receiver, on any network, what it is. They're the bridge between the layers: routing metadata that travels inside the document.

Copy them verbatim. Don't construct them.

Identity and routing: EndpointID and the EAS codes

The next thing BIS 3.0 adds on top of bare EN 16931 is mandatory electronic addresses for both parties:

<cac:AccountingSupplierParty>
  <cac:Party>
    <cbc:EndpointID schemeID="9930">DE123456789</cbc:EndpointID>
    <!-- postal address, VAT ID, legal entity ... -->
    <cac:PartyLegalEntity>
      <cbc:RegistrationName>Acme GmbH</cbc:RegistrationName>
    </cac:PartyLegalEntity>
  </cac:Party>
</cac:AccountingSupplierParty>

Three Peppol-only rules fire here, and none of them exist in plain EN 16931:

  • PEPPOL-EN16931-R020: "Seller electronic address MUST be provided" — EndpointID under AccountingSupplierParty (BT-34).
  • PEPPOL-EN16931-R010: "Buyer electronic address MUST be provided" — same element under AccountingCustomerParty (BT-49).
  • PEPPOL-EN16931-R003: "A buyer reference or purchase order reference MUST be provided" — either cbc:BuyerReference (BT-10) or cac:OrderReference/cbc:ID (BT-13). This is the one that surprises people: a document that's perfectly valid EN 16931 fails BIS validation because nobody filled in a buyer reference. German readers will recognize the pattern — XRechnung's Leitweg-ID rides in the same BT-10 field.

The schemeID attribute is an EAS code — a four-digit identifier from the Electronic Address Scheme code list that says what kind of identifier follows: 0088 is a GLN, 0192 a Norwegian org number, 9930 a German VAT number, and so on. The list is maintained as part of the BIS release cycle and grows constantly — the January 2026 hotfix existed almost entirely to add 0245 (Slovak tax identification number). Treat the EAS list as data, not as an enum you hardcode.

Both parties also need a registration name (BT-27/BT-44) and a country code (BT-40/BT-55). That, plus a VAT identifier where VAT applies, is essentially all the party data a minimal invoice needs.

The minimal document, top to bottom

Here's the skeleton with every mandatory element and its business term. Cardinalities per the official UBL syntax binding:

<Invoice xmlns="urn:oasis:names:specification:ubl:schema:xsd:Invoice-2"
         xmlns:cac="urn:oasis:names:specification:ubl:schema:xsd:CommonAggregateComponents-2"
         xmlns:cbc="urn:oasis:names:specification:ubl:schema:xsd:CommonBasicComponents-2">
  <cbc:CustomizationID>urn:cen.eu:en16931:2017#compliant#urn:fdc:peppol.eu:2017:poacc:billing:3.0</cbc:CustomizationID>
  <cbc:ProfileID>urn:fdc:peppol.eu:2017:poacc:billing:01:1.0</cbc:ProfileID>
  <cbc:ID>INV-2026-0815</cbc:ID>                        <!-- BT-1, 1..1 -->
  <cbc:IssueDate>2026-08-12</cbc:IssueDate>             <!-- BT-2, 1..1, YYYY-MM-DD -->
  <cbc:DueDate>2026-09-11</cbc:DueDate>                 <!-- BT-9: due date or payment terms when amount is due -->
  <cbc:InvoiceTypeCode>380</cbc:InvoiceTypeCode>        <!-- BT-3, 1..1 -->
  <cbc:DocumentCurrencyCode>EUR</cbc:DocumentCurrencyCode>  <!-- BT-5, 1..1 -->
  <cbc:BuyerReference>PO-4711</cbc:BuyerReference>      <!-- BT-10: satisfies R003 -->

  <cac:AccountingSupplierParty> … </cac:AccountingSupplierParty>  <!-- 1..1, R020 -->
  <cac:AccountingCustomerParty> … </cac:AccountingCustomerParty>  <!-- 1..1, R010 -->

  <cac:TaxTotal>                                        <!-- 1..2 (second only for tax currency) -->
    <cbc:TaxAmount currencyID="EUR">19.00</cbc:TaxAmount>   <!-- BT-110 -->
    <cac:TaxSubtotal>
      <cbc:TaxableAmount currencyID="EUR">100.00</cbc:TaxableAmount>
      <cbc:TaxAmount currencyID="EUR">19.00</cbc:TaxAmount>
      <cac:TaxCategory>
        <cbc:ID>S</cbc:ID>
        <cbc:Percent>19</cbc:Percent>
        <cac:TaxScheme><cbc:ID>VAT</cbc:ID></cac:TaxScheme>
      </cac:TaxCategory>
    </cac:TaxSubtotal>
  </cac:TaxTotal>

  <cac:LegalMonetaryTotal>                              <!-- 1..1 -->
    <cbc:LineExtensionAmount currencyID="EUR">100.00</cbc:LineExtensionAmount>  <!-- BT-106 -->
    <cbc:TaxExclusiveAmount currencyID="EUR">100.00</cbc:TaxExclusiveAmount>    <!-- BT-109 -->
    <cbc:TaxInclusiveAmount currencyID="EUR">119.00</cbc:TaxInclusiveAmount>    <!-- BT-112 -->
    <cbc:PayableAmount currencyID="EUR">119.00</cbc:PayableAmount>              <!-- BT-115 -->
  </cac:LegalMonetaryTotal>

  <cac:InvoiceLine>                                     <!-- 1..n -->
    <cbc:ID>1</cbc:ID>                                  <!-- BT-126 -->
    <cbc:InvoicedQuantity unitCode="C62">1</cbc:InvoicedQuantity>  <!-- BT-129 -->
    <cbc:LineExtensionAmount currencyID="EUR">100.00</cbc:LineExtensionAmount>  <!-- BT-131 -->
    <cac:Item>
      <cbc:Name>Consulting, August</cbc:Name>
      <cac:ClassifiedTaxCategory>
        <cbc:ID>S</cbc:ID>
        <cbc:Percent>19</cbc:Percent>
        <cac:TaxScheme><cbc:ID>VAT</cbc:ID></cac:TaxScheme>
      </cac:ClassifiedTaxCategory>
    </cac:Item>
    <cac:Price>
      <cbc:PriceAmount currencyID="EUR">100.00</cbc:PriceAmount>
    </cac:Price>
  </cac:InvoiceLine>
</Invoice>

A few reading notes on the parts that generate the most support questions:

The monetary total is a chain, not four independent numbers. LineExtensionAmount must equal the sum of line amounts (BR-CO-10), TaxInclusiveAmount must equal tax-exclusive plus tax (BR-CO-15), and PayableAmount follows its own equation (BR-CO-16). Each link is checked to the cent. If your totals come out of floating-point arithmetic, read our one-cent bug piece before you ship.

Tax appears twice, and both must agree. Each line carries a ClassifiedTaxCategory; the document carries a TaxTotal with per-category subtotals. The breakdown is computed per category over line sums — not by summing per-line VAT. The category codes themselves (S, Z, E, AE, K, G, O) are their own minefield; we mapped them in the four kinds of zero.

unitCode="C62" is UN/ECE Recommendation 20 for "one" — the unit for anything countable that has no better unit. It's the safe default for services.

The spec under your invoice moves twice a year

Here's the operational detail that turns a working integration into a broken one: BIS Billing 3.0 is a living specification. OpenPeppol ships releases on a fixed cadence, and each release has a date on which it becomes mandatory across the network.

As of this writing, the current release is 3.0.20 (published 24 November 2025, mandatory since 23 February 2026), which among other things added new EAS/ICD identifiers and introduced Danish payment validation rules as warnings scheduled to become fatal later. A hotfix followed on 27 January 2026 (the Slovak 0245 code), and a May 2026 release is already published as upcoming on docs.peppol.eu.

The pattern to internalize: rules enter as warnings, then become errors a release or two later. A document that validates clean today can fail in six months with zero changes on your side. That's not a reason to panic — it's a reason to validate continuously, in CI, against a maintained ruleset, instead of vendoring a schematron snapshot from the year your integration shipped.

Validating it: one HTTP call

FakturWire keeps the current BIS Billing ruleset live behind a single endpoint, so a CI check is a curl one-liner:

curl -s -X POST https://fakturwire.com/v1/validate \
  -H "Authorization: Bearer $FAKTURWIRE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "profile": "peppol-bis-3",
    "xml": "<Invoice xmlns=\"urn:oasis:names:specification:ubl:schema:xsd:Invoice-2\">…</Invoice>"
  }'

Findings come back machine-readable — rule ID, severity, and the XPath of the offending element:

{
  "valid": false,
  "findings": [
    {
      "rule": "PEPPOL-EN16931-R003",
      "severity": "error",
      "message": "A buyer reference or purchase order reference MUST be provided.",
      "path": "/Invoice"
    }
  ],
  "counts": { "error": 1, "warning": 0 }
}

An invalid document is an HTTP 422 — and a 422 is never billed, so wiring this into a test suite costs you nothing on red builds.

Where to go from here

The minimal invoice above is a legitimate, network-ready BIS 3.0 document — but "minimal" is doing real work in that sentence. Payment means, allowances and charges, attachments, credit notes (a different document type in UBL), and the per-country CIUS layers on top (XRechnung, the Nordic profiles) each add their own rules. The layering is your friend here: master the core skeleton once, and every extension is additive.

Try it now: paste your own XML into the free validator — no account needed — or sign up for 50 free credits and put /v1/validate into your CI before the May release lands.


Last verified: 2026-08-12. Sources: Peppol BIS Billing 3.0 specification (November 2025 release); UBL Invoice syntax binding (mandatory elements and cardinalities); release notes (3.0.20 of 2025-11-24, mandatory 2026-02-23; hotfix 2026-01-27 adding EAS 0245); May 2026 upcoming release; rule texts PEPPOL-EN16931-R003, R010, R020.