Validate and generate e-invoices in TypeScript
the whole rule book, as one function call.
You write TypeScript or Node.js and need to check an invoice file a
supplier sent, or produce invoice files your customers will accept.
You would rather not send anything to a service.
@attestwire/en16931 does both from one npm package. It
needs no account or key, runs offline, and is MIT-licensed.
A European e-invoice follows EN 16931, the European standard that says what an electronic invoice must contain. On disk it is an XML file in one of two formats, UBL or CII. The package reads either format into one invoice object, checks it against the rules Attestwire implements, and can write UBL or CII XML back out from invoice data. It runs locally, without an API key or a network call.
npm install @attestwire/en16931@0.14.0
v0.14.0 on npm, published with provenance attestations · source at GitHub.
Check an existing UBL invoice
This section is for you if a supplier sent you a file. If you are producing an invoice from your own data, the generation example below is the other path.
Save this as check-invoice.mjs. It reads a UBL invoice
from a local file and does not upload it. The build runs every call in
it against the package's own sample before printing it.
import { readFileSync } from 'node:fs';
import { parseUbl, validateInput } from '@attestwire/en16931';
const file = process.argv[2];
if (!file) throw new Error('Usage: node check-invoice.mjs invoice.xml');
const parsed = parseUbl(readFileSync(file, 'utf8'));
const result = validateInput(parsed.invoice);
console.log(JSON.stringify({
...result,
unmapped: parsed.unmapped
}, null, 2));
process.exitCode = result.valid ? 0 : 1;node check-invoice.mjs invoice.xml
For a CII invoice, import parseCiiInvoice and use it in
place of parseUbl. A CII file has a root element called
CrossIndustryInvoice. UBL invoices and credit notes use
Invoice and CreditNote roots with UBL
namespaces. A filename alone does not tell you which format you have.
The example is a command-line check. It does not handle every application error. An unreadable or unsupported file can throw before validation, so handle parser exceptions separately from invoice findings in your application.
Understand the result before acting on it
Each rule result is a finding, and each finding has a severity. The result sorts them into three arrays.
-
errorsholds the fatal findings. These makevalidfalse. -
warningsandinformationhold the other findings to review. They do not by themselves makevalidfalse. -
unmappedlists XML content that did not map directly into the invoice object (the parser calls this content unmapped), including values the engine recomputes. Review those entries. A successful parse does not prove that every original element was checked or can be preserved through regeneration.
A passing result means the parsed invoice data passed the implemented checks. It is not a full check against the XML Schema or against Schematron, the official rule files receivers run against the XML. It is not a promise that the receiver will accept the invoice. Check coverage and limits.
Verify your integration
The package's committed UBL and CII minimal fixtures (the sample
invoices kept in the repository for testing) passed this workflow with
engine v0.14.0. In a separate synthetic local check with
the same engine version, removing the supply timing produced the
finding BR-DE-TMP-32 under information, and
valid stayed true. That is why applications should
inspect all three finding arrays.
Keep a known passing fixture and a fixture that is meant to fail in your own tests. Run invoice checks in CI, your automated test pipeline, when your mapping or generation code changes.
When to use the hosted API or MCP
Use the library when checks should run in your own process. Use the hosted API, the same checks as a web service you call over HTTP, when you want an HTTP service. Use MCP, a way for an AI assistant to call these tools, when your assistant needs the hosted tools.
Live VAT and Peppol lookups (Peppol is the network many European buyers receive invoices through) are separate hosted operations. This local example does not perform them.
Validate, then generate
This example uses one of the package's own exported sample invoices, so you can paste it into an empty project and get the comment on the last line. The build runs it before printing it.
import {
generateXRechnungUBL,
minimalXRechnung,
validateInput,
type InvoiceInput,
} from '@attestwire/en16931';
// Swap this for your own object. Keep the type on it: without it,
// TypeScript widens `profile` to `string` and this stops compiling.
const invoice: InvoiceInput = minimalXRechnung;
const { valid, errors } = validateInput(invoice);
const xml = valid ? generateXRechnungUBL(invoice) : '';
console.log(valid, errors.length, xml.startsWith('<?xml')); // true 0 true
Swap the sample for your own object and the types do the rest. Keep the
type annotation: profile is a union of string literals, so
an unannotated object widens it to string and stops
compiling. That is the compiler catching a profile the generator would
have thrown on.
Available types
These 7 exports cover the core integration. There is no client to construct, no configuration object and no lifecycle.
| Export | What it is |
|---|---|
InvoiceInput |
The one input model, and the whole contract. profile is a union of string literals, so a profile the generators would refuse does not compile. |
validateInput |
Runs every input rule and returns { valid, profile, errors, warnings, information }. It reports every finding, not the first. |
TeachingError |
One finding: rule, field, severity, message, fix, xpath, docsUrl, example. The same object in all three doors. |
generateXRechnungUBL |
Model → UBL 2.1 Invoice XML, or CreditNote when the type code is a credit-note code. |
generateCii |
Model → UN/CEFACT CrossIndustryInvoice (D16B) XML. XML only: this function never writes a PDF. |
parseUbl |
UBL XML → { invoice, unmapped, customizationId, profileId }. Reads invoices and credit notes alike. |
parseCiiInvoice |
The same, for a CII document, where invoices and credit notes are one document type. |
The package exports considerably more (the official code lists, the totals helpers, the CI report writers, the extractor for Factur-X, a PDF invoice with the XML invoice attached inside it), and the package README tables all of it. None of it is needed to get a document out.
The profile field is the control
One field decides which rule sets run and which XML format comes out.
-
generateXRechnungUBLwrites UBL 2.1 foren16931,xrechnung-ubl,peppol-bis-3. -
generateCiiwrites CII D16B foren16931,xrechnung-cii,facturx-en16931,peppol-bis-3.
Credit notes are one field too: invoiceTypeCode: "381" emits
a ubl:CreditNote in UBL and ram:TypeCode 381 in
CII, with the same rule set applied.
facturx-en16931 writes the Factur-X CII XML payload and
nothing else. Building the PDF/A-3 container around it is a separate
step, with a separate library.
Where the PDF boundary sits
Handle inputs the library cannot accept
Generation refuses rather than emitting a document that will be rejected
downstream. Both errors extend GenerationError, carry a
stable code, and say in the message what is supported.
| Error | When |
|---|---|
UnsupportedProfileError |
From generateXRechnungUBL, when the profile is a CII one. |
UnsupportedCiiProfileError |
From generateCii, when the profile is xrechnung-ubl, the name of the UBL binding specifically. |
The generator calculates totals from the invoice data. Verify the
output against your target rule version and your receiver's
requirements. Generating XML does not guarantee downstream acceptance.
Rounding is half-up at two decimals on each line before the sum. That
is what the rule BR-CO-10 asks for; it is the standard's
rule that checks the line total against the sum of the lines.
Library or hosted API?
The library and the hosted API run the same engine and return the same findings. The difference is where the engine runs and who keeps it up to date.
| Use the library when… | Use the API when… |
|---|---|
| Your application runs JavaScript or TypeScript | Your application uses another language |
| You want processing to stay entirely local | You want a managed endpoint |
| You prefer to control when the rule set changes | You want the deployed rule set without package upgrades |
| You do not need centralised usage | You need one shared service across several systems |
The hosted plans · The same engine as agent tools
Keep control of your integration
The package is MIT-licensed and runs without us. Read the licence, and see the continuity terms for what happens to the hosted service.
Questions people ask next
Is there an EN 16931 library for Node.js?
@attestwire/en16931 is one: MIT-licensed TypeScript, currently v0.14.0, with zero runtime dependencies and no JVM (Java runtime). It carries the rule set itself rather than shelling out to another engine, so validation is a function call.
Does it run in the browser?
Yes, and on Deno, Bun, Cloudflare Workers and the edge. It uses no platform API beyond the JavaScript standard library; even the DEFLATE decoder the Factur-X reader needs is in-repo rather than borrowed from Node. The playground is this package running client-side: the invoice never leaves the tab.
How big is the bundle?
The package is ESM with sideEffects: false, so a bundler drops what you do not import. The bulk of the data is the official code lists (16 of them, 4,762 codes, 16.6 kB gzipped for the whole set and 6.0 kB of that the unit list), and each is a separate module, so a build that never references one drops it. There are no runtime dependencies to add to that.
What happens when the rules change?
You upgrade the package, in a pull request, and review any changed findings before they enforce. That is the trade against the hosted API, where the deployed rule set moves without you. Either way the tracked artefacts and their versions are published, and the changelog says what moved in each release.
What is the licence, and what happens if Attestwire stops?
MIT (the licence), and the source is at github.com/attestwire/en16931. The package runs with no key and no network call, so an integration built on it keeps working whatever happens to the hosted service. The continuity terms