Skip to main content

E-invoicing: Factur-X and ZUGFeRD

A hybrid e-invoice is one file that a person reads and an accounts system books. The PDF shows the invoice on the page. The same invoice is attached to the PDF as UN/CEFACT Cross Industry Invoice (CII) XML, which software reads without anyone retyping a figure. Factur-X and ZUGFeRD (version 2.1 and later) are the same format under two names, and the European e-invoicing mandates accept it.

The format is a PDF/A-3 document with the XML attached under exact rules. Getting any rule slightly wrong produces a file that opens in every reader and is rejected by the system it was sent to. The PdfPinata.EInvoice package applies those rules for you. It has no dependencies beyond the core package and targets netstandard2.1, net8.0 and net10.0.

dotnet add package PdfPinata.EInvoice

You bring the XML

PdfPinata.EInvoice does not create the invoice XML, and it does not check it. Producing valid EN 16931 XML, with each country's business rules, is a separate job for a separate library or service. FacturXInvoice takes the bytes that job produced and puts them into the PDF correctly.

Build the invoice

  1. Build the PDF that shows the invoice, as you would any other document.
  2. Set Info.Title. The document will claim PDF/A-3, and PDF/A requires a title.
  3. Create a FacturXInvoice from the XML bytes, and set its Profile.
  4. Call AttachTo(document).
  5. Save the document.
src/SampleApp/Demos/FacturXDemo.cs
var document = new PdfDocument();

// A PDF/A document has to have a title, and attaching the invoice below is what makes this
// a PDF/A document - so this line is load-bearing rather than decorative.
document.Info.Title = "Invoice 2026-0042";
document.Info.Author = "PdfPinata sample app";
document.Info.Subject = "A Factur-X invoice: the page and the XML are the same invoice";

// Nothing here says anything about colour, and the document still gets the output intent
// PDF/A requires: colours written as RGB by a library nobody told otherwise are sRGB, so an
// RGB document that names no profile is given PdfOutputIntents.SrgbProfile and the sRGB
// condition to name it by. The Archive demo sets it explicitly and gets the same bytes.
src/SampleApp/Demos/FacturXDemo.cs
var xml = Encoding.UTF8.GetBytes(CrossIndustryInvoice());

// The whole of the PDF side of ZUGFeRD and Factur-X. It names the attachment factur-x.xml,
// relates it to the document as /Data, calls it text/xml, associates it with the catalog so
// that it is part of the document rather than merely inside it, claims PDF/A-3 - the only
// archival profile that may carry a file at all - and writes the two metadata descriptions
// the format wants. None of that is difficult; all of it is silent when it is wrong.
var invoice = new FacturXInvoice(xml)
{
// The profile is a claim about the XML, and the spelling of it is a trap worth an enum:
// the value a receiver reads is "EN 16931", with the space, and "BASIC WL" for the one
// below it. A document writing EN16931 passes every check that looks at the PDF.
Profile = EInvoiceProfile.En16931,
Description = "Invoice 2026-0042 as EN 16931 CII data"
};

var attached = invoice.AttachTo(document);

AttachTo does all of this:

  • attaches the XML as factur-x.xml, the name a receiver looks for;
  • marks it as text/xml, with the relationship /Data, which says the XML and the page are the same invoice;
  • associates the attachment with the document, which PDF/A-3 requires;
  • claims PDF/A-3b, if the document claims nothing yet;
  • adds an XMP extension schema to the metadata that declares the four Factur-X properties (fx:DocumentType, fx:DocumentFileName, fx:Version and fx:ConformanceLevel), and writes them.

It returns the PdfFileSpecification of the attachment, so you can change anything it did not set.

The document needs no colour setup. An RGB document that claims PDF/A gets an sRGB output intent automatically. A CMYK document must supply its own profile. See PDF/A archiving.

Choose the profile

Profile says how much of an invoice the XML contains. It is written to fx:ConformanceLevel. Set it to the profile your XML really meets: PdfPinata does not read the XML to check.

