The French e-invoicing integration playbook
Why this playbook
From 1 September 2026 every French VAT-liable business must be able to receive e-invoices — and large & mid-sized companies must also send them. SMEs follow on 1 September 2027. There is no free state exchange platform anymore (the PPF was reduced to the directory + tax concentrator in October 2024), so every invoice flows through a Plateforme Agréée. Flowie is PA n° 0040: this page is the complete, code-first path from zero to a compliant integration — sending, receiving, and the full 200–213 lifecycle.
Seven steps, each with copy-paste code. A focused team ships this in days, not months.
Architecture in 60 seconds
Your ERP / app
│ REST + webhooks (one API for every country)
▼
Flowie (PA n° 0040) ── annuaire lookup ──► recipient's PA ──► buyer
│
└── e-reporting & mandatory statuses ──► PPF concentrator ──► DGFiP (≤ 24 h)
- You never talk to the PPF. Flowie routes invoices PA-to-PA (resolved via the annuaire) and reports the mandatory data and statuses to the concentrator within the 24-hour window.
- One integration, both directions. The same document API sends and receives; direction is just a field.
- Statuses are first-class. Lifecycle statuses travel as CDAR messages between platforms; you emit and observe them through the lifecycle API and webhooks — never by parsing XML.
Onboard your companies & the annuaire
Create each French legal entity with its SIREN/SIRET, then register it. Registration provisions the receiving flow and (for entities you manage) the annuaire entry that tells every other PA to route invoices for this SIREN to Flowie.
curl -X POST https://back.flowie.ink/exchange/v1/companies \
-H "Authorization: Bearer $KEY" \
-d '{
"name": "Maison Lumière SAS",
"country": "FR",
"vatNumber": "FR26921376265",
"additionalIdentifiers": { "siret": "92137626500017" }
}'
curl -X POST https://back.flowie.ink/exchange/v1/companies/comp_01H…/register \
-H "Authorization: Bearer $KEY"
Before invoicing a French counterparty, check how they’re reachable (their PA, their identifiers) with a directory search — a bare SIREN/SIRET is routed to an exact lookup:
curl "https://back.flowie.ink/exchange/v1/directory/search?q=552100554" \
-H "Authorization: Bearer $KEY"
When a counterparty exposes several reception points (lignes annuaire) on the same SIRET, address the exact one by passing the composed identifier {siren}_{siret}[_{suffix}] (e.g. 75297877500027_001) as the send to — Flowie resolves the participant from the SIRET and carries the suffixeAdressage through as routing metadata. See Reception-point addressing.
Receive — you have to be ready first
Reception is the first legal deadline (all companies, September 2026) and the easy half. Register a webhook endpoint, and every inbound invoice — Factur-X, UBL or CII, from any PA — lands as a normalized document:
curl -X POST https://back.flowie.ink/exchange/v1/webhooks \
-H "Authorization: Bearer $KEY" \
-d '{
"url": "https://erp.example.fr/hooks/flowie",
"events": ["document.received", "lifecycle.updated",
"compliance.reported", "compliance.reported.failed",
"document.failed"]
}'
On document.received, pull whichever view your system prefers — the structured JSON, the original XML, or the rendered PDF:
curl https://back.flowie.ink/exchange/v1/documents/doc_inb1/structured \
-H "Authorization: Bearer $KEY" # flat JSON for your ERP
# also available: …/xml (signed original) and …/pdf (human view)
Send — Factur-X by default
The standard send call works unchanged; for French domestic B2B, Flowie generates Factur-X (EN 16931, CIUS-FR) and routes via the annuaire. Mind the French required fields — SIRET, and since 2026 the buyer’s SIREN, delivery address when it differs, and the nature of the operation (goods / services / mixed):
curl -X POST https://back.flowie.ink/exchange/v1/documents/send \
-H "Authorization: Bearer $KEY" \
-H "Idempotency-Key: fa-2027-0042" \
-d '{
"type": "invoice",
"from": "comp_01H…",
"to": "0009:55210055400013",
"document": {
"number": "FA-2027-0042",
"issueDate": "2027-09-01",
"dueDate": "2027-10-01",
"currency": "EUR",
"buyer": { "name": "Grand Client SA", "vatNumber": "FR40552100554",
"additionalIdentifiers": { "siren": "552100554" } },
"lines": [
{ "description": "Prestation de conseil — août 2027",
"quantity": 8, "unit": "days", "unitPrice": 950.00,
"vatRate": 20, "vatCategory": "S" }
]
}
}'
A 201 response means the invoice passed Flowie’s controls — status 200 Déposée is emitted and its data will reach the PPF within 24 h. A platform rejection later (unknown SIRET, duplicate number…) surfaces as document.failed with the motif — that is status 213 Rejetée: fix and send a new invoice, never a mutation of the old one. B2G invoices (Chorus Pro) additionally need buyerReference (Service Exécutant) and orderReference — see required fields.
Emit the lifecycle — who owes which status
The explorer covers every status in depth; here is the split of responsibilities your integration must implement. Everything is one endpoint: POST /v1/documents/{id}/lifecycle.
| When… | You call | FR status emitted | Tier |
|---|---|---|---|
| As buyer (inbound invoices) | |||
| AP takes the invoice into processing | {"status":"under_review"} | 204 Prise en charge | Recommended |
| You approve it in full | {"status":"approved"} | 205 Approuvée | Recommended |
| You approve it in part | {"status":"approved","remainingAmount":…} | 206 Approuvée partiellement | Recommended |
| You contest it (without refusing) | {"status":"disputed","reason":"…"} | 207 En litige | Libre |
| You need supporting documents | {"status":"disputed","reasonCode":"suspended","reason":"…"} | 208 Suspendue | Libre |
| You refuse it (business decision) | {"status":"rejected","reasonCode":"…","reason":"…"} | 210 Refusée | Mandatory |
| Your payment went out | {"status":"paid","paymentDate":"…"} | 211 Paiement transmis | Recommended |
| As supplier (outbound invoices) | |||
Buyer asked for documents (you received 208) | POST …/actions {"action":"link","relatedDocumentId":"…"} | 209 Complétée | Libre |
| The money arrived on your account | {"status":"paid","paymentDate":"…","paymentAmount":…} | 212 Encaissée | Mandatory |
| Partial collection | {"status":"partially_paid","paymentAmount":…,"remainingAmount":…} | 212 Encaissée (partial) | Mandatory |
210 when you refuse as a buyer, 212 when you’re paid as a supplier. Both are legally mandatory, both are auto-reported by Flowie to the DGFiP, and 212 is what pre-fills the CA3 VAT return for services. Everything else improves visibility; these two keep you compliant. Statuses 200, 201, 202, 203 and 213 are Flowie’s job — never emit them yourself.
The webhook handler — your single integration point
One endpoint, four event families, out-of-order-safe. This is the reference shape (Node; the logic transposes 1-to-1 to any stack):
import express from "express";
import crypto from "node:crypto";
const app = express();
app.use(express.raw({ type: "application/json" })); // keep the raw body for HMAC
// French statuses can be skipped (only 4 of 14 are mandatory) — never
// assume ordering. Rank them and ignore stale updates.
const RANK = { submitted: 0, received: 1, under_review: 2, disputed: 3,
approved: 4, partially_paid: 5, paid: 6, rejected: 9 };
app.post("/hooks/flowie", async (req, res) => {
const sig = crypto.createHmac("sha256", process.env.FLOWIE_WEBHOOK_SECRET)
.update(req.body).digest("hex");
if (!crypto.timingSafeEqual(Buffer.from(sig),
Buffer.from(req.headers["x-flowie-signature"] ?? ""))) {
return res.status(401).end();
}
const event = JSON.parse(req.body);
res.status(200).end(); // ack fast, process async
switch (event.type) {
case "document.received": // FR code 203 — you are the buyer
await queue.push("import-invoice", event.data.documentId);
break;
case "lifecycle.updated": { // any 2xx status, either direction
const { documentId, currentStatus, previousStatus, reasonCode, reason } = event.data;
if (RANK[currentStatus] <= RANK[previousStatus]) break; // stale / dup
await erp.setInvoiceStatus(documentId, currentStatus, { reasonCode, reason });
break;
}
case "document.failed": // FR code 213 — technical reject
await alerting.page("invoice-rejected", event.data); // fix + re-send
break;
case "compliance.reported": // DGFiP leg confirmed
case "compliance.reported.failed": // DGFiP leg rejected — investigate
await audit.log(event.type, event.data);
break;
}
});
Three properties make this production-grade, and all three matter in France specifically:
- Idempotent + monotonic — statuses may arrive twice (retries) or out of order (skipped optional statuses); the rank check handles both.
- Fast ack — status bursts happen (a buyer platform replaying a backlog); never do ERP writes before responding.
- Compliance events audited —
compliance.reportedis your proof the mandatory statuses reached the DGFiP; keep the trail (FEC audits ask for it).
E-reporting — mostly Flowie’s job, two things are yours
E-reporting covers what e-invoicing doesn’t: B2C and international / intra-community transactions (transmitted as data, not exchanged invoices), plus payment data for services. What that means for you:
- Domestic B2B — nothing to do. The mandatory statuses you emit (esp.
212with its collected amount) are the payment e-reporting; Flowie forwards them on schedule. - B2C & cross-border — send those transactions through the same API (the recipient just isn’t on the French network); Flowie derives and files the transaction data at your VAT regime’s frequency.
- Watch the failures.
compliance.reported.failedmeans the DGFiP leg bounced; Flowie retries with backoff, but repeated failures (bad SIREN, closed period) need your action.
Prove it in the sandbox — the French test matrix
Every scenario above is reproducible with a flw_test_ key, deterministic test identifiers and the simulateCompliance org flag:
| What you’re proving | How | Expect |
|---|---|---|
| Happy path 200→212 | Send, then walk under_review → approved → paid | lifecycle.updated ×3, then compliance.reported (with simulateCompliance:"accept") |
| Refusal (210) with motif | {"status":"rejected","reasonCode":"…"} as buyer | Terminal state; motif echoed verbatim in the event |
| Platform reject (213) | simulateCompliance: "reject_00058" | compliance.reported.failed with code 00058 |
| Illegal transition guard | received → paid directly | 409 invalid_transition + allowed next states |
| DGFiP outage resilience | simulateCompliance: "timeout_30s" | Delayed failure event — exercise your retry/alerting |
| Flaky network | simulateCompliance: "flaky_50pct" | Random accept/fail — your handler must be idempotent |
Go-live checklist
- ☐ Every French entity created with SIREN/SIRET and registered (annuaire entry live — verify with a directory search on your own SIREN).
- ☐ Webhook endpoint deployed, HMAC-verified, idempotent, monotonic — and subscribed to the five event types above.
- ☐ Buyer-side flows wired: approve / partial-approve / dispute / refuse-with-motif (210).
- ☐ Supplier-side encaissement (212) wired to your bank reconciliation (incl. partial collections).
- ☐
213alerting in place — a technical reject must page someone; the invoice legally doesn’t exist until re-sent. - ☐ Sandbox matrix green, including the timeout and flaky simulators.
- ☐ Sending: French required fields present (SIRET, buyer SIREN, delivery address if different, nature of operation; Service Exécutant for B2G).
- ☐ Compliance audit trail persisted (
compliance.reportedevents).
References
- France overview — deadlines, required fields, PPF error codes, refus/rejet motifs.
- Lifecycle explorer — all 14 statuses, interactive, with per-status code.
- API reference · Webhook cookbook · Sandbox guide.
- impots.gouv.fr — official reform portal & PA list; external specifications (current v3.2).
- FNFE-MPE — AFNOR XP Z12-012/013/014 annexes and Schematrons.