Check a UBL or CII invoice in your browser
two spellings of the same invoice.
Someone has sent you an electronic invoice as an XML file, or your software has produced one, and you want to know whether anything in it is wrong before it goes any further. The check reports every problem it finds, not just the first.
The file will be in one of two formats. EN 16931 is the European standard that says what an electronic invoice must contain, and UBL and CII are the two XML file formats such an invoice can be written in (this page reads UBL 2.1 and CII D16B).
You do not need to know which one you have. Paste the file into the playground, read the findings, and come back here for what the check does and does not cover.
Where your file goes
Your file is processed locally in your browser. It is not uploaded. No request carrying your document leaves this page, and no analytics event records anything about it. Open your network panel and watch, if you would rather see it than read it.
Paste your XML in the playground
What exists in each syntax
EN 16931 has two formats, so what the engine can do is a two-column question. Some cells carry a qualification instead of a plain yes or no. Read the whole cell before relying on it. The check described here looks at the invoice data, not the XML structure; the table's closing sentence calls that a semantic preflight.
| Capability | UBL 2.1 | CII D16B |
|---|---|---|
| Generate invoices | Yes | Yes |
| Generate credit notes | Yes | Yes |
| Read existing XML | Yes | Yes |
| Read a Factur-X or ZUGFeRD PDF | No PDF form exists | XML extracted; container checked |
| XRechnung profile | Yes | Yes |
| Peppol BIS 3 | Yes | Optional binding (library); API emits UBL |
| Factur-X EN 16931 payload | No | XML payload only |
| Finished PDF/A-3 | No | API, every plan (watermarked on Free) |
| Transmission | No | No |
Attestwire builds and checks the document data. Your access point or approved platform handles delivery. Uploaded XML gets a semantic preflight, not a receiver guarantee.
The same boundary, with everything around it
What the reader gets back
| What | Detail |
|---|---|
| Both root elements, by URI | A UBL 2.1 Invoice or CreditNote, and a UN/CEFACT CrossIndustryInvoice (D16B). Namespaces resolve by URI, not by prefix, so a document using a: and b: reads the same as one using cac: and cbc:. Element order does not matter. |
| The document type, detected | Taken from the root element rather than asked for, and returned in invoiceTypeCode. A credit note read here regenerates as a credit note. |
| The totals the document states | BT-106 to BT-115 land in declaredTotals and are checked against ours under the BR-CO rules, so a document whose stated totals do not match its lines produces a finding. |
| Everything it could not use | Anything that did not reach the model comes back in unmapped with its path, its name and the reason. kind: "unknown" means there is no field for it and the content is gone from the model; kind: "recomputed" means the value is derived from the lines instead of stored. Everything the parser could not use is reported. |
import { readFile } from "node:fs/promises";
import { validate } from "@attestwire/en16931";
const result = validate(await readFile("invoice.xml"));
const { invoice, unmapped } = result;
validate tells UBL from CII by itself and reads a Factur-X
PDF too. The readers underneath are parseUbl and
parseCiiInvoice, for when you want only the invoice object.
Both return the same shape, and both read credit notes as readily as
invoices.
What it refuses
The XML came from someone else, so the reader throws rather than
returning a half-read invoice. Every error extends
ParseError and carries a stable code.
| Error | When |
|---|---|
UnsupportedSyntaxError |
The root element is neither a UBL invoice nor a UBL credit note. A CII document gets its own message naming parseCiiInvoice rather than a generic failure. |
XmlSecurityError |
The document hit a size, depth, element or attribute cap, declared a DOCTYPE, or used a custom entity. An unknown entity is refused rather than dropped, because dropping one would change the text of a tax document without saying so. |
XmlSyntaxError |
Not well-formed, or outside the accepted subset: mixed content, unbound prefixes, control characters XML 1.0 does not permit. |
The caps are memory limits, chosen from measurement:
8,000,000 characters,
50,000 elements,
100 levels deep and
256 attributes per element.
A UBL invoice nests about eight levels and a thousand-line invoice lands
around 300 kB, so ordinary documents are nowhere near them. Every one is
an option on the call: parseUbl(xml, { maxCharacters }).
The parser does not process DTDs or expand custom entities, which is what closes XXE and billion-laughs. Comments and processing instructions are skipped: a stylesheet instruction cannot make this library fetch anything.
Two things that catch people out
The xpath on a finding describes where the field
lives in a UBL invoice. It is not a pointer into your own file.
It is a fixed UBL path per rule, and it stays a UBL path on CII
documents and on credit notes. Rules that exist
because the document is a credit note name
/ubl:CreditNote correctly; the rest do not.
Read it as "where this field lives" and map it to your file yourself.
A Factur-X or ZUGFeRD PDF cannot be pasted here. Those
are a PDF invoice with the CII XML attached inside it (a PDF/A-3
container). Extract the XML first, which
extractFacturX(bytes) does in the library, then check that,
or send the PDF itself to the hosted API as application/pdf.
In the other direction, what generation writes for
facturx-en16931 is the CII XML payload and not a Factur-X
file: take it to a PDF/A-3 library to build the container, or, on the
Solo, Starter, and Scale plans, ask the hosted API for
?format=pdf, which returns the Factur-X PDF.
The PDF boundary in both directions
This is a preflight
Attestwire reads your XML into the input model and checks the model. In other words, we check the invoice data, not the XML structure. Receivers also run Schematron, the official rule files that check the XML itself, and a receiver that checks structure can still refuse a document this engine passed.
The rules that constrain the XML rather than the data in it do not run here. The limits page lists every gap rather than a representative sample.
Questions people ask next
What is the difference between UBL and CII?
They are the two XML file formats an EN 16931 invoice can be written in, and they carry the same invoice. UBL 2.1 is the OASIS binding, with a separate Invoice and CreditNote document and the cac:/cbc: element families. UN/CEFACT CII (D16B) is one CrossIndustryInvoice document for both, with the ram: family, and it is the format carried inside ZUGFeRD and Factur-X PDFs.
The fields (BT-1, BT-10, BG-16 and so on) are identical in both; only the spelling changes.
How do I know which syntax my invoice uses?
Look at the root element. <Invoice> or <CreditNote> in the OASIS UBL namespace is UBL; <rsm:CrossIndustryInvoice> is CII. If the file is a PDF, it may contain invoice XML as part of a ZUGFeRD or Factur-X file. An ordinary PDF may have no invoice XML. You do not have to work it out to check the file: the playground reads the root element itself and tells you what it found.
Can I validate against EN 16931 offline?
Yes. The MIT package has zero runtime dependencies and makes no network call, so parseUbl plus validateInput runs entirely in your own process, including in CI, where the GitHub Action bundles a pinned engine and never leaves the runner. The browser check on this site is the same code compiled for a tab.
Is my invoice uploaded when I check it here?
No. The playground loads the engine as a module and runs it in your tab, so no request carrying your document leaves the page. The one door that does receive the document is the hosted API, and payloads there are processed in memory and never stored (security).
Does a clean result mean my customer will accept the invoice?
No. This check reads the invoice data and tests it against the rules; the rules that constrain the XML structure itself do not run at all. A document this engine accepts can still be rejected by the official national checker, an access point, a national add-on or the receiver. What a passing result means