EInvoiceProfileWritten asWhat the XML carries
MinimumMINIMUMParties, dates and totals. No line items. Not a legal invoice in France.
BasicWithoutLinesBASIC WLThe full header, tax breakdown and totals. No line items.
BasicBASICA subset of EN 16931, with line items.
En16931 (default)EN 16931The full European standard EN 16931. What the mandates mean by "e-invoice".
ExtendedEXTENDEDEN 16931 plus sector or national extensions.
XRechnungXRECHNUNGThe German XRechnung rules, carried as CII.

The enum exists because of the spelling. Two of the values contain a space, and a document that writes EN16931 passes every PDF check and is then rejected by the receiver.

Read an invoice you receive

To take the XML out of a hybrid invoice, open the PDF and call FacturXInvoice.ReadFrom. It returns the XML bytes exactly as they were embedded, or null if the document carries no invoice:

using PdfPinata.EInvoice;

PdfDocument received = PdfReader.Open("incoming.pdf", PdfDocumentOpenMode.Import);
byte[] xml = FacturXInvoice.ReadFrom(received);
if (xml == null)
{
// Not a hybrid invoice.
}

FacturXInvoice.FindIn returns the attachment's PdfFileSpecification instead. Both look for the invoice by file name: factur-x.xml, zugferd-invoice.xml, xrechnung.xml or order-x.xml, ignoring case. They do not guess from the media type, because a document can carry other XML that is not an invoice.

Add your own metadata

AttachTo adds its metadata through document.AddMetadataContributor, which adds to the metadata rather than replacing it. Your own document.CustomizeMetadata delegate and any contributors you register still run, whether you set them before or after AttachTo. If your own properties use a namespace of your own, declare it with XmpMetadata.DeclareSchema, as the PDF/A page shows. Each declared schema needs its own prefix; fx is taken.

Things to know

  • The claim is enforced. A document that reaches Save with no title, or as CMYK with no output intent profile, is refused. The message says what to set.
  • A PDF/A-1 or PDF/A-2 claim is refused, not upgraded. Neither level may carry an attachment. If the document already claims one, AttachTo throws and leaves the document unchanged. Set Options.Conformance to PdfA3B, or leave it unset.
  • An accessible invoice keeps its claim. If the document already claims PdfA3A, AttachTo keeps that claim. PdfA3A requires the document to be tagged; see Accessibility.
  • One invoice per name. Attaching a second invoice under a file name already used throws, and the document is left as it was.
  • An empty XML array is refused. new FacturXInvoice(Array.Empty<byte>()) throws.
  • ZUGFeRD 1.0 and Order-X are possible but not tested. FileName, NamespaceUri, Prefix, SchemaName, DocumentType and Relationship are settable, so you can produce the older ZUGFeRD 1.0 layout or an Order-X document. The defaults are Factur-X, and only Factur-X has been validated. ZUGFeRD 1.0 is superseded.
  • Validate the whole invoice. PdfPinata's own build validates a Factur-X document built this way with veraPDF, so the PDF/A side is checked. Check the XML with a validator for your profile, and test the PDF against the receiver's own checks where you can.

See it in action

The FacturX demo draws an invoice, attaches the same invoice as CII XML built from the same line items, and prints the metadata the package wrote. It then reopens the file and reads the invoice back, the way a receiving system would.

The full FacturX demo
src/SampleApp/Demos/FacturXDemo.cs
var heading = new XFont(BundledFontResolver.SansFamily, 16, XFontStyle.Bold);
var label = new XFont(BundledFontResolver.SansFamily, 9.5, XFontStyle.Bold);
var body = new XFont(BundledFontResolver.SansFamily, 9);
var mono = new XFont(BundledFontResolver.MonoFamily, 7);

var document = new PdfDocument();

// A PDF/A document has to have a title, and attaching the invoice below is what makes this
// a PDF/A document - so this line is load-bearing rather than decorative.
document.Info.Title = "Invoice 2026-0042";
document.Info.Author = "PdfPinata sample app";
document.Info.Subject = "A Factur-X invoice: the page and the XML are the same invoice";

// Nothing here says anything about colour, and the document still gets the output intent
// PDF/A requires: colours written as RGB by a library nobody told otherwise are sRGB, so an
// RGB document that names no profile is given PdfOutputIntents.SrgbProfile and the sRGB
// condition to name it by. The Archive demo sets it explicitly and gets the same bytes.

// ----- page one: the invoice a person reads ------------------------------------------------

var first = document.AddPage();
using (var gfx = XGraphics.FromPdfPage(first))
{
var prose = new XTextFormatter(gfx);

gfx.DrawString("Invoice 2026-0042", heading, XBrushes.Black, 50, 60);
gfx.DrawString("Issued 14 August 2026 · payable within 30 days", body, XBrushes.Gray, 50, 78);

gfx.DrawString("PdfPinata Ltd", label, XBrushes.Black, 50, 108);
gfx.DrawString("Kölnstraße 1, 50667 Köln", body, XBrushes.Black, 50, 122);
gfx.DrawString("VAT DE123456789", body, XBrushes.Black, 50, 136);

gfx.DrawString("Billed to", label, XBrushes.Black, 330, 108);
gfx.DrawString("Beispiel GmbH", body, XBrushes.Black, 330, 122);
gfx.DrawString("Musterweg 12, 10115 Berlin", body, XBrushes.Black, 330, 136);

// A column of money is read by comparing digits that line up, so every numeric column -
// and the heading over it - is placed by its right edge instead of its left. The point
// overload of DrawString takes an XStringFormat, and BaseLineRight is the one that moves
// the alignment without moving the baseline: XStringFormats.TopRight would also shift
// every line down by the ascent, because a point has no rectangle to sit at the top of.
var figures = XStringFormats.BaseLineRight;

double y = 180;
gfx.DrawString("Description", label, XBrushes.Black, 50, y);
gfx.DrawString("Qty", label, XBrushes.Black, 375, y, figures);
gfx.DrawString("Unit", label, XBrushes.Black, 460, y, figures);
gfx.DrawString("Amount", label, XBrushes.Black, 545, y, figures);
gfx.DrawLine(XPens.Black, 50, y + 5, 545, y + 5);

y += 22;
foreach (var item in Items)
{
var amount = item.Quantity * item.UnitPrice;
gfx.DrawString(item.Description, body, XBrushes.Black, 50, y);
gfx.DrawString(item.Quantity.ToString(Invariant), body, XBrushes.Black, 375, y, figures);
gfx.DrawString(Money(item.UnitPrice), body, XBrushes.Black, 460, y, figures);
gfx.DrawString(Money(amount), body, XBrushes.Black, 545, y, figures);
y += 16;
}

gfx.DrawLine(XPens.Gray, 350, y + 2, 545, y + 2);
y += 20;

(string Caption, decimal Amount)[] totals =
{
("Net", Net),
("VAT 19%", Tax),
("Total due", Gross)
};

foreach (var total in totals)
{
var font = total.Caption == "Total due" ? label : body;
gfx.DrawString(total.Caption, font, XBrushes.Black, 460, y, figures);
gfx.DrawString(Money(total.Amount), font, XBrushes.Black, 545, y, figures);
y += 16;
}

gfx.DrawString("This page is half of the invoice", label, XBrushes.Firebrick, 50, y + 34);

prose.DrawString(
"The other half is attached to this file as XML, and the two are the same invoice "
+ "rather than a document with a copy of itself inside it. A person reads the page; "
+ "an accounts system reads the attachment and books it without anybody retyping a "
+ "figure. Open the attachments pane of a reader to find it, under the name the "
+ "standard requires - factur-x.xml, and nothing else, because a receiver looks for "
+ "it by that name.",
body, XBrushes.Black, new XRect(50, y + 48, 495, 62));
}

// ----- the attachment: what makes it a Factur-X invoice ------------------------------------

var xml = Encoding.UTF8.GetBytes(CrossIndustryInvoice());

// The whole of the PDF side of ZUGFeRD and Factur-X. It names the attachment factur-x.xml,
// relates it to the document as /Data, calls it text/xml, associates it with the catalog so
// that it is part of the document rather than merely inside it, claims PDF/A-3 - the only
// archival profile that may carry a file at all - and writes the two metadata descriptions
// the format wants. None of that is difficult; all of it is silent when it is wrong.
var invoice = new FacturXInvoice(xml)
{
// The profile is a claim about the XML, and the spelling of it is a trap worth an enum:
// the value a receiver reads is "EN 16931", with the space, and "BASIC WL" for the one
// below it. A document writing EN16931 passes every check that looks at the PDF.
Profile = EInvoiceProfile.En16931,
Description = "Invoice 2026-0042 as EN 16931 CII data"
};

var attached = invoice.AttachTo(document);

// ----- page two: what that did -------------------------------------------------------------

var second = document.AddPage();
using (var gfx = XGraphics.FromPdfPage(second))
{
var prose = new XTextFormatter(gfx);

gfx.DrawString("What the attachment has to satisfy", heading, XBrushes.Black, 50, 60);

prose.DrawString(
"Read back from this document rather than described. The relationship says what the "
+ "file is to the document - /Data means the XML and the page are one invoice - and "
+ "the catalog's /AF array is what associates it. A file in the embedded-files name "
+ "tree alone is invisible to a validator; a file in /AF alone is invisible to a "
+ "reader's attachments pane. Both, or it is not an e-invoice.",
body, XBrushes.Black, new XRect(50, 80, 495, 62));

(string Field, string Value)[] facts =
{
("File name", attached.FileName),
("/AFRelationship", "/" + attached.Relationship),
("Media type", attached.EmbeddedFile.MimeType),
("Description", attached.Description),
("Attached bytes", xml.Length.ToString("N0", Invariant) + " bytes of CII XML"),
("Conformance now claimed", document.Options.Conformance.ToString()),
("Output intent", PdfOutputIntents.SrgbIdentifier + ", "
+ PdfOutputIntents.SrgbProfile.Length.ToString("N0", Invariant)
+ " bytes, supplied by the writer")
};

double y = 155;
foreach (var fact in facts)
{
gfx.DrawString(fact.Field, label, XBrushes.Black, 50, y);
gfx.DrawString(fact.Value, body, XBrushes.Black, 230, y);
y += 16;
}

gfx.DrawString("Nobody set the conformance", label, XBrushes.Firebrick, 50, y + 14);

prose.DrawString(
"Attaching the invoice claimed PDF/A-3, because a Factur-X document is a PDF/A-3 "
+ "document by definition and no other archival profile may carry a file. A claim of "
+ "PDF/A-1 or PDF/A-2 already on the document is refused rather than promoted: those "
+ "profiles carry nothing, and quietly rewriting a caller's claim would be deciding "
+ "for them which standard their document meets.",
body, XBrushes.Black, new XRect(50, y + 28, 495, 62));

gfx.DrawString("The metadata, from a document built the same way", label, XBrushes.Black, 50, y + 100);

prose.DrawString(
"PDF/A holds every property in the metadata packet to a schema the file either "
+ "predefines or describes, and the invoice namespace is nobody's predefined schema. "
+ "So the packet declares the four fx: properties in an extension schema before "
+ "writing them - without that, the file fails validation for its metadata rather "
+ "than for its invoice, which is a confusing way to be wrong. Note that "
+ "fx:DocumentFileName is the name the file was actually attached under: a receiver "
+ "takes the attachment by the name the metadata gives it.",
body, XBrushes.Black, new XRect(50, y + 114, 495, 76));

y += 202;
foreach (var line in InvoiceMetadataOfAProbe())
{
if (y > 792)
break;

gfx.DrawString(line, mono, XBrushes.Black, 50, y);
y += 8.4;
}
}