Why Factur-X validation is genuinely hard in .NET
The generation side is well-served by existing libraries. The validation side is where .NET hits a wall.
There is no native XSLT 2.0 processor in .NET. EN 16931 Schematron validation is implemented as XSLT 2.0 artefacts published by the EU and the national bodies. .NET's built-in System.Xml.Xsl.XslCompiledTransform supports XSLT 1.0 only. It throws on XSLT 2.0 constructs. There is no official Saxon-HE port for .NET. The community workaround is SaxonHE12s9apiExtensions via IKVM, which cross-compiles the Java Saxon library to .NET assemblies. It works, but it introduces a four-way version compatibility matrix (Saxon, IKVM, the NuGet wrapper, your .NET runtime), platform-specific native libraries, and Java type interop (javax.xml.transform namespaces) sitting inside your C# codebase. For a production compliance service this is a fragile foundation.
PDF/A-3b conformance is partial in most libraries. Factur-X requires CII XML embedded inside a PDF/A-3b container with embedded fonts, ICC colour profiles, XMP metadata declaring the Factur-X version and profile, and the AFRelationship="Alternative" declaration. The .NET libraries handle most of this but edge cases around font subsetting and XMP extension schemas occasionally produce files that pass visual inspection but fail at a Plateforme Agréée.
Version maintenance across formats compounds. ZUGFeRD 2.4 (Factur-X 1.08) added mandatory fields and deprecated NIL elements. XRechnung releases a new CIUS annually. Each format you support adds a Schematron family to track and update. The libraries follow eventually, but the lag between a standard's effective date and library support is a compliance risk you carry.
Setting up the InvoiceXML client
The examples use HttpClient from System.Net.Http and System.Text.Json, both built into .NET. No external dependencies required. 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 (appsettings, environment variable, or Azure Key Vault), not in source.
In ASP.NET Core, register a typed client with IHttpClientFactory:
// 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);
});
For a console app or simple script, instantiate directly:
using var http = new HttpClient { BaseAddress = new Uri("https://api.invoicexml.com") };
http.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer",
Environment.GetEnvironmentVariable("INVOICEXML_API_KEY"));
Create Factur-X invoices in C#
Send your invoice as JSON. Totals and VAT breakdown are auto-calculated from the line items.
using System.Net.Http.Json;
var payload = new
{
invoice = new
{
invoiceNumber = "INV-2025-001",
issueDate = "2025-09-01",
currency = "EUR",
seller = new
{
name = "Dupont Conseil SARL",
vatIdentifier = "FR32123456789",
postalAddress = new
{
line1 = "12 Rue de Rivoli",
city = "Paris",
postCode = "75001",
country = "FR",
},
},
buyer = new
{
name = "Martin Industries SAS",
postalAddress = new
{
line1 = "45 Avenue des Champs-Elysees",
city = "Paris",
postCode = "75008",
country = "FR",
},
},
paymentDetails = new
{
paymentAccountIdentifier = "FR7630006000011234567890189",
},
lines = new[]
{
new
{
quantity = 10,
item = new { name = "Conseil en transformation digitale" },
priceDetails = new { netPrice = 250.00 },
vatInformation = new { rate = 20 },
},
},
},
};
var response = await http.PostAsJsonAsync("/v1/create/facturx", payload);
response.EnsureSuccessStatusCode();
var pdfBytes = await response.Content.ReadAsByteArrayAsync();
await File.WriteAllBytesAsync("invoice-facturx.pdf", pdfBytes);
That is the whole request. The API computes totals and the VAT breakdown, embeds the factur-x.xml attachment, applies the PDF/A-3b conformance layer with all required XMP metadata, and validates against EN 16931 Schematron before returning the binary PDF.
Full create example on GitHub →
Validate Factur-X invoices in C#
Before sending an invoice to a customer or submitting it to a French Plateforme Agréée, validate that it passes all EN 16931 Schematron rules and PDF/A-3b conformance checks. This is the operation that is hard to do natively in .NET and where the API earns its place.
using System.Net.Http.Json;
async Task<ValidationResult> ValidateFacturXAsync(HttpClient http, string pdfPath)
{
using var form = new MultipartFormDataContent();
using var fileStream = File.OpenRead(pdfPath);
form.Add(new StreamContent(fileStream), "file", Path.GetFileName(pdfPath));
var response = await http.PostAsync("/v1/validate/facturx", 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 ValidateFacturXAsync(http, "invoice-facturx.pdf");
if (result.Valid)
{
Console.WriteLine($"Valid Factur-X invoice ({result.Data.ConformanceLevel})");
}
else
{
Console.WriteLine($"Validation failed with {result.Errors.Length} error(s):");
foreach (var err in result.Errors)
{
Console.WriteLine($" [{err.Rule}] {err.Message}");
if (err.Fields?.Length > 0)
Console.WriteLine($" Fields: {string.Join(", ", err.Fields)}");
}
}
Both valid and invalid invoices return HTTP 200. Branch on result.Valid rather than the HTTP status code. This keeps the integration clean without exception handling for the expected invalid case.
errors is a flat array of findings. Each entry carries a plain-language message, the business term codes (btCodes), the JSON paths into the document (fields), and the verbatim validator output (raw). warnings uses the same shape for non-blocking issues:
{
"valid": false,
"detail": "Validation failed with 1 error(s)",
"data": {
"schemaValid": true,
"schematronValid": false,
"conformanceLevel": "EN16931"
},
"errors": [
{
"rule": "BR-CO-14",
"line": null,
"message": "The 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."
}
],
"warnings": []
}
Use this in xUnit or NUnit test suites to validate generated output on every build, with no IKVM or Saxon dependency in your project.
Full validation example on GitHub →
Convert PDF invoices to Factur-X
If you receive PDF invoices from suppliers and need to ingest them as Factur-X for archival or further processing, use the transform endpoint. The AI pipeline extracts every EN 16931 field from the source PDF, including scanned and photographed documents.
async Task<byte[]> PdfToFacturXAsync(HttpClient http, string pdfPath)
{
using var form = new MultipartFormDataContent();
using var fileStream = File.OpenRead(pdfPath);
form.Add(new StreamContent(fileStream), "file", Path.GetFileName(pdfPath));
form.Add(new StringContent("en16931"), "profile");
var response = await http.PostAsync("/v1/transform/to/facturx", form);
response.EnsureSuccessStatusCode();
return await response.Content.ReadAsByteArrayAsync();
}
var facturxBytes = await PdfToFacturXAsync(http, "supplier-invoice.pdf");
await File.WriteAllBytesAsync("supplier-facturx.pdf", facturxBytes);
The profile parameter accepts minimum, basicwl, basic, en16931, and extended. The endpoint also accepts JPEG, PNG, TIFF, WEBP, HEIC, DOCX, and XLSX inputs in addition to PDF.
For incoming Factur-X invoices that need to be imported into your ERP or accounting system, the extract endpoint returns either the raw CII XML or a fully parsed JSON representation:
using System.Net.Http.Json;
async Task<JsonElement> ExtractFacturXAsync(HttpClient http, string pdfPath)
{
using var form = new MultipartFormDataContent();
using var fileStream = File.OpenRead(pdfPath);
form.Add(new StreamContent(fileStream), "file", Path.GetFileName(pdfPath));
var response = await http.PostAsync("/v1/extract/json", form);
return await response.Content.ReadFromJsonAsync<JsonElement>();
}
var data = await ExtractFacturXAsync(http, "incoming-invoice.pdf");
Console.WriteLine($"Invoice {data.GetProperty("invoiceNumber").GetString()}");
Console.WriteLine($"Total: {data.GetProperty("totalAmount").GetDecimal()}");
To extract just the embedded CII XML rather than parsed JSON, use /v1/extract/xml. The response is the raw XML as application/xml, which you can load into an XDocument for further processing.
Render Factur-X as a human-readable PDF
The XML data inside a Factur-X file is not designed for human reading. For standalone CII XML files (extracted from a Factur-X PDF or received via EDI), render as a clean PDF for review workflows:
async Task<byte[]> RenderFacturXPdfAsync(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/cii/to/pdf", form);
response.EnsureSuccessStatusCode();
return await response.Content.ReadAsByteArrayAsync();
}
var pdfBytes = await RenderFacturXPdfAsync(http, "invoice-cii.xml");
await File.WriteAllBytesAsync("invoice-preview.pdf", pdfBytes);
The rendered PDF is for visual review only. It has no legal standing. The original Factur-X file remains the authoritative document.
ASP.NET Core integration: a typed service
Wrap the operations in a service registered with the DI container. This is the idiomatic .NET approach and keeps the API key and HttpClient lifecycle managed correctly:
// Services/IFacturXService.cs
public interface IFacturXService
{
Task<byte[]> GenerateAsync(object invoiceData, CancellationToken ct = default);
Task<ValidationResult> ValidateAsync(byte[] pdfBytes, CancellationToken ct = default);
Task<string> GenerateAndStoreAsync(object invoiceData, string invoiceNumber, CancellationToken ct = default);
}
// Services/FacturXService.cs
public class FacturXService : IFacturXService
{
private readonly HttpClient _http;
private readonly IBlobStorage _storage;
public FacturXService(IHttpClientFactory factory, IBlobStorage storage)
{
_http = factory.CreateClient("InvoiceXML");
_storage = storage;
}
public async Task<byte[]> GenerateAsync(object invoiceData, CancellationToken ct = default)
{
var response = await _http.PostAsJsonAsync(
"/v1/create/facturx",
new { invoice = invoiceData },
ct);
response.EnsureSuccessStatusCode();
return await response.Content.ReadAsByteArrayAsync(ct);
}
public async Task<ValidationResult> ValidateAsync(byte[] pdfBytes, CancellationToken ct = default)
{
using var form = new MultipartFormDataContent();
form.Add(new ByteArrayContent(pdfBytes), "file", "invoice.pdf");
var response = await _http.PostAsync("/v1/validate/facturx", form, ct);
return await response.Content.ReadFromJsonAsync<ValidationResult>(ct);
}
public async Task<string> GenerateAndStoreAsync(
object invoiceData, string invoiceNumber, CancellationToken ct = default)
{
var pdf = await GenerateAsync(invoiceData, ct);
var validation = await ValidateAsync(pdf, ct);
if (!validation.Valid)
{
var errors = string.Join("\n",
validation.Errors.Select(e => $"[{e.Rule}] {e.Message}"));
throw new InvalidOperationException(
$"Generated invoice failed validation:\n{errors}");
}
var path = $"invoices/facturx/{invoiceNumber}.pdf";
await _storage.SaveAsync(path, pdf, ct);
return path;
}
}
Register and inject:
// Program.cs
builder.Services.AddScoped<IFacturXService, FacturXService>();
// Controller
[ApiController]
[Route("invoices")]
public class InvoiceController : ControllerBase
{
private readonly IFacturXService _facturX;
public InvoiceController(IFacturXService facturX) => _facturX = facturX;
[HttpPost("{id}/facturx")]
public async Task<IActionResult> Generate(int id, CancellationToken ct)
{
var invoice = await LoadInvoiceData(id);
var pdf = await _facturX.GenerateAsync(invoice, ct);
return File(pdf, "application/pdf", $"{invoice.Number}.pdf");
}
}
For Dynamics 365 plugins or Power Platform custom connectors, the same HttpClient pattern applies. A plugin that generates Factur-X when an invoice record reaches a certain status:
public class GenerateFacturXPlugin : IPlugin
{
public void Execute(IServiceProvider serviceProvider)
{
var context = (IPluginExecutionContext)serviceProvider
.GetService(typeof(IPluginExecutionContext));
var service = ((IOrganizationServiceFactory)serviceProvider
.GetService(typeof(IOrganizationServiceFactory)))
.CreateOrganizationService(context.UserId);
var invoice = (Entity)context.InputParameters["Target"];
var payload = BuildFacturXPayload(invoice);
using var http = new HttpClient();
http.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", GetApiKey());
var response = http.PostAsJsonAsync(
"https://api.invoicexml.com/v1/create/facturx",
new { invoice = payload }).Result;
var pdf = response.Content.ReadAsByteArrayAsync().Result;
// Attach the PDF to the Dynamics record as an annotation
AttachToRecord(service, invoice.Id, pdf);
}
}
For a no-code approach, the Power Automate integration uses the HTTP action instead of a plugin.
Error handling with Polly
For production resilience, use Polly (the standard .NET resilience library) to add retry with exponential backoff on the typed client:
// 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"]);
})
.AddTransientHttpErrorPolicy(policy =>
policy.WaitAndRetryAsync(
retryCount: 3,
sleepDurationProvider: attempt =>
TimeSpan.FromSeconds(Math.Pow(2, attempt))));
This retries automatically on 5xx and transient network errors. 4xx errors (validation failures, auth errors) are not retried since they are permanent until the request itself changes.
Why use a REST API instead of a .NET library
This is the closest build-versus-buy decision of any language, because the .NET libraries are genuinely good at generation. The honest comparison:
For pure ZUGFeRD or Factur-X generation, with no Schematron validation requirement, ZUGFeRD-csharp is a reasonable choice. It is well-maintained, free, and keeps everything in-process.
The case for the API becomes compelling when you need:
EN 16931 Schematron validation. .NET has no native XSLT 2.0 processor. Doing this locally means the IKVM/Saxon-HE workaround with its four-way version compatibility matrix, platform-specific native binaries, and Java interop in your C# codebase. For a service that determines whether legally significant invoices are accepted, this is a fragile foundation. The API runs Schematron with a production XSLT 2.0 processor and returns structured, typed results.
National CIUS overlays. The same endpoint handles Peppol BIS 3.0, XRechnung CIUS-REC-DE, NLCIUS, and EHF. Supporting these natively means tracking and integrating multiple Schematron families per format.
Version maintenance. New ZUGFeRD versions, Schematron releases, and CIUS changes are deployed before their effective dates with no change to your code. With a local library you wait for the maintainer and bear the compliance risk of the gap.
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 common pattern is to use a local library for generation and the API for validation. Both work together and the API validate endpoint accepts files generated by any library.
Complete endpoint reference for .NET
| Operation | Endpoint | Input | Output |
| Create Factur-X | POST /v1/create/facturx | JSON | PDF/A-3b binary |
| PDF to Factur-X (AI) | POST /v1/transform/to/facturx | PDF, image, DOCX, XLSX | PDF/A-3b binary |
| Validate Factur-X | POST /v1/validate/facturx | Factur-X PDF | Validation JSON |
| Extract as JSON | POST /v1/extract/json | Factur-X PDF | Structured JSON |
| Extract CII XML | POST /v1/extract/xml | Factur-X PDF | CII XML |
| Render as PDF | POST /v1/render/cii/to/pdf | CII XML | PDF 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 European e-invoice compliance covering Factur-X, ZUGFeRD, XRechnung, Peppol UBL, and CII. Stateless processing, GDPR compliant by architecture, and integration support from any .NET application: ASP.NET Core, Dynamics 365, Azure Functions, or desktop software.