Automation MCP Server Features Blog Pricing Contact
Back to Blog
Integration May 28, 2026 16 min read

Create and Validate Peppol UBL Invoices in C# / .NET: Complete Guide

A practical guide to the full Peppol BIS 3.0 UBL lifecycle in C# and .NET: creating invoices from structured data, validating incoming UBL against EN 16931 plus the Peppol overlay, converting between CII and UBL syntax, and rendering UBL as PDFs. Covers Belgium 2026, the Netherlands, Norway, and global Peppol markets.

Belgium's January 2026 B2B mandate made Peppol BIS 3.0 UBL a daily requirement for every VAT-registered business in the country. The Netherlands, Norway, Denmark, Singapore, Australia, and Japan all run on the same Peppol network. For .NET developers building ASP.NET Core services, Dynamics 365 extensions, or enterprise ERP integrations that touch European invoicing, structured UBL output is no longer optional.

The .NET ecosystem has good support for UBL XML generation through libraries like UblSharp, which provides strongly typed classes generated from the OASIS UBL 2.1 schemas. For producing structurally valid UBL XML, it works well. What it does not provide, and what no .NET UBL library provides, is EN 16931 Schematron validation plus the Peppol BIS 3.0 overlay, because .NET has no native XSLT 2.0 processor. That gap is the reason many .NET teams move the Peppol compliance layer to an API.

This guide shows how to handle the full UBL lifecycle in C# using the InvoiceXML REST API: creating Peppol BIS 3.0 invoices from structured data, validating incoming UBL against EN 16931 and Peppol overlays, converting between CII and UBL syntax, and rendering UBL XML as human-readable PDFs. All examples are available in the InvoiceXML .NET examples repository.

Complete UBL Toolkit

Everything you need to create, convert, validate, and preview UBL invoices, via REST API or online.

Build a validated UBL 2.1 XML from structured data, Peppol BIS Billing 3.0 compliant.

Validate UBL XML against EN 16931 and Peppol BIS Billing 3.0 Schematron rules.

Convert any PDF invoice into a Peppol-compliant UBL 2.1 XML using AI extraction.

Render a UBL XML as a human-readable PDF for review and approval.

Why Peppol UBL validation is genuinely hard in .NET

UBL generation in .NET is well-served. Peppol compliance validation is where .NET hits a wall, and it hits it harder for Peppol than for any other format.

Two Schematron layers, both XSLT 2.0, neither runnable natively in .NET. Peppol BIS 3.0 validation requires the EN 16931 base Schematron (200+ rules) plus the Peppol BIS 3.0 overlay (50+ additional rules). Both are XSLT 2.0. .NET's XslCompiledTransform is XSLT 1.0 only. There is no official Saxon-HE port for .NET. The community option, SaxonHE12s9apiExtensions via IKVM, cross-compiles Java Saxon to .NET assemblies and brings a four-way version compatibility matrix, platform-specific native libraries, and Java type interop into your C# project. For Peppol specifically, the maintenance pain is worse because the Peppol overlay updates quarterly, so you are re-integrating new Schematron artefacts four times a year on top of an already fragile setup.

UblSharp validates structure, not business rules. UblSharp generates and deserialises UBL 2.1 XML correctly against the OASIS XSD. But XSD validation catches only structural errors. The business rules where most Peppol rejections originate (VAT calculation consistency, mandatory endpoint identifiers, type code requirements) live in Schematron, which UblSharp does not run. An invoice can pass UblSharp validation perfectly and still be rejected by a Peppol access point.

National CIUS fragmentation. A single UBL implementation needs Peppol BIS 3.0 for Belgium and cross-border, NLCIUS for Dutch public sector, EHF for Norway, PINT variants for APAC markets, and XRechnung UBL for German B2G. Each has a distinct Schematron overlay and slightly different mandatory fields. Building and maintaining this routing in .NET is rarely worth the engineering investment.


Setting up the InvoiceXML client

The examples use HttpClient and System.Text.Json, both built into .NET. Examples target .NET 8 but work on .NET 6 and later.

Sign up for a free InvoiceXML account and you will receive 100 free credits on signup, no credit card required. Store the API key in configuration or Azure Key Vault, not in source.

Register a typed client with IHttpClientFactory in ASP.NET Core:

// Program.cs
builder.Services.AddHttpClient("InvoiceXML", client =>
{
    client.BaseAddress = new Uri("https://api.invoicexml.com");
    client.DefaultRequestHeaders.Authorization =
        new AuthenticationHeaderValue(
            "Bearer",
            builder.Configuration["InvoiceXML:ApiKey"]);
    client.Timeout = TimeSpan.FromSeconds(30);
});

Create Peppol UBL invoices in C#

Send your invoice as JSON. Totals and the VAT breakdown are auto-calculated, and the output is Peppol BIS Billing 3.0 UBL 2.1.

using System.Net.Http.Json;

var payload = new
{
    invoice = new
    {
        invoiceNumber = "INV-2026-001",
        issueDate = "2026-02-15",
        currency = "EUR",
        seller = new
        {
            name = "Belga Solutions BVBA",
            vatIdentifier = "BE0123456789",
            postalAddress = new
            {
                line1 = "Rue Royale 12",
                city = "Brussels",
                postCode = "1000",
                country = "BE",
            },
        },
        buyer = new
        {
            name = "Antwerp Industries NV",
            postalAddress = new
            {
                line1 = "Meir 45",
                city = "Antwerp",
                postCode = "2000",
                country = "BE",
            },
        },
        paymentDetails = new
        {
            paymentAccountIdentifier = "BE68539007547034",
        },
        lines = new[]
        {
            new
            {
                quantity = 100,
                item = new { name = "Software development services" },
                priceDetails = new { netPrice = 50.00 },
                vatInformation = new { rate = 21 },
            },
        },
    },
};

var response = await http.PostAsJsonAsync("/v1/create/ubl", payload);
response.EnsureSuccessStatusCode();

var ublXml = await response.Content.ReadAsStringAsync();
await File.WriteAllTextAsync("invoice-peppol.xml", ublXml);

That is the whole request. The API computes totals, builds the VAT breakdown, applies the Peppol BIS Billing 3.0 CustomizationID, and validates against EN 16931 plus the Peppol overlay before returning the UBL 2.1 XML. This is the profile used across Belgium, the Netherlands, the Nordics, and cross-border EU traffic on the Peppol network. For German public-sector UBL, use the dedicated /v1/create/xrechnung endpoint instead.

Full create example on GitHub →


Validate incoming UBL invoices in C#

When suppliers send UBL invoices through Peppol or by email, validate them before importing into your ERP. The endpoint runs the EN 16931 Schematron plus the Peppol overlay automatically. This is the operation .NET cannot do natively without the IKVM/Saxon workaround.

using System.Net.Http.Json;

async Task<ValidationResult> ValidateUblAsync(HttpClient http, string xmlPath)
{
    using var form = new MultipartFormDataContent();
    using var fileStream = File.OpenRead(xmlPath);
    form.Add(new StreamContent(fileStream), "file", Path.GetFileName(xmlPath));

    var response = await http.PostAsync("/v1/validate/ubl", form);
    return await response.Content.ReadFromJsonAsync<ValidationResult>();
}

// Strongly typed response model
public record ValidationResult(
    bool Valid,
    string Detail,
    ValidationData Data,
    ValidationFinding[] Errors,
    ValidationFinding[] Warnings);

public record ValidationData(
    bool? SchemaValid,
    bool? SchematronValid,
    string ConformanceLevel);

public record ValidationFinding(
    string Rule,
    int? Line,
    string Message,
    string[] BtCodes,
    string[] Fields,
    string Raw);

Usage:

var result = await ValidateUblAsync(http, "supplier-invoice.xml");

if (result.Valid)
{
    Console.WriteLine($"Valid {result.Data.ConformanceLevel} invoice");
    // proceed with ERP import
}
else
{
    Console.WriteLine($"Validation failed with {result.Errors.Length} error(s):");
    foreach (var err in result.Errors)
    {
        var layerLabel = err.Rule.StartsWith("PEPPOL", StringComparison.OrdinalIgnoreCase)
            ? "Peppol network rule"
            : "EN 16931 rule";
        Console.WriteLine($"  [{err.Rule}] ({layerLabel}) {err.Message}");
    }
    // reject or route to review queue
}

Both valid and invalid invoices return HTTP 200. Branch on result.Valid rather than the HTTP status code.

errors is a flat array of findings. Each entry carries the rule code, a plain-language message, the EN 16931 business term codes (btCodes), the JSON field paths (fields), and the verbatim validator output (raw). warnings uses the same shape for non-blocking issues:

{
  "valid": false,
  "detail": "Validation failed with 2 error(s)",
  "data": {
    "schemaValid": true,
    "schematronValid": false,
    "conformanceLevel": "UBL 2.1"
  },
  "errors": [
    {
      "rule": "BR-CO-14",
      "line": null,
      "message": "Invoice total VAT amount does not match the sum of VAT breakdown amounts.",
      "btCodes": ["BT-110"],
      "fields": ["totals.taxTotalAmount"],
      "raw": "[BR-CO-14] EN16931: Invoice total VAT amount = sum of VAT category tax amounts."
    },
    {
      "rule": "PEPPOL-EN16931-R004",
      "line": null,
      "message": "Invoice type code should be 380 for a standard invoice.",
      "btCodes": ["BT-3"],
      "fields": ["invoiceTypeCode"],
      "raw": "[PEPPOL-EN16931-R004] Invoice type code (BT-3) MUST be set to 380."
    }
  ],
  "warnings": []
}

The rule code tells you which layer failed. EN 16931 base rules (codes like BR-CO-14) flag wrong invoice data and go back to the supplier, while Peppol overlay rules (codes prefixed PEPPOL-) flag an incorrect network profile and go to your access point provider.


Convert between CII and UBL syntax

The two EN 16931 syntax bindings carry the same data in different XML shapes. Converting between them is needed when bridging ZUGFeRD-based and Peppol-based ecosystems:

async Task<string> ConvertAsync(HttpClient http, string endpoint, string xmlPath)
{
    using var form = new MultipartFormDataContent();
    using var fileStream = File.OpenRead(xmlPath);
    form.Add(new StreamContent(fileStream), "file", Path.GetFileName(xmlPath));

    var response = await http.PostAsync(endpoint, form);
    response.EnsureSuccessStatusCode();
    return await response.Content.ReadAsStringAsync();
}

// UBL to CII
var cii = await ConvertAsync(http, "/v1/convert/ubl/to/cii", "invoice-ubl.xml");

// CII to UBL
var ubl = await ConvertAsync(http, "/v1/convert/cii/to/ubl", "invoice-cii.xml");

This is the bridge between ZUGFeRD/Factur-X (CII-based) and Peppol UBL. A German supplier sending ZUGFeRD to a Belgian customer who can only accept Peppol UBL needs the data converted on the way through. No .NET UBL library performs this conversion.


Render UBL as a human-readable PDF

UBL XML is not designed for human reading. For AP approval workflows where finance teams review invoices before payment, render the XML as a clean PDF:

async Task<byte[]> RenderUblAsPdfAsync(HttpClient http, string xmlPath)
{
    using var form = new MultipartFormDataContent();
    using var fileStream = File.OpenRead(xmlPath);
    form.Add(new StreamContent(fileStream), "file", Path.GetFileName(xmlPath));

    var response = await http.PostAsync("/v1/render/ubl/to/pdf", form);
    response.EnsureSuccessStatusCode();
    return await response.Content.ReadAsByteArrayAsync();
}

var pdfBytes = await RenderUblAsPdfAsync(http, "peppol-invoice.xml");
await File.WriteAllBytesAsync("invoice-preview.pdf", pdfBytes);

The rendered PDF shows the line items, VAT breakdown, payment details, and IBAN: everything an approver needs without opening the underlying XML. It is for visual review only and carries no legal standing.


Extract structured JSON from UBL

For ingestion into systems that prefer JSON over XML, extract the full invoice data:

using System.Net.Http.Json;

async Task<JsonElement> ExtractUblAsync(HttpClient http, string xmlPath)
{
    using var form = new MultipartFormDataContent();
    using var fileStream = File.OpenRead(xmlPath);
    form.Add(new StreamContent(fileStream), "file", Path.GetFileName(xmlPath));

    var response = await http.PostAsync("/v1/extract/json", form);
    return await response.Content.ReadFromJsonAsync<JsonElement>();
}

var data = await ExtractUblAsync(http, "incoming-invoice.xml");
Console.WriteLine($"Invoice {data.GetProperty("invoiceNumber").GetString()}");
Console.WriteLine($"Total: {data.GetProperty("totalAmount").GetDecimal()}");

ASP.NET Core integration: a typed Peppol service

Wrap the operations in a DI-registered service:

// Services/IPeppolUblService.cs
public interface IPeppolUblService
{
    Task<string> GenerateAsync(object invoiceData, CancellationToken ct = default);
    Task<ValidationResult> ValidateAsync(string ublXml, CancellationToken ct = default);
    Task<PeppolResult> GenerateValidatedAsync(object invoiceData, CancellationToken ct = default);
}

public record PeppolResult(string Xml, string ConformanceLevel);

// Services/PeppolUblService.cs
public class PeppolUblService : IPeppolUblService
{
    private readonly HttpClient _http;

    public PeppolUblService(IHttpClientFactory factory)
        => _http = factory.CreateClient("InvoiceXML");

    public async Task<string> GenerateAsync(object invoiceData, CancellationToken ct = default)
    {
        var response = await _http.PostAsJsonAsync(
            "/v1/create/ubl",
            new { invoice = invoiceData },
            ct);
        response.EnsureSuccessStatusCode();
        return await response.Content.ReadAsStringAsync(ct);
    }

    public async Task<ValidationResult> ValidateAsync(string ublXml, CancellationToken ct = default)
    {
        using var form = new MultipartFormDataContent();
        form.Add(new StringContent(ublXml), "file", "invoice.xml");

        var response = await _http.PostAsync("/v1/validate/ubl", form, ct);
        return await response.Content.ReadFromJsonAsync<ValidationResult>(ct);
    }

    public async Task<PeppolResult> GenerateValidatedAsync(object invoiceData, CancellationToken ct = default)
    {
        var ubl = await GenerateAsync(invoiceData, ct);
        var validation = await ValidateAsync(ubl, ct);

        if (!validation.Valid)
        {
            var errors = string.Join("\n",
                validation.Errors.Select(e => $"[{e.Rule}] {e.Message}"));
            throw new InvalidOperationException(
                $"Generated UBL failed validation:\n{errors}");
        }

        return new PeppolResult(ubl, validation.Data.ConformanceLevel);
    }
}

The GenerateValidatedAsync method is the production pattern: generate, validate, and only return XML that passed. This catches data errors before transmission to a Peppol access point rather than receiving a rejection after the fact.


Azure Functions: serverless Peppol generation

For event-driven architectures, an Azure Function that generates and validates UBL when an invoice message arrives on a queue:

public class PeppolFunction
{
    private readonly IPeppolUblService _peppol;

    public PeppolFunction(IPeppolUblService peppol) => _peppol = peppol;

    [Function("GeneratePeppolUbl")]
    public async Task Run(
        [QueueTrigger("invoices-to-process")] InvoiceMessage message,
        FunctionContext context)
    {
        var result = await _peppol.GenerateValidatedAsync(message.InvoiceData);

        // Store the validated UBL and queue for Peppol access point submission
        await StoreAndQueue(result.Xml, message.InvoiceId);
    }
}

The serverless model suits Peppol generation well because invoice processing is bursty (month-end runs) and the API's stateless design means no shared state between function invocations.


Error handling with Polly

Add retry resilience on the typed client:

builder.Services.AddHttpClient("InvoiceXML", client =>
{
    client.BaseAddress = new Uri("https://api.invoicexml.com");
    client.DefaultRequestHeaders.Authorization =
        new AuthenticationHeaderValue("Bearer",
            builder.Configuration["InvoiceXML:ApiKey"]);
})
.AddTransientHttpErrorPolicy(policy =>
    policy.WaitAndRetryAsync(
        retryCount: 3,
        sleepDurationProvider: attempt =>
            TimeSpan.FromSeconds(Math.Pow(2, attempt))));

This retries on 5xx and transient network errors. Validation failures (4xx) are not retried.


Why use a REST API instead of a .NET library

UblSharp is good at what it does: generating and deserialising structurally valid UBL 2.1 XML. For producing UBL where the receiving system does its own validation, it works.

The case for the API is the compliance layer:

EN 16931 Schematron plus Peppol BIS 3.0 overlay validation. .NET has no native XSLT 2.0 processor. Running both Schematron layers locally means the IKVM/Saxon-HE workaround with its compatibility matrix and Java interop, plus re-integrating the Peppol overlay every quarter when OpenPeppol updates it. The API runs both layers and returns typed findings with the rule code, message, and field paths for each violation.

National CIUS coverage. Validation runs the EN 16931 base rules plus the Peppol BIS 3.0 overlay for UBL, with dedicated endpoints for German XRechnung. You do not maintain or update these Schematron rule sets yourself.

Bidirectional CII and UBL conversion. Lossless and validated, for bridging ZUGFeRD-based and Peppol-based flows. No .NET UBL library does this.

Version maintenance. Quarterly Peppol updates and annual CIUS revisions are deployed before their effective dates with no change to your code.

Stateless processing. Documents are processed in memory and purged immediately on response delivery. Nothing is written to disk, nothing is logged, no invoice data is used for model training. GDPR and HIPAA compliant by architecture.

A workable hybrid is UblSharp for generation and the API for validation. The validate endpoint accepts UBL produced by any library.


Complete UBL endpoint reference for .NET

OperationEndpointInputOutput
Create UBLPOST /v1/create/ublJSONUBL 2.1 XML
PDF to UBL (AI)POST /v1/transform/to/ublPDF, image, CII XMLUBL 2.1 XML
Validate UBLPOST /v1/validate/ublUBL XMLValidation JSON
UBL to CIIPOST /v1/convert/ubl/to/ciiUBL XMLCII XML
CII to UBLPOST /v1/convert/cii/to/ublCII XMLUBL XML
Extract as JSONPOST /v1/extract/jsonUBL XMLStructured JSON
Render as PDFPOST /v1/render/ubl/to/pdfUBL XMLPDF binary

Full OpenAPI 3.1 schema: api.invoicexml.com/v1/openapi  |  Interactive API explorer: api.invoicexml.com/v1/scalar


Get started

Every C# snippet in this guide is available as a runnable example on GitHub. Clone the repo, set INVOICEXML_API_KEY, and you have a working integration in under a minute:

InvoiceXML/facturx-api-examples/csharp →

Create a free InvoiceXML account → get 100 credits for free, no credit card required.

Related resources:


InvoiceXML is a REST API for Peppol-ready e-invoice compliance covering UBL, CII, ZUGFeRD, Factur-X, and XRechnung. Stateless processing, GDPR compliant by architecture, and integration support from any .NET application: ASP.NET Core, Dynamics 365, Azure Functions, or enterprise ERP integrations.


Ready to automate your invoices?

Start your 30-day free trial. No credit card required.

Get Started For Free