Skip to main content

On This Page

Integrating Peppol e-Invoicing into SaaS: Infrastructure vs. Custom Build

3 min read
Share

These articles are AI-generated summaries. Please check the original sources for full details.

How to add Peppol e-invoicing to your SaaS without making it your team’s problem

The Peppol network provides a standardized framework for B2B electronic invoicing across Europe. Belgium has already required structured domestic B2B e-invoicing since January 1, 2026.

Why This Matters

Building a custom Peppol integration creates a long-term maintenance tax due to the complexity of UBL 2.1 (Peppol BIS Billing 3.0) and country-specific validation rules. Rather than managing XML generation, Access Point connectivity, and directory lookups manually, engineers should treat Peppol as infrastructure—similar to a payment processor—to avoid compliance drift and diversion from core product development.

Key Insights

  • EU Compliance Timelines: Belgium mandated B2B e-invoicing on Jan 1, 2026; Germany required receiving capabilities on Jan 1, 2025; France begins rollout Sept 1, 2026.
  • SaaS Architecture Shapes: A ‘Verified Sender’ model (one legal entity) is simple, whereas a ‘Delegated Sender’ model (SaaS sending on behalf of customers) requires complex KYB and authorization gates.
  • UBL Complexity: The standard involves UBL 2.1 conforming to Peppol BIS Billing 3.0 with hundreds of optional fields and non-trivial validation trees.
  • @getpeppr SDK/CLI: Provides tools for JSON-to-UBL mapping and offline validation via npx @getpeppr/cli validate.

Working Examples

Initializing the getpeppr SDK and sending an invoice by mapping internal data to a provider JSON shape.

import { Peppol } from "@getpeppr/sdk";

export const peppol = new Peppol({
  apiKey: process.env.GETPEPPR_API_KEY!,
});

async function sendInvoice(invoice: YourInvoice) {
  return peppol.invoices.send({
    number: invoice.number,
    date: invoice.date,
    dueDate: invoice.dueDate,
    currency: invoice.currency ?? "EUR",
    buyerReference: invoice.buyerReference ?? invoice.recipient.reference,
    to: {
      name: invoice.recipient.legalName,
      peppolId: invoice.recipient.peppolId,
      street: invoice.recipient.street,
      city: invoice.recipient.city,
      postalCode: invoice.recipient.postalCode,
      country: invoice.recipient.country,
      vatNumber: invoice.recipient.vatNumber,
    },
    lines: invoice import { webhooks } from "@getpeppr/sdk";
invoice import { webhooks } from "@getpeppr/sdk";
invoice import { webhooks } from "@getpeppr/sdk";
invoice import { webhooks } from "@getpeppr/sdk";
invoice import { webhooks } from "@getpeppr/sdk";
invoice import { webhooks } from "@getpeppr/sdk";
invoice import { webhooks } from "@getpeppr/sdk";
invoice import { webhooks } from "@getpeppr/sdk";
invoice import { webhooks } from "@getpeppr/sdk";
invoice import { webhooks } from "@getpeppr/sdk";
invoice import { webhooks } from "@getpeppr/sdk";
invoice import { webhooks } from "@getpeppr/sdk";
invoice .map((line) => ({
      description: line description,
      quantity: line quantity,
      unitPrice: line unitPrice,
      vatRate: line vatRate,
    })),
  });
}

Handling asynchronous delivery statuses via signed webhooks.

import express from "express";
import { webhooks } from "@getpeppr/sdk";

app post("/webhooks/getpeppr", express raw({ type: "*/*" }), async (req res) => {
try {	const event = await webhooks constructEvent(	req body toString("utf8")),	req headers["getpeppr-signature"] as string,	process env GETPEPPR_WEBHOOK_SECRET! ">	switch (event type) {	case "invoice sent": case "invoice accepted": markDeliveredInYourApp(event data invoiceId); break;	case "invoice refused": case "invoice error": flagForReview(event data invoiceId, event); break;	case "invoice paid": markPaid(event data invoiceId); break;	}	res sendStatus(200);	} catch {	res sendStatus(400);	}"});

Practical Applications

References:

Continue reading

Next article

Why Qualified Candidates Fail the ATS: The Hidden Gap in Modern Hiring

Related Content