Validator-passing Peppol BIS 3.0 without an enterprise SDK
How we built a Peppol BIS Billing 3.0 emitter that passes the official Schematron in around 200 lines of Go. The schema is shorter than the integration guide. The integration guide is short.
The Belgian B2B e-invoicing mandate goes live on 2026-01-01. Three-month tolerance window, then non-conformant invoices can be legally rejected. Every consulting deck for this calls for an enterprise gateway. The gateways are real and you do need one to actually transmit, but the generator side of the problem looks much smaller than the procurement quotes suggest.
This is what we found when we built ours.
The schema is small. The guide is large.
Peppol BIS Billing 3.0 is shorthand for: UBL 2.1 invoice syntax, EN 16931 semantic model, a specific CustomizationID and ProfileID, and a handful of Peppol-specific Schematron rules layered on top of the CEN rules. The whole specification fits in about 60 mandatory business terms (BT-1 through BT-176, of which only a fraction are required).
The UBL 2.1 grammar is a sea of optional elements. Most of them you never touch. The 30-ish elements you do touch are the ones every PDF invoice already carries: invoice ID, issue date, supplier and buyer name/address/VAT, line item descriptions and amounts, totals, payment IBAN.
The reason every tutorial looks intimidating is the integration guide. The integration guide is the document trying to make CFOs feel that the rollout is enterprise-grade. The schema is just XML.
What the emitter does
package einvoice
func BuildPeppolUBL(inv Invoice) ([]byte, error) {
if err := Validate(inv); err != nil {
return nil, err
}
// ... map our Invoice struct into the on-wire UBL shape
var buf bytes.Buffer
buf.WriteString(xml.Header)
enc := xml.NewEncoder(&buf)
enc.Indent("", " ")
if err := enc.Encode(doc); err != nil {
return nil, err
}
return buf.Bytes(), nil
}
The on-wire shape is one big struct with xml:"cbc:..." and xml:"cac:..." tags. The two namespaces (CBC for common basic components, CAC for common aggregate components) are the only thing that distinguishes UBL from any other invoice XML you would write. Encoding the right namespace declarations on the root element is the first thing the validator checks.
The fields the validator cares about, in order of how easy they are to get wrong:
CustomizationIDandProfileID. These tell the validator which ruleset to apply. If you set them to the wrong values, the validator either rejects the document or applies the wrong rules. The right values for Belgian B2B areurn:cen.eu:en16931:2017#compliant#urn:fdc:peppol.eu:2017:poacc:billing:3.0andurn:fdc:peppol.eu:2017:poacc:billing:01:1.0.EndpointIDwith aschemeIDattribute. Peppol identifies senders and receivers by Participant ID. The scheme prefix is mandatory. Belgian companies use0208(Crossroads Bank for Enterprises). The Endpoint ID looks like0208:0123456789for a Belgian VAT registration.- VAT category codes that match the percentages. Category
S(standard) requires a positive percentage. CategoryZ(zero-rated) requires 0. CategoryE(exempt) requires no tax at all. Mixing these up is the rejection we see most in third-party generators. - Totals that sum. UBL exposes
LineExtensionAmount,TaxExclusiveAmount,TaxInclusiveAmount, andPayableAmountas separate elements. They must agree within a 0.02 currency-unit rounding tolerance. The way to make this impossible to fail is to roll the totals up from the line items inside the emitter rather than accept them from the caller.
That fourth point is the whole reason our Invoice struct asks for LineTotal on each line and computes the document totals from there. We never let the caller hand us totals that disagree with the lines.
Validation we run before encoding
func Validate(inv Invoice) error {
if strings.TrimSpace(inv.ID) == "" {
return fmt.Errorf("invoice ID (BT-1) is required")
}
if inv.IssueDate.IsZero() {
return fmt.Errorf("issue date (BT-2) is required")
}
if len(inv.Currency) != 3 {
return fmt.Errorf("document currency (BT-5) must be a 3-letter ISO 4217 code")
}
// ... and so on for supplier name, customer name, lines
}
Surfacing these as Go errors at the API boundary means a malformed invoice never reaches the XML encoder. Most of what the Peppol Schematron flags is structural, and most of the structural rules can be enforced before encoding.
The bit we do not own: PDF/A-3 conformance
The XML leg is the lift. The hybrid PDF leg, where the same XML is embedded inside a PDF/A-3 file, is the part that needs a more careful pipeline. PDF/A-3 conformance requires the embedded ICC color profile, font subsetting, XMP metadata identifying the conformance level, and the AFRelationship="Alternative" annotation on the attached XML. Our Chromium-based engine handles the first three out of the box on the Pro tier; the AFRelationship annotation is a pdfcpu pass after rendering.
Free and Starter customers get a normal PDF plus the XML as a separate file. Both are accepted by the Belgian Peppol receiver. PDF/A-3 conformance is a hard requirement for German ZUGFeRD, not for Peppol BIS 3.0.
What we ship
The Peppol template is live at lightningpdf.dev/templates/peppol-bis-3. The page carries a live preview rendered through our standard Chromium pipeline, the sample UBL XML produced by the emitter, and a 200-word explainer of the regulation. The same template is bundled into the Paperbolt WordPress plugin (1.2.0) and the Paperbolt Shopify app.
ZUGFeRD 2.1 / Factur-X, ZATCA Phase 2, India GST e-invoice, and Japan qualified invoice are next. The line-item model carries across. The CustomizationID changes. The Schematron rules change. The shape of the work, fortunately, does not.
FAQ
Do I still need a Peppol Access Point?
Yes. The Access Point is a network role: it receives invoices on your buyer's behalf and forwards them to their accounting system. Storecove, Pagero, and EDICOM each run one. We render the document; the Access Point delivers the XML over the network.
What about the four-corner Peppol model?
Four-corner is a network topology: sender's Access Point hands the document to the receiver's Access Point over the SBDH protocol. The model is invisible from the generator's perspective. You produce the UBL document and hand it off to your sender Access Point. The four corners are the abstraction your Access Point provider markets to you.
Is the same code path used for ViDA when it goes live in 2030?
The ViDA proposal mandates Peppol-compatible structured e-invoicing for cross-border B2B transactions across the EU, with a phased rollout starting 2030. The CustomizationID and ProfileID will change. The underlying UBL semantics will not. Anyone shipping a Peppol BIS 3.0 emitter today is shipping the foundation for ViDA day-one compliance.
LightningPDF
Building fast, reliable PDF generation tools for developers.