Why Peppol UBL is genuinely hard in Node.js
Three compounding technical realities make UBL particularly awkward in the Node.js ecosystem.
Two layers of Schematron validation, neither easy in Node.js. Peppol BIS 3.0 requires both the EN 16931 base Schematron (200+ rules) and the Peppol BIS 3.0 overlay (50+ additional rules). Both are XSLT 2.0 artefacts. Node.js has no production-grade XSLT 2.0 processor. The standard workaround in other ecosystems is Saxon-HE, which is a Java library with no clean Node.js port. Implementations that try to skip Schematron and rely on XSD-only validation will silently accept invoices that the Peppol network rejects.
Quarterly Peppol BIS updates. OpenPeppol releases new Peppol BIS Schematron artefacts roughly every quarter. Each release requires downloading the new XSLT files, regression-testing against your invoice samples (because rule semantics occasionally change), and deploying. Miss a quarterly release and your validation accepts invoices that real Peppol access points reject. The maintenance cost is real and continuous.
National CIUS profile fragmentation. A single UBL API needs to handle Peppol BIS 3.0 for Belgium and cross-border, NLCIUS for Dutch public sector, EHF for Norway, PINT variants for Singapore, Australia, Japan, Malaysia and New Zealand, and XRechnung UBL for German B2G. Each has its own Schematron overlay and slightly different mandatory fields. Building this routing yourself is non-trivial.
Setting up the InvoiceXML client
The file-upload endpoints (validate, transform, extract, render) 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. Sign up for a free InvoiceXML account and you will receive 100 credits to get started, 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 Peppol UBL invoices in Node.js
Send your invoice as JSON. Totals and the VAT breakdown are auto-calculated from the line items. Set the Peppol profile via the options.profile parameter.
import fs from 'node:fs/promises';
const payload = {
invoice: {
invoiceNumber: 'INV-2026-001',
issueDate: '2026-02-15',
currency: 'EUR',
seller: {
name: 'Belga Solutions BVBA',
vatIdentifier: 'BE0123456789',
postalAddress: { line1: 'Rue Royale 12', city: 'Brussels', postCode: '1000', country: 'BE' },
},
buyer: {
name: 'Antwerp Industries NV',
postalAddress: { line1: 'Meir 45', city: 'Antwerp', postCode: '2000', country: 'BE' },
},
paymentDetails: { paymentAccountIdentifier: 'BE68539007547034' },
lines: [{
quantity: 100,
item: { name: 'Software development services, Q1 2026' },
priceDetails: { netPrice: 50.00 },
vatInformation: { rate: 21 },
}],
},
};
const response = await fetch('https://api.invoicexml.com/v1/create/ubl', {
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.INVOICEXML_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(payload),
});
await fs.writeFile('invoice-peppol.xml', await response.text());
That is the whole request. The API computes totals, builds the VAT breakdown, stamps the Peppol BIS Billing 3.0 CustomizationID, and validates against EN 16931 plus the Peppol overlay before returning the UBL 2.1 XML. /v1/create/ubl emits Peppol BIS 3.0 by default. To pin a different CIUS, set invoice.specificationId to the target CustomizationID URI (for example urn:cen.eu:en16931:2017#compliant#urn:fdc:nen.nl:nlcius:v1.0 for NLCIUS).
Validate incoming UBL invoices in Node.js
When suppliers send you UBL invoices through Peppol or via email attachment, validate them before importing into your ERP or accounting system. The validation endpoint runs the EN 16931 Schematron plus the appropriate Peppol overlay automatically based on the CustomizationID declared in the document.
import { invoicexml } from './invoicexml-client.js';
import FormData from 'form-data';
import { createReadStream } from 'fs';
async function validateUbl(xmlPath) {
const form = new FormData();
form.append('file', createReadStream(xmlPath));
const response = await invoicexml.post('/v1/validate/ubl', form, {
headers: form.getHeaders(),
});
return response.data;
}
const result = await validateUbl('supplier-invoice.xml');
if (result.valid) {
console.log(`Valid ${result.data.conformanceLevel} invoice`);
} 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.
The response carries a data block describing which validation layers passed (XSD and Schematron), plus flat errors and warnings arrays. Each finding is self-contained: the human-readable message, the business term codes (btCodes), the JSON paths into the document (fields), and the verbatim validator output (raw).
{
"valid": false,
"detail": "Validation failed with 2 error(s)",
"data": {
"schemaValid": true,
"schematronValid": false,
"conformanceLevel": "UBL 2.1"
},
"errors": [
{
"rule": "BR-CO-14",
"line": null,
"message": "Invoice total VAT amount does not match the sum of VAT breakdown amounts.",
"btCodes": ["BT-110"],
"fields": ["totals.taxTotalAmount"],
"raw": "[BR-CO-14] EN16931: ... (at /...)"
},
{
"rule": "PEPPOL-EN16931-R004",
"line": null,
"message": "Invoice type code should be 380 for a standard invoice.",
"btCodes": ["BT-3"],
"fields": ["typeCode"],
"raw": "[PEPPOL-EN16931-R004] Peppol-BIS: ... (at /...)"
}
],
"warnings": []
}
The raw string identifies the rule's origin (EN 16931 base vs. Peppol BIS overlay) by its prefix. Rules with BR- codes come from the EN 16931 base; rules with PEPPOL- codes come from the Peppol overlay. This matters operationally: an EN 16931 finding means the invoice data is wrong and goes back to the supplier, a Peppol finding means the document declares an incorrect network profile and goes to your access point provider.
Convert UBL to CII (and vice versa)
The two EN 16931 syntax bindings are equivalent in data, different in structure. The InvoiceXML API converts between them losslessly:
// UBL to CII
async function ublToCii(ublPath) {
const form = new FormData();
form.append('file', createReadStream(ublPath));
const response = await invoicexml.post('/v1/convert/ubl/to/cii', form, {
headers: form.getHeaders(),
responseType: 'text',
});
return response.data;
}
// CII to UBL
async function ciiToUbl(ciiPath) {
const form = new FormData();
form.append('file', createReadStream(ciiPath));
const response = await invoicexml.post('/v1/convert/cii/to/ubl', form, {
headers: form.getHeaders(),
responseType: 'text',
});
return response.data;
}
This matters for cross-network interoperability. A German supplier sending ZUGFeRD (CII inside PDF/A-3b) to a Belgian customer who can only accept Peppol UBL needs the data converted on the way through.
Render UBL as a human-readable PDF
UBL XML is not designed for human reading. For AP approval workflows where finance teams need to review invoices before payment, render the XML as a clean PDF:
import { invoicexml } from './invoicexml-client.js';
import FormData from 'form-data';
import { createReadStream } from 'fs';
import { writeFile } from 'fs/promises';
async function renderUblAsPdf(xmlPath) {
const form = new FormData();
form.append('file', createReadStream(xmlPath));
const response = await invoicexml.post('/v1/render/ubl/to/pdf', form, {
headers: form.getHeaders(),
responseType: 'arraybuffer',
});
return Buffer.from(response.data);
}
const pdfBuffer = await renderUblAsPdf('peppol-invoice.xml');
await writeFile('invoice-preview.pdf', pdfBuffer);
The rendered PDF shows the detected Peppol profile, all line items, the VAT breakdown, payment details, and IBAN: everything the approver needs without ever opening the underlying XML.
For ingestion into ERP or AP systems that prefer JSON over XML, extract the full invoice data:
async function extractUblToJson(xmlPath) {
const form = new FormData();
form.append('file', createReadStream(xmlPath));
const response = await invoicexml.post('/v1/extract/json', form, {
headers: form.getHeaders(),
});
return response.data;
}
const data = await extractUblToJson('incoming-invoice.xml');
console.log(`Invoice ${data.invoiceNumber} from ${data.seller.name}`);
console.log(`Total: ${data.totals.grandTotalAmount} ${data.currency}`);
console.log(`Lines: ${data.lines.length}`);
The structured JSON output makes it straightforward to insert directly into a relational database or pass to downstream services without XML parsing in your Node.js code.
Production patterns: the inbound gate
Invoices you generate go straight to the network. POST /v1/create/ubl runs EN 16931 plus the Peppol overlay before it returns, so a document that would be rejected comes back as an HTTP 400 listing the violated rules rather than as XML. Re-validating that response tells you nothing new, and your Express route is one call shorter for it.
The gate belongs on documents that entered your service from elsewhere: supplier invoices off the Peppol network, output from a legacy billing system, files from a subsidiary on its own ERP.
import { writeFile } from 'fs/promises';
// Gate UBL that arrived from a supplier or a foreign system
async function acceptIncomingUbl(ublXml, reference) {
const tmpPath = `/tmp/${reference}.xml`;
await writeFile(tmpPath, ublXml);
const validation = await validateUbl(tmpPath);
if (!validation.valid) {
const errors = validation.errors
.map(e => `[${e.rule}] ${e.message}`)
.join('\n');
throw new Error(`Incoming UBL failed validation:\n${errors}`);
}
return {
xml: ublXml,
conformanceLevel: validation.data.conformanceLevel,
warnings: validation.warnings,
};
}
// Outgoing: create and send, no second call
app.post('/invoices', async (req, res) => {
try {
const ublXml = await createUblInvoice(req.body);
await peppolAccessPoint.send(ublXml);
res.json({ status: 'sent' });
} catch (error) {
res.status(400).json({ error: error.message });
}
});
// Inbound: validate, then book
app.post('/invoices/inbound', async (req, res) => {
try {
const result = await acceptIncomingUbl(req.body.xml, req.body.reference);
await ledger.book(result.xml);
res.json({ status: 'accepted', conformanceLevel: result.conformanceLevel });
} catch (error) {
res.status(422).json({ error: error.message });
}
});
A document that fails the gate never reaches your ledger, and each finding carries the rule id, a plain-language message, and the affected business terms, enough to tell the sender exactly what to fix.
Why use a REST API instead of building UBL in Node.js
The Node.js ecosystem for UBL is genuinely underdeveloped. A few libraries exist for basic XML generation but none implement the full Peppol BIS 3.0 compliance pipeline.
What InvoiceXML handles:
EN 16931 Schematron validation for all 200+ base business rules, executed via a production-grade XSLT 2.0 processor with consistent semantics across all formats.
Peppol BIS 3.0 overlay validation. The Peppol-specific rules update quarterly from OpenPeppol. Updates are deployed before the new specification's effective date with no integration change required on your side.
National CIUS detection on validation. The validate endpoint reads the CustomizationID declared in the incoming UBL and applies the matching overlay automatically: Peppol BIS 3.0, NLCIUS, EHF, PINT variants, or XRechnung UBL. No routing logic needed on your side.
Bidirectional syntax conversion. CII to UBL, UBL to CII, lossless and validated. Useful when bridging ZUGFeRD-based and Peppol-based ecosystems in a single workflow.
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 UBL endpoint reference for Node.js
| Operation | Endpoint | Input | Output |
| Create UBL | POST /v1/create/ubl | JSON invoice | UBL 2.1 XML |
| Parse PDF with AI | POST /v1/parse/json | Invoice PDF (typed or scanned) | Structured JSON + confidence |
| Validate UBL | POST /v1/validate/ubl | UBL XML | Validation JSON |
| UBL to CII | POST /v1/convert/ubl/to/cii | UBL XML | CII XML |
| CII to UBL | POST /v1/convert/cii/to/ubl | CII XML | UBL XML |
| Extract as JSON | POST /v1/extract/json | UBL XML | Structured JSON |
| Render as PDF | POST /v1/render/ubl/to/pdf | UBL XML | PDF binary |
Full OpenAPI 3.1 schema: api.invoicexml.com/v1/openapi. Interactive API explorer: api.invoicexml.com/v1/scalar.
Get started
Create a free InvoiceXML account: includes 100 credits to get started, no credit card required.
Related resources:
InvoiceXML is a REST API for Peppol-ready e-invoice compliance covering UBL, CII, ZUGFeRD, Factur-X, and XRechnung. Stateless processing, GDPR compliant by architecture, integration support from any Node.js stack.