Generate and parse Peppol BIS Billing 3.0 (UBL 2.1, EN 16931) invoices and credit notes. Framework-agnostic, fully typed, zero runtime configuration.
From 2026, structured e-invoicing (Peppol) is becoming mandatory for B2B in Belgium and across the EU. This library does the one hard part — turning clean data into spec-compliant UBL 2.1 XML, and parsing inbound documents back into typed JSON — without dragging in an access point, a database, or a web framework.
- ✅ Invoice (type 380) and CreditNote (type 381) generation
- ✅ Inbound UBL parser → normalized, JSON-safe shape
- ✅ Belgian VAT → Peppol participant ID (scheme 0208) derivation; explicit IDs for any EAS scheme
- ✅ Per-line VAT grouping, discounts, IBAN/BIC payment means, multi-currency
- ✅ Correct XML escaping everywhere; strict TypeScript types
- ✅ Ships ESM + CJS +
.d.ts, no peer framework, one tiny dependency (fast-xml-parser)
Scope & honesty. This produces and reads BIS Billing 3.0 UBL. It does not transmit over the Peppol network (bring your own access point), and it does not run Schematron validation — run the output through the official Peppol BIS validator before going live. Belgian participant-ID derivation is first-class; for other countries pass
participantIdexplicitly.
npm install peppol-bis-billingimport { generateInvoice, parseUbl } from "peppol-bis-billing";
const xml = generateInvoice({
number: "INV-2026-001",
issueDate: "2026-06-10",
paymentTermsDays: 30,
supplier: {
name: "ALPHA & CO",
vatNumber: "BE1028386674", // → EndpointID 0208:1028386674
address: { street: "Ninoofsesteenweg 77", city: "Dilbeek", postalCode: "1700", country: "BE" },
},
customer: {
name: "Client BVBA",
vatNumber: "BE0123456789",
address: { city: "Brussels", postalCode: "1000", country: "BE" },
},
payment: { iban: "BE68539007547034", bic: "GKCCBEBB" },
lines: [
{ name: "Carrelage 60x60", quantity: 10, unitPrice: 24.65, vatRate: 21, sku: "PL18805" },
{ name: "Robinet Mitigeur", quantity: 1, unitPrice: 85.5, vatRate: 21 },
],
});
// Parse an inbound document
const result = parseUbl(xml);
if (result.ok) {
console.log(result.document.totals.taxInclusive); // 401.72
}import { generateCreditNote } from "peppol-bis-billing";
const xml = generateCreditNote({
number: "CN-2026-001",
issueDate: "2026-06-12",
originalInvoice: { number: "INV-2026-001", issueDate: "2026-06-10" }, // BillingReference
supplier: { name: "ALPHA & CO", vatNumber: "BE1028386674" },
customer: { name: "Client BVBA", vatNumber: "BE0123456789" },
lines: [{ name: "Carrelage 60x60", quantity: 2, unitPrice: 24.65, vatRate: 21 }],
});import { belgianVatToParticipantId, parseParticipantId } from "peppol-bis-billing";
belgianVatToParticipantId("BE 1028.386.674"); // "0208:1028386674"
parseParticipantId("0208:1028386674"); // { scheme: "0208", value: "1028386674" }| Function | Purpose |
|---|---|
generateInvoice(input): string |
UBL 2.1 Invoice (type 380), BIS Billing 3.0 |
generateCreditNote(input): string |
UBL 2.1 CreditNote (type 381) with BillingReference |
parseUbl(xml): ParseResult |
Parse inbound Invoice/CreditNote → normalized JSON |
belgianVatToParticipantId(vat): string |
Derive 0208:NNNNNNNNNN from a Belgian VAT number |
parseParticipantId(id) / resolveParticipant(party) |
Participant-ID helpers |
All input/output types (InvoiceInput, CreditNoteInput, LineItem, ParsedDocument, …) are exported. Field comments reference the EN 16931 business-term IDs (BT-1, BT-9, …).
{ name: "Item", quantity: 2, unitPrice: 100, vatRate: 21, discount: { type: "percent", value: 10 } }
// or: discount: { type: "fixed", value: 5 } // per unitLines are aggregated into TaxSubtotal groups by (vatCategory, vatRate). Default category is S (standard rate); override per line with vatCategory.
Sending a Peppol invoice has three layers. This library owns the hardest one — the document — and plugs into the rest:
| Layer | Responsibility | This library |
|---|---|---|
| 1. Document | Build valid BIS 3.0 UBL / parse inbound UBL | ✅ |
| 2. Validation | Check against the official Peppol BIS rules (Schematron) | |
| 3. Transport | Actually send/receive over the Peppol network | ❌ use an Access Point |
You still need a registered Peppol Access Point (e.g. Storecove, Peppyrus, Tradeshift, …) to put a document on the network. This library produces the XML the Access Point expects:
import { generateInvoice } from "peppol-bis-billing";
const ubl = generateInvoice(invoiceInput); // BIS 3.0 UBL string
// Optional but recommended before go-live: validate the XML against the official
// Peppol BIS rules (https://docs.peppol.eu/poacc/billing/3.0/). Do this in CI.
// Hand the XML to your Access Point provider. Shape varies per provider:
await fetch("https://api.your-access-point.example/v1/documents", {
method: "POST",
headers: { Authorization: `Bearer ${process.env.AP_API_KEY}`, "Content-Type": "application/xml" },
body: ubl,
});When your Access Point pushes an inbound document (usually a webhook with the raw UBL), parse it into a typed object:
import { parseUbl } from "peppol-bis-billing";
app.post("/webhooks/peppol", (req, res) => {
const result = parseUbl(req.body.xml);
if (!result.ok) return res.status(422).json({ error: result.error });
const { documentId, supplier, totals } = result.document;
// persist result.document, mark it for review, etc.
res.sendStatus(200);
});See examples/ for runnable scripts. This is exactly how the library is
used in production: the app builds the XML, the Access Point handles transport.
npm install
npm test # vitest — round-trip generate↔parse + unit tests
npm run typecheck # tsc --noEmit
npm run build # tsup → dist (esm + cjs + dts)Contributions welcome — especially more EAS-scheme derivations, additional countries, and Schematron validation. See CONTRIBUTING.md.
MIT — see LICENSE.