Building Factur-X support in Node.js: what actually works
Factur-X is not just CII XML and not just a PDF. It is CII XML embedded inside a PDF/A-3b container with specific requirements around embedded fonts, ICC colour profiles, XMP metadata declaring the Factur-X version and profile, and the AFRelationship="Alternative" declaration on the XML attachment. Most Node.js PDF libraries handle PDF generation but PDF/A-3b conformance is a different problem entirely.
The approach in this guide is to keep the compliance layer behind a single REST API call and focus your Node.js code on the business logic: assembling invoice data, calling the API, and storing or transmitting the response.
Why Factur-X is hard in Node.js specifically
Three technical realities make this format particularly awkward in the Node.js ecosystem.
PDF/A-3b conformance. Factur-X is not just CII XML and not just a PDF. It is CII XML embedded inside a PDF/A-3b container with specific requirements around embedded fonts, ICC colour profiles, XMP metadata declaring the Factur-X version and profile, and the AFRelationship="Alternative" declaration on the XML attachment. Node.js PDF libraries like PDFKit, pdf-lib, and jsPDF handle PDF generation but PDF/A-3b conformance is a different problem. Most Node.js implementations fall short here, producing files that look correct in a PDF viewer but fail when submitted to a Plateforme Agréée.
Schematron validation. EN 16931 defines over 200 business rules implemented as Schematron XSLT 2.0 artefacts. Node.js has limited XSLT 2.0 support. There is no Node.js equivalent of Java's Saxon-HE. The standard solution in other ecosystems is to shell out to a Java process running Saxon, which defeats the purpose of staying in Node.js.
Version maintenance. ZUGFeRD 2.4 (Factur-X 1.08) shipped with new mandatory fields and deprecated NIL elements. XRechnung releases a new CIUS version roughly annually. Peppol BIS Schematron updates quarterly. Tracking these updates across multiple format families is ongoing work that someone has to do, and the existing Node.js libraries are maintained by individual contributors with limited time.
Setting up the InvoiceXML client
Install your preferred HTTP client. All examples below use axios and form-data, which work in any Node.js version from 18 onwards.
npm install axios form-data
Set your API key as an environment variable. Get one by signing up for a free InvoiceXML account (100 free credits on signup, no credit card required):
export INVOICEXML_API_KEY="your_key_here"
Create a reusable client:
// invoicexml-client.js
import axios from 'axios';
export const invoicexml = axios.create({
baseURL: 'https://api.invoicexml.com',
headers: {
Authorization: `Bearer ${process.env.INVOICEXML_API_KEY}`,
},
timeout: 30000,
});
Create Factur-X invoices in Node.js
The most common use case. You have invoice data in your database or coming from a webhook, and you need to produce a compliant Factur-X PDF.
import fs from 'node:fs/promises';
const payload = {
invoice: {
invoiceNumber: 'INV-2025-001',
issueDate: '2025-07-01',
currency: 'EUR',
seller: {
name: 'Acme GmbH',
vatIdentifier: 'DE123456789',
legalRegistration: { identifier: 'HRB98765' },
postalAddress: { line1: 'Musterstr. 1', city: 'Berlin', postCode: '10115', country: 'DE' },
},
buyer: {
name: 'Example Corp',
postalAddress: { line1: '12 Rue de Rivoli', city: 'Paris', postCode: '75001', country: 'FR' },
},
paymentDetails: { paymentAccountIdentifier: 'DE89370400440532013000' },
lines: [{
quantity: 10,
priceDetails: { netPrice: 150.00 },
vatInformation: { rate: 19 },
item: { name: 'Consulting Services' },
}],
},
};
const response = await fetch('https://api.invoicexml.com/v1/create/facturx', {
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.INVOICEXML_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(payload),
});
await fs.writeFile('invoice-facturx.pdf', Buffer.from(await response.arrayBuffer()));
The response is a binary PDF/A-3b file with the factur-x.xml attachment embedded. Totals, VAT breakdowns, and the specification identifier are computed by the API from the line items.
Full create example on GitHub →
Validate Factur-X invoices in Node.js
Invoices from /v1/create/facturx are validated before the API hands them back, so this endpoint is for documents that came from somewhere else: a supplier's Factur-X, output from a legacy generator, or a partner's file you are about to forward to a Plateforme Agréée. It runs the full EN 16931 Schematron plus PDF/A-3b conformance on any hybrid, whoever produced it.
import { invoicexml } from './invoicexml-client.js';
import FormData from 'form-data';
import { createReadStream } from 'fs';
async function validateFacturX(pdfPath) {
const form = new FormData();
form.append('file', createReadStream(pdfPath));
const response = await invoicexml.post('/v1/validate/facturx', form, {
headers: form.getHeaders(),
});
return response.data;
}
// Usage
const result = await validateFacturX('invoice-facturx.pdf');
if (result.valid) {
console.log(`Valid Factur-X invoice (${result.data.conformanceLevel})`);
} else {
console.error(`Validation failed with ${result.errors.length} errors:`);
result.errors.forEach(err => {
console.error(` [${err.rule}] ${err.message}`);
if (err.fields?.length) console.error(` Fields: ${err.fields.join(', ')}`);
});
}
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. (at /...)"
}
],
"warnings": []
}
Invoices from /v1/create/facturx are validated before the API returns them, so this call is for the files you did not generate: supplier invoices arriving in your inbox, output from a legacy system, or a partner's PDF you are about to forward to a Plateforme Agréée. In CI it is worth pointing at test fixtures and at any document your pipeline assembles by hand.
Full validation example on GitHub →
Parse supplier PDFs with AI
Not every PDF that lands in your AP inbox is a Factur-X hybrid. For plain supplier invoices with no embedded XML (native PDFs, scans, even smartphone photos of paper), /v1/parse/json runs OCR and AI field extraction and returns the invoice as structured JSON, in the same shape the extract endpoint below produces for real Factur-X files. Because an AI read is never a certainty, the response also carries a confidence object: an overall score plus four area scores (seller identification, buyer identification, tax calculation, line items) between 0.0 and 1.0, so your code can decide what to auto-book and what a human should see first.
import { invoicexml } from './invoicexml-client.js';
import FormData from 'form-data';
import { createReadStream } from 'fs';
async function parseSupplierPdf(pdfPath) {
const form = new FormData();
form.append('file', createReadStream(pdfPath));
const response = await invoicexml.post('/v1/parse/json', form, {
headers: form.getHeaders(),
});
return response.data; // { invoice, confidence }
}
const { invoice, confidence } = await parseSupplierPdf('supplier-invoice.pdf');
if (confidence.overall >= 0.7) {
await importIntoAccounting(invoice);
} else {
await flagForReview(invoice, confidence);
}
A document that turns out not to be an invoice at all is rejected with error code 4008, and a file containing several invoices with 4009, so junk mail never reaches your booking logic. If the invoice carries embedded supporting documents (delivery notes or timesheets in the EN 16931 BG-24 group), /v1/extract/attachments returns them as a ZIP; see the attachment extraction reference.
For incoming Factur-X invoices that need to be imported into your ERP or AP system, the extract endpoint returns either the raw CII XML or a fully parsed JSON representation:
import { invoicexml } from './invoicexml-client.js';
import FormData from 'form-data';
import { createReadStream } from 'fs';
async function extractFacturX(pdfPath) {
const form = new FormData();
form.append('file', createReadStream(pdfPath));
const response = await invoicexml.post('/v1/extract/json', form, {
headers: form.getHeaders(),
});
return response.data;
}
const data = await extractFacturX('incoming-invoice.pdf');
console.log(`Invoice ${data.invoiceNumber} from ${data.seller.name}`);
console.log(`Total: ${data.totals.grandTotalAmount} ${data.currency}`);
console.log(`Lines: ${data.lines.length}`);
To extract just the embedded CII XML rather than parsed JSON, use /v1/extract/xml instead. The response is the raw XML as application/xml.
Full extract example on GitHub →
Error handling pattern for production
Wrap API calls in a service module that handles retry logic, timeout, and error parsing consistently:
import { invoicexml } from './invoicexml-client.js';
export async function callInvoiceXmlApi(endpoint, form, options = {}) {
const { responseType = 'json', retries = 2 } = options;
for (let attempt = 0; attempt <= retries; attempt++) {
try {
const response = await invoicexml.post(endpoint, form, {
headers: form.getHeaders(),
responseType,
});
return response.data;
} catch (error) {
if (error.response?.status >= 500 && attempt < retries) {
await new Promise(r => setTimeout(r, 1000 * Math.pow(2, attempt)));
continue;
}
if (error.response?.status === 400) {
const detail = error.response.data?.detail || 'Bad request';
throw new Error(`InvoiceXML validation error: ${detail}`);
}
if (error.response?.status === 401) {
throw new Error('InvoiceXML API key invalid');
}
throw error;
}
}
}
Why use a REST API instead of building it in Node.js
There are Node.js libraries that handle parts of the Factur-X lifecycle. node-zugferd and @stafyniaksacha/facturx both exist and can generate Factur-X PDFs. Both are functional for basic use cases but neither runs the official EN 16931 Schematron validation, neither implements the full PDF/A-3b conformance pipeline including XMP metadata extension blocks and ICC profile embedding, and neither tracks Schematron updates across the major format families (Factur-X, ZUGFeRD, XRechnung, Peppol BIS).
For development and learning, these libraries are fine. For production e-invoicing where the output determines whether your customer's invoice is accepted by a French Plateforme Agréée or a German B2B partner, the maintenance commitment is significant.
InvoiceXML handles:
The full PDF/A-3b conformance pipeline including embedded fonts, ICC colour profile injection, XMP metadata with the correct Factur-X extension namespace, and the AFRelationship="Alternative" declaration that DATEV and other accounting systems check on import.
EN 16931 Schematron validation across all profiles (MINIMUM, BASIC WL, BASIC, EN 16931, EXTENDED). Updates are deployed before the new specification's effective date, with no integration change required on your side.
National CIUS validation overlays. The same API handles Peppol BIS 3.0 for Belgian and Dutch B2B, XRechnung CIUS-REC-DE for German public sector, NLCIUS for Netherlands public sector, and EHF for Norway.
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.
Complete endpoint reference for Node.js
| Operation | Endpoint | Input | Output |
| Create Factur-X | POST /v1/create/facturx | Form data | PDF/A-3b binary |
| Parse PDF with AI | POST /v1/parse/json | Invoice PDF (typed or scanned) | Structured JSON + confidence |
| Extract attachments | POST /v1/extract/attachments | Factur-X PDF or CII/UBL XML | ZIP archive |
| 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
Get started
Every Node.js 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/nodejs →
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 Node.js stack.