DEV Community

Cover image for Adding Peppol e-invoicing (EN 16931) to a self-hosted Flask app
Dries Peeters for Drytrix

Posted on

Adding Peppol e-invoicing (EN 16931) to a self-hosted Flask app

Since 1 January 2026, Belgian businesses have to exchange B2B invoices as structured e-invoices over the Peppol network. A PDF by email no longer counts.

I maintain TimeTracker, an open-source, self-hosted time tracker that also generates invoices. I'm based in Belgium and so are many of the people it's built for, so the mandate turned a nice-to-have into a must-have.

This post covers what I learned building it: the standards, the architecture choice that made it workable for a self-hosted app, and the parts that were harder than expected.

GitHub logo DRYTRIX / TimeTracker

Self-hosted time tracking & invoicing for freelancers and teams: timers, projects, PDF and Peppol e-invoicing. Open-source Toggl/Clockify alternative.

TimeTracker

Professional Time Tracking & Project Management for Teams

Track time. Manage projects. Generate invoices. All in one place.

🆕 What's New • 📥 Download & Install • 🚀 Quick Start • ✨ Features • 📸 Screenshots • 📖 Getting Started • 📚 Documentation • 🗑️ Uninstall • 📋 Changelog • 🐳 Deploy


🎯 What is TimeTracker?

TimeTracker is a self-hosted, web-based time tracking application designed for freelancers, teams, and businesses who need professional time management with complete control over their data.

Perfect for:

  • 💼 Freelancers tracking billable hours across multiple clients
  • 👥 Small Teams managing projects and tracking productivity
  • 🏢 Agencies needing detailed reporting and client billing
  • 🔒 Privacy-focused organizations wanting self-hosted solutions

You can support the project and purchase a key to hide donate prompts in your instance.


📥 Download & Install

Method Best for Get started
Docker image Servers, VPS, homelab docker pull ghcr.io/drytrix/timetracker:latest or Docker Hub
…

Not legal or tax advice. I'm a developer, not an accountant. Check the official guidance from the Belgian FPS Finance before you rely on any of this for compliance.


The alphabet soup, untangled

When I started, the terms blurred together. Here's what finally made it click:

Term What it actually is
EN 16931 The European semantic standard: which fields an e-invoice must contain (seller, buyer, VAT breakdown, totals, and so on). It defines data, not a file format.
UBL 2.1 and CII Two XML syntaxes that can carry EN 16931 data.
Peppol BIS Billing 3.0 The Peppol network's rules on top of EN 16931, expressed in UBL.
Peppol The transport: a network where businesses connect through certified Access Points ("4-corner model").
Factur-X / ZUGFeRD A hybrid PDF: a normal human-readable invoice with CII XML embedded inside it.

The key insight: Peppol is the pipe, UBL is what goes through it, and Factur-X is a separate format that happens to share the same underlying standard. They need different XML (UBL for Peppol, CII for Factur-X), so I ended up generating both.


The architecture decision: don't become an Access Point

To send over Peppol you go through an Access Point. You can, in theory, speak the protocol yourself: look up the receiver in the SML/SMP directory, then send the document over AS4 with the right certificates, signatures and receipts.

I built an experimental "native" mode that does SML/SMP lookup and AS4 sending. It works for testing, but it lacks WS-Security, digital signatures and receipt handling, and getting certified is a business in itself. For a self-hosted app used by freelancers, that's the wrong layer to own.

So the recommended path is a generic HTTP transport: TimeTracker builds the UBL and POSTs it, with routing metadata, to an adapter URL. The adapter forwards it to whatever certified Access Point provider the user already has.

The contract is deliberately small:

{
  "recipient": { "endpoint_id": "…", "scheme_id": "…" },
  "sender":    { "endpoint_id": "…", "scheme_id": "…" },
  "document": {
    "id": "INV-…",
    "type_id": "urn:oasis:names:specification:ubl:schema:xsd:Invoice-2::Invoice##…::2.1",
    "process_id": "urn:fdc:peppol.eu:2017:poacc:billing:01:1.0"
  },
  "payload": { "ubl_xml": "<?xml version=\"1.0\" …>…</Invoice>" }
}
Enter fullscreen mode Exit fullscreen mode

The adapter returns something like { "message_id": "…" }. Any HTTP status of 400 or higher marks the attempt as failed, and every attempt is stored (pending → sent or failed) so the invoice page can show a full send history.

The bridge sidecar

Asking self-hosters to write their own adapter would kill adoption. So the repo ships a small peppol-bridge service that runs next to the app in Docker Compose:

  peppol-bridge:
    build:
      context: .
      dockerfile: peppol_bridge/Dockerfile
    environment:
      - PEPPOL_BRIDGE_AUTH_TOKEN=${PEPPOL_BRIDGE_AUTH_TOKEN:?Set PEPPOL_BRIDGE_AUTH_TOKEN}
      - PEPPOL_BRIDGE_PROVIDER=einvoice      # or: peppyrus, generic_custom
      - EINVOICE_API_KEY=${EINVOICE_API_KEY:?Set EINVOICE_API_KEY}
    restart: unless-stopped
Enter fullscreen mode Exit fullscreen mode

It exposes /health, /test (checks provider credentials) and /send (the contract above). Provider differences stay inside the bridge. For example, one provider authenticates with an X-Api-Key header instead of Authorization: Bearer, and the app never needs to know.

An admin setup wizard generates this snippet and points the app at http://peppol-bridge:8088/send. Adding a new provider means adding a preset to the bridge, not changing the invoicing code.


Identifiers: small detail, lots of failed sends

Every Peppol party is identified by a scheme ID plus an endpoint ID. For Belgian companies that's typically scheme 0208 with the enterprise number as endpoint.

Two lessons:

  1. Validate identifiers before sending. Both sender and recipient IDs are checked for scheme and format before anything leaves the server. A malformed ID otherwise fails somewhere deep in a provider's API with an unhelpful error.
  2. Recipients are per client. Each client record holds its own endpoint and scheme. The Send via Peppol button only appears when both are present, so users can't try to send to a client who isn't set up.

"Make all invoices Peppol compliant"

Most users don't want to think about BT-codes. So there's a single admin toggle that:

  • adds the mandatory BIS Billing 3.0 elements, such as InvoiceTypeCode 380 and a Buyer reference (BT-10). If the user leaves BT-10 empty it falls back to the project name, then the invoice number;
  • shows warnings on the invoice page when required data is missing (company VAT ID, sender endpoint, client endpoint);
  • adds a Download UBL button, so users whose accountant handles sending can still hand over a compliant file.

The warnings matter as much as the XML. A perfectly generated document still fails if the company VAT ID or the client's endpoint is missing, so surfacing those gaps before the user clicks Send is what makes the feature usable.


Factur-X: the hard part was the PDF, not the XML

Factur-X looks simple: attach an XML file to a PDF. In practice, validators are strict about how.

What the export does:

  • Embeds factur-x.xml (CII, EN 16931 profile) as a PDF Associated File with relationship Data, MIME type text/xml, and the Factur-X XMP metadata.
  • Optionally normalises the file to PDF/A-3b in the same pikepdf pass: XMP identification, an sRGB ICC output intent, and the pdfaExtension schema declaration.
  • Embeds Liberation fonts (metric-compatible with Helvetica, Times and Courier). PDF/A validators reject the unembedded base-14 fonts that many PDF generators use by default, and it's an easy thing to miss.
  • Can run veraPDF after export to check PDF/A-3b conformance.

And one rule I'd recommend to anyone doing this: fail loudly. If embedding is enabled and the step fails, the export aborts with an error. It never quietly returns a plain PDF that the user believes is compliant. Pre-export checks also block the download when a seller or buyer country is missing, or when reverse charge (VAT category AE) is used without a buyer VAT ID.


What it doesn't do (yet)

Being clear about limits matters more with compliance features than anywhere else:

  • Sending, not receiving. The mandate also requires businesses to receive e-invoices from suppliers. TimeTracker has no accounts-payable inbox; that half needs another tool or your Access Point provider's portal.
  • An Access Point account is still needed. The bridge is an adapter, not network membership.
  • XRechnung (Germany's variant) isn't supported.
  • In-app validation checks structure, not the full Peppol Schematron rules. External validators are still worth running before you go live.

Takeaways for other developers

  1. Separate "generate the document" from "transport the document." A thin adapter contract let me support several providers without touching invoicing code.
  2. Invest in data-quality warnings. Correct XML is useless if the VAT ID or endpoint behind it is missing.
  3. Hybrid PDFs are a PDF/A project. Budget for fonts, colour profiles and metadata.
  4. Fail loudly on compliance steps. A silent fallback produces non-compliant invoices nobody notices.

Full setup docs are in the repo: PEPPOL_EINVOICING.md and PEPPOL_BRIDGE.md. There's also a plain-language overview at timetracker.drytrix.com/peppol-einvoicing.

If you're working on e-invoicing too, especially for another EU country, I'd love to compare notes in the comments. And if this was useful, a ⭐ on GitHub helps other people find it.

Top comments (0)