DEV Community

Come
Come

Posted on

The e-invoicing mandate hits France on Sept 1. Here's the one call your agent needs before paying any French supplier

On 1 September 2026, receiving an electronic invoice becomes mandatory for every
VAT-liable company in France. Issuance is phased in behind it: large and mid-size
companies from that same date, SMEs and micro-enterprises from 1 September 2027
(art. 91, Loi de finances pour 2024). Belgium's B2B mandate has been live since
1 January 2026.

If you are building an agent that pays suppliers, the mandate is not really your problem.
What the mandate does is make a pre-existing gap impossible to ignore: your agent is
about to send a structured invoice to, or move money towards, a legal entity it has never
checked.

Three things your agent does not know

Take a French supplier record coming out of your ERP, your CRM, or a PDF an LLM just
parsed. It has a SIREN, a VAT number and an IBAN. Three questions are unanswered:

  1. Is the company still active? A ceased company keeps a perfectly well-formed, checksum-valid SIREN. Nothing in the string tells you it stopped trading in March.
  2. Is the intra-EU VAT number valid today? Not last quarter, when someone exported the supplier master file. Today. VIES is the only authority on that, and its answer moves.
  3. Does that IBAN belong to a real, identified bank? mod-97 tells you the string is well-formed. It does not tell you the bank code maps to an institution that exists.

Each question has a public answer. Getting all three usually means three integrations,
two accounts and a SOAP client. That is the actual friction.

One call

GET /v1/facturation/dossier?siren=552032534&iban=FR1420041010050500013M02606
Enter fullscreen mode Exit fullscreen mode

$0.03. It returns the recipient's legal identity and obligation dates, the computed
intra-EU VAT number checked live against VIES, an IBAN check against official bank
registries, and a deterministic verdict:

{
  "verdict": {
    "pret_a_facturer": true,
    "raisons": []
  }
}
Enter fullscreen mode Exit fullscreen mode

pret_a_facturer is a boolean, and raisons is a closed list. Blocking reasons:
entreprise_cessee, tva_invalide_vies, tva_non_calculable, iban_invalide.
Informational ones: diffusion_partielle, tva_non_verifiable, iban_non_fourni,
banque_non_identifiee, preparation_degradee. An agent branches on code. It never
parses prose, and it never has to ask a model what a sentence meant.

One design decision worth stating because it cuts the other way: a VIES outage produces
tva_non_verifiable (informational), never a false tva_invalide_vies.
An API that
turns "the tax authority did not answer" into "this VAT number is invalid" will eventually
make you refuse to pay a supplier who did nothing wrong.

And since 16 August there is a second angle, for the invoice you received:
GET /v1/facture/verifier?siren=&tva=&iban= ($0.02) cross-checks the identifiers printed
on an invoice against each other — the French VAT key is deterministic, so a VAT number
that is perfectly VIES-valid but belongs to another company than the SIREN on the
invoice is caught. A plain VIES check never sees that case.

The payment flow, in full

There is no account and no API key. The first call returns 402 with a signable quote:

curl -i "https://api.sirenic.eu/v1/facturation/dossier?siren=552032534"
# HTTP/1.1 402 Payment Required
# PAYMENT-REQUIRED: <base64 quote — amount, asset, network, expiry>
Enter fullscreen mode Exit fullscreen mode

Your agent signs that quote (USDC or EURC on Base, same numeric amount) and replays the
request with a PAYMENT-SIGNATURE header. Any x402 client does both steps for you:

import { privateKeyToAccount } from "viem/accounts";
import { wrapFetchWithPayment } from "@x402/fetch";
import { x402Client } from "@x402/core/client";
import { registerExactEvmScheme } from "@x402/evm/exact/client";

const account = privateKeyToAccount(process.env.WALLET_KEY as `0x${string}`);
const client = new x402Client();
registerExactEvmScheme(client, { signer: account });
const payingFetch = wrapFetchWithPayment(fetch, client);

const url =
  "https://api.sirenic.eu/v1/facturation/dossier" +
  "?siren=552032534&iban=FR1420041010050500013M02606";

const res = await payingFetch(url, { signal: AbortSignal.timeout(60_000) });
const body = Buffer.from(await res.arrayBuffer()); // exact bytes — see below
Enter fullscreen mode Exit fullscreen mode

Errors are never charged: the paywall quotes before input validation, so a 400 or a
404 costs nothing. With on-chain micro-payments you cannot refund, so "we don't charge for
errors" has to be structural rather than a promise.

Verify the signature before you read the body

Every paid 2xx response carries a detached Ed25519 signature in its headers. The signed
message is rebuilt from the digest of the exact bytes you received:

sirenic-v1:{kid}:{timestamp}:{base64(sha256(body))}
Enter fullscreen mode Exit fullscreen mode
import { createHash, createPublicKey, verify } from "node:crypto";

function verifySignature(body: Buffer, sig: Signature, publicKeyBase64: string): boolean {
  const digest = createHash("sha256").update(body).digest("base64");
  const message = Buffer.from(`sirenic-v1:${sig.kid}:${sig.timestamp}:${digest}`, "utf8");
  const publicKey = createPublicKey({
    key: Buffer.from(publicKeyBase64, "base64"),
    format: "der",
    type: "spki",
  });
  return verify(null, message, publicKey, Buffer.from(sig.signature, "base64"));
}

const sig = {
  kid: res.headers.get("x-sirenic-key-id") ?? "",
  timestamp: res.headers.get("x-sirenic-timestamp") ?? "",
  signature: res.headers.get("x-sirenic-signature") ?? "",
};
const key = await (await fetch("https://api.sirenic.eu/.well-known/sirenic-signing-key")).json();
if (!verifySignature(body, sig, key.public_key)) throw new Error("invalid signature");

const file = JSON.parse(body.toString()); // only now
Enter fullscreen mode Exit fullscreen mode

The order matters, and it is the one mistake I see most often: JSON.parse then
JSON.stringify reorders or reformats something, the digest changes, and the signature
fails for a payload that was never tampered with. Keep the bytes. Verify. Then parse.

Why the signed response is an audit exhibit

The interesting part is not the signature on its own — it is what sits inside the
signed bytes. Each response carries a provenance array: for every block of data, the
official register it came from, its licence, and its as_of date with the exact meaning
of that date.

destinataire.destinataire   INSEE Sirene                    stock      2026-07-01 (publication_officielle)
destinataire.tva            Calcul Sirenic                  calcul    no date (computed block)
tva_vies                    VIES (European Commission)      temps_reel 2026-08-11T09:14:22Z (consultation)
banque.banque               REGAFI (ACPR / Banque de France) temps_reel 2026-08-11T09:14:23Z (consultation)
banque.banque.bic           GLEIF BIC-to-LEI mapping        temps_reel 2026-08-11T09:14:23Z (consultation)
verdict                     Sirenic rules                   calcul    no date (computed block)
Enter fullscreen mode Exit fullscreen mode

(abridged — the exact blocks depend on what was actually consulted; no IBAN means no bank
register is declared, because we do not claim to have read a register we never opened.)

Three properties fall out of this, and they are the reason the whole thing exists:

  • A computed block never carries a date. mode: "calcul" means Sirenic derived it from the other blocks. Stamping "now" on a verdict would suggest a freshness that belongs to its inputs, not to it.
  • A real-time block carries the date of the consultation that actually happened — the timestamp of the call we actually made to VIES, stored alongside the cached answer, not the moment you opened the JSON. A cached answer from an hour ago was not consulted "now".
  • An unavailable source says so. When VIES is down, the tva_vies entry exists with precision_as_of: "indisponible" and no date. Absence of an answer is recorded as absence of an answer.

So: the payload is authenticated, and it states where every field came from and when. That
is what an auditor asks for, months later, about a payment you made in August.

The archive that re-verifies without us

If provenance lives inside the signed body, you can store four files and throw the API
away:

dossier-facturation-552032534-2026-08-11T09-14-22Z/
├── reponse.json        the exact bytes — never reformat them
├── signature.txt       kid, timestamp, Ed25519 signature (base64)
├── cle-publique.json   the public key as published, at the time
└── LISEZ-MOI.md        the commands to re-check, offline
Enter fullscreen mode Exit fullscreen mode

Re-checking, months later, with Node and nothing else — no network, no dependency, no
Sirenic:

node -e '
const fs=require("fs"), c=require("crypto");
const corps=fs.readFileSync("reponse.json");
const s={}; for (const l of fs.readFileSync("signature.txt","utf8").trim().split("\n")) s[l.slice(0,l.indexOf("="))]=l.slice(l.indexOf("=")+1);
const cle=JSON.parse(fs.readFileSync("cle-publique.json","utf8")).public_key;
const message="sirenic-v1:"+s.kid+":"+s.timestamp+":"+c.createHash("sha256").update(corps).digest("base64");
const pub=c.createPublicKey({key:Buffer.from(cle,"base64"),format:"der",type:"spki"});
console.log(c.verify(null,Buffer.from(message),pub,Buffer.from(s.signature,"base64")) ? "VALID" : "INVALID");
'
Enter fullscreen mode Exit fullscreen mode

An openssl pkeyutl -verify variant is in the archive's README for machines without Node.
Change one byte of reponse.json and both fail.

The runnable version of everything above — pay, verify, read, write the archive, then
re-verify from the written files only — is one file:
examples/verify-invoice-file.ts.
An archive that needs the API to be re-checked is not an archive, so the script fails hard
if the round trip through disk does not verify.

Belgium and Poland, because your suppliers are not all French

GET /v1/eu/facturation/dossier?pays=&id=&iban= — same $0.03, same verdict shape.

Belgium: registry identity, VAT against VIES, and Peppol reachability. The mandate has
been live since 1 January 2026.

Poland is the one worth knowing about even if you never invoice a Polish company, because
it is the case where verifying a bank account has a direct tax consequence. The
wykaz podatników VAT — the official White List — records which bank accounts a taxpayer
has declared. Paying more than 15,000 PLN into an account that is not on that list
costs the buyer the VAT deduction and creates joint liability for the supplier's unpaid
VAT (art. 117ba, Ordynacja podatkowa). So the response tells you whether the IBAN you are
about to pay is DECLARED by that taxpayer, and the provenance entry carries the date the
White List was actually queried — which is the date that makes the check provable.

Two limits, stated in every response

This is not a payee verification. The account holder's name is never checked. Every
IBAN response says so in the payload itself (verification_titulaire: non_disponible). If
an invoice tells you to pay "ACME SARL" and the IBAN belongs to an identified French bank,
we confirm the second half of that sentence and say nothing about the first.

Sirenic is not an accredited platform (PDP/PA). It has no access to the restricted
central directory, and it never issues, transmits, converts or routes invoices. It answers
questions about a company before you invoice or pay it. Choosing your PDP is a separate
decision, and this is not it.

The verdict is deterministic decision support with closed-list reasons traced to their
source. It is not tax-compliance advice.

Free things to poke at first

  • GET /v1/reperer?texte=… — detects SIREN / SIRET / VAT / LEI in raw text (checksum validated) and returns the recommended call with its price. Free.
  • GET /v1/provenance/registres — the join table for every source_code you will see in a provenance array: register, authority, country, mode, licence, URL. Free.
  • https://api.sirenic.eu/llms.txt — the whole catalogue written for a model rather than for a human.
  • MCP, if your agent speaks it: https://api.sirenic.eu/mcp (streamable HTTP). Every tool takes an optional x_payment argument; call without it and you get the signable quote back, so the discovery flow costs nothing.

The six routes in scope here: /v1/facturation/dossier ($0.03),
/v1/eu/facturation/dossier ($0.03), /v1/facture/verifier ($0.02),
/v1/entreprise/{siren}/facturation-prep ($0.02), /v1/iban/verifier/{iban} ($0.005),
/v1/tva/verifier/{numero} ($0.003). All except facture/verifier carry the
provenance array inside the signed body.

Happy to answer anything about the mandate timeline, the closed-list verdict, or the x402
flow. If you find a case where the verdict is wrong — a company we call active that isn't,
an IBAN we resolve to the wrong bank — that is the feedback I want most.

Top comments (0)