Automation Features Blog Pricing Contact
Back to Blog
Integration May 17, 2026 15 min read

Create and Validate Peppol UBL Invoices in Node.js: Complete Guide

A practical guide to handling the full Peppol BIS 3.0 UBL lifecycle in Node.js: creating invoices from structured data, validating against EN 16931 plus the Peppol overlay, converting between CII and UBL, and rendering UBL XML as human-readable PDFs.

Belgium's January 2026 B2B mandate made Peppol BIS 3.0 UBL a daily reality for every VAT-registered business in the country. The Netherlands, Norway, Denmark, Singapore, Australia, and Japan all run on the same Peppol network. If you are building anything in Node.js that issues or processes invoices for European or Peppol-connected markets, structured UBL output is no longer optional.

The bad news for Node.js developers: the ecosystem support is sparse. There is no mature Node.js library equivalent to Java's Mustangproject or the established UBL toolchains in other languages. EN 16931 Schematron validation, which requires XSLT 2.0 processing, has no good Node.js implementation. The Peppol BIS 3.0 overlay updates quarterly and tracking those updates yourself becomes ongoing work.

This guide shows how to handle the full UBL lifecycle in Node.js using the InvoiceXML REST API: creating Peppol BIS 3.0 invoices from structured data, validating incoming UBL against EN 16931 and Peppol overlays, converting between CII and UBL syntax, and rendering UBL XML as human-readable PDFs for approval workflows.

Complete UBL Toolkit

Everything you need to create, convert, validate, and preview UBL invoices, via REST API or online.

Build a validated UBL 2.1 XML from structured data, Peppol BIS Billing 3.0 compliant.

Validate UBL XML against EN 16931 and Peppol BIS Billing 3.0 Schematron rules.

Convert any PDF invoice into a Peppol-compliant UBL 2.1 XML using AI extraction.

Render a UBL XML as a human-readable PDF for review and approval.

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.


Extract structured JSON from UBL

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: validate before submission

A typical Peppol workflow generates an invoice, validates it, and only then transmits it to the network. Combining the create and validate endpoints in a single Node.js function:

import { writeFile } from 'fs/promises';

async function createAndValidateUbl(invoice) {
  // Step 1: generate UBL
  const ublXml = await createUblInvoice(invoice);

  // Step 2: validate before transmission
  const tmpPath = `/tmp/${invoice.invoiceNumber}.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(`Generated UBL failed validation:\n${errors}`);
  }

  return {
    xml: ublXml,
    conformanceLevel: validation.data.conformanceLevel,
    warnings: validation.warnings,
  };
}

// Usage in an Express route
app.post('/invoices', async (req, res) => {
  try {
    const result = await createAndValidateUbl(req.body);
    await peppolAccessPoint.send(result.xml);
    res.json({ status: 'sent', conformanceLevel: result.conformanceLevel });
  } catch (error) {
    res.status(400).json({ error: error.message });
  }
});

This pattern catches data errors before transmission rather than receiving a rejection from your Peppol access point. The validation cost is minimal and the integration reliability improvement is significant.


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

OperationEndpointInputOutput
Create UBLPOST /v1/create/ublJSON invoiceUBL 2.1 XML
PDF to UBL (AI)POST /v1/transform/to/ublPDF, image, CII XMLUBL 2.1 XML
Validate UBLPOST /v1/validate/ublUBL XMLValidation JSON
UBL to CIIPOST /v1/convert/ubl/to/ciiUBL XMLCII XML
CII to UBLPOST /v1/convert/cii/to/ublCII XMLUBL XML
Extract as JSONPOST /v1/extract/jsonUBL XMLStructured JSON
Render as PDFPOST /v1/render/ubl/to/pdfUBL XMLPDF 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.


Ready to automate your invoices?

Start your 30-day free trial. No credit card required.

Get Started For Free