<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Zero Lopp Labs</title>
    <description>The latest articles on DEV Community by Zero Lopp Labs (@zerolooplabs).</description>
    <link>https://dev.to/zerolooplabs</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3810612%2Fa33b06b1-c0a7-48dd-9351-6a1e5398aa5a.png</url>
      <title>DEV Community: Zero Lopp Labs</title>
      <link>https://dev.to/zerolooplabs</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/zerolooplabs"/>
    <language>en</language>
    <item>
      <title>Add Peppol e-invoicing to your SaaS — the easy way to get ahead of the EU mandates</title>
      <dc:creator>Zero Lopp Labs</dc:creator>
      <pubDate>Tue, 02 Jun 2026 16:37:57 +0000</pubDate>
      <link>https://dev.to/zerolooplabs/add-peppol-e-invoicing-to-your-saas-the-easy-way-to-get-ahead-of-the-eu-mandates-1e1b</link>
      <guid>https://dev.to/zerolooplabs/add-peppol-e-invoicing-to-your-saas-the-easy-way-to-get-ahead-of-the-eu-mandates-1e1b</guid>
      <description>&lt;p&gt;When you're building a young B2B SaaS, Peppol e-invoicing lives in the "we'll deal with it later" pile. Invoicing is a feature, not your product. No customer has demanded it yet. And the whole thing looks like XML plumbing for an EU regulation that may not even apply to you this year.&lt;/p&gt;

&lt;p&gt;Here's the trap: "later" has a habit of arriving as "this sprint, and it's now on fire." A customer signs, and during onboarding asks whether you can send their invoices over Peppol. Or your home market publishes a mandate date and your roadmap gets rewritten for you.&lt;/p&gt;

&lt;p&gt;This article makes the opposite case. For an early-stage SaaS, the cheapest and lowest-risk time to add Peppol is &lt;em&gt;before&lt;/em&gt; you're forced to — not because of urgency theatre, but because of how the costs actually break down once you look at them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the EU mandates stand today
&lt;/h2&gt;

&lt;p&gt;You don't need to memorise EU tax law. You need to know that the deadlines are real, dated, and arriving on a schedule you can plan around:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Market&lt;/th&gt;
&lt;th&gt;Receive&lt;/th&gt;
&lt;th&gt;Send (issue)&lt;/th&gt;
&lt;th&gt;Network&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;🇧🇪 Belgium&lt;/td&gt;
&lt;td&gt;Mandatory since 1 Jan 2026 (in-scope domestic Belgian VAT-liable B2B)&lt;/td&gt;
&lt;td&gt;Mandatory since 1 Jan 2026 (in-scope domestic Belgian VAT-liable B2B)&lt;/td&gt;
&lt;td&gt;Structured e-invoice, generally via Peppol&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;🇫🇷 France&lt;/td&gt;
&lt;td&gt;1 Sept 2026 (all businesses)&lt;/td&gt;
&lt;td&gt;1 Sept 2026 (large + mid-sized); 1 Sept 2027 (SMEs &amp;amp; micro)&lt;/td&gt;
&lt;td&gt;Approved platforms; EN 16931 formats such as Factur-X, UBL and CII&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;🇩🇪 Germany&lt;/td&gt;
&lt;td&gt;Since 1 Jan 2025 (domestic B2B must be able to receive)&lt;/td&gt;
&lt;td&gt;Transition rules through 2026; through 2027 for issuers up to €800K prior-year turnover&lt;/td&gt;
&lt;td&gt;EN 16931, typically XRechnung or ZUGFeRD; Peppol optional&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;🇪🇺 EU (ViDA)&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;1 July 2030 (intra-EU cross-border B2B digital reporting)&lt;/td&gt;
&lt;td&gt;Real-time reporting based on e-invoicing / EN 16931&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Primary references: Belgium's &lt;a href="https://efacture.belgium.be/fr/FAQ/questions-generales-b2b" rel="noopener noreferrer"&gt;e-facture FAQ&lt;/a&gt;, France's &lt;a href="https://www.impots.gouv.fr/professionnel/questions/partir-de-quand-suis-je-concerne-par-la-reforme-de-la-facturation" rel="noopener noreferrer"&gt;impots.gouv.fr calendar&lt;/a&gt;, Germany's &lt;a href="https://www.bundesfinanzministerium.de/Content/DE/FAQ/e-rechnung.html" rel="noopener noreferrer"&gt;BMF e-invoice FAQ&lt;/a&gt;, and the European Commission's &lt;a href="https://taxation-customs.ec.europa.eu/taxation/vat/vat-digital-age-vida_en" rel="noopener noreferrer"&gt;ViDA page&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Poland, Romania and Spain are phasing in their own mandates on top of these. The pattern is one-directional: structured e-invoicing is becoming the default for B2B across the bloc, with Peppol either mandated, supported or sitting close to the infrastructure in several markets. The EU's &lt;em&gt;VAT in the Digital Age&lt;/em&gt; (ViDA) package locks in the cross-border digital-reporting layer for 2030.&lt;/p&gt;

&lt;p&gt;The takeaway for a young SaaS is the good kind: you still get to choose &lt;em&gt;when&lt;/em&gt; you do this work, on your own terms. Even if &lt;em&gt;you&lt;/em&gt; aren't directly in scope yet, a growing share of your EU B2B customers either already are, or will be soon — and the direction of travel won't reverse. Doing it early and calmly is worth real money.&lt;/p&gt;

&lt;h2&gt;
  
  
  The two costs nobody puts on the roadmap
&lt;/h2&gt;

&lt;p&gt;When founders estimate "add Peppol," they price the obvious thing — a vendor's monthly fee — and skip the two costs that actually hurt.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The build cost.&lt;/strong&gt; Peppol looks like "just an XML format." It isn't. To send a compliant invoice you have to emit &lt;strong&gt;UBL 2.1 conforming to Peppol BIS Billing 3.0&lt;/strong&gt;, with country-specific extensions (CIUS) layered on top. Then you need to:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Map your invoice model to UBL (your data shape is not UBL's shape)&lt;/li&gt;
&lt;li&gt;Pass schema &lt;em&gt;and&lt;/em&gt; Schematron validation before sending, or the access point rejects you&lt;/li&gt;
&lt;li&gt;Look up recipients in the Peppol Directory across participant identifier schemes (&lt;code&gt;0208&lt;/code&gt; for Belgium, &lt;code&gt;0009&lt;/code&gt; for France, &lt;code&gt;0007&lt;/code&gt; for Sweden, &lt;code&gt;0088&lt;/code&gt; for GLN, and so on)&lt;/li&gt;
&lt;li&gt;Submit through a &lt;em&gt;certified&lt;/em&gt; Access Point — you don't talk to recipients directly, and becoming an Access Point yourself is a multi-month, audited, fee-bearing project almost no SaaS takes on&lt;/li&gt;
&lt;li&gt;Handle asynchronous delivery, where "accepted by the access point" is not "delivered," which is not "accepted by the recipient's accounting system"&lt;/li&gt;
&lt;li&gt;Surface failures that can arrive hours later when a recipient rejects for a compliance reason&lt;/li&gt;
&lt;li&gt;Archive everything in machine-readable form for seven years&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In our experience that's roughly one backend engineer for two quarters to ship a first version — then ongoing maintenance every time a country updates its CIUS or a new mandate lands. For a team where invoicing is a feature and not the product, that's a tax you pay forever.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The cost of waiting.&lt;/strong&gt; Do this work under a mandate deadline and you pay a rush premium. There's no time to learn the failure modes in a calm sandbox, no buffer for the recipient-rejected-it-on-Tuesday surprises, and no room to ship behind a flag and watch it for a week. In markets where the mandate is already live, there's also direct fine exposure — Belgium's regime runs &lt;strong&gt;€1,500 to €5,000&lt;/strong&gt; per escalating offence (the &lt;a href="https://dev.to/zerolooplabs/belgiums-e-invoicing-grace-period-ended-heres-the-developers-playbook-44lk"&gt;Belgium developer playbook&lt;/a&gt; covers exactly how that lands on engineering).&lt;/p&gt;

&lt;p&gt;Against either of those, the line item everyone fixates on — what the API costs per month — is the cheapest part of the whole story.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why "start early" is structurally cheaper
&lt;/h2&gt;

&lt;p&gt;It's not a motivational slogan; it's a difference in how the work behaves.&lt;/p&gt;

&lt;p&gt;When you have time, you integrate at your own pace. You wire the SDK into a sandbox that's free and unlimited, push fake invoices through, and deliberately break things to learn what &lt;code&gt;invoice.refused&lt;/code&gt; looks like before it matters. You ship behind a feature flag, run it read-only alongside your existing flow, and make Peppol &lt;em&gt;boring&lt;/em&gt; — which is exactly what infrastructure should be.&lt;/p&gt;

&lt;p&gt;When you're under a deadline, you do the same work serially, at speed, with a fine clock ticking and a customer waiting. Same code, very different stress — and a meaningfully higher chance of shipping something that passes your tests but trips a recipient's Schematron in production.&lt;/p&gt;

&lt;p&gt;There's an upside most founders miss, too: being Peppol-ready early is a &lt;em&gt;sales&lt;/em&gt; asset. When an EU B2B prospect asks "can you send our invoices over Peppol?", "yes, today" closes deals that make your competitors say "it's on the roadmap."&lt;/p&gt;

&lt;h2&gt;
  
  
  The early-stage on-ramp: sandbox, then a pilot
&lt;/h2&gt;

&lt;p&gt;This is where &lt;a href="https://getpeppr.dev" rel="noopener noreferrer"&gt;getpeppr&lt;/a&gt; fits, and it's built around exactly this "start small" shape. (It's what we ship — but the reasoning holds for any decent Peppol-as-a-service API.) The whole idea is a TypeScript-first wrapper around a certified Access Point: a JSON object goes in, a Peppol-compliant invoice goes out, and you never touch the XML unless you ask to.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step one is free.&lt;/strong&gt; &lt;a href="https://console.getpeppr.dev/sign-up" rel="noopener noreferrer"&gt;Create an account&lt;/a&gt; and the sandbox is yours — free forever, no credit card, no time limit. You get the full SDK surface, real validation errors, and webhook events — just no live Peppol traffic. This is where a young team should live for the first week: learn the model with zero commitment.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step two is a pilot, not a platform contract.&lt;/strong&gt; When you're ready to send for your first real customers, the &lt;strong&gt;Platform Pilot&lt;/strong&gt; tier is sized for precisely that moment: &lt;strong&gt;€99/month&lt;/strong&gt;, up to &lt;strong&gt;10 production Legal Entities&lt;/strong&gt;, &lt;strong&gt;€0.35 per successfully sent document&lt;/strong&gt;, as a &lt;strong&gt;6-month pilot bridge&lt;/strong&gt;. It's described as being "for early SaaS platforms validating production Peppol with first customers," includes guided onboarding, and has a defined conversion path to the larger Starter and Growth tiers as you grow.&lt;/p&gt;

&lt;p&gt;Be aware of one honest caveat: production sending is &lt;strong&gt;contract-gated&lt;/strong&gt; — a signed platform agreement and a DPA are required before live customer sending is enabled. That isn't a paywall; it's the compliance reality of sending invoices on another company's behalf. Plan a few days for it rather than discovering it the afternoon a customer wants to go live.&lt;/p&gt;

&lt;p&gt;If you're sending your &lt;em&gt;own&lt;/em&gt; invoices rather than acting as a platform, the direct-business tiers are public and self-serve: Starter €49/mo (100 docs), Pro €149/mo (800 docs), Business €399/mo (2,000 docs), with overage from €0.20/doc.&lt;/p&gt;

&lt;h2&gt;
  
  
  The TypeScript path, in about fifteen lines
&lt;/h2&gt;

&lt;p&gt;Here's the entire happy path. The code uses real &lt;code&gt;@getpeppr/sdk&lt;/code&gt; v1.5.0 calls.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Install and initialise.&lt;/strong&gt; Keys are environment-prefixed — &lt;code&gt;sk_sandbox_...&lt;/code&gt; for test, &lt;code&gt;sk_live_...&lt;/code&gt; for production. Switching environments is a key change, not a code change.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm &lt;span class="nb"&gt;install&lt;/span&gt; @getpeppr/sdk
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Peppol&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@getpeppr/sdk&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;peppol&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Peppol&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;apiKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;GETPEPPR_API_KEY&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Send an invoice.&lt;/strong&gt; The shape mirrors the EN 16931 model — supplier, customer, line items with VAT — but stays JSON-shaped:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;peppol&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;invoices&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;number&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;INV-2026-001&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;from&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Your Customer Ltd&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;peppolId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;0208:BE0456789012&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// the legal entity sending&lt;/span&gt;
    &lt;span class="na"&gt;country&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;BE&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;to&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Acme Corp&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;peppolId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;0208:BE9876543210&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;street&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;123 Business Ave&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;city&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Brussels&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;postalCode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;1000&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;country&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;BE&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;lines&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Consulting services&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;quantity&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;unitPrice&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;150&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;vatRate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;21&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Under the hood the SDK builds UBL 2.1, validates it against Peppol BIS Billing 3.0, signs the envelope, and submits via the Access Point.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Validate offline first, with no key at all.&lt;/strong&gt; The &lt;code&gt;@getpeppr/cli&lt;/code&gt; package (v0.4.3) generates, validates and converts invoices entirely offline. Use it to sanity-check your JSON shape before you wire up the live API:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx @getpeppr/cli validate my-invoice.json
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Handle status asynchronously.&lt;/strong&gt; Peppol delivery isn't synchronous — &lt;code&gt;send()&lt;/code&gt; gives you a queued ID, and the rest arrives via webhooks (&lt;code&gt;invoice.sent&lt;/code&gt;, &lt;code&gt;invoice.accepted&lt;/code&gt;, &lt;code&gt;invoice.refused&lt;/code&gt;, &lt;code&gt;invoice.error&lt;/code&gt;, &lt;code&gt;invoice.paid&lt;/code&gt;, and friends). Signatures are HMAC-SHA256 on the &lt;code&gt;Getpeppr-Signature&lt;/code&gt; header, and the SDK verifies them for you:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;peppol&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;webhooks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;constructEvent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;getpeppr-signature&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;PEPPR_WEBHOOK_SECRET&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;invoice.refused&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nf"&gt;flagForReview&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;invoiceId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the whole surface. If you're sending on behalf of &lt;em&gt;many&lt;/em&gt; customers — the multi-tenant case where one master API key fans out to N legal entities — the &lt;a href="https://getpeppr.dev/docs/platform/" rel="noopener noreferrer"&gt;platform integration docs&lt;/a&gt; cover that model end to end (master keys, per-tenant legal entities, KYB), and the &lt;a href="https://dev.to/zerolooplabs/how-to-add-peppol-e-invoicing-to-your-saas-without-making-it-your-teams-problem-1jj7"&gt;multi-tenant pattern&lt;/a&gt; (G2 in this series) walks through the code.&lt;/p&gt;

&lt;h2&gt;
  
  
  The boring truth about doing it later
&lt;/h2&gt;

&lt;p&gt;Retrofitting Peppol under a deadline is the same engineering as doing it calmly now — minus the safety margins. You lose the sandbox-learning week, the flag-gated rollout, the parallel-run validation, and the option to push a fix without a customer breathing down your neck. You also lose negotiating leverage: "we need this live in three weeks" is not the position you want to be in with any dependency.&lt;/p&gt;

&lt;p&gt;Starting early converts a future fire drill into a quiet afternoon of integration work. For a young SaaS, that trade is almost always worth making while it's cheap.&lt;/p&gt;

&lt;h2&gt;
  
  
  Next steps
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://console.getpeppr.dev/sign-up" rel="noopener noreferrer"&gt;Create a sandbox key&lt;/a&gt; — free, no credit card, full SDK. Push a fake invoice through this week.&lt;/li&gt;
&lt;li&gt;Validate one of your real invoices offline first: &lt;code&gt;npx @getpeppr/cli validate your-invoice.json&lt;/code&gt;. Two minutes, zero commitment.&lt;/li&gt;
&lt;li&gt;For the regulatory context, read the &lt;a href="https://dev.to/zerolooplabs/belgiums-e-invoicing-grace-period-ended-heres-the-developers-playbook-44lk"&gt;Belgium developer playbook&lt;/a&gt; (G1). For the multi-tenant architecture, read &lt;a href="https://dev.to/zerolooplabs/how-to-add-peppol-e-invoicing-to-your-saas-without-making-it-your-teams-problem-1jj7"&gt;how to add Peppol to your SaaS&lt;/a&gt; (G2).&lt;/li&gt;
&lt;li&gt;Building a &lt;em&gt;platform&lt;/em&gt; that sends for many customers? The &lt;a href="https://getpeppr.dev/docs/platform/" rel="noopener noreferrer"&gt;platform integration docs&lt;/a&gt; cover master API keys, per-tenant legal entities, and KYB by country.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;No call required, no demo to book — just an API key. The mandates are arriving on a published schedule, and the calmest time to get ready is now, on your own terms.&lt;/p&gt;

&lt;p&gt;— Zero Loop Labs&lt;/p&gt;

</description>
      <category>peppol</category>
      <category>saas</category>
      <category>typescript</category>
      <category>einvoicing</category>
    </item>
    <item>
      <title>How to add Peppol e-invoicing to your SaaS without making it your team's problem</title>
      <dc:creator>Zero Lopp Labs</dc:creator>
      <pubDate>Sun, 24 May 2026 20:26:06 +0000</pubDate>
      <link>https://dev.to/zerolooplabs/how-to-add-peppol-e-invoicing-to-your-saas-without-making-it-your-teams-problem-1jj7</link>
      <guid>https://dev.to/zerolooplabs/how-to-add-peppol-e-invoicing-to-your-saas-without-making-it-your-teams-problem-1jj7</guid>
      <description>&lt;p&gt;If your SaaS already does B2B billing for customers in Europe, sooner or later one of them will ask: &lt;em&gt;"Can you send my invoices through Peppol?"&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Belgium has required structured domestic B2B e-invoicing since 1 January 2026. Germany has required businesses to be able to receive e-invoices since 1 January 2025, with issuing obligations phased in from 2027 to 2028. France starts its B2B e-invoicing rollout from 1 September 2026 through approved platforms. The EU ViDA package brings digital reporting for intra-EU B2B transactions from 1 July 2030.&lt;/p&gt;

&lt;p&gt;The exact rails differ by country. Belgium is Peppol-first. Germany and France are not simply "Peppol only". But the direction is clear: structured e-invoicing is becoming part of the default B2B stack.&lt;/p&gt;

&lt;p&gt;Your team has two options. Build it yourself — UBL templates, country variants, Peppol Access Point integration, retry semantics, status webhooks, compliance drift, the works. Or treat Peppol as infrastructure you call out to, the way you treat your card processor or your transactional email.&lt;/p&gt;

&lt;p&gt;This is the second path: how to add Peppol to a SaaS product so that your devs stay focused on your product and Peppol becomes an API integration plus a webhook handler.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why "build it yourself" turns into a tax
&lt;/h2&gt;

&lt;p&gt;Peppol looks deceptively small. It's just an XML format, right?&lt;/p&gt;

&lt;p&gt;It isn't. The standard you usually need to emit is UBL 2.1 conforming to Peppol BIS Billing 3.0, with country-specific rules layered on top. The XML has hundreds of optional fields and a non-trivial validation tree. You have to:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Generate UBL from your invoice data shape. Your model is not UBL's model, so there is mapping work.&lt;/li&gt;
&lt;li&gt;Validate before sending, or the Access Point rejects the document.&lt;/li&gt;
&lt;li&gt;Look up the recipient's Peppol identifier in the Peppol Directory.&lt;/li&gt;
&lt;li&gt;Submit through a certified Peppol Access Point. You do not talk to recipients directly; you talk to an AP that talks to their AP.&lt;/li&gt;
&lt;li&gt;Handle async delivery. "Accepted by Access Point" is not the same as "delivered to recipient".&lt;/li&gt;
&lt;li&gt;Track failures. A recipient can reject a document later, and your tenant needs to see that state.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For a SaaS where invoicing is a feature and not the product, that is a tax. The framing I keep hearing from devs at billing, healthcare, and ERP SaaS companies is the same: Peppol is important, but it is not the core business.&lt;/p&gt;

&lt;h2&gt;
  
  
  First decision: who is the sender?
&lt;/h2&gt;

&lt;p&gt;Before writing any code, draw this line. There are two different SaaS shapes that people often mix together.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Shape A: your SaaS sends invoices as your own legal entity.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Example: you run a B2B SaaS and invoice your customers. You have one verified sender identity. This is the simple integration path: one API key, one onboarding flow, one webhook endpoint, many recipients.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Shape B: your SaaS sends invoices on behalf of your customers.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Example: a healthcare, ERP, or marketplace SaaS where each customer is its own legal entity and needs to send invoices under its own Peppol participant identifier. That is a platform/delegated-sender model. It needs KYB, authorization, sender selection, audit trails, and often country-specific verification.&lt;/p&gt;

&lt;p&gt;Do not fake Shape B by stuffing a tenant's Peppol ID into a random &lt;code&gt;from&lt;/code&gt; field unless your provider explicitly supports delegated sender selection. A good provider should expose a real sender model, usually something like a legal entity ID or sub-account ID, plus authorization gates.&lt;/p&gt;

&lt;p&gt;getpeppr's public API today is designed around the verified-account sender model. We are actively working through the delegated-sender pattern with platform customers, but this is not a "just pass &lt;code&gt;from.peppolId&lt;/code&gt; and you're done" feature. If that is your use case, talk to us before making customer promises.&lt;/p&gt;

&lt;h2&gt;
  
  
  What your platform owns vs. what you delegate
&lt;/h2&gt;

&lt;p&gt;Your platform should still own the product-specific parts:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Your tenant/account model&lt;/li&gt;
&lt;li&gt;Your invoice rows, line items, tax decisions, and UI&lt;/li&gt;
&lt;li&gt;Billing-side metering for how many Peppol sends happen&lt;/li&gt;
&lt;li&gt;The UX where a user sees "queued", "sent", "accepted", "refused", or "failed"&lt;/li&gt;
&lt;li&gt;For delegated-sender use cases: consent, KYB, and authorization UX for each sender&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;You delegate the Peppol-specific machinery:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;UBL generation from a sane JSON shape&lt;/li&gt;
&lt;li&gt;Validation against Peppol BIS 3.0 and relevant business rules&lt;/li&gt;
&lt;li&gt;Access Point transmission&lt;/li&gt;
&lt;li&gt;Peppol Directory lookups&lt;/li&gt;
&lt;li&gt;Status webhooks&lt;/li&gt;
&lt;li&gt;Compliance updates as mandates roll out&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The rest of this article uses &lt;a href="https://getpeppr.dev" rel="noopener noreferrer"&gt;getpeppr&lt;/a&gt; as the provider because that's what we ship, but the principle applies to any decent Peppol-as-a-service API: keep tenancy and product logic in your platform, push Peppol-specific work out.&lt;/p&gt;

&lt;h2&gt;
  
  
  The TypeScript path, end to end
&lt;/h2&gt;

&lt;p&gt;This is what a clean current getpeppr integration looks like for the verified-sender model.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Install and initialise
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm &lt;span class="nb"&gt;install&lt;/span&gt; @getpeppr/sdk
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Peppol&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@getpeppr/sdk&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;peppol&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Peppol&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;apiKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;GETPEPPR_API_KEY&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// sandbox or production key&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The sender identity is configured through getpeppr onboarding and tied to the account/API key. In production, that sender needs a verified Peppol identity before documents can be sent.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Send an invoice
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;sendInvoice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;invoice&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;YourInvoice&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;peppol&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;invoices&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;number&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;invoice&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;date&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;invoice&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;date&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;dueDate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;invoice&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;dueDate&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;currency&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;invoice&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;currency&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;EUR&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;buyerReference&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;invoice&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;buyerReference&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="nx"&gt;invoice&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;recipient&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;reference&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;to&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;invoice&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;recipient&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;legalName&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;peppolId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;invoice&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;recipient&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;peppolId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;street&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;invoice&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;recipient&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;street&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;city&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;invoice&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;recipient&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;city&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;postalCode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;invoice&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;recipient&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;postalCode&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;country&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;invoice&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;recipient&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;country&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;vatNumber&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;invoice&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;recipient&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;vatNumber&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="na"&gt;lines&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;invoice&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;lines&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;line&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;line&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;description&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;quantity&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;line&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;quantity&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;unitPrice&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;line&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;unitPrice&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;vatRate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;line&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;vatRate&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;})),&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The important boundary: your product maps your invoice model to a provider JSON shape. Your product does not generate UBL XML directly.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Receive status updates
&lt;/h3&gt;

&lt;p&gt;The Peppol pipeline is asynchronous. You do not want your UI to rely on a single success/failure boolean from &lt;code&gt;send()&lt;/code&gt;. You want a local invoice state and a webhook handler.&lt;/p&gt;

&lt;p&gt;Current getpeppr webhook events include &lt;code&gt;invoice.sent&lt;/code&gt;, &lt;code&gt;invoice.accepted&lt;/code&gt;, &lt;code&gt;invoice.refused&lt;/code&gt;, &lt;code&gt;invoice.error&lt;/code&gt;, &lt;code&gt;invoice.registered&lt;/code&gt;, &lt;code&gt;invoice.received&lt;/code&gt;, &lt;code&gt;invoice.paid&lt;/code&gt;, and &lt;code&gt;test.ping&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;express&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;express&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;webhooks&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@getpeppr/sdk&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/webhooks/getpeppr&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;express&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;*/*&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
  &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;webhooks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;constructEvent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;utf8&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;getpeppr-signature&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;GETPEPPR_WEBHOOK_SECRET&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="p"&gt;);&lt;/span&gt;

      &lt;span class="k"&gt;switch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="kd"&gt;type&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;invoice.sent&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;invoice.accepted&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
          &lt;span class="nf"&gt;markDeliveredInYourApp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;invoiceId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
          &lt;span class="k"&gt;break&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

        &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;invoice.refused&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;invoice.error&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
          &lt;span class="nf"&gt;flagForReview&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;invoiceId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
          &lt;span class="k"&gt;break&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

        &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;invoice.paid&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
          &lt;span class="nf"&gt;markPaid&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;invoiceId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
          &lt;span class="k"&gt;break&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;

      &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sendStatus&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sendStatus&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;400&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The signature header is &lt;code&gt;Getpeppr-Signature&lt;/code&gt;. Verify it on every request. Webhook URLs are public; they will be poked.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Validate offline before wiring the live API
&lt;/h3&gt;

&lt;p&gt;The &lt;code&gt;@getpeppr/cli&lt;/code&gt; package lets you generate, validate, and convert invoices locally.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx @getpeppr/cli init my-invoice.json
npx @getpeppr/cli validate my-invoice.json
npx @getpeppr/cli convert my-invoice.json &lt;span class="nt"&gt;--validate&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; invoice.xml
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is useful before you connect your production invoice rows to a live Peppol send. Start with a representative sample: one normal invoice, one VAT exemption, one credit note, and one cross-border example.&lt;/p&gt;

&lt;h2&gt;
  
  
  If your SaaS needs delegated sending
&lt;/h2&gt;

&lt;p&gt;If each of your customers needs to send under their own Peppol ID, ask your provider these questions before you commit to a rollout:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;How do I create and verify a sender legal entity?&lt;/li&gt;
&lt;li&gt;Is sender selection explicit in the API, or is it inferred from the API key?&lt;/li&gt;
&lt;li&gt;How do you handle consent and KYB for each sender?&lt;/li&gt;
&lt;li&gt;Can one platform account manage many sender legal entities?&lt;/li&gt;
&lt;li&gt;Are sender events and invoice events scoped clearly enough for my tenant model?&lt;/li&gt;
&lt;li&gt;What happens if a sender verification fails after the customer already started onboarding?&lt;/li&gt;
&lt;li&gt;Which countries are actually supported for KYB today, not just "on the Peppol network"?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This distinction matters. Sending an invoice to a Peppol participant is one capability. Letting a SaaS platform onboard hundreds of legal senders is another.&lt;/p&gt;

&lt;h2&gt;
  
  
  What works country by country
&lt;/h2&gt;

&lt;p&gt;Be explicit in customer conversations. "Peppol support" and "local e-invoicing compliance" are not always the same thing.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Region&lt;/th&gt;
&lt;th&gt;Practical status&lt;/th&gt;
&lt;th&gt;Notes&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Belgium&lt;/td&gt;
&lt;td&gt;Domestic B2B structured e-invoicing live since 1 Jan 2026&lt;/td&gt;
&lt;td&gt;Peppol is the main rail. Common scheme: &lt;code&gt;0208&lt;/code&gt; for Belgian CBE/KBO.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Germany&lt;/td&gt;
&lt;td&gt;B2B e-invoice receiving obligation live since 1 Jan 2025; issuing phases in 2027-2028&lt;/td&gt;
&lt;td&gt;Peppol can be part of the stack, but Germany is not Peppol-only. XRechnung/ZUGFeRD context matters.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;France&lt;/td&gt;
&lt;td&gt;Rollout starts 1 Sept 2026&lt;/td&gt;
&lt;td&gt;Requires approved platforms/PDP-style flows. Peppol alone is not the whole France compliance story.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Nordics&lt;/td&gt;
&lt;td&gt;Mature e-invoicing markets&lt;/td&gt;
&lt;td&gt;Strong Peppol usage, but sender onboarding and identifier schemes differ by country.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;EU cross-border&lt;/td&gt;
&lt;td&gt;ViDA digital reporting from 1 July 2030&lt;/td&gt;
&lt;td&gt;This is EU-wide digital reporting, not a blanket "Peppol mandate".&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;If you are scoping a customer migration, ask for two or three real invoice examples and the countries involved before estimating the work. The edge cases live in the invoice samples.&lt;/p&gt;

&lt;h2&gt;
  
  
  Migrating from in-house XML
&lt;/h2&gt;

&lt;p&gt;If you already have an in-house UBL builder and an Access Point arrangement that you want to retire, keep the migration boring:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Map your invoice model to the provider's JSON shape. Unit-test the mapper against representative invoices.&lt;/li&gt;
&lt;li&gt;Run offline validation and XML conversion in CI for a small fixture set.&lt;/li&gt;
&lt;li&gt;Send through sandbox first and compare the resulting XML with your current pipeline.&lt;/li&gt;
&lt;li&gt;Move read-only status display first, then flip sends for one low-volume customer.&lt;/li&gt;
&lt;li&gt;Keep the old path warm until you have enough successful production evidence to remove it safely.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;You are not trying to make Peppol exciting. You are trying to make it disappear into infrastructure.&lt;/p&gt;

&lt;h2&gt;
  
  
  Next steps
&lt;/h2&gt;

&lt;p&gt;If you are sizing this work for your SaaS:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Read the &lt;a href="https://dev.to/zerolooplabs/belgiums-e-invoicing-grace-period-ended-heres-the-developers-playbook-44lk"&gt;Belgium developer playbook&lt;/a&gt; for mandate context.&lt;/li&gt;
&lt;li&gt;Spin up a sandbox key at &lt;a href="https://getpeppr.dev" rel="noopener noreferrer"&gt;getpeppr.dev&lt;/a&gt; and push a fake invoice through.&lt;/li&gt;
&lt;li&gt;Validate one of your real invoices offline: &lt;code&gt;npx @getpeppr/cli validate your-invoice.json&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;If you are a platform that needs delegated sending for many customer legal entities, send us the countries and two or three real invoice examples. That is the fastest way to find the real integration shape.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;No sales theatre. Just the actual invoice shape, the sender model, and the countries involved.&lt;/p&gt;

&lt;p&gt;— Zero Loop Labs&lt;/p&gt;

</description>
      <category>api</category>
      <category>backend</category>
      <category>saas</category>
      <category>softwareengineering</category>
    </item>
    <item>
      <title>Belgium's e-invoicing grace period ended. Here's the developer's playbook.</title>
      <dc:creator>Zero Lopp Labs</dc:creator>
      <pubDate>Tue, 28 Apr 2026 12:05:58 +0000</pubDate>
      <link>https://dev.to/zerolooplabs/belgiums-e-invoicing-grace-period-ended-heres-the-developers-playbook-44lk</link>
      <guid>https://dev.to/zerolooplabs/belgiums-e-invoicing-grace-period-ended-heres-the-developers-playbook-44lk</guid>
      <description>&lt;h2&gt;
  
  
  The grace period is over
&lt;/h2&gt;

&lt;p&gt;On 27 March 2026, Belgium's FPS Finance went on Radio 2 to confirm what most CFOs in Brussels were quietly dreading: the three-month tolerance period for B2B e-invoicing wouldn't be extended. Since 1 April 2026, the new rules are fully enforced.&lt;/p&gt;

&lt;p&gt;If your company sends invoices between Belgian VAT-registered businesses and you're still emailing PDFs, you're now exposed to a graduated penalty regime: €1,500 for the first offence, €3,000 for the second, €5,000 for the third — within a three-month escalation window. The Royal Decree of 8 July 2025 codified this in article 13ter of Royal Decree No. 1, with the penalty schedule in Royal Decree No. 44.&lt;/p&gt;

&lt;p&gt;This article isn't a legal explainer. It's a developer's playbook: what the law actually requires from your stack, why this lands on your engineering team and not just your finance department, and a working TypeScript path to compliance.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the law actually requires
&lt;/h2&gt;

&lt;p&gt;Three things, in order of how often they trip up dev teams.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A structured electronic format.&lt;/strong&gt; PDFs are out. The mandate requires invoices in formats conforming to EN 16931 — primarily UBL 2.1 and CII 16B. Hybrid formats like Factur-X and ZUGFeRD are recognised for specific cases. In practice, for Belgium, you generate UBL 2.1 XML following the &lt;strong&gt;Peppol BIS Billing 3.0&lt;/strong&gt; specification.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The Peppol network as the default transmission channel.&lt;/strong&gt; You can't just email an XML file. Invoices flow through Peppol, a four-corner network of certified Access Points. Alternative channels (EDI, point-to-point) are allowed only by mutual agreement and only if they meet the same EN 16931 standards. The default Peppol track must always be available — your customer can require it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Coverage scope.&lt;/strong&gt; All B2B transactions between Belgian-established VAT-registered taxpayers fall under the mandate. That includes foreign companies with a Belgian fixed establishment, and VAT groups. Out of scope: B2C, cross-border (until ViDA in 2030), and entities exclusively performing Article 44-exempt activities. Self-billing benefits from an extended tolerance until end of June 2026.&lt;/p&gt;

&lt;p&gt;Existing record-keeping rules still apply — but now to structured XML. Seven years of archival, machine-readable.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why your engineering team owns this
&lt;/h2&gt;

&lt;p&gt;In most companies, e-invoicing was a finance problem. Print, sign, send. That's no longer true. The 2026 mandate is a systems integration problem first, and a finance problem second.&lt;/p&gt;

&lt;p&gt;You need to:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Generate UBL 2.1 XML that validates against EN 16931 schematron rules&lt;/li&gt;
&lt;li&gt;Look up your trading partners' Peppol identifiers&lt;/li&gt;
&lt;li&gt;Submit through a certified Access Point (you don't get to be one — that's a regulated entity with infrastructure requirements)&lt;/li&gt;
&lt;li&gt;Receive incoming invoices through the same network&lt;/li&gt;
&lt;li&gt;Handle status callbacks (accepted / rejected / errored) via webhooks&lt;/li&gt;
&lt;li&gt;Persist everything for seven years&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of that lives on a finance team's laptop. It lives in your codebase, with your auth, your retry logic, your monitoring, your audit trail.&lt;/p&gt;

&lt;p&gt;If your invoicing today is an export-to-PDF function in your accounting page, the gap between "what you have" and "what the law requires" is a backend project, not a vendor selection.&lt;/p&gt;

&lt;h2&gt;
  
  
  The 4-corner Peppol model in plain English
&lt;/h2&gt;

&lt;p&gt;Peppol's architecture has four parties. Most articles describe it as a network. It's easier to think of it as a delivery contract.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[ You ] → [ Your Access Point ] → [ Recipient's Access Point ] → [ Recipient ]
  C1            C2                          C3                       C4
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;C1 — You.&lt;/strong&gt; The supplier issuing the invoice. You generate UBL XML.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;C2 — Your Access Point.&lt;/strong&gt; A certified service provider that signs your message, validates it against Peppol BIS 3.0, and routes it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;C3 — Recipient's Access Point.&lt;/strong&gt; The buyer's certified provider, who validates and delivers.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;C4 — Recipient.&lt;/strong&gt; Your customer, whose accounting system receives the structured invoice.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;You only operate C1. C2 is a vendor choice — you connect to one Access Point, and that single connection gives you reach to every Peppol participant in the EU and beyond. The Access Point handles certificate management, dynamic discovery (looking up where to send a given recipient's invoice), and message-level acknowledgements.&lt;/p&gt;

&lt;p&gt;In Belgium, all in-scope businesses must be Peppol-capable, which in practice means having a contract with — or an integration into — a certified Access Point. Becoming an Access Point yourself is a multi-month project with annual fees, audits, and certificate authorities. Almost no SaaS does it directly.&lt;/p&gt;

&lt;p&gt;This is the same model that has run B2G invoicing in Belgium federally since 1 March 2024 (and regionally since 2017 for Flanders, 2020 for Brussels, 2022 for Wallonia), through the Mercurius platform. The 2026 mandate just extends the four corners to B2B. A five-corner variant arrives in 2028 for e-reporting — the fifth corner being FPS Finance itself.&lt;/p&gt;

&lt;h2&gt;
  
  
  A TypeScript path to compliance
&lt;/h2&gt;

&lt;p&gt;Here's where &lt;a href="https://getpeppr.dev" rel="noopener noreferrer"&gt;getpeppr&lt;/a&gt; fits. We're a TypeScript-first wrapper around a certified Access Point, with the Stripe-style developer experience: a JSON object goes in, a Peppol-compliant invoice goes out.&lt;/p&gt;

&lt;p&gt;Install the SDK:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm &lt;span class="nb"&gt;install&lt;/span&gt; @getpeppr/sdk
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Initialise the client. API keys are environment-prefixed — &lt;code&gt;sk_sandbox_...&lt;/code&gt; for test, &lt;code&gt;sk_live_...&lt;/code&gt; for production:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Peppol&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@getpeppr/sdk&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;peppol&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Peppol&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;apiKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;GETPEPPR_API_KEY&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Send your first invoice. The shape mirrors the EN 16931 model — supplier and customer identification, line items with VAT rates, totals — but stays JSON-shaped:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;peppol&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;invoices&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;number&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;INV-2026-001&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;to&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Acme Corp&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;peppolId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;0208:BE9876543210&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;street&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;123 Business Ave&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;city&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Brussels&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;postalCode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;1000&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;country&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;BE&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;lines&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Consulting services&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;quantity&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;unitPrice&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;150&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;vatRate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;21&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`Invoice sent! ID: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Under the hood, the SDK generates UBL 2.1 XML, validates it against Peppol BIS Billing 3.0 schematron rules, signs the envelope, and submits via a certified Access Point. You don't see the XML unless you ask for it.&lt;/p&gt;

&lt;p&gt;What's not shown above but matters in production:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Status updates via webhooks.&lt;/strong&gt; Peppol delivery is asynchronous. Your endpoint receives &lt;code&gt;invoice.accepted&lt;/code&gt;, &lt;code&gt;invoice.rejected&lt;/code&gt;, and &lt;code&gt;invoice.errored&lt;/code&gt; events with HMAC-SHA256 signature verification.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Directory lookup.&lt;/strong&gt; Before sending, you usually want to verify the recipient is on the network. &lt;code&gt;peppol.directory.lookup("0208:BE...")&lt;/code&gt; returns capability metadata — country, registered name, supported document types.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Credit notes and corrections.&lt;/strong&gt; All EN 16931 invoice types are supported (380 standard, 381 credit note, 383 debit, 386 prepayment, 389 self-billed). The same &lt;code&gt;.send()&lt;/code&gt; method handles them by setting &lt;code&gt;isCreditNote: true&lt;/code&gt; plus an &lt;code&gt;invoiceReference&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The sandbox is free and unlimited. No real Peppol traffic is generated, but you get the full API surface, validation errors, and webhook events. When you switch the key prefix from &lt;code&gt;sk_sandbox_&lt;/code&gt; to &lt;code&gt;sk_live_&lt;/code&gt;, the only change in your code is the &lt;code&gt;apiKey&lt;/code&gt; value. Live pricing: Starter €49/mo (100 docs), Pro €149/mo (800 docs), Business €399/mo (2,000 docs), with overage from €0.20/doc.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's not in scope (yet)
&lt;/h2&gt;

&lt;p&gt;The 2026 Belgium mandate is intentionally narrow. If you operate beyond it, you have more time — but you're already on the same regulatory glide path.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;B2C transactions&lt;/strong&gt; stay outside the mandate. You can keep emailing PDFs to consumers.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cross-border invoices&lt;/strong&gt; (intra-EU and extra-EU) are out of scope until the EU's "VAT in the Digital Age" (ViDA) Digital Reporting Requirements take effect on 1 July 2030.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Article 44-exempt entities&lt;/strong&gt; (mostly certain financial, medical, and educational services) don't have to issue structured e-invoices for their exempt activities.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Self-billing&lt;/strong&gt; has an extended tolerance until end of June 2026 — that's two months from now.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pre-existing invoicing fines&lt;/strong&gt; (late issuance, missing fields, incorrect VAT rounding) remain in force on top of the new technical-readiness penalty regime.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Looking ahead, 1 January 2028 brings near-real-time e-reporting to FPS Finance via Peppol's five-corner model, replacing the annual customer listing. Your 2026 work is the foundation for it — same Access Point, same XML, plus a tax-authority recipient.&lt;/p&gt;

&lt;p&gt;If you're building B2B SaaS for the EU, the practical horizon isn't "Belgium 2026". It's "ViDA 2030". Belgium is operationalising now what the rest of the EU is preparing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Next steps
&lt;/h2&gt;

&lt;p&gt;If you're a developer at a Belgian B2B SaaS, three things are worth doing this week:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Map your invoice flow.&lt;/strong&gt; Where does PDF generation live in your codebase? How do you identify customers' VAT numbers? How do you persist invoices today? You need this before scoping any integration.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Verify your customers' Peppol presence.&lt;/strong&gt; Many are already receiving invoices via Peppol from B2G suppliers since 2024. The Peppol Directory lookup is a one-line check.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Spin up a sandbox.&lt;/strong&gt; &lt;a href="https://getpeppr.dev" rel="noopener noreferrer"&gt;getpeppr.dev&lt;/a&gt; — free, unlimited, takes an afternoon to wire into a typical Node.js codebase. No call required, no demo to book. Just an API key.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The grace period is over. The first €1,500 fine could land on someone's desk this quarter.&lt;/p&gt;

</description>
      <category>peppol</category>
      <category>einvoicing</category>
      <category>typescript</category>
      <category>compliance</category>
    </item>
    <item>
      <title>SWIFT Is Killing MT940 — Here's How to Future-Proof Your Bank Statement Pipeline</title>
      <dc:creator>Zero Lopp Labs</dc:creator>
      <pubDate>Mon, 23 Mar 2026 17:06:11 +0000</pubDate>
      <link>https://dev.to/zerolooplabs/swift-is-killing-mt940-heres-how-to-future-proof-your-bank-statement-pipeline-267i</link>
      <guid>https://dev.to/zerolooplabs/swift-is-killing-mt940-heres-how-to-future-proof-your-bank-statement-pipeline-267i</guid>
      <description>&lt;p&gt;On November 22, 2025, SWIFT pulled the plug on legacy MT payment messages. Cross-border payments now run exclusively on ISO 20022's MX format.&lt;/p&gt;

&lt;p&gt;MT940 — the bank account statement format your reconciliation pipeline probably depends on — wasn't part of that first wave. It's a reporting message, not a payment message. But it's next. SWIFT has formally deprecated MT940, stopped maintaining it, and announced that disincentives for continued usage are coming.&lt;/p&gt;

&lt;p&gt;If your application parses bank statements, you need a migration plan. Here's what's actually happening, what the formats look like under the hood, and how to build a pipeline that handles both.&lt;/p&gt;




&lt;h2&gt;
  
  
  What MT940 Actually Looks Like
&lt;/h2&gt;

&lt;p&gt;Before we talk about replacing MT940, let's look at what we're replacing. Here's a real MT940 statement:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;:20:STMT2603230001
:25:NL91ABNA0417164300
:28C:15/1
:60F:C260322EUR1234,56
:61:2603220322D45,00NTRFNONREF//ACME-INV-2026-042
:86:999~00SEPA OVERBOEKING~20KENMERK: ACME-INV-2026-042
~21Acme Corp Ltd~22PAYMENT FOR SERVICES~23March 2026
~30DEUTDEDB~31DE89370400440532013000~32Acme Corp Ltd
~33Frankfurt
:62F:C260322EUR1189,56
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you've never parsed this, here's a field-by-field breakdown:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Tag&lt;/th&gt;
&lt;th&gt;Name&lt;/th&gt;
&lt;th&gt;What's in it&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;:20:&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Transaction Reference&lt;/td&gt;
&lt;td&gt;Message identifier (max 16 chars)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;:25:&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Account ID&lt;/td&gt;
&lt;td&gt;Your IBAN or account number (max 35 chars)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;:28C:&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Statement Number&lt;/td&gt;
&lt;td&gt;Sequence number (&lt;code&gt;15/1&lt;/code&gt; = statement 15, page 1)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;:60F:&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Opening Balance&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;C&lt;/code&gt; = Credit, &lt;code&gt;260322&lt;/code&gt; = 22 Mar 2026, &lt;code&gt;EUR&lt;/code&gt;, &lt;code&gt;1234,56&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;:61:&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Statement Line&lt;/td&gt;
&lt;td&gt;Date + D/C + amount + type code + reference (max 80 chars)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;:86:&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Information&lt;/td&gt;
&lt;td&gt;Free-text transaction details (up to 6 lines × 65 chars)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;:62F:&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Closing Balance&lt;/td&gt;
&lt;td&gt;Same format as opening balance&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The &lt;code&gt;:86:&lt;/code&gt; field is where the real pain lives. Banks cram debtor names, payment references, creditor IBANs, and remittance information into a single text block. There is no universal standard for how this data is structured.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;~&lt;/code&gt; delimiters you see above? That's one bank's convention (common in SEPA countries). Other banks use &lt;code&gt;/&lt;/code&gt; prefixes, &lt;code&gt;?&lt;/code&gt; codes, or just dump everything as plain text. You end up writing bank-specific regex patterns to extract structured data — and they break every time the bank changes their layout.&lt;/p&gt;

&lt;h2&gt;
  
  
  What CAMT.053 Looks Like Instead
&lt;/h2&gt;

&lt;p&gt;Here's the same transaction in CAMT.053:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;Ntry&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;Amt&lt;/span&gt; &lt;span class="na"&gt;Ccy=&lt;/span&gt;&lt;span class="s"&gt;"EUR"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;45.00&lt;span class="nt"&gt;&amp;lt;/Amt&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;CdtDbtInd&amp;gt;&lt;/span&gt;DBIT&lt;span class="nt"&gt;&amp;lt;/CdtDbtInd&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;BkgDt&amp;gt;&amp;lt;Dt&amp;gt;&lt;/span&gt;2026-03-22&lt;span class="nt"&gt;&amp;lt;/Dt&amp;gt;&amp;lt;/BkgDt&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;VlDt&amp;gt;&amp;lt;Dt&amp;gt;&lt;/span&gt;2026-03-22&lt;span class="nt"&gt;&amp;lt;/Dt&amp;gt;&amp;lt;/VlDt&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;AcctSvcrRef&amp;gt;&lt;/span&gt;ACME-INV-2026-042&lt;span class="nt"&gt;&amp;lt;/AcctSvcrRef&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;NtryDtls&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;TxDtls&amp;gt;&lt;/span&gt;
      &lt;span class="nt"&gt;&amp;lt;Refs&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;EndToEndId&amp;gt;&lt;/span&gt;ACME-INV-2026-042&lt;span class="nt"&gt;&amp;lt;/EndToEndId&amp;gt;&lt;/span&gt;
      &lt;span class="nt"&gt;&amp;lt;/Refs&amp;gt;&lt;/span&gt;
      &lt;span class="nt"&gt;&amp;lt;RltdPties&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;Cdtr&amp;gt;&lt;/span&gt;
          &lt;span class="nt"&gt;&amp;lt;Nm&amp;gt;&lt;/span&gt;Acme Corp Ltd&lt;span class="nt"&gt;&amp;lt;/Nm&amp;gt;&lt;/span&gt;
          &lt;span class="nt"&gt;&amp;lt;PstlAdr&amp;gt;&lt;/span&gt;
            &lt;span class="nt"&gt;&amp;lt;TwnNm&amp;gt;&lt;/span&gt;Frankfurt&lt;span class="nt"&gt;&amp;lt;/TwnNm&amp;gt;&lt;/span&gt;
          &lt;span class="nt"&gt;&amp;lt;/PstlAdr&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;/Cdtr&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;CdtrAcct&amp;gt;&lt;/span&gt;
          &lt;span class="nt"&gt;&amp;lt;Id&amp;gt;&amp;lt;IBAN&amp;gt;&lt;/span&gt;DE89370400440532013000&lt;span class="nt"&gt;&amp;lt;/IBAN&amp;gt;&amp;lt;/Id&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;/CdtrAcct&amp;gt;&lt;/span&gt;
      &lt;span class="nt"&gt;&amp;lt;/RltdPties&amp;gt;&lt;/span&gt;
      &lt;span class="nt"&gt;&amp;lt;RltdAgts&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;CdtrAgt&amp;gt;&lt;/span&gt;
          &lt;span class="nt"&gt;&amp;lt;FinInstnId&amp;gt;&lt;/span&gt;
            &lt;span class="nt"&gt;&amp;lt;BIC&amp;gt;&lt;/span&gt;DEUTDEDB&lt;span class="nt"&gt;&amp;lt;/BIC&amp;gt;&lt;/span&gt;
          &lt;span class="nt"&gt;&amp;lt;/FinInstnId&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;/CdtrAgt&amp;gt;&lt;/span&gt;
      &lt;span class="nt"&gt;&amp;lt;/RltdAgts&amp;gt;&lt;/span&gt;
      &lt;span class="nt"&gt;&amp;lt;RmtInf&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;Ustrd&amp;gt;&lt;/span&gt;PAYMENT FOR SERVICES March 2026&lt;span class="nt"&gt;&amp;lt;/Ustrd&amp;gt;&lt;/span&gt;
      &lt;span class="nt"&gt;&amp;lt;/RmtInf&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;/TxDtls&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;/NtryDtls&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/Ntry&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No regex. No bank-specific parsing rules. The creditor name is in &lt;code&gt;&amp;lt;Cdtr&amp;gt;&amp;lt;Nm&amp;gt;&lt;/code&gt;. The IBAN is in &lt;code&gt;&amp;lt;CdtrAcct&amp;gt;&amp;lt;Id&amp;gt;&amp;lt;IBAN&amp;gt;&lt;/code&gt;. The BIC is in &lt;code&gt;&amp;lt;CdtrAgt&amp;gt;&amp;lt;FinInstnId&amp;gt;&amp;lt;BIC&amp;gt;&lt;/code&gt;. The remittance info has its own dedicated &lt;code&gt;&amp;lt;RmtInf&amp;gt;&lt;/code&gt; element — with support for both unstructured text and structured sub-fields.&lt;/p&gt;

&lt;p&gt;The full CAMT.053 document wraps everything in a clear hierarchy:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Document
  └─ BkToCstmrStmt (Bank-to-Customer Statement)
      ├─ GrpHdr       → Message ID, creation timestamp
      └─ Stmt         → One per account
          ├─ Acct      → IBAN, BIC, account name
          ├─ Bal[]     → OPBD, CLBD, CLAV, FWAV (all timestamped)
          └─ Ntry[]    → One per transaction
              └─ NtryDtls
                  └─ TxDtls → Refs, parties, agents, remittance
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here's the technical comparison side-by-side:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Feature&lt;/th&gt;
&lt;th&gt;MT940&lt;/th&gt;
&lt;th&gt;CAMT.053&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Format&lt;/td&gt;
&lt;td&gt;Proprietary text (SWIFT FIN)&lt;/td&gt;
&lt;td&gt;XML (ISO 20022, XSD-validated)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Transaction details&lt;/td&gt;
&lt;td&gt;Packed into &lt;code&gt;:61:&lt;/code&gt; (80 chars) + &lt;code&gt;:86:&lt;/code&gt; (6×65 chars)&lt;/td&gt;
&lt;td&gt;Dedicated elements: &lt;code&gt;Refs&lt;/code&gt;, &lt;code&gt;RltdPties&lt;/code&gt;, &lt;code&gt;RltdAgts&lt;/code&gt;, &lt;code&gt;RmtInf&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Remittance info&lt;/td&gt;
&lt;td&gt;Crammed into &lt;code&gt;:86:&lt;/code&gt;, bank-specific delimiters&lt;/td&gt;
&lt;td&gt;Structured &lt;code&gt;&amp;lt;RmtInf&amp;gt;&lt;/code&gt; with &lt;code&gt;&amp;lt;Strd&amp;gt;&lt;/code&gt; sub-fields, unlimited length&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Balance types&lt;/td&gt;
&lt;td&gt;Opening (&lt;code&gt;:60F:&lt;/code&gt;) and Closing (&lt;code&gt;:62F:&lt;/code&gt;) only&lt;/td&gt;
&lt;td&gt;OPBD, CLBD, CLAV, PRCD, FWAV — all with timestamps&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Currency&lt;/td&gt;
&lt;td&gt;Single currency per statement&lt;/td&gt;
&lt;td&gt;Multi-currency with exchange rate details per transaction&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Character set&lt;/td&gt;
&lt;td&gt;SWIFT X charset (A-Z, 0-9, basic punctuation — no accents)&lt;/td&gt;
&lt;td&gt;Full UTF-8 / Unicode&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Date format&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;YYMMDD&lt;/code&gt; (ambiguous century)&lt;/td&gt;
&lt;td&gt;ISO 8601: &lt;code&gt;YYYY-MM-DD&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Validation&lt;/td&gt;
&lt;td&gt;Manual — hope the parser handles edge cases&lt;/td&gt;
&lt;td&gt;XSD schema validation built-in&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  The Migration Timeline — What's Actually Happening
&lt;/h2&gt;

&lt;p&gt;There's a lot of confusion around "SWIFT is killing MT940" because the migration is happening in waves. Here's the accurate picture:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What already happened (November 22, 2025):&lt;/strong&gt;&lt;br&gt;
SWIFT ended the MT/ISO 20022 coexistence period for &lt;strong&gt;payment messages&lt;/strong&gt;. MT103 (credit transfers) and MT202 (institution transfers) are formally retired. All cross-border payments now use ISO 20022 MX format exclusively via the FINplus service.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What's happening now (2026):&lt;/strong&gt;&lt;br&gt;
MT940 and other &lt;strong&gt;reporting messages&lt;/strong&gt; are deprecated and no longer maintained by SWIFT — but they haven't been withdrawn yet. J.P. Morgan's ISO 20022 FAQ puts it clearly: "Reporting and statement messages will not be immediately withdrawn from the FIN service. Although these message types are deprecated and no longer maintained by SWIFT, disincentives for their use will be introduced at a later date."&lt;/p&gt;

&lt;p&gt;Banks are transitioning on their own timelines. J.P. Morgan has been accepting CAMT.052/053/054 since Q4 2024. Bank of America completed its Fedwire ISO 20022 implementation in July 2025. 44% of banks are behind schedule on their November 2026 milestones.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What's coming (2027+):&lt;/strong&gt;&lt;br&gt;
SWIFT's roadmap targets full MT message retirement, including enquiry and investigation messages (MT199/MT299 → camt.110/camt.111) by November 2027. The reporting messages (MT940 → CAMT.053) will follow.&lt;/p&gt;

&lt;p&gt;The bottom line: MT940 still works today, but no one is maintaining or improving it. New features, new validation rules, new regulatory requirements — all of that goes into CAMT.053. Building on MT940 now is building on a dead-end.&lt;/p&gt;
&lt;h2&gt;
  
  
  Your Options as a Developer
&lt;/h2&gt;
&lt;h3&gt;
  
  
  Option 1: Build It Yourself
&lt;/h3&gt;

&lt;p&gt;There are open-source libraries for individual formats:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;mt940-rs&lt;/code&gt; (Rust) — MT940 parser&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;pycamt&lt;/code&gt; / &lt;code&gt;camt_parser&lt;/code&gt; (Python) — CAMT.053 parsers&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;ofxstatement&lt;/code&gt; (Python) — OFX converter&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;Cmxl&lt;/code&gt; (Ruby) — MT940 parser with extensible design&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The problem: you need &lt;strong&gt;multiple libraries&lt;/strong&gt; plus glue code to normalize outputs into a common model. You need to handle bank-specific &lt;code&gt;:86:&lt;/code&gt; field variations, character encoding edge cases (MT940's SWIFT X charset vs. UTF-8), date format conversions (&lt;code&gt;YYMMDD&lt;/code&gt; → &lt;code&gt;YYYY-MM-DD&lt;/code&gt;), and amount parsing (comma vs. dot decimal separators).&lt;/p&gt;

&lt;p&gt;For a single-format, single-bank integration, this works. For anything multi-bank or multi-format, you're signing up for ongoing maintenance as banks change their implementations.&lt;/p&gt;
&lt;h3&gt;
  
  
  Option 2: Enterprise Aggregators
&lt;/h3&gt;

&lt;p&gt;Plaid (12,000+ institutions), Finicity (Mastercard), and Wise offer bank data APIs. But they solve a different problem — they connect to &lt;strong&gt;live bank accounts&lt;/strong&gt; via OAuth. If you already have statement files (SFTP drops, email attachments, file exports), these platforms are overkill. They're also expensive and come with heavy onboarding.&lt;/p&gt;
&lt;h3&gt;
  
  
  Option 3: A Dedicated Conversion API
&lt;/h3&gt;

&lt;p&gt;This is the gap. You have files in format A, you need them in format B. No bank connections, no OAuth flows, no aggregation. Just conversion — stateless, fast, spec-compliant.&lt;/p&gt;

&lt;p&gt;That's what we're building.&lt;/p&gt;
&lt;h2&gt;
  
  
  Introducing FinConvert
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://finconvert.dev" rel="noopener noreferrer"&gt;FinConvert&lt;/a&gt; is a REST API that converts bank statement files between financial formats. One endpoint, any supported format in, any supported format out.&lt;/p&gt;

&lt;p&gt;The core architecture uses a &lt;strong&gt;Universal Transaction Model&lt;/strong&gt;: every input format is parsed and normalized into a single internal representation, then serialized to the requested output format. This means adding new formats requires N+M adapters, not N×M conversion paths.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Currently supported:&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Direction&lt;/th&gt;
&lt;th&gt;Formats&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Input&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;MT940, CAMT.053 (OFX, BAI2, QIF coming soon)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Output&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;CAMT.053, CSV, JSON, OFX (MT940 output coming soon)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Design principles:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Privacy-first&lt;/strong&gt; — No files are stored. Conversion is stateless. Your financial data is processed in memory and discarded.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Spec-compliant&lt;/strong&gt; — Output is validated against official SWIFT and ISO 20022 XSD schemas.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fast&lt;/strong&gt; — Sub-200ms average conversion time. Pure computation, no I/O bottleneck.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;London-hosted&lt;/strong&gt; — EU data residency for compliance-conscious teams.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;
  
  
  Show Me the Code
&lt;/h2&gt;

&lt;p&gt;Convert an MT940 file to structured JSON:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;curl:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-X&lt;/span&gt; POST https://api.finconvert.dev/v1/convert &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer fc_your_api_key"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-F&lt;/span&gt; &lt;span class="s2"&gt;"file=@statement.mt940"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-F&lt;/span&gt; &lt;span class="s2"&gt;"output_format=json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-o&lt;/span&gt; converted.json
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;TypeScript:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;convertStatement&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;File&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;ConvertedStatement&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;formData&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;FormData&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="nx"&gt;formData&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;file&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;formData&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;output_format&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;json&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;https://api.finconvert.dev/v1/convert&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;Authorization&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Bearer fc_your_api_key&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;formData&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`Conversion failed: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;What you get back&lt;/strong&gt; — structured, typed, no regex required:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"statement"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"account"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"NL91ABNA0417164300"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"currency"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"EUR"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"opening_balance"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;1234.56&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"closing_balance"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;1189.56&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"statement_number"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"15/1"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"date"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-03-22"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"transactions"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"date"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-03-22"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"amount"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;-45.00&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"currency"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"EUR"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"DEBIT"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"reference"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"ACME-INV-2026-042"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"description"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"PAYMENT FOR SERVICES March 2026"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"creditor"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Acme Corp Ltd"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"iban"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"DE89370400440532013000"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"bic"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"DEUTDEDB"&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"transaction_count"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"format_source"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"MT940"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"format_output"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"JSON"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That MT940 &lt;code&gt;:86:&lt;/code&gt; field with bank-specific &lt;code&gt;~&lt;/code&gt; delimiters? Parsed into clean, typed JSON. The creditor IBAN that was buried in &lt;code&gt;~31&lt;/code&gt;? Extracted into &lt;code&gt;creditor.iban&lt;/code&gt;. No bank-specific logic on your end.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pricing
&lt;/h2&gt;

&lt;p&gt;Usage-based — you pay for what you convert:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Plan&lt;/th&gt;
&lt;th&gt;Price&lt;/th&gt;
&lt;th&gt;Conversions/month&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Free&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;$0&lt;/td&gt;
&lt;td&gt;100&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Pro&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;$49/mo&lt;/td&gt;
&lt;td&gt;5,000&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Business&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;$149/mo&lt;/td&gt;
&lt;td&gt;50,000&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Enterprise&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Custom&lt;/td&gt;
&lt;td&gt;Custom&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The free tier is enough for testing and low-volume integrations. No credit card required.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's Next
&lt;/h2&gt;

&lt;p&gt;FinConvert is currently in early access. We're onboarding developers from the waitlist and expanding format support based on demand.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;On the roadmap:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;OFX, BAI2, and QIF input support&lt;/li&gt;
&lt;li&gt;MT940 output (for systems that still require it during the transition)&lt;/li&gt;
&lt;li&gt;Auto-format detection — upload any file, we figure out what it is&lt;/li&gt;
&lt;li&gt;Batch conversion endpoint for bulk processing&lt;/li&gt;
&lt;li&gt;Bank-specific &lt;code&gt;:86:&lt;/code&gt; field profiles for higher extraction accuracy&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you're building anything that touches bank statement data — accounting software, reconciliation tools, fintech integrations, ERP connectors — the MT940 deprecation is real. It still works today, but the writing is on the wall: SWIFT has stopped maintaining it, banks are migrating, and every new feature goes into CAMT.053.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://finconvert.dev" rel="noopener noreferrer"&gt;Join the waitlist at finconvert.dev&lt;/a&gt;&lt;/strong&gt; to get early access and lock in free-tier usage during beta.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Built by &lt;a href="https://zerolooplabs.dev" rel="noopener noreferrer"&gt;Zero Loop Labs&lt;/a&gt; — the same team behind &lt;a href="https://sealtrail.dev" rel="noopener noreferrer"&gt;SealTrail&lt;/a&gt; (tamper-proof audit trails) and &lt;a href="https://pdfforge.dev" rel="noopener noreferrer"&gt;PDFForge&lt;/a&gt; (document generation API).&lt;/em&gt;&lt;/p&gt;

</description>
      <category>fintech</category>
      <category>api</category>
      <category>banking</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Why your INSERT INTO audit_log proves nothing</title>
      <dc:creator>Zero Lopp Labs</dc:creator>
      <pubDate>Fri, 13 Mar 2026 11:11:08 +0000</pubDate>
      <link>https://dev.to/zerolooplabs/why-your-insert-into-auditlog-proves-nothing-1ohf</link>
      <guid>https://dev.to/zerolooplabs/why-your-insert-into-auditlog-proves-nothing-1ohf</guid>
      <description>&lt;p&gt;You've seen this pattern. You might have written it yourself. I know I did.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;audit_log&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;id&lt;/span&gt;         &lt;span class="nb"&gt;SERIAL&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;user_id&lt;/span&gt;    &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;action&lt;/span&gt;     &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;resource&lt;/span&gt;   &lt;span class="nb"&gt;TEXT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;metadata&lt;/span&gt;   &lt;span class="n"&gt;JSONB&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="n"&gt;TIMESTAMPTZ&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every time something important happens — a user signs a contract, an admin changes permissions, a payment goes through — you &lt;code&gt;INSERT INTO audit_log&lt;/code&gt;. Job done. You have an audit trail.&lt;/p&gt;

&lt;p&gt;Except you don't.&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem nobody talks about
&lt;/h2&gt;

&lt;p&gt;That &lt;code&gt;audit_log&lt;/code&gt; table sits in the same database as everything else. Anyone with write access can do this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- Oops. Never happened.&lt;/span&gt;
&lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="n"&gt;audit_log&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;action&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'invoice.viewed'&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;action&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'invoice.deleted'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;-- Or just make it disappear entirely&lt;/span&gt;
&lt;span class="k"&gt;DELETE&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;audit_log&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'admin_42'&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;action&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'permission.escalated'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No trace. No alert. No way to know it ever happened.&lt;/p&gt;

&lt;p&gt;This isn't a theoretical attack. It's the &lt;strong&gt;default state&lt;/strong&gt; of every audit log built on a regular database table. Your DBA can do it. A compromised admin account can do it. A SQL injection exploit can do it. And nobody will ever know.&lt;/p&gt;

&lt;h2&gt;
  
  
  "But we have backups"
&lt;/h2&gt;

&lt;p&gt;Sure. But backups tell you what the data &lt;em&gt;was&lt;/em&gt;, not whether it was &lt;em&gt;tampered with&lt;/em&gt; between now and then. If someone edits a row on Monday and you check the backup on Friday, you're comparing against a backup that might already include the edit.&lt;/p&gt;

&lt;h2&gt;
  
  
  "We use triggers and permissions"
&lt;/h2&gt;

&lt;p&gt;Better. But triggers can be disabled by superusers. &lt;code&gt;ALTER TABLE ... DISABLE TRIGGER ALL&lt;/code&gt; is one command. And PostgreSQL &lt;code&gt;SECURITY DEFINER&lt;/code&gt; functions can bypass row-level security.&lt;/p&gt;

&lt;p&gt;The fundamental issue isn't about access control. It's about &lt;strong&gt;provability&lt;/strong&gt;. Can you &lt;em&gt;prove&lt;/em&gt; — cryptographically, not just organizationally — that a log entry hasn't been modified since it was written?&lt;/p&gt;

&lt;p&gt;With a regular INSERT? No. You can't.&lt;/p&gt;

&lt;h2&gt;
  
  
  What auditors actually want
&lt;/h2&gt;

&lt;p&gt;When a compliance auditor asks "show me the audit trail for this transaction," they're not asking for a database dump. They want to know:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Completeness&lt;/strong&gt; — Are all events present? Can you prove nothing was deleted?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Integrity&lt;/strong&gt; — Can you prove nothing was modified after the fact?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ordering&lt;/strong&gt; — Can you prove the sequence of events hasn't been rearranged?&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;A plain &lt;code&gt;audit_log&lt;/code&gt; table answers none of these questions with certainty.&lt;/p&gt;

&lt;p&gt;SOC 2, HIPAA, PCI-DSS Requirement 10, GDPR Article 32 — they all expect or require audit records with demonstrable integrity. "We have a database table" doesn't cut it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Hash chains: the fix
&lt;/h2&gt;

&lt;p&gt;The solution has existed since the 1990s (Haber &amp;amp; Stornetta, the paper that later inspired Bitcoin's blockchain). The idea is simple:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Each log entry includes a cryptographic hash of itself &lt;em&gt;and&lt;/em&gt; the previous entry.&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Event 1: hash("payload_1" + GENESIS_HASH)        → hash_1
Event 2: hash("payload_2" + hash_1)               → hash_2
Event 3: hash("payload_3" + hash_2)               → hash_3
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now if someone modifies Event 2, &lt;code&gt;hash_2&lt;/code&gt; changes. But Event 3 was computed using the &lt;em&gt;original&lt;/em&gt; &lt;code&gt;hash_2&lt;/code&gt;. The chain breaks. The tampering is immediately detectable.&lt;/p&gt;

&lt;p&gt;You can't silently edit a single row without invalidating every subsequent hash in the chain.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this looks like in practice
&lt;/h2&gt;

&lt;p&gt;Here's a concrete example. You're logging invoice events in a SaaS app:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;SealTrail&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;sealtrail&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;st&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;SealTrail&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;apiKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;SEALTRAIL_API_KEY&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="c1"&gt;// Log an event — hash chain is built automatically&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;st&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;events&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;actor&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;finance_user_42&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;action&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;invoice.approved&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;resource&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;inv_12345&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;context&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;5000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;currency&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;USD&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;hash&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;            &lt;span class="c1"&gt;// "a1b2c3d4e5f6..."&lt;/span&gt;
&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;chain&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;position&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;  &lt;span class="c1"&gt;// 42&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each event gets a SHA-256 hash computed from the canonicalized event payload (actor, action, resource, context), the previous event's hash, and the event timestamp.&lt;/p&gt;

&lt;p&gt;The hash and chain position are returned with every event. They're not just metadata — they're &lt;strong&gt;cryptographic proof&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Verification: the part that matters
&lt;/h2&gt;

&lt;p&gt;Logging isn't enough. The whole point is that you — or an auditor, or an automated compliance check — can &lt;em&gt;verify&lt;/em&gt; that nothing was touched:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;st&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;events&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;verify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;evt_abc123&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;valid&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Event integrity verified&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Chain intact:&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;chainIntact&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;TAMPER DETECTED&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Expected:&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;computedHash&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Got:&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;eventHash&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Verification recomputes the hash from scratch. If the stored hash doesn't match the computed one, someone changed the data. If &lt;code&gt;chainIntact&lt;/code&gt; is false, someone broke the link between events — meaning an event was inserted, deleted, or reordered.&lt;/p&gt;

&lt;p&gt;This isn't trust. It's math.&lt;/p&gt;

&lt;h2&gt;
  
  
  The DIY trap
&lt;/h2&gt;

&lt;p&gt;At this point you might be thinking: "I can build this myself. SHA-256, a &lt;code&gt;previous_hash&lt;/code&gt; column, done."&lt;/p&gt;

&lt;p&gt;I thought the same thing. Here's what I ran into:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Concurrent writes.&lt;/strong&gt; Two events arrive at the same millisecond. Both read the same &lt;code&gt;previous_hash&lt;/code&gt;. Both compute their hash against it. You now have a forked chain. Fix: atomic transactions with unique constraints on chain position. Retry on conflict.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Canonical serialization.&lt;/strong&gt; &lt;code&gt;JSON.stringify({ a: 1, b: 2 })&lt;/code&gt; and &lt;code&gt;JSON.stringify({ b: 2, a: 1 })&lt;/code&gt; produce different strings, therefore different hashes. You need deterministic JSON canonicalization, or your verification breaks when key order varies.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Chain partitioning.&lt;/strong&gt; One global chain becomes a bottleneck. You need per-resource or per-tenant chains. But then you need to manage chain creation, head tracking, and cross-chain references.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pagination at scale.&lt;/strong&gt; Offset-based pagination breaks when events are being inserted. You need cursor-based pagination with stable sort keys.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Retry logic.&lt;/strong&gt; Rate limits, network failures, concurrent conflicts — your client needs exponential backoff with jitter, and it needs to handle 409 Conflict responses specifically for chain contention.&lt;/p&gt;

&lt;p&gt;It's solvable. But it's a week of work to build, and a lifetime of maintenance to keep secure. And if you get the canonicalization wrong, your entire chain is silently invalid.&lt;/p&gt;

&lt;h2&gt;
  
  
  Stop building underpants
&lt;/h2&gt;

&lt;p&gt;There's a term in engineering for components that every team rebuilds from scratch despite it not being their core business: &lt;strong&gt;underpants&lt;/strong&gt; (as in, everyone needs them, nobody should be hand-stitching them).&lt;/p&gt;

&lt;p&gt;Your audit trail is underpants. It needs to work. It needs to be tamper-proof. But it's not what your users are paying you for.&lt;/p&gt;

&lt;p&gt;I built &lt;a href="https://sealtrail.dev" rel="noopener noreferrer"&gt;SealTrail&lt;/a&gt; because I needed this for my own SaaS products and got tired of reimplementing it. It's an API — &lt;code&gt;npm install sealtrail&lt;/code&gt;, log events, verify integrity. Hash chains, cursor pagination, isolated chains per resource or tenant, and cryptographic verification are handled for you.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Your entire audit trail integration&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;SealTrail&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;sealtrail&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;st&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;SealTrail&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;apiKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;SEALTRAIL_API_KEY&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="c1"&gt;// Log (events are chained per chain — default or custom)&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;st&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;events&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;actor&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;action&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;document.signed&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;resource&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;documentId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;context&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;ip&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ip&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;chain&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;documents&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;  &lt;span class="c1"&gt;// optional — isolates chains per resource type&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="c1"&gt;// Verify&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;proof&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;st&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;events&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;verify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;eventId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="c1"&gt;// proof.valid === true | false&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three lines to log. One line to verify. Hash chain built automatically.&lt;/p&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;&lt;code&gt;INSERT INTO audit_log&lt;/code&gt;&lt;/th&gt;
&lt;th&gt;Hash chain audit trail&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Can admin silently edit?&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;No — hash changes, chain breaks&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Can rows be deleted?&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Detectable — position gaps&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Can order be changed?&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;No — each hash includes previous&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cryptographic proof?&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;SHA-256 chain verification&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Compliance-ready?&lt;/td&gt;
&lt;td&gt;Questionable&lt;/td&gt;
&lt;td&gt;Verifiable record&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;If your audit log is a regular database table, it proves nothing. It's a convenience log, not an audit trail.&lt;/p&gt;

&lt;p&gt;If that's fine for your use case, carry on. But if you're handling financial data, healthcare records, legal documents, or anything where "we can prove this wasn't tampered with" matters — you need hash chains.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I'm Sylvain, solo founder at &lt;a href="https://zerolooplabs.dev" rel="noopener noreferrer"&gt;Zero Loop Labs&lt;/a&gt;. I build developer tools. SealTrail is my latest — a tamper-proof audit trail API for developers. &lt;a href="https://sealtrail.dev" rel="noopener noreferrer"&gt;Try it free&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>security</category>
      <category>typescript</category>
      <category>backend</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
