On this page
If you've ever thrown a Peppol invoice at a validator and got something like:
BR-CO-10
or:
PEPPOL-EN16931-R020
back, your first reaction was probably something along the lines of:
Great. Very useful.
The good news is that Peppol validation isn't actually that mysterious once you understand how the pieces fit together and the main thing to know is this:
There isn't one Peppol validation step.
Your invoice effectively moves through a stack of checks:
XML > UBL > EN 16931 > Peppol BIS > Country-specific rules
Each layer checks something different and that distinction matters quite a lot when you're trying to work out why an invoice is broken.
Start with the boring bit: is it actually valid XML?
Before Peppol cares about VAT, totals, electronic addresses or anything remotely interesting, it needs to be able to read the document.
This:
<Invoice>
<cbc:ID>INV-1001</cbc:ID>
</Invoice>
is at least XML.
This:
<Invoice>
<cbc:ID>INV-1001
</Invoice>
is not.
The cbc:ID tag is never closed.
At this stage we're not really doing invoice validation at all. We're asking whether an XML parser can safely make sense of what you've given it.
Typical failures here are things like:
- broken tags
- invalid encoding
- bad namespace declarations
- truncated documents
- invalid XML entities
- generally mangled XML
If the XML itself is broken, there isn't much point trying to calculate whether the VAT totals are correct.
Next up: does it actually look like a UBL invoice?
Once the XML parser is happy, we can start checking structure and Peppol BIS Billing uses UBL 2.1 for invoices and credit notes.
UBL defines the vocabulary we're working with.
Things like:
<cbc:ID>
<cbc:IssueDate>
<cac:AccountingSupplierParty>
<cac:AccountingCustomerParty>
<cac:InvoiceLine>
<cac:LegalMonetaryTotal>
aren't just random XML tags, they're actually part of UBL.
A very stripped-down invoice might look something like:
<Invoice>
<cbc:ID>INV-1001</cbc:ID>
<cbc:IssueDate>2026-09-21</cbc:IssueDate>
<cac:AccountingSupplierParty>
...
</cac:AccountingSupplierParty>
<cac:AccountingCustomerParty>
...
</cac:AccountingCustomerParty>
<cac:InvoiceLine>
...
</cac:InvoiceLine>
<cac:LegalMonetaryTotal>
...
</cac:LegalMonetaryTotal>
</Invoice>
Schema validation checks whether those elements exist in the right places, use the correct types and generally conform to UBL.
What it doesn't do is work out whether your invoice makes sense.
You could have this:
Line 1: £100
Line 2: £50
Invoice line total: £140
All three values are perfectly valid numbers and they can all live happily inside perfectly valid UBL but the invoice is still wrong and that's where EN 16931 starts getting involved.
EN 16931 is where the invoice starts having to make sense
EN 16931 defines the semantic model and business rules used for European electronic invoicing.
This is where validation moves beyond:
Is this element allowed here?
and starts asking:
Do these values actually make sense together?
It covers things such as:
- invoice numbers
- buyer and seller details
- VAT information
- currencies
- payment information
- invoice lines
- allowances
- charges
- tax totals
- payable totals
- relationships between all of the above
Take our broken invoice:
Line 1: 100.00
Line 2: 50.00
Declared line total: 140.00
The XML can be fine and so can the UBL too.
But:
100.00 + 50.00 = 150.00
and not:
140.00
So the invoice fails and one of the rules that catches this is:
BR-CO-10
which checks that the sum of your invoice line net amounts matches the invoice-level line net total, this is the point where a lot of developers initially get tripped up.
Valid XML does not mean valid invoice.
You can build absolutely beautiful XML describing complete financial nonsense.
What the hell are all the BT- and BG- things?
Once you start digging through EN 16931 you'll also run into identifiers like:
BT-1
BT-31
BT-106
BG-4
These are part of the semantic model.
BT means Business Term.
BG means Business Group.
They're describing concepts in an invoice rather than XML elements directly.
For example, an invoice number is a business concept.
UBL then has a particular XML element used to represent that concept. So when debugging a validation error you're often working backwards through something like:
Validation rule > Business term > UBL element > Your invoice model > Whatever produced the bad value
That last step is usually the interesting one because the XML is often just where the bug finally comes to light.
Then Peppol adds its own rules
Passing EN 16931 does not automatically mean your invoice is valid Peppol.
Peppol BIS Billing is a CIUS.
Which stands for:
Core Invoice Usage Specification
In practical terms, Peppol takes EN 16931 and says:
We're using it like this.
It narrows things down and adds additional requirements needed for the Peppol network.
That's where rules like these come from:
PEPPOL-EN16931-R004
PEPPOL-EN16931-R020
PEPPOL-EN16931-R040
These are Peppol-specific rules.
For example, PEPPOL-EN16931-R020 requires the seller to have an electronic address.
In UBL you'll see something along the lines of:
<cac:AccountingSupplierParty>
<cac:Party>
<cbc:EndpointID schemeID="0088">
1234567890123
</cbc:EndpointID>
</cac:Party>
</cac:AccountingSupplierParty>
There are then more rules around the scheme and the value itself and this is why a validation result can quite reasonably be:
XML PASS
UBL PASS
EN 16931 PASS
Peppol FAIL
Nothing contradictory is happening and your invoice is structurally valid and satisfies the underlying EN 16931 rules, but doesn't meet all the requirements Peppol adds on top.
And then countries get involved
Because apparently we hadn't stacked enough rules on the invoice yet and some Peppol validation rules only apply in particular country contexts.
A Swedish-specific rule shouldn't suddenly make a UK invoice invalid.
Likewise, a requirement that exists because of Italian, German, Dutch or Norwegian invoicing rules may only make sense when that country is relevant to the invoice. So the real validation stack starts looking more like:
XML > UBL > EN 16931 > Peppol BIS > Applicable national rules
This is also why:
Is this valid Peppol XML?
is actually a fairly loaded question.
Valid against what ruleset?
For which Peppol release?
And with which country-specific rules active?
Those details matter.
XSD and Schematron are doing different jobs
This also explains why you can't build a proper Peppol validator by loading an XSD and calling it a day.
XML Schema is great at structural validation and it can check things like:
- this element is allowed here
- this element is required
- this value must be a decimal
- this sequence of elements has to follow a particular structure
What XML Schema isn't particularly good at is business logic.
Imagine a rule like:
If the invoice contains X, and the VAT category is Y, then Z must exist and this total must equal the sum of these other values.
That's a different sort of problem.
Peppol and EN 16931 validation therefore make heavy use of Schematron.
Schematron rules can express relationships between values throughout the document and produce the rule identifiers developers see when something fails so a real validator is doing a lot more than just:
validateAgainstXSD(xml)
Why validation order matters
Let's say we have three invoices.
Invoice A
Malformed XML
There's no point firing hundreds of business rules at it if the validator can't even reliably parse the document.
Invoice B
Valid XML
Invalid UBL
Maybe an element is in the wrong place or the document doesn't match the UBL schema. Again, trying to reason about invoice semantics at this point isn't particularly useful.
Invoice C
Valid XML
Valid UBL
Wrong totals
Now we're into actual invoice logic. EN 16931 can tell us that the numbers don't add up. That's why I prefer validators that expose the validation stage rather than returning something wildly informative like:
{
"valid": false
}
Yeah.. Cheers.
Rule IDs are actually pretty useful
At first glance something like:
BR-CO-10
looks like some cryptic nonsense but stable rule identifiers are useful. They give you something machine-readable that doesn't rely on parsing a human error message.
You can:
- store them
- search for them
- link them to documentation
- track common failures
- write tests against them
- provide better error messages
- build remediation guidance around them
Instead of:
Invoice invalid.
you can show:
BR-CO-10
The invoice line total doesn't match the sum
of the individual invoice lines.
Expected: 150.00
Found: 140.00
This is something a developer can actually work with. It's also why we're building individual reference pages for the common rules inside Ironfang Finance. If you hit BR-CO-10, I want you to be able to look it up and immediately see:
- what failed
- why it failed
- which UBL elements are involved
- an example of broken XML
- an example of fixed XML
No archaeology required.
Validation and sending the invoice are different things
This one is worth making clear because they're easy to mentally bundle together. A valid Peppol invoice has not necessarily been sent anywhere.
Validation doesn't:
- transmit the invoice
- prove the receiver exists
- register anybody on Peppol
- guarantee the receiver will accept it
- guarantee their accounting software will import it
- guarantee you're getting paid on Friday
Validation is checking the document, and transmission is another part of the Peppol ecosystem. A Peppol Access Point deals with sending and receiving documents across the network.
A validator answers:
Is this document valid against the rules we're checking?
Still important, it's just a different job.
The ruleset version matters too
Peppol isn't a thing you implement once, tick a box, and never touch again because validation artefacts change and rules get updated, code lists change, national requirements evolve, bugs get fixed and new releases are published.
So a useful validation result should tell you which ruleset produced it.
For example:
Peppol BIS Billing 3
Release: 3.0.21
Validation artefacts: 1.3.16
That's far more useful than simply:
VALID
because six months later you can still work out what the invoice was actually validated against. At the time of writing, we're using the May 2026 Peppol BIS Billing release and its corresponding validation artefacts, we'll update the validator as the upstream rules move.
How we're doing it in Ironfang Finance
Ironfang Finance doesn't try to replace Peppol's rule identifiers with our own interpretation of them.
If upstream validation says:
BR-CO-10
we return:
BR-CO-10
The validation pipeline is split into layers so an integration can tell the difference between:
XML failure
and:
EN 16931 business rule failure
So conceptually, a result looks more like:
{
"rule": "BR-CO-10",
"layer": "en16931",
"severity": "error"
}
rather than chucking everything into a generic array of errors and making the caller figure it out. Under the hood we're using PHIVE with the official validation artefacts, with Saxon handling the XSLT/Schematron side of the validation pipeline. I'm quite deliberately leaning on the existing ecosystem here rather than reimplementing EN 16931 rules ourselves.
Writing our own interpretation of hundreds of financial validation rules would be a spectacular way to create bugs.
How I debug validation failures
My process is basically:
1. Find the layer
First work out whether the problem is:
XML
UBL
EN 16931
Peppol
Country-specific
That immediately cuts down the search space.
2. Grab the rule ID
If it's a business rule failure, get the actual identifier.
For example:
BR-CO-10
3. Work out what business terms are involved
Find what the rule is actually checking.
4. Map that back to the UBL
Find the relevant XML elements.
5. Then go further upstream
This bit matters.
If your generated XML says:
<cbc:LineExtensionAmount currencyID="GBP">
140.00
</cbc:LineExtensionAmount>
but the invoice lines add up to:
150.00
don't just edit the XML to say 150.00, you want to actually work out why your application produced 140.00 and the real chain probably looks something like:
Database > Invoice model > Calculation logic > UBL generator > XML
Fix it where it went wrong otherwise the next invoice is going to do exactly the same thing.
Validate the exact document you're going to send
This is another easy trap, imagine your pipeline does this:
Invoice model > Validation > Generate UBL > Transform XML > Send
What have you actually proved? You've proved that the invoice model was valid according to whatever you checked but you haven't proved the resulting XML is valid and something could go wrong during generation, transformation or serialisation so the safer pipeline is:
Invoice model > Generate UBL > Final XML bytes > Validate > Store / send
Validate the artifact you're actually going to use, that's how the generator in Ironfang Finance works. If we generate an invoice, the final generated XML bytes go back through the validator before we consider the operation successful.
It costs us a bit more CPU but I can live with that, I'd rather spend CPU than send broken invoices.
Validation should be straightforward
That's really the end goal, you don't want Peppol validation to be some ritual somebody performs manually when an invoice gets rejected. It should just live in the pipeline:
Generate > Validate > Store > Send
If it's broken, stop it there and don't let bad invoices make it three systems downstream before somebody finds out.
And once you understand the validation layers, all those weird rule IDs start to make sense and be appreciated.
And to be honest It's mostly just:
- Can I parse it?
- Is it valid UBL?
- Does the invoice make sense?
- Does it satisfy Peppol?
- Are there any extra national rules?
That's Peppol invoice validation in a nutshell.
Try it out
We've built a Peppol and EN 16931 validator into Ironfang Finance.
It shows which validation layer failed and preserves the upstream rule identifiers, so if something blows up you can work out what actually happened rather than getting a generic invalid invoice response.
You can also use the validator without creating an account.
Try the Ironfang Finance validator
Related articles
- peppol
- en-16931
- ubl
- einvoicing
- validation

