ZUGFeRD serves B2B. A PDF/A-3 the recipient opens like any invoice, carrying a factur-x.xml attachment their software parses; version 2.3 is current and technically identical to France's Factur-X 1.0.7. Reception has been mandatory for all German businesses since January 2025, issuance follows from January 2027 (above EUR 800k turnover) and January 2028 (everyone), and profiles EN 16931 and above qualify while MINIMUM and BASIC WL remain booking aids.
XRechnung serves B2G. Germany's national CIUS of EN 16931, maintained by KoSIT, version 3.0 in force since February 2024 and required by federal portals since November 2020. It adds the BR-DE rules, makes seller contact details and electronic addresses mandatory, and requires the Leitweg-ID in BT-10. ZRE, OZG-RE, and the state portals validate on acceptance, so anything short of fully compliant bounces.
GoBD archiving applies to both. Invoices must be stored as received, immutable and machine-evaluable, for the statutory retention period. ZUGFeRD archives as one self-contained file; XRechnung archives as the XML itself, which is why this guide includes a rendering step for human review.
Underneath sits the real engineering: roughly 200 EN 16931 business rules, the German overlays, PDF/A-3 conformance with exact XMP metadata, and Schematron pipelines to prove all of it. The technical overview of ZUGFeRD automation walks each layer.
Routing invoices to the right endpoint
| Your customer | Format | Deliverable | Endpoint |
| German business (B2B) | ZUGFeRD EN 16931 | Hybrid PDF/A-3 | /v1/create/zugferd |
| Federal or state authority (B2G) | XRechnung 3.0 | Pure XML + Leitweg-ID | /v1/create/xrechnung |
| French business | Factur-X | Hybrid PDF/A-3 | /v1/create/facturx |
| Peppol network recipient | Peppol BIS 3.0 | UBL XML | /v1/create/ubl |
Every row consumes the same invoice object, so multi-format support in C# is a conditional on the endpoint string, not four codebases. This guide works the German rows; Factur-X and Peppol UBL have their own C# guides.
The .NET library landscape
.NET has one library that genuinely matters here:
ZUGFeRD-csharp has been maintained for a decade and its InvoiceDescriptor model writes and reads ZUGFeRD 1.x and 2.x CII documents across the profile ladder, XRechnung profile included. Plenty of German ISVs have shipped on it, which is exactly why its boundaries deserve a precise look.
Its scope, and the gaps around it, are worth stating precisely:
The descriptor is not the document. The library produces and parses the XML. For ZUGFeRD you still need a PDF/A-3 container with the attachment merged in and the XMP metadata declaring the profile, which means pairing it with a PDF library and owning archival conformance (embedded fonts, color spaces) yourself. Files that look fine and fail a recipient's PDF/A check are the classic failure mode.
Official validation does not run in-process. The EN 16931 and KoSIT rule sets compile to XSLT 2.0; .NET's XslCompiledTransform implements XSLT 1.0 and stopped there. Saxon's .NET edition can execute the transforms, but no maintained package wires the official pipelines together and tracks KoSIT's yearly XRechnung releases, so a locally generated document is structurally plausible, not verified.
Rendering, attachments, and unstructured input are out of scope. No XRechnung preview PDFs, no BG-24 attachment handling, and no way to read the plain supplier PDFs that still dominate real AP inboxes.
The rest of this guide runs the full lifecycle over the API, and the closing section weighs that maintenance load against a complete compliance service.
Client setup with HttpClient
One client instance, configured once and reused; the console examples create it directly, and the ASP.NET Core section moves the same configuration into IHttpClientFactory.
Create a free InvoiceXML account to get an API key with 100 free credits, no credit card needed. Read the key from the environment or your secrets store, never from source:
using System.Net.Http.Headers;
var http = new HttpClient { BaseAddress = new Uri("https://api.invoicexml.com") };
http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(
"Bearer", Environment.GetEnvironmentVariable("INVOICEXML_API_KEY"));
http.Timeout = TimeSpan.FromSeconds(120);
Create ZUGFeRD invoices in C#
An anonymous object is all the payload modeling required; PostAsJsonAsync serializes it camelCase as-is. Send net prices, quantities, and rates, and the API computes totals and the per-rate VAT breakdown, generates the CII XML, produces the conformant PDF/A-3, and validates against the full EN 16931 rule set before returning. The sample mixes Germany's 19% and 7% rates:
using System.Net.Http.Json;
var invoice = new
{
invoice = new
{
invoiceNumber = "RE-2026-4407",
issueDate = "2026-07-28",
currency = "EUR",
seller = new
{
name = "Elbufer Systeme GmbH",
vatIdentifier = "DE243871650",
postalAddress = new
{
line1 = "Käthe-Kollwitz-Ufer 21",
city = "Dresden",
postCode = "01307",
country = "DE"
}
},
buyer = new
{
name = "Taunus Pharma Handel GmbH",
postalAddress = new
{
line1 = "Wilhelmstraße 24",
city = "Wiesbaden",
postCode = "65183",
country = "DE"
}
},
paymentDetails = new { paymentAccountIdentifier = "DE27100777770209299700" },
lines = new object[]
{
new
{
quantity = 12,
item = new { name = "Laborinformationssystem, Modul QS, Juli 2026" },
priceDetails = new { netPrice = 240.00 },
vatInformation = new { rate = 19 }
},
new
{
quantity = 60,
item = new { name = "Validierungshandbuch (Druck)" },
priceDetails = new { netPrice = 16.00 },
vatInformation = new { rate = 7 }
}
}
}
};
var response = await http.PostAsJsonAsync("/v1/create/zugferd", invoice);
if (!response.IsSuccessStatusCode)
{
throw new InvalidOperationException(await response.Content.ReadAsStringAsync());
}
await File.WriteAllBytesAsync("rechnung-zugferd.pdf",
await response.Content.ReadAsByteArrayAsync());
The response is a finished ZUGFeRD 2.3 document: PDF/A-3 container, embedded factur-x.xml, XMP metadata declaring the EN 16931 profile, checked against the official Schematron. Data that cannot yield a compliant invoice is refused with HTTP 400 and the violated rules as structured findings, which is why the error body is worth reading rather than swallowing.
Runnable .NET examples on GitHub →
Validate ZUGFeRD invoices in C#
Validate outgoing documents before dispatch and supplier hybrids on arrival. The endpoint extracts the embedded XML, reads the declared profile from BT-24, and runs that profile's official XSD plus Schematron rules. Records plus ReadFromJsonAsync (which is case-insensitive by default) give you a typed verdict with no mapping code:
record ValidationReport(bool Valid, string? Detail, ReportData? Data,
List<Finding>? Errors, List<Finding>? Warnings);
record ReportData(bool? SchemaValid, bool? SchematronValid, string? Profile);
record Finding(string? Rule, string? Layer, string? Message, List<string>? Fields);
static async Task<ValidationReport> ValidateZugferdAsync(HttpClient http, string path)
{
using var form = new MultipartFormDataContent();
await using var stream = File.OpenRead(path);
form.Add(new StreamContent(stream), "file", Path.GetFileName(path));
var response = await http.PostAsync("/v1/validate/zugferd", form);
return (await response.Content.ReadFromJsonAsync<ValidationReport>())!;
}
var report = await ValidateZugferdAsync(http, "rechnung-zugferd.pdf");
if (report.Valid)
{
Console.WriteLine($"Gültiges ZUGFeRD, Profil {report.Data?.Profile}");
}
else
{
foreach (var finding in report.Errors ?? [])
{
Console.WriteLine($"[{finding.Rule}] {finding.Message}");
Console.WriteLine($" Felder: {string.Join(", ", finding.Fields ?? [])}");
}
}
A completed validation always answers HTTP 200; the verdict is the Valid property, and non-2xx statuses mean transport problems such as a bad key or an unreadable upload. Findings carry rule ids, plain-language messages, business term codes, and field paths, ready for a UI or a supplier email without post-processing.
Create XRechnung invoices in C#
Same object shape, different endpoint, plus the two additions XRechnung enforces beyond base EN 16931:
buyerReference carries the Leitweg-ID. Mapped to BT-10 and enforced by rule BR-DE-15, assigned by the public sector buyer during onboarding. It is routing infrastructure rather than invoice data: keep it on the buyer record and inject it into every request.
Seller contact and electronic addresses are mandatory. The full seller.contact group (name, phone, email) plus electronic addresses for both parties.
var xrechnung = new
{
invoice = new
{
invoiceNumber = "RE-2026-4408",
issueDate = "2026-07-28",
dueDate = "2026-08-27",
currency = "EUR",
buyerReference = "14612-00088-27",
seller = new
{
name = "Elbufer Systeme GmbH",
vatIdentifier = "DE243871650",
postalAddress = new
{
line1 = "Käthe-Kollwitz-Ufer 21",
city = "Dresden",
postCode = "01307",
country = "DE"
},
contact = new
{
name = "Katrin Seidel",
phone = "+49 351 8802340",
email = "[email protected]"
},
electronicAddress = new { identifier = "DE243871650", schemeId = "0204" }
},
buyer = new
{
name = "Landeshauptstadt Dresden, Amt für Schulen",
postalAddress = new
{
line1 = "Fiedlerstraße 30",
city = "Dresden",
postCode = "01307",
country = "DE"
},
electronicAddress = new { identifier = "14612-00088-27", schemeId = "0204" }
},
paymentDetails = new
{
paymentMeansCode = 58,
paymentAccountIdentifier = "DE27100777770209299700"
},
lines = new object[]
{
new
{
quantity = 140,
unitCode = "HUR",
item = new { name = "Betrieb Schulverwaltungssoftware, Juli 2026" },
priceDetails = new { netPrice = 98.00 },
vatInformation = new { rate = 19, categoryCode = "S" }
}
}
}
};
var response = await http.PostAsJsonAsync("/v1/create/xrechnung", xrechnung);
if (!response.IsSuccessStatusCode)
{
throw new InvalidOperationException(await response.Content.ReadAsStringAsync());
}
await File.WriteAllBytesAsync("rechnung-xrechnung.xml",
await response.Content.ReadAsByteArrayAsync());
The response is XRechnung 3.0 in CII syntax, already validated against the EN 16931 Schematron and the KoSIT rules. If your stack also serves the Peppol network, generate the legally equivalent UBL binding instead: POST /v1/create/ubl with options = new { profile = "xrechnung" } and the same invoice object.
Validate XRechnung invoices in C#
POST /v1/validate/xrechnung auto-detects CII or UBL syntax and runs three layers: the XSD, the EN 16931 core Schematron, and the German BR-DE rules from the KoSIT Schematron, the same stack ZRE and OZG-RE apply. It accepts XML from any producer, ZUGFeRD-csharp output included, and the record types from the ZUGFeRD section deserialize this verdict too:
static async Task<ValidationReport> ValidateXrechnungAsync(HttpClient http, string path)
{
using var form = new MultipartFormDataContent();
await using var stream = File.OpenRead(path);
form.Add(new StreamContent(stream), "file", Path.GetFileName(path));
var response = await http.PostAsync("/v1/validate/xrechnung", form);
return (await response.Content.ReadFromJsonAsync<ValidationReport>())!;
}
var report = await ValidateXrechnungAsync(http, "rechnung-xrechnung.xml");
if (!report.Valid)
{
foreach (var finding in report.Errors ?? [])
{
// finding.Layer is "xsd", "en16931", or "cius" (the BR-DE rules)
Console.WriteLine($"[{finding.Rule}] ({finding.Layer}) {finding.Message}");
}
}
Surface the Layer value to users: a cius finding means the document is a valid EN 16931 invoice that specifically misses a German requirement, with BR-DE-15 on a missing Leitweg-ID as the textbook case. This endpoint accepts XML only; a ZUGFeRD hybrid declaring the XRechnung reference profile goes to /v1/validate/zugferd, and its embedded XML reaches the same KoSIT rules automatically.
Read incoming invoices: extraction plus AI
Reception has been mandatory since 2025, and the resulting inbox is heterogeneous: ZUGFeRD hybrids, XRechnung XML, and plain PDFs from suppliers who never modernised. One method covers all three, trying deterministic extraction first and falling back to AI only when the API reports error code 4006 (no embedded XML):
using System.Text.Json;
record ParseConfidence(double Overall, double SellerIdentification,
double BuyerIdentification, double TaxCalculation, double LineItems);
static async Task<HttpResponseMessage> UploadAsync(
HttpClient http, string endpoint, string path)
{
using var form = new MultipartFormDataContent();
await using var stream = File.OpenRead(path);
form.Add(new StreamContent(stream), "file", Path.GetFileName(path));
return await http.PostAsync(endpoint, form);
}
static async Task<(string Source, JsonElement Invoice, ParseConfidence? Confidence)>
ReadIncomingInvoiceAsync(HttpClient http, string path)
{
var extract = await UploadAsync(http, "/v1/extract/json", path);
if (extract.IsSuccessStatusCode)
{
var invoice = await extract.Content.ReadFromJsonAsync<JsonElement>();
return ("embedded-xml", invoice, null);
}
var error = await extract.Content.ReadFromJsonAsync<JsonElement>();
if (!error.TryGetProperty("errorCode", out var code) || code.GetInt32() != 4006)
{
throw new InvalidOperationException(error.ToString());
}
// No embedded XML: an old-school PDF, run the AI parser
var parse = await UploadAsync(http, "/v1/parse/json", path);
parse.EnsureSuccessStatusCode();
var envelope = await parse.Content.ReadFromJsonAsync<JsonElement>();
var confidence = envelope.GetProperty("confidence").Deserialize<ParseConfidence>(
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
return ("ai", envelope.GetProperty("invoice"), confidence);
}
The two paths differ in kind. Extraction is exact XML parsing: what the supplier declared is what your ERP imports. The AI path reads the document visually (OCR included, scans and photos welcome), so it reports a typed ParseConfidence next to the invoice: an overall score and four area scores (seller, buyer, tax calculation, line items) between 0.0 and 1.0. Gate the automation on it:
var (source, invoice, confidence) =
await ReadIncomingInvoiceAsync(http, "eingang/lieferant-scan.pdf");
if (source == "ai" && confidence!.Overall < 0.7)
{
await QueueForReviewAsync(invoice, confidence);
}
else
{
await ImportIntoErpAsync(invoice);
}
Non-invoices fail the AI path with error code 4008 and multi-invoice files with 4009. The third intake endpoint handles a German B2G habit: supporting documents (delivery notes, timesheets, order copies) travel base64-embedded inside the invoice XML in the BG-24 group, and POST /v1/extract/attachments decodes them into a ZIP, answering 404 when the invoice carries none:
using System.Net;
static async Task<string?> ExtractAttachmentsAsync(
HttpClient http, string path, string zipPath)
{
var response = await UploadAsync(http, "/v1/extract/attachments", path);
if (response.StatusCode == HttpStatusCode.NotFound)
{
return null; // invoice carries no embedded attachments
}
response.EnsureSuccessStatusCode();
await File.WriteAllBytesAsync(zipPath,
await response.Content.ReadAsByteArrayAsync());
return zipPath;
}
await ExtractAttachmentsAsync(http, "rechnung-xrechnung.xml", "belege.zip");
Prefer the raw embedded CII over parsed JSON? /v1/extract/xml streams the XML out of a hybrid PDF untouched, ready for XDocument or the ZUGFeRD-csharp reader.
Render XRechnung previews
XRechnung gives a human nothing to look at, which turns into a workflow gap the first time accounting wants to check an invoice before portal submission. POST /v1/render/xrechnung/to/pdf lays every business term out on a clean, readable PDF, Leitweg-ID labelled in the buyer reference section, with both syntaxes detected automatically:
static async Task RenderXrechnungAsync(HttpClient http, string xmlPath, string pdfPath)
{
var response = await UploadAsync(http, "/v1/render/xrechnung/to/pdf", xmlPath);
response.EnsureSuccessStatusCode();
await File.WriteAllBytesAsync(pdfPath,
await response.Content.ReadAsByteArrayAsync());
}
await RenderXrechnungAsync(http, "rechnung-xrechnung.xml", "rechnung-vorschau.pdf");
The preview has no legal standing; the XML remains the invoice, and only the XML is submitted.
ASP.NET Core integration
In an ASP.NET Core service, register a typed client with IHttpClientFactory and collapse the B2B/B2G split into one boolean:
// Program.cs
builder.Services.AddHttpClient<GermanInvoiceClient>(client =>
{
client.BaseAddress = new Uri(builder.Configuration["InvoiceXml:BaseUrl"]!);
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(
"Bearer", builder.Configuration["InvoiceXml:ApiKey"]);
client.Timeout = TimeSpan.FromSeconds(120);
});
// GermanInvoiceClient.cs
public class InvoiceComplianceException(string findings) : Exception(findings);
public class GermanInvoiceClient(HttpClient http)
{
public async Task<(byte[] Content, string Extension)> CreateAsync(
object invoice, bool publicSector)
{
var endpoint = publicSector ? "/v1/create/xrechnung" : "/v1/create/zugferd";
var response = await http.PostAsJsonAsync(endpoint, new { invoice });
if (!response.IsSuccessStatusCode)
{
throw new InvoiceComplianceException(
await response.Content.ReadAsStringAsync());
}
return (await response.Content.ReadAsByteArrayAsync(),
publicSector ? "xml" : "pdf");
}
public async Task<ValidationReport> ValidateAsync(byte[] content, bool publicSector)
{
var endpoint = publicSector ? "/v1/validate/xrechnung" : "/v1/validate/zugferd";
var filename = publicSector ? "invoice.xml" : "invoice.pdf";
using var form = new MultipartFormDataContent();
form.Add(new ByteArrayContent(content), "file", filename);
var response = await http.PostAsync(endpoint, form);
return (await response.Content.ReadFromJsonAsync<ValidationReport>())!;
}
}
For resilience, add AddStandardResilienceHandler (or Polly directly) to the registration and let it retry transport failures and 5xx responses only. HTTP 400 must not be retried: it carries validation findings about your data, identical input reproduces identical findings, and that is exactly why InvoiceComplianceException exists as a distinct type your retry policy ignores. Keep the Leitweg-ID on the buyer record and validate its presence before work is enqueued, not at the portal.
The highest-leverage habit is a compliance test in CI. Two xUnit facts pin the whole mapping from your domain model to both formats:
[Fact]
public async Task Zugferd_payload_stays_compliant()
{
var client = CreateClient();
var (content, _) = await client.CreateAsync(SampleB2bPayload(), publicSector: false);
var report = await client.ValidateAsync(content, publicSector: false);
Assert.True(report.Valid,
string.Join("\n", (report.Errors ?? []).Select(e => e.Message)));
}
[Fact]
public async Task Xrechnung_payload_passes_the_kosit_rules()
{
var client = CreateClient();
var (content, _) = await client.CreateAsync(SampleB2gPayload(), publicSector: true);
var report = await client.ValidateAsync(content, publicSector: true);
Assert.True(report.Valid,
string.Join("\n", (report.Errors ?? []).Select(e => e.Message)));
}
.NET library vs REST API
The fair split for .NET teams:
The library is not a solution, it is a build project. ZUGFeRD-csharp is free and mature, and adopting it still leaves you owning everything around the descriptor: a PDF/A-3 pipeline with archival conformance, validation you cannot run in-process (structural plausibility is not the 200+ business rules, and the difference surfaces as recipient rejections), XRechnung previews, intake, and the KoSIT release calendar. Every specification update means watching for the announcement, waiting for the package to catch up, bumping the version, retesting, and redeploying every service that touches invoices, and German e-invoicing updates every year.
The API is a complete compliance service. Creation, validation, extraction, AI parsing, and rendering behind one typed client, with the official EN 16931 and KoSIT rule sets live server-side before their effective dates. New ZUGFeRD and XRechnung versions ship out of the box: nothing to monitor, no package to wait for, no redeploy. Your solution carries zero compliance dependencies, the domain expertise sits on our side with professional support behind it, missing features get integrated on request, and the same integration extends to Factur-X and Peppol UBL by swapping an endpoint string.
The deciding question is who owns the moving target. Compliance is not a feature you ship once; the rules change on a government calendar, not yours. With a library, that calendar drives your release schedule forever. With the API, your integration is finished the day it works, and staying compliant is our job.
Data handling is stateless by architecture: documents are processed in memory and purged when the response ships. Nothing is stored, nothing is logged, and no invoice data trains any model. VAT numbers, IBANs, and Leitweg-IDs exist server-side for the duration of one HTTP exchange, nothing more.
Endpoint reference
| Operation | Endpoint | Input | Output |
| Create ZUGFeRD | POST /v1/create/zugferd | JSON | PDF/A-3 binary |
| Create XRechnung (CII) | POST /v1/create/xrechnung | JSON | XRechnung 3.0 XML |
| Create XRechnung (UBL) | POST /v1/create/ubl + options.profile: xrechnung | JSON | XRechnung UBL XML |
| Validate ZUGFeRD | POST /v1/validate/zugferd | ZUGFeRD PDF | Validation JSON |
| Validate XRechnung | POST /v1/validate/xrechnung | XRechnung XML (CII or UBL) | Validation JSON |
| Extract as JSON | POST /v1/extract/json | ZUGFeRD PDF or XRechnung XML | Structured JSON |
| Parse PDF with AI | POST /v1/parse/json | Any invoice PDF (typed, scanned, photo) | Structured JSON + confidence |
| Extract attachments | POST /v1/extract/attachments | ZUGFeRD PDF or XRechnung XML | ZIP archive |
| Render XRechnung as PDF | POST /v1/render/xrechnung/to/pdf | XRechnung XML | PDF binary |
| Extract CII XML | POST /v1/extract/xml | ZUGFeRD PDF | CII XML |
Full OpenAPI 3.1 schema: api.invoicexml.com/v1/openapi | Interactive API explorer: api.invoicexml.com/v1/scalar
Get started
Everything in this guide runs on stock .NET 8 with zero NuGet packages: set INVOICEXML_API_KEY and run. The runnable versions live in the examples repository:
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 European e-invoice compliance covering ZUGFeRD, XRechnung, Factur-X, Peppol UBL, and CII. Stateless processing, GDPR compliant by architecture, and callable from any .NET application: ASP.NET Core, Azure Functions, WPF or WinForms desktop software, or a console job.