How to generate Peppol BIS 3.0 invoices in C#
Generating a Peppol BIS Billing 3.0 invoice in C# is not conceptually difficult. The difficult part is getting the namespace declarations right, putting the right identifiers in the right fields, and producing output that passes Schematron validation.
This article walks through a minimal but complete implementation. It assumes you have read the Peppol BIS 3.0 introduction and understand the EN16931 data model. If you are unfamiliar with Business Terms like BT-24 or BT-131, start there.
Required namespaces
UBL 2.1 uses three XML namespaces for invoice documents:
static readonly XNamespace Ubl = "urn:oasis:names:specification:ubl:schema:xsd:Invoice-2";
static readonly XNamespace Cac = "urn:oasis:names:specification:ubl:schema:xsd:CommonAggregateComponents-2";
static readonly XNamespace Cbc = "urn:oasis:names:specification:ubl:schema:xsd:CommonBasicComponents-2";
Ubl is the root element namespace. Cac holds aggregate (composite) elements like PartyName and InvoiceLine. Cbc holds basic (scalar) elements like ID, Name, and Amount.
Every element in the invoice uses one of these three namespaces. Getting a namespace wrong causes schema validation to fail before Schematron even runs.
The mandatory header fields
A minimum valid Peppol BIS 3.0 invoice needs these header elements, in this order:
var invoice = new XElement(Ubl + "Invoice",
new XAttribute(XNamespace.Xmlns + "cac", Cac.NamespaceName),
new XAttribute(XNamespace.Xmlns + "cbc", Cbc.NamespaceName),
// BT-24: Specification identifier — identifies the exact profile
new XElement(Cbc + "CustomizationID",
"urn:cen.eu:en16931:2017#compliant#urn:fdc:peppol.eu:2017:poacc:billing:3.0::2.1"),
// BT-23: Business process type
new XElement(Cbc + "ProfileID",
"urn:fdc:peppol.eu:2017:poacc:billing:01:1.0"),
// BT-1: Invoice number
new XElement(Cbc + "ID", "INV-2026-001"),
// BT-2: Invoice issue date
new XElement(Cbc + "IssueDate", "2026-01-14"),
// BT-3: Invoice type code (380 = Commercial Invoice)
new XElement(Cbc + "InvoiceTypeCode", "380"),
// BT-5: Invoice currency code
new XElement(Cbc + "DocumentCurrencyCode", "EUR")
);
The CustomizationID value is not configurable. This exact string is required for Peppol BIS 3.0. Using the EN16931 base URI instead of the Peppol BIS one is a common mistake that causes profile-level rejection.
Seller and buyer parties
Party structure in UBL is nested: the party sits inside a role element.
// BG-4: Seller party
var seller = new XElement(Cac + "AccountingSupplierParty",
new XElement(Cac + "Party",
// BT-34: Seller electronic address (Peppol endpoint ID)
new XElement(Cbc + "EndpointID",
new XAttribute("schemeID", "0208"),
"0468863455"),
// BT-27: Seller name
new XElement(Cac + "PartyName",
new XElement(Cbc + "Name", "Acme GmbH")),
// BG-5: Seller postal address
new XElement(Cac + "PostalAddress",
new XElement(Cbc + "StreetName", "Hauptstrasse 1"),
new XElement(Cbc + "CityName", "Berlin"),
new XElement(Cbc + "PostalZone", "10115"),
new XElement(Cac + "Country",
new XElement(Cbc + "IdentificationCode", "DE"))),
// BT-31: Seller VAT identifier
new XElement(Cac + "PartyTaxScheme",
new XElement(Cbc + "CompanyID", "DE123456789"),
new XElement(Cac + "TaxScheme",
new XElement(Cbc + "ID", "VAT"))),
// BT-30: Seller legal registration identifier
new XElement(Cac + "PartyLegalEntity",
new XElement(Cbc + "RegistrationName", "Acme GmbH"),
new XElement(Cbc + "CompanyID", "HRB 12345"))
)
);
// BG-7: Buyer party
var buyer = new XElement(Cac + "AccountingCustomerParty",
new XElement(Cac + "Party",
// BT-49: Buyer electronic address (Peppol endpoint ID)
new XElement(Cbc + "EndpointID",
new XAttribute("schemeID", "0106"),
"123456789B01"),
// BT-44: Buyer name
new XElement(Cac + "PartyName",
new XElement(Cbc + "Name", "Buyer BV")),
new XElement(Cac + "PostalAddress",
new XElement(Cbc + "StreetName", "Hoofdstraat 1"),
new XElement(Cbc + "CityName", "Amsterdam"),
new XElement(Cbc + "PostalZone", "1000 AA"),
new XElement(Cac + "Country",
new XElement(Cbc + "IdentificationCode", "NL"))),
new XElement(Cac + "PartyLegalEntity",
new XElement(Cbc + "RegistrationName", "Buyer BV"),
new XElement(Cbc + "CompanyID", "123456789B01"))
)
);
The schemeID attribute on EndpointID is the ISO 6523 scheme. 0208 is Belgian enterprise number, 0106 is Dutch KvK number. Using the wrong scheme code causes participant lookup to fail in the Peppol network.
Line items
Each line item maps to an InvoiceLine element:
static XElement BuildLine(int lineId, string description, decimal quantity,
decimal unitPrice, decimal vatRate)
{
var lineNetAmount = Math.Round(quantity * unitPrice, 2);
return new XElement(Cac + "InvoiceLine",
// BT-126: Invoice line identifier
new XElement(Cbc + "ID", lineId.ToString()),
// BT-129: Invoiced quantity
new XElement(Cbc + "InvoicedQuantity",
new XAttribute("unitCode", "C62"), // C62 = piece/unit
quantity),
// BT-131: Invoice line net amount
new XElement(Cbc + "LineExtensionAmount",
new XAttribute("currencyID", "EUR"),
lineNetAmount),
// BG-29: Price
new XElement(Cac + "Price",
new XElement(Cbc + "PriceAmount",
new XAttribute("currencyID", "EUR"),
unitPrice)),
// BG-30: Line VAT
new XElement(Cac + "Item",
new XElement(Cbc + "Name", description),
new XElement(Cac + "ClassifiedTaxCategory",
new XElement(Cbc + "ID", "S"),
new XElement(Cbc + "Percent", vatRate),
new XElement(Cac + "TaxScheme",
new XElement(Cbc + "ID", "VAT"))))
);
}
The unitCode is a UN/ECE recommendation 20 code. C62 means each (piece). Other common codes: HUR (hour), KGM (kilogram), MTR (metre).
Tax totals
EN16931 requires tax amounts grouped by category and rate. For a simple single-rate invoice:
static XElement BuildTaxTotal(decimal taxableAmount, decimal vatRate, decimal vatAmount)
{
return new XElement(Cac + "TaxTotal",
new XElement(Cbc + "TaxAmount",
new XAttribute("currencyID", "EUR"),
vatAmount),
new XElement(Cac + "TaxSubtotal",
new XElement(Cbc + "TaxableAmount",
new XAttribute("currencyID", "EUR"),
taxableAmount),
new XElement(Cbc + "TaxAmount",
new XAttribute("currencyID", "EUR"),
vatAmount),
new XElement(Cac + "TaxCategory",
new XElement(Cbc + "ID", "S"),
new XElement(Cbc + "Percent", vatRate),
new XElement(Cac + "TaxScheme",
new XElement(Cbc + "ID", "VAT"))))
);
}
Document totals
The LegalMonetaryTotal must satisfy several EN16931 calculation rules. Round at the aggregate level, not per line:
static XElement BuildTotals(decimal lineNetTotal, decimal vatAmount, decimal grandTotal)
{
return new XElement(Cac + "LegalMonetaryTotal",
// BT-106: Sum of line net amounts
new XElement(Cbc + "LineExtensionAmount",
new XAttribute("currencyID", "EUR"), lineNetTotal),
// BT-109: Invoice total amount without VAT
new XElement(Cbc + "TaxExclusiveAmount",
new XAttribute("currencyID", "EUR"), lineNetTotal),
// BT-112: Invoice total amount with VAT
new XElement(Cbc + "TaxInclusiveAmount",
new XAttribute("currencyID", "EUR"), grandTotal),
// BT-115: Amount due for payment
new XElement(Cbc + "PayableAmount",
new XAttribute("currencyID", "EUR"), grandTotal)
);
}
Compute amounts as decimal, round with Math.Round(value, 2, MidpointRounding.AwayFromZero) consistently throughout. Mixing rounding strategies between lines and totals is the most common cause of BR-CO-10 and BR-CO-15 failures.
Schematron validation
Schema validation (XSD) catches structural errors. Schematron validation catches business rule violations. You need both.
Download the pre-compiled XSLT files from the Peppol BIS GitHub releases. Then run them with Saxon HE:
dotnet add package SaxonHE12s9apiExtensions
using net.sf.saxon.s9api;
static IEnumerable<string> ValidateWithSchematron(string invoiceXml, string xsltPath)
{
var proc = new Processor(licensed: false);
var exec = proc.newXsltCompiler()
.compile(new StreamSource(xsltPath));
var trans = exec.load30();
var result = new XdmDestination();
trans.applyTemplates(
new StreamSource(new java.io.StringReader(invoiceXml)),
result);
// Parse SVRL for failed-assert elements
var svrl = XDocument.Parse(result.XdmNode.ToString());
var svrlNs = XNamespace.Get("http://purl.oclc.org/dsdl/svrl");
return svrl.Descendants(svrlNs + "failed-assert")
.Select(e => e.Element(svrlNs + "text")?.Value?.Trim() ?? "")
.Where(t => !string.IsNullOrEmpty(t));
}
Run this against peppol-en16931-ubl.xsl from the release artifacts. The XSLT file applies both the EN16931 base rules and the Peppol BIS 3.0 extension rules.
Payment means
If you need to specify bank transfer (SEPA), add this before TaxTotal:
new XElement(Cac + "PaymentMeans",
// BT-81: Payment means code (30 = Credit transfer)
new XElement(Cbc + "PaymentMeansCode", "30"),
new XElement(Cac + "PayeeFinancialAccount",
new XElement(Cbc + "ID", "NL91ABNA0417164300"), // IBAN
new XElement(Cac + "FinancialInstitutionBranch",
new XElement(Cbc + "ID", "ABNANL2A")))) // BIC
BT-81 code 30 (credit transfer) is the most common. 58 (SEPA credit transfer) is the variant when SEPA-specific fields are needed.
What comes next
Generating a valid XML invoice is the foundation. The next layer is transmission: the invoice needs to reach the buyer through a Peppol Access Point. That requires knowing the buyer’s endpoint, which you get through SMP/SML discovery. See how Peppol SMP and SML discovery works for the lookup process.
For production use, SealDoc exposes a REST API that handles UBL generation, Schematron validation, and Peppol network routing in a single call. The input is your invoice data in JSON; the output is a validated, timestamped document ready for delivery.