DEV Community

Stefan Meier
Stefan Meier

Posted on

Why validating Peppol UBL e-invoices in .NET is harder than it looks

Why validating Peppol UBL e-invoices in .NET is harder than it looks

When I started building InvoiceXML (a REST API for Peppol and EN 16931 e-invoice compliance), one of the first technical walls I hit was EN 16931 Schematron validation in .NET. I assumed it would be straightforward. It was not.

This post is about that journey: what Peppol UBL validation actually requires under the hood, why .NET makes it particularly awkward, what the community workarounds look like, and why I eventually decided none of them were good enough for a production compliance service.

If you are building e-invoicing into a .NET application, whether for Belgium's January 2026 B2B mandate, the broader Peppol network, ZUGFeRD/Factur-X for Germany and France, or XRechnung for German public sector, this problem applies to all of them equally.


What Peppol UBL validation actually involves

Most developers, when they first encounter Peppol e-invoicing, assume "validate the invoice" means "check the XML schema." Run the UBL XSD, confirm the document is well-formed, move on.

That is step one. The real validation is step two: Schematron.

EN 16931 defines 200+ business rules that go beyond what XSD can express. Rules like:

  • BR-CO-14: Invoice total VAT amount must equal the sum of all VAT category tax amounts
  • BR-61: When payment means code is 30 or 58, a payment account identifier (IBAN) must be present
  • BR-AE-05: When VAT category is reverse charge, the VAT rate must be zero
  • BR-01: The invoice must declare a specification identifier

Then on top of EN 16931, Peppol BIS Billing 3.0 adds its own overlay rules:

  • PEPPOL-EN16931-R004: Invoice type code must be 380 for a standard invoice
  • PEPPOL-EN16931-R010: Buyer endpoint identifier must use a valid Peppol scheme
  • Additional rules around party identifiers, payment references, and document structure

These rules check semantic correctness across the entire document - arithmetic consistency, cross-field relationships, conditional requirements. XSD cannot express any of this. You need Schematron.

Schematron is an ISO standard for rule-based XML validation. The EN 16931 and Peppol BIS Schematron artefacts are XSLT files, specifically XSLT 2.0, that you run against the invoice XML to produce an SVRL (Schematron Validation Report Language) output listing every rule violation.

Here is the problem: XSLT 2.0 has no official native .NET implementation.

The same problem applies to every EN 16931-based format. ZUGFeRD and Factur-X use CII XML inside a PDF/A-3b container, same Schematron requirement. XRechnung adds its own CIUS-REC-DE national extension rules on top. Peppol BIS updates its Schematron quarterly. Every format, same underlying wall.


The .NET XSLT 2.0 problem

.NET has built-in XSLT support via System.Xml.Xsl.XslCompiledTransform. It supports XSLT 1.0. The EN 16931 Schematron artefacts require XSLT 2.0. XslCompiledTransform will not run them, it throws immediately on XSLT 2.0 constructs.

In Java, the standard solution is Saxon-HE, a mature, well-supported XSLT 2.0/3.0 processor from Saxonica. It works, it is what every Java-based e-invoicing tool uses, and it has official support.

For .NET there is no official Saxon port. What exists is a community effort: IKVM, a tool that cross-compiles Java bytecode to .NET assemblies. Someone took Saxon-HE, ran it through IKVM, and published the result as a NuGet package. This is the SaxonHE12s9apiExtensions package you will find if you search for "Saxon .NET Core."

Let me show you what actually using this looks like in practice.


The IKVM approach: what it looks like in code

// NuGet packages required:
// SaxonHE12s9apiExtensions
// IKVM.Runtime
// IKVM.Runtime.win-x64 (+ linux-x64, osx-x64, osx-arm64 for cross-platform)

using net.sf.saxon.s9api;

var processor = new Processor(false);
var compiler = processor.newXsltCompiler();

// Load the Peppol BIS Schematron XSLT (embedded as resource)
using var schematronStream = Assembly
    .GetExecutingAssembly()
    .GetManifestResourceStream("MyApp.Resources.peppolbis-en16931-ubl.xslt");

var executable = compiler.compile(
    new javax.xml.transform.stream.StreamSource(
        new java.io.InputStream.Wrapper(schematronStream)
    )
);

var transformer = executable.load30();

var invoiceSource = new javax.xml.transform.stream.StreamSource(
    new java.io.StringReader(invoiceXml)
);

var destination = new net.sf.saxon.s9api.XdmDestination();
transformer.applyTemplates(invoiceSource, destination);

// Now parse the SVRL output XML to find violations
var svrl = destination.getXdmNode().toString();
// ... parse SVRL XML
// ... extract failed-assert elements
// ... read @location, @test, and text() from each
// ... filter fatal errors from warnings
// ... map technical rule codes to human-readable messages
// ... figure out which line item the violation refers to
Enter fullscreen mode Exit fullscreen mode

If you got to this point and it compiled and ran, congratulations. Before you got here you also needed to:

  • Identify the correct combination of IKVM NuGet packages (there are several, they do not all work together on every .NET version)
  • Handle platform-specific native libraries, IKVM ships separate packages per runtime identifier and you need all of them for cross-platform deployment
  • Embed the correct Schematron XSLT files as EmbeddedResource in your project
  • Build the SVRL parsing layer entirely yourself, the IKVM/Saxon output is raw XML that you then need to traverse to extract rule violations, their locations, and their messages
  • Deal with Java type bridging throughout your C# codebase: javax.xml.transform.stream.StreamSource, java.io.InputStream.Wrapper, net.sf.saxon.s9api, Java namespaces sitting in the middle of your C# application

That last point is worth pausing on. When you see javax.xml.transform in a C# file, something has gone architecturally sideways. It works (right now, with this combination of package versions, on this runtime). But it is not a foundation.


Why this is not something you want in production

Here is the specific failure surface you are accepting:

A four-way version compatibility matrix. Your validation depends on Saxon-HE (Saxonica's release schedule), IKVM (community project, no commercial support, no SLA), the NuGet package publisher maintaining the cross-compilation, and your .NET runtime version. All four need to be compatible simultaneously. When they fall out of sync, and they do, your validation stops working. You find out when a customer reports an unhandled exception.

Quarterly Schematron updates. OpenPeppol releases new Peppol BIS artefacts roughly every quarter. Each release requires you to download the new XSLT files from the OpenPeppol GitHub repository, embed them in your application, regression-test them against your invoice samples (because occasionally a rule's semantics change, not just its wording), and deploy. Miss a release and your validation accepts invoices that the Peppol network rejects. Your customer's invoice gets bounced and they call your support line.

ZUGFeRD, Factur-X, and XRechnung add their own update cycles. FeRD releases new ZUGFeRD/Factur-X versions roughly annually. KoSIT releases new XRechnung CIUS versions annually. Each has its own Schematron artefacts you need to track and update independently.

The SVRL parsing layer is entirely your problem. IKVM/Saxon gives you raw SVRL output — an XML document listing violations with XPath locations and technical rule assertions. Turning that into structured error objects with rule codes, severity levels, human-readable messages, and line item references is a non-trivial parsing and mapping exercise that you write, own, and maintain.

Java bleeds into your codebase permanently. Every developer who joins your team has to understand why there are Java package names in the C# source. Every code review touching the validation layer requires context that is not obvious from reading .NET documentation. It is a maintenance cost that compounds quietly over time.


What we built instead

When building InvoiceXML we looked at this honestly and decided that running cross-compiled Java inside a .NET compliance service, where the output determines whether legally significant financial documents are accepted or rejected, was not an acceptable foundation. We built our own validation infrastructure properly.

The result is a REST endpoint that accepts a UBL invoice, ZUGFeRD or Factur-X PDF, XRechnung XML, or standalone CII document and returns structured validation results in JSON. For UBL it runs the EN 16931 Schematron, detects the CustomizationID profile automatically, and applies the correct CIUS overlay (Peppol BIS 3.0, NLCIUS, EHF, PINT, or XRechnung) in a single call.

From a .NET application, the integration is clean:

using var client = new HttpClient();
client.DefaultRequestHeaders.Authorization =
    new AuthenticationHeaderValue("Bearer", apiKey);

using var form = new MultipartFormDataContent();
form.Add(
    new StreamContent(File.OpenRead("invoice-peppol.xml")),
    "file",
    "invoice-peppol.xml"
);

var response = await client.PostAsync(
    "https://api.invoicexml.com/v1/validate/ubl",
    form
);

var result = await response.Content
    .ReadFromJsonAsync<ValidationResult>();

if (!result.Valid)
{
    foreach (var error in result.Errors.Friendly)
    {
        Console.WriteLine($"[{error.Rule}] [{error.Layer}] {error.Message}");
        // e.g. [BR-CO-14] [en16931] Invoice total VAT does not match...
        // e.g. [PEPPOL-EN16931-R004] [peppol-bis] Invoice type code must be 380...
    }
}
Enter fullscreen mode Exit fullscreen mode

The response:

{
  "valid": false,
  "detail": "Validation failed with 2 error(s)",
  "data": {
    "detectedProfile": "Peppol BIS Billing 3.0",
    "documentType": "Invoice",
    "conformanceLevel": "EN16931",
    "customizationId": "urn:cen.eu:en16931:2017#compliant#urn:fdc:peppol.eu:2017:poacc:billing:3.0"
  },
  "errors": {
    "xml": [
      "[BR-CO-14] Invoice total VAT amount must equal the sum of VAT category tax amounts.",
      "[PEPPOL-EN16931-R004] Invoice type code should be 380 for a standard invoice."
    ],
    "friendly": [
      {
        "rule": "BR-CO-14",
        "layer": "en16931",
        "line": null,
        "message": "The invoice total VAT amount does not match the sum of VAT breakdown amounts. Check that all tax subtotals are consistent with TaxTotal."
      },
      {
        "rule": "PEPPOL-EN16931-R004",
        "layer": "peppol-bis",
        "line": null,
        "message": "The InvoiceTypeCode is not 380. Peppol BIS 3.0 requires type code 380 for standard invoices."
      }
    ]
  }
}
Enter fullscreen mode Exit fullscreen mode

The layer field is worth highlighting, it tells you whether a violation is a fundamental EN 16931 data problem or a Peppol network-specific configuration issue. That distinction matters operationally: a layer: en16931 error means the invoice data is wrong; a layer: peppol-bis error means the invoice data might be fine but it will be rejected by the Peppol network specifically. Different root causes, different teams to involve.

Both valid and invalid invoices return HTTP 200. Branch on the valid boolean, not on HTTP status code. This keeps the integration clean, no exception handling for the expected invalid case.

No XSLT processor in your project. No IKVM. No Java type interop. No Schematron files to embed and update quarterly. No SVRL parsing layer to write and maintain. No javax.xml in your C# codebase.

The same endpoint handles ZUGFeRD and Factur-X PDFs (CII is extracted from the PDF/A-3b container automatically), XRechnung (CIUS-REC-DE rules applied on top of EN 16931), and CII XML, all from the same HTTP call pattern, all returning the same response structure.


The broader point

The IKVM/Saxon path is the only community answer to a real gap in the .NET ecosystem. For a proof of concept, a local testing tool, or a one-off conversion script, it is functional. For a production application where invoice validation determines whether legally significant financial documents are accepted or rejected, the maintenance overhead, four-way version compatibility, quarterly Schematron updates across multiple formats, SVRL parsing, Java type leakage, is a recurring cost that is very easy to underestimate at the "install the NuGet package" stage.

If you are adding e-invoicing validation to a .NET application and the compliance surface is growing, Belgium's mandate is live, France follows September 2026, Germany's issuing obligation hits January 2027, it is worth thinking honestly about whether owning that maintenance is the right use of your team's time.


Resources


I'm Stefan, founder of InvoiceXML - a REST API for Peppol and EN 16931 e-invoice compliance covering Peppol UBL, ZUGFeRD, Factur-X, XRechnung, and CII. Happy to answer questions in the comments.

Top comments (0)