# Flowie Exchange — full documentation
# Generated by scripts/build_llm_docs.py
# Each section below is one page from https://docs.get-flowie.com
========================================================================
# Introduction
# Source: https://docs.get-flowie.com/index.html
========================================================================
---
title: "Flowie Exchange — One API for every e-invoicing network"
description: "The developer platform for electronic invoicing across every network — Peppol, France PPF, Italy SDI, KSA Fatoora, IL ITA, IN GST IRP, MY MyInvois. 47 countries across Europe, MENA, and Asia-Pacific. White-label ready."
canonical: "https://docs.get-flowie.com/"
source: "https://docs.get-flowie.com/index.html"
---
# Flowie Exchange — One API for every e-invoicing network
API v3.0 · Released April 2026
# Flowie Exchange API — one API for every e-invoicing network
Search the documentation Search
**[Developer portal]()** — get an API key with no signup, read the [OpenAPI spec](), browse the [API reference](), or connect over [MCP](<.well-known/mcp/server-card.json>) / [A2A](<.well-known/agent-card.json>).
Send and receive e-invoices across [**47 countries**]() — Europe, MENA, and Asia-Pacific. Peppol, France PPF, Italy SDI, KSA Fatoora, India GST IRP, Malaysia MyInvois — one integration, every network handled.
⚡ Get a test API key (free, no signup) [Start in 5 minutes →](<#quickstart>) [Browse the API]()
**🤖 Hand this URL to your LLM** — it does the integration. [How it works →]() Copy link
`https://back.flowie.ink/exchange/docs-public/agent-onboarding.html`
An agent that fetches this URL is auto-authenticated against a fresh sandbox.
**Personalize** — bind to your org so the agent operates as your account.
Paste any existing API key (`flw_test_…` / `flw_live_…`). We mint a **single-use, 10-min handoff token** scoped to `send`, `receive`, `documents.read`, `companies.read`, `stats`. The key never leaves your browser.
Generate
`` Copy personalized link
**🇫🇷 On another Plateforme Agréée? Port your taxpayers to Flowie.** Your SIREN/SIRET keeps routing, so nothing downstream is re-addressed. Import a taxpayer from its SIRET — or a whole client book in bulk — and we build the normalised inter-PA message: 24 h to acknowledge, 5 _jours ouvrés_ to decide, _silence vaut accord_ after that. **France (PPF) only.** [Portability guide →]() [Elsewhere in Europe →]()
## Platform capabilities
### Universal Peppol access point
Coverage across [47 countries]() on four continents — single integration for Europe, MENA, and Asia-Pacific. Auto-SMP registration and directory verification included.
### Compliance on autopilot
PPF (FR) and SDI (IT) are reported automatically when you update invoice status. Belgium runs pure Peppol — no separate report needed. Zero extra wiring.
### Platform & white-label
Manage thousands of tenant companies under one account. Scoped keys, per-tenant quotas, custom branding.
### Reliable webhooks
Signed deliveries, exponential retries, at-least-once guarantees. Event replay through the Events API.
### Structured or raw
Send invoices as JSON and we generate valid UBL 2.1. Or send your own UBL/CII — we validate and deliver it.
### AFNOR XP Z12-013 ready
French PDP-compliant adapter, cXML PunchOut, SIRET/SIREN directory — all behind the same account.
## Send your first invoice in 5 minutes
Sign up, grab a test API key, and fire three requests. No SDK required — it's just JSON over HTTPS.
1. ### Authenticate
Every request carries a bearer token — either a Flowie JWT (if you already use the dashboard) or an Exchange API key (`flw_live_…` / `flw_test_…`).
[code] export FLOWIE_KEY="flw_test_your_key_here"
curl https://back.p2p-flowie.com/exchange/v1/companies \
-H "Authorization: Bearer $FLOWIE_KEY"
[/code]
2. ### Register the sending company
Pass a VAT number. We enrich the legal name, address, and Peppol identifier for you, then publish the company to the Peppol SMP.
[code] curl -X POST https://back.p2p-flowie.com/exchange/v1/companies \
-H "Authorization: Bearer $FLOWIE_KEY" \
-H "Content-Type: application/json" \
-d '{"vatNumber": "BE0123456789"}'
[/code]
3. ### Send an invoice
Describe the invoice in JSON, set an `Idempotency-Key`, and we deliver it — UBL-XML-formatted and Peppol-signed — to the recipient's access point.
[code] curl -X POST https://back.p2p-flowie.com/exchange/v1/documents/send \
-H "Authorization: Bearer $FLOWIE_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: inv-2026-001" \
-d '{
"type": "invoice",
"from": "comp_abc123",
"to": "0208:9876543210",
"document": {
"number": "INV-2026-001",
"issueDate": "2026-04-25",
"dueDate": "2026-05-25",
"currency": "EUR",
"lines": [{
"description": "Consulting, April 2026",
"quantity": 10, "unit": "hours",
"unitPrice": 150.00, "vatRate": 21
}]
}
}'
[/code]
✓ That's it — your invoice is live on Peppol.
You'll receive a `document.delivered` webhook once the recipient's access point confirms.
## Complete starter programs
The same three steps, packaged as a single runnable file in your language. Copy, set `FLOWIE_KEY`, and you have a working integration.
[code]
# pip install httpx
import os, uuid, httpx
BASE = "https://back.p2p-flowie.com/exchange/v1"
KEY = os.environ["FLOWIE_KEY"]
api = httpx.Client(base_url=BASE, headers={"Authorization": f"Bearer {KEY}"})
# 1. Register sender
sender = api.post("/companies", json={"vatNumber": "BE0123456789"}).json()
print("sender:", sender["id"], sender["peppolId"])
# 2. Verify recipient before sending
ver = api.post("/directory/verify", json={
"peppolId": "0208:9876543210", "documentType": "INVOICE",
}).json()
assert ver["canReceive"], f"recipient unreachable: {ver}"
# 3. Send
doc = api.post(
"/documents/send",
headers={"Idempotency-Key": str(uuid.uuid4())},
json={
"type": "invoice",
"from": sender["id"],
"to": "0208:9876543210",
"document": {
"number": "INV-2026-0417",
"issueDate": "2026-04-25",
"dueDate": "2026-05-25",
"currency": "EUR",
"lines": [{
"description": "Consulting — April 2026",
"quantity": 10, "unit": "hours",
"unitPrice": 150.00, "vatRate": 21,
}],
},
},
).json()
print("invoice:", doc["id"], doc["status"], doc["deliveryStatus"])
[/code]
Need another language? Every example follows the same pattern: bearer auth, JSON body, `Idempotency-Key`. Open a PR for your language at [github.com/flowie-fr/exchange-api-docs]().
## Where to go next
Pick the track that matches what you're building.
### [API Reference → Every endpoint, every field, every error. With runnable examples in four languages. ]() ### [Integration Guides → Playbooks for sending, receiving, going live, and building white-label products. ]() ### [🇫🇷 Portability — change of PA → Already with another Plateforme Agréée? Migrate in France without losing your SIRET addressing: SIRET import, bulk import, and the normalised inter-PA message with its 24 h / 5-day clocks. France (PPF) only. ]() ### [Compliance · 47 countries → Per-country deep-dives across Europe, MENA, and Asia-Pacific. Mandates, formats, deadlines, and primary government sources for France PPF, Italy SDI, KSA Fatoora, India GST, Malaysia MyInvois, Singapore InvoiceNow, and more. ]() ### [Webhook Cookbook → Event catalog, signing, retry policy, and idempotency patterns for robust listeners. ]() ### [Build with AI → Plug Claude Desktop, Claude Code, Cursor, or your own Python agent into the API as native tools — over MCP, with agent-ready docs and self-service onboarding. ]() ### [Agent onboarding → Two paths for an agent to self-provision: zero-friction sandbox bootstrap (no human in the loop) or OAuth-style consent flow with PKCE for production-grade scope grants. ]() ### [Error Catalog → Every error code with a remediation. Because "500 Internal Server Error" isn't a diagnosis. ]() ### [Sandbox → Every test scenario as a row. Force any error, advance any clock, simulate any recipient. ]() ### [Platform Onboarding Kit → Build under your own brand. Onboard 1 tenant or 1,000 with the same playbook. ]() ### [Data Model → One diagram that makes the whole API click. Read this first, thank yourself later. ]() ### [Webhook Fixtures → Real JSON payloads for every event. Drop them into your handler tests. ]()
========================================================================
# Change platform (portability app)
# Source: https://docs.get-flowie.com/portability/index.html
========================================================================
---
title: "Change platform"
description: "Move a company from one e-invoicing platform to another, in any country we cover: type a name, we resolve the company and its directory line, one company or five thousand, with the deadlines that country sets."
canonical: "https://docs.get-flowie.com/portability/"
source: "https://docs.get-flowie.com/portability/index.html"
---
# Change platform
Portability
# Change platform
Type a company name, or paste a tax ID — in any country we cover. We identify the company against its national register or the Peppol network, read the routing directory where the country has one, and tell you what its move requires _there_ : one company in thirty seconds, or five thousand in an afternoon.
## One company
No account, no API key — the page mints a throwaway sandbox key and forgets it when you close the tab.
Company name, tax ID, VAT number or Peppol id
Look it up
Three letters are enough. Names are matched against the national register where a country publishes one, and against the Peppol network everywhere else — so a Belgian, Italian or British company answers to its name too. A tax ID, a VAT number or a Peppol id always works.
## Many companies at once
A platform migration is rarely one company, and rarely one country. Paste the list — names, tax IDs, VAT numbers or Peppol ids, one per line, straight out of a spreadsheet — and every line is resolved the same way the single lookup above resolves, four at a time so nothing is throttled. What comes back is a table you can act on: who each line really is, who routes them today, and what is still missing before their move can be filed in their own country.
One company per line
Resolve the list Copy as CSV Copy the import payload
You typed| Company| Company ID| Routed today by| Still needed
---|---|---|---|---
**Then file them in one call.** The _Copy the import payload_ button gives you the body for [`POST /v1/companies/import/batch`](<../reference/index.html#import-companies-batch>) — up to 500 companies per call, each idempotent on its identifier, each reporting its own outcome so a partial failure never costs you the batch. Put the agreed `effectiveDate` on the items and the routing address is created for _that_ date rather than the moment the call lands.
What the list does not do by itself
Resolving a list tells you who these companies are and what their move needs. It does not send anything: the inter-platform messages go out when you open the requests, so you can look at the table, fix the three lines that came back incomplete, and only then commit.
## How a change of platform actually works
Four steps, the same in every country we cover — what changes is the paperwork each one demands, the deadlines it runs on, and whether there is a public directory to re-point at all. The [country-by-country matrix](<../guides/portability-europe.html>) has the differences; the [French walkthrough](<../guides/portability.html>) has one regime end to end. This is the shape they share.
1
### Identify the taxpayer
One identifier — or a name, resolved above — becomes the legal name, the country, the national identifiers, the Peppol id and, where that country publishes a directory, the line it routes on. Whatever cannot be resolved comes back in `stillNeeded` instead of being guessed.
2
### Open the request
Opening one produces the agreement that country's rules require, its deadlines computed on that country's own business calendar, and a hash-linked evidence chain — so the proof survives the argument, not just the happy path.
3
### Tell the other platform
Where a country prescribes a format between platforms — France's normalised subject line and 18-field CSV, for one — it is built and sent to the outgoing platform, addressed from the register of approved platforms. It is logged with the hash of what was sent, whether or not it left.
4
### Switch the routing
On the agreed effective date — not before — the routing address moves. The outgoing platform stops sending that day and keeps receiving for as long as its country requires (twelve months in France), so nothing in flight is lost.
The clocks belong to the country, not to us
In France a request must be acknowledged within **24 hours** and processed within **5 jours ouvrés** , past which silence counts as agreement — in both directions, so the deadline protects you as the incoming platform and exposes you as the outgoing one. Other countries set different windows, and some set none at all: `resolve` returns the regime that applies to the company you looked up, and the [per-country matrix](<../guides/portability-europe.html>) spells the rest out.
## Drive it from your own system
Everything on this page is the public API with a sandbox key. The same calls, with your own key, are what an integrator or an agent uses. They are country-agnostic except where the table says otherwise:
Call| What it does
---|---
`GET /v1/portability/companies?q=`| Type a name, get the company — what the field above runs on. National register first where one is connected (France), then the Peppol network; identifiers resolve on either.
`POST /v1/portability/resolve`| One identifier in, the whole taxpayer out, plus what its country requires.
`POST /v1/portability/requests`| Open the request: designation agreement, deadlines, evidence chain.
`POST /v1/portability/messages`| Send the normalised inter-platform message and keep the proof.
`GET /v1/portability/annuaire/{siren}`| 🇫🇷 Who routes this taxpayer today, since when, until when — the French _annuaire_.
`POST /v1/portability/routing`| Move the routing on the agreed date.
`POST /v1/companies/import/batch`| Onboard the whole list, per-item results.
Full parameters in the [API reference](<../reference/index.html#portability>), the country-by-country rules in [changing platform in Europe](<../guides/portability-europe.html>), and one regime end to end in the [French portability guide](<../guides/portability.html>).
========================================================================
# API reference
# Source: https://docs.get-flowie.com/reference/index.html
========================================================================
---
title: "API Reference"
description: "Complete API reference for Flowie Exchange. Endpoints, schemas, parameters, errors, and runnable examples."
canonical: "https://docs.get-flowie.com/reference/"
source: "https://docs.get-flowie.com/reference/index.html"
---
# API Reference
API Reference · v3.0.0
# Flowie Exchange API
The Flowie Exchange API is a single REST API for **sending, receiving, and managing electronic invoices over the Peppol network**. It covers [47 countries](<../compliance/index.html>) across Europe, MENA, and Asia-Pacific, handles regulatory compliance reporting automatically, and scales from a freelancer sending one invoice per month to a white-label platform managing thousands of tenant companies.
Base URL
`https://back.p2p-flowie.com/exchange` in production · `https://back.flowie.ink/exchange` in sandbox. Every path below is prefixed with `/v1`. Pick an environment in the session menu (top right) and each endpoint header below shows the full URL for it, ready to copy.
### Quick index
* [Postman collection](<#postman>)
* [Authentication1](<#authentication>)
* [Idempotency](<#idempotency>)
* [Pagination](<#pagination>)
* [Rate limits & quotas](<#rate-limits>)
* [Errors1](<#errors>)
* [Versioning](<#versioning>)
* [Sandbox mode6](<#sandbox>)
* [Agent auth6](<#agent-auth>)
* [Companies14](<#companies>)
* [Documents10](<#documents>)
* [Lifecycle4](<#lifecycle>)
* [Directory3](<#directory>)
* [Partners7](<#partners>)
* [Purchase orders1](<#purchase-orders>)
* [Webhooks4](<#webhooks>)
* [Events4](<#events>)
* [Compliance2](<#compliance>)
* [Stats1](<#stats>)
* [Platform8](<#platform>)
* [API keys3](<#api-keys>)
* [Categorization6](<#categorization>)
* [Payments3](<#payments>)
* [Request log3](<#request-log>)
* [Portability9](<#portability>)
* [UBL generator (France)7](<#ubl-generator>)
* [AFNOR XP Z12-01318](<#afnor>)
* [PunchOut cart callback2](<#punchout>)
* [Health3](<#health>)
* [Appendices](<#appendices>)
## Postman collection
Prefer to explore the API in [Postman]()? Download the ready-made collection — every endpoint, pre-filled with a working example body — and import it in seconds.
[⬇ Download Postman collection](<../postman_collection.json>) [⬇ OpenAPI 3.1 spec](<../openapi.json>)
In Postman: **Import** → drop the file, or paste the URL `https://docs.get-flowie.com/postman_collection.json`. Then set the collection variables:
* `baseUrl` — `https://back.flowie.ink/exchange` (sandbox) or `https://back.p2p-flowie.com/exchange` (production).
* `token` — your API key or JWT. It is sent as `Authorization: Bearer {{token}}` on every request (collection-level bearer auth).
Authenticating with the [client-credentials grant](<#m2m>) instead? Leave `token` empty, fill in `tokenUrl`, `clientId`, `clientSecret`, `audience` and `organizationId`, and send **auth → Get an access token** : it stores the token in `{{token}}` for every other request, and each request carries `X-Flowie-Organization-Id: {{organizationId}}` — which a machine token cannot do without.
Hit **Send** on any request to call the sandbox straight away. The collection is regenerated on every release, so it always matches this reference. Prefer to generate your own client? Import the [`openapi.json`](<../openapi.json>) spec instead.
## Authentication
Every request must carry a bearer token. Flowie Exchange supports three kinds of credentials; pick whichever matches your caller.
### Flowie JWT
If the caller is a Flowie dashboard user, pass the Auth0-issued JWT you already use elsewhere. The organization is resolved from the `_permissions` claim.
#### Switching organizations
JWTs typically grant access to multiple organizations (the user's `_permissions` claim is a dict of `org_id → permissions`). By default the API picks the first one in that dict. To act as a specific organization, pass the `X-Flowie-Organization-Id` header on every request:
[code]
curl https://back.p2p-flowie.com/exchange/v1/documents \
-H "Authorization: Bearer eyJhbGc..." \
-H "X-Flowie-Organization-Id: 685a5670efafaa26ebf0128e"
[/code]
The header is validated against the JWT's `_permissions`: passing an org the token doesn't grant returns `403`. `Organization-Id` (the legacy name used by the AFNOR routes) is also accepted as an alias.
To list every org a caller can switch to, hit [`GET /v1/me`](<#get-me>). The Flowie docs auth widget uses this endpoint to render the org-picker dropdown next to your email.
**API keys** are bound to a single org at creation time and ignore this header — a key issued for one org, sent with a header naming another, answers `200` with the _key's_ rows and no warning. Confirm which org a key acts as with [`GET /v1/me`](<#get-me>).
### OAuth 2.0 client credentials (machine-to-machine)
For a backend that runs unattended — an ERP or D365 connector, a nightly sync, a webhook consumer — with no user to sign in. Flowie issues a client id and secret per integration, backed by a technical account made a member of your organization with a role; that membership is what grants access. Staging and production are separate applications with separate credentials.
[code]
curl -X POST https://login.flowieapp.io/oauth/token \
-H "Content-Type: application/json" \
-d '{
"grant_type": "client_credentials",
"client_id": "...",
"client_secret": "...",
"audience": "https://auth.flowie.me"
}'
[/code]
Token endpoint: `https://login.flowieapp.io/oauth/token` in sandbox, `https://login.flowie.me/oauth/token` in production. The response is `{"access_token": "eyJ…", "token_type": "Bearer", "expires_in": 86400}` — put `access_token` in the `Authorization` header. Cache it and renew it on `expires_in` rather than minting one per call.
The `audience` is required and is _not_ the API base URL. A wrong one is refused at the token endpoint with `403 access_denied` — which reads like a credentials problem and is not one: it means the client is not authorized for that audience, either because it is misspelled or because it has not been granted access to the Flowie API.
A machine token carries no organization
An Auth0 `client_credentials` token has an empty `_permissions` claim and names no organization, so [`X-Flowie-Organization-Id`](<#org-switching>) is not an override here — it is the only thing that tells the API which tenant you mean. Send it on **every** call. Omit it and you get `403 No organization found in token`; name an organization your technical account is not a member of and you get `403 Token does not grant access to organization '…'. Available: none.` (`Available` reads `none` for every machine token — that part is normal.) Getting a token proves the audience grant only; the organization membership is a second, separate grant.
[code]
curl https://back.p2p-flowie.com/exchange/v1/documents \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H "X-Flowie-Organization-Id: 019b47ba-..."
[/code]
The whole grant is in the [Postman collection](<#postman>) — send _auth → Get an access token_ and every other request is authenticated. Full walkthrough, including provisioning and rate limits: [the authentication guide](<../auth.md>).
### Exchange API keys
For programmatic access, issue an Exchange API key from the dashboard or [via the API](<#create-api-key>). Keys are prefixed so you can tell them apart at a glance:
* flw_live_…Personal key
Scoped to a single company. Use for server-to-server calls from your own stack.
* flw_plat_live_…Platform key
Scoped to an organization that manages other companies. Combine with `X-Flowie-Company` to act on behalf of a tenant.
* flw_wl_live_…White-label key
Same as a platform key, plus the ability to customize branding, quotas, and settings per tenant.
* flw_test_…Sandbox key
Any of the above with `_test_` in the prefix hits sandbox. No real Peppol delivery.
🔒 Keys are shown once
The full key string is returned exactly once, at creation. After that, only the key prefix is visible. Rotate a compromised key immediately — revoke it at [DELETE /v1/api-keys/{id}](<#revoke-api-key>).
### Scopes
Keys carry a list of scopes. Use `*` only for full-access keys you control end-to-end; prefer the narrowest set your workload needs.
`send` `receive` `documents.read` `documents.search` `documents.write` `companies.read` `companies.write` `directory` `partners` `payments` `lifecycle` `compliance` `stats` `platform` `*`
[code]
curl https://back.p2p-flowie.com/exchange/v1/companies \
-H "Authorization: Bearer flw_live_abc123"
[/code]
##### Acting on a managed company (platform keys)
[code]
Authorization: Bearer flw_plat_live_xyz789
X-Flowie-Company: comp_abc123def456
[/code]
### Get caller identity + accessible orgs
GET/v1/me
Returns who the caller is, which organizations they can act as, and the active org for the current request. Works with both JWT and API-key auth. Used by the docs auth widget to render the organization-switcher dropdown.
#### Returns
[code]
{
"authMethod": "jwt",
"userId": "user_…",
"email": "alice@example.com",
"keyType": "jwt",
"organizationId": "org_685a5670efafaa26ebf0128e",
"organizationIds": ["org_685a…", "org_72b1…"],
"organizations": [
{ "id": "org_685a…", "name": "PMU", "country": "FR", "vatNumber": "FR12345678901" },
{ "id": "org_72b1…", "name": "Subsidiary", "country": "FR", "vatNumber": "FR98765432109" }
],
"scopes": ["*"],
"isTestMode": false
}
[/code]
[code]
curl https://back.p2p-flowie.com/exchange/v1/me \
-H "Authorization: Bearer eyJhbGc..."
[/code]
## Idempotency
Network calls are imperfect. Any `POST` in this API accepts an `Idempotency-Key` header; if a request with that key has already completed in the last 24 hours, we return the original response byte-for-byte instead of acting again.
* Keys are strings, up to 255 characters. UUID v4 works great.
* Cache TTL is 24 hours. After that, a repeated key is treated as new.
* If you retry _before_ the first response has finished processing, you'll get a `409 idempotency_in_progress`. Retry in a moment.
* Mutating a request under the same key is never allowed. We compare the full body hash — mismatched retries return `422 idempotency_body_mismatch`.
Best practice
Generate the idempotency key _before_ the first attempt — typically from your database row ID, not a random UUID on retry. That way, a crash between generation and HTTP call can still be recovered.
[code]
curl -X POST …/v1/documents/send \
-H "Authorization: Bearer $KEY" \
-H "Idempotency-Key: $(uuidgen)" \
-d @invoice.json
[/code]
## Pagination
All list endpoints are cursor-paginated. Don't hard-code offsets — the cursor is an opaque server-issued token and will change format without notice.
* limitintegeroptional
Page size. Default `20`, max `100`.
* cursorstringoptional
Pass the `cursor` value returned by the previous page. Omit to start at the first page.
Every list response has the same envelope:
[code]
{
"data": [ /* records */ ],
"hasMore": true,
"cursor": "eyJpZCI6ImRvY19YLi4uIn0"
}
[/code]
##### Iterate all pages
[code]
cursor = None
while True:
params = {"limit": 100}
if cursor: params["cursor"] = cursor
page = api.get("/documents", params=params).json()
for doc in page["data"]:
process(doc)
if not page["hasMore"]: break
cursor = page["cursor"]
[/code]
## Rate limits & quotas
Rate limits are enforced with a 60-second sliding window per key. Quotas are enforced monthly per organization. Both depend on your plan:
Plan| Requests / min| Documents / month
---|---|---
Free| 60| 50
Starter| 120| 500
Pro| 300| 5,000
Platform| 600| 50,000
White-label| 1,200| Unlimited
Every response includes the current state:
* X-RateLimit-Limitinteger
Requests allowed in the current 60-second window.
* X-RateLimit-Remaininginteger
Requests left before you're throttled.
* X-RateLimit-Resetunix timestamp
When the window rolls over.
* Retry-Afterseconds
Present only on `429`. How long to wait before retrying.
Exponential backoff
On `429` or `503`, wait `Retry-After` seconds (or `2ⁿ × 250ms` jittered) and try again. Don't retry `4xx` client errors — they'll always fail.
##### 429 response
[code]
HTTP/1.1 429 Too Many Requests
Retry-After: 37
X-RateLimit-Limit: 300
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1714046400
{
"error": {
"type": "rate_limit_error",
"code": "RATE_LIMITED",
"message": "You have exceeded 300 req/min. Retry in 37s.",
"requestId": "req_01HXYZ…"
}
}
[/code]
## Errors
Every error response uses the same envelope (RFC 7807 + Flowie extensions). See the [error catalog]() for a full list of codes and how to fix them.
Status| Meaning
---|---
`400`| The request is malformed or fails validation.
`401`| Missing, expired, or invalid credentials.
`403`| Credentials are valid but lack the required scope or company access.
`404`| The resource doesn't exist (or isn't visible to you).
`409`| Conflict — typically an idempotency or state transition issue.
`422`| Semantically invalid (e.g. VAT not in directory, unreachable recipient).
`429`| Rate-limited. Honor `Retry-After`.
`500`| Internal error. Report `requestId` to support.
`502 / 503`| Upstream service unavailable. Circuit breaker may be open.
### Request inspector
GET/v1/requests/{request_id}
Every response carries an `X-Request-Id` header (and a `requestId` field on errors). Pass it back to this endpoint to retrieve the full request trace: timing, intermediate upstream calls, validation diff, and final status. Mirrors what you see at [requests.html](<../playground/requests.html>).
##### Error shape
[code]
{
"error": {
"type": "validation_error",
"code": "INVALID_REQUEST",
"message": "Request validation failed",
"details": [
{ "field": "document.lines[0].vatRate",
"rule": "range",
"message":"Must be between 0 and 100" }
],
"requestId": "req_01HXYZ2K3M4N5P6Q7R",
"docUrl": "https://docs.get-flowie.com/errors#INVALID_REQUEST"
}
}
[/code]
## Versioning
The API version is baked into the URL (`/v1/…`). We follow semantic versioning with these commitments:
* **Breaking changes** ship under a new path (`/v2/…`). Old paths stay alive for at least 12 months.
* **Additive changes** — new fields, new enum values, new endpoints — land in `/v1/` without notice.
* **Deprecations** are announced in the [changelog](<../changelog.html>) and flagged with the `Sunset` response header 6+ months before removal.
Forward-compatible parsers
Ignore unknown fields. Treat enum values as opaque strings. That way your integration survives any additive change automatically.
##### Sunset header example
[code]
Sunset: Wed, 01 Oct 2026 00:00:00 GMT
Deprecation: true
Link: ; rel="deprecation"
[/code]
## Sandbox mode
Use a `flw_test_…` key with the staging base URL. Sandbox behaves identically to live with these differences:
* Documents are **not** delivered to real Peppol access points — they're routed to an internal echo endpoint.
* Compliance reporting goes to a mock PPF/SDI that always accepts.
* Webhooks fire the same events with `"livemode": false` in the payload.
* There are no quotas; rate limits remain.
### Bootstrap a sandbox key
POST/v1/sandbox/bootstrap
Public, unauthenticated. Mints a fresh `flw_test_*` API key bound to a brand-new throwaway organization plus a Belgian sandbox company (`BE0000000001`, peppolId `0208:0000000001`). Returns the key only once. Rate-limited per IP; meant for the docs Playground and CI smoke tests.
#### Request body
* labelstringoptional
Free-form tag for the issued key — appears in the dashboard. Default `quickstart`. Max 64 chars.
* emailstringoptional
Optional contact email (we may follow up with usage tips).
* keyTypeenumoptional
`personal``platform``white_label`
Defaults to `personal` (token prefix `flw_test_`). Pass `platform` to mint a multi-tenant key (`flw_plat_test_`) that satisfies the platform-key gate on `/v1/platform/*` ops, or `white_label` for the branding-enabled variant (`flw_wl_test_`). See [Sandbox · Key types](<../sandbox/index.html#key-types>).
### Reset sandbox state
POST/v1/sandbox/reset
Wipes events, idempotency cache, and pending scheduled events for the calling organization. Test-mode key only.
#### Request body
* confirmenumrequired
Type the literal string `yes` to acknowledge the wipe.
* scopeenumoptional
`all``documents``events``idempotency`
What to wipe. Defaults to `all`.
### Advance virtual clock
POST/v1/sandbox/clock/advance
Move the company-scoped virtual clock forward — used to test 60-day overdue flows, retry escalations, etc. Wakes any scheduled events whose virtual fire-time is now in the past.
#### Request body
* companyIdstringrequired
Company whose virtual clock should be advanced.
* bystringrequired
How far to jump. Accepts compact units: `1h`, `3d`, `2w`, `1m`, `1y`.
### Reset virtual clock
POST/v1/sandbox/clock/reset
Snap the virtual clock back to wall-clock time for a company.
#### Request body
* companyIdstringrequired
### Force rate-limit
POST/v1/sandbox/rate-limit/exhaust
Make every subsequent request from this organization return `429`. Use to validate your client's retry/backoff path against a real `Retry-After`.
#### Request body
* durationSecondsintegeroptional
How long the forced `429` should last. Default `60`, range 1–3600 (max 1 hour).
### Flush idempotency cache
POST/v1/sandbox/idempotency/flush
Drop the 24h idempotency cache for the calling key — useful when you want to re-issue a request that previously succeeded under the same `Idempotency-Key`. No request body.
##### Base URL
[code]
https://back.flowie.ink/exchange/v1
[/code]
## Agent auth — OAuth & handoff
Three ways an AI agent gets a key. **Handoff** is the fastest: a human generates a single-use link and pastes it to the agent, which redeems it in one call. **Sandbox bootstrap** ([below](<#sandbox-bootstrap>)) needs no human at all. **OAuth with PKCE** is the full consent flow when the agent must act on a real user's account and you want an approval screen. The end-to-end walkthrough lives in the [agent onboarding guide](<../build-with-ai/agent-onboarding.html>).
### List grantable scopes
GET/v1/oauth/scopes
**Authentication:** none — this endpoint is public.
Every grantable scope with a human-readable description. Agents call this once at boot to render an honest scope-selection UI before starting the consent flow.
[code]
{
"scopes": [
{ "id": "send", "description": "Send documents" },
{ "id": "documents.read", "description": "Read documents" },
{ "id": "lifecycle", "description": "Advance lifecycle statuses" }
]
}
[/code]
### Start consent (PKCE)
POST/v1/oauth/authorize
**Authentication:** none — this endpoint is public.
Step 1 of the consent flow. Returns the URL the agent shows the user. RFC 7636 PKCE: the agent keeps a random `code_verifier` secret and sends only its SHA-256 challenge.
#### Request body
* client_namestringrequired
Agent display name, shown on the consent screen.
* scopesstring[]required
Scopes requested, from [the catalogue](<#oauth-scopes>).
* code_challengestringrequired
`BASE64URL(SHA256(code_verifier))`, no padding.
* code_challenge_methodstringoptional
`S256`. The plain method is not accepted.
* redirect_uristringoptional
Omit for out-of-band: the code is shown on screen for the user to paste.
* statestringoptional
Echoed back on redirect.
[code]
{
"client_name": "My Agent",
"scopes": ["send", "documents.read"],
"code_challenge": "E9Melhoa2Ow…",
"code_challenge_method": "S256"
}
[/code]
[code]
{
"request_id": "areq_01HY…",
"consent_url": "https://back.flowie.ink/exchange/consent?request=areq_01HY…",
"expires_in": 600
}
[/code]
### Exchange the code for a key
POST/v1/oauth/token
**Authentication:** none — this endpoint is public.
Final step of the consent flow. The server hashes `code_verifier` and checks it against the challenge recorded at [/authorize](<#oauth-authorize>). The code is single-use and expires 5 minutes after consent.
#### Request body
* grant_typestringrequired
`authorization_code`.
* codestringrequired
The one-time code from the consent screen.
* code_verifierstringrequired
The 43–128 character secret whose SHA-256 was sent as the challenge.
[code]
{
"grant_type": "authorization_code",
"code": "ac_01HY…",
"code_verifier": "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
}
[/code]
[code]
{
"access_token": "flw_test_…",
"scopes": ["send", "documents.read"],
"expires_in": 604800,
"organization_id": "org_01HY…",
"company_id": "comp_01HY…"
}
[/code]
### Mint a handoff link
POST/v1/oauth/handoff
**Authentication:** `Authorization: Bearer ` with a live or test Exchange key (`flw_live_…` / `flw_test_…`) or a Flowie JWT.
Generate a single-use, pre-approved link to paste to an agent you already trust. The agent redeems the embedded token at [/handoff/exchange](<#oauth-handoff-exchange>) and gets a key bound to _your_ organization — no consent screen. You can only grant scopes you hold yourself.
#### Request body
* scopesstring[]optional
Defaults to the scopes of the calling key.
* labelstringoptional
Shown in the API-key list so you can revoke the right one later.
* ttl_secondsintegeroptional
Token lifetime, 60 minutes maximum.
[code]
{
"scopes": ["send"],
"label": "claude-desktop",
"ttl_seconds": 900
}
[/code]
[code]
{
"handoff_url": "https://docs.get-flowie.com/build-with-ai/agent-onboarding.html?handoff=hand_AbC…",
"handoff_token": "hand_AbC…",
"expires_in": 900
}
[/code]
### Anonymous sandbox handoff
POST/v1/oauth/handoff/sandbox
**Authentication:** none — this endpoint is public.
Bootstraps a fresh sandbox organization _and_ mints a handoff token in one call. Rate-limited to 120 requests per IP per hour, like [sandbox bootstrap](<#sandbox-bootstrap>). This is what lets the docs home page hand an agent a URL that is already authenticated.
[code]
{
"handoff_token": "hand_AbC…",
"organization_id": "org_sbx_01HY…",
"company_id": "comp_sbx_01HY…",
"expires_in": 3600
}
[/code]
### Redeem a handoff token
POST/v1/oauth/handoff/exchange
**Authentication:** none — this endpoint is public.
Redeem the token for an API key. Single-use: a second attempt returns `400 invalid_grant`. This is the whole of path 1 — one POST, no PKCE, no consent UI.
#### Request body
* handoff_tokenstringrequired
The `hand_…` value from the URL you were given.
[code]
{
"handoff_token": "hand_AbC…"
}
[/code]
[code]
{
"access_token": "flw_test_…",
"scopes": ["send"],
"expires_in": 604800,
"organization_id": "org_01HY…",
"company_id": "comp_01HY…"
}
[/code]
## Companies
A **company** represents a legal entity that can send or receive documents on Peppol. Create one per VAT number you operate under. Flowie auto-enriches the legal name, address, and Peppol identifier, then registers the company with the Peppol SMP so other access points can route messages to it.
The company object
* idstring
Unique identifier, `comp_…`.
* name / legalNamestring
Display name and registered legal name.
* vatNumberstring
Normalized `^[A-Z]{2}[A-Z0-9]+$`.
* countryISO 3166-1 α-2
Derived from the VAT prefix.
* peppolIdstring
Scheme-prefixed Peppol participant identifier, e.g. `0208:0123456789`.
* additionalIdentifiersobject[]
Extra identifiers (GLN, DUNS, SIRET…).
* addressAddress
Postal address. See [Address](<#address-object>).
* capabilitiesobject
Which document types the company can send/receive.
* statusstring
`active`, `inactive`, or `suspended`.
* smpRegisteredboolean
True once the SMP record is live.
* smpRegisteredAttimestamp
When SMP registration completed.
* complianceobject
Per-country compliance status (PPF for FR, SDI for IT). Belgium has no regulator-side report; the field is empty for BE companies.
* settingsobject
Sending preferences, default currency, auto-reporting toggles.
* statsobject
Summary counters (documents sent, received).
* metadataobject
Your free-form key-value store.
* createdAt / updatedAttimestamp
ISO 8601 UTC.
### Create a company
POST/v1/companies
Registers a new company. Only `vatNumber` is strictly required — everything else is auto-enriched from the national registry (INSEE, KBO, Camera di Commercio, …) and the Peppol directory.
#### Request body
* vatNumberstringrequired
Country prefix + number, e.g. `BE0123456789`. Pattern `^[A-Z]{2}[A-Z0-9]+$`.
* namestringoptional
Display name. Defaults to the enriched legal name.
* addressAddressoptional
Overrides the auto-enriched address.
* additionalIdentifiersobject[]optional
Extra routing identifiers. `{ "scheme": "0088", "value": "1234567890128" }` for GLN, etc.
* capabilitiesobjectoptional
`{"send": ["invoice","credit-note"], "receive": ["invoice"]}`. Default: full set.
* settingsobjectoptional
Default currency, auto-compliance toggles, preferred contact.
* complianceobjectoptional
Per-country compliance configuration overrides (e-reporting enrolment, PPF/SDI routing hints).
* metadataobjectoptional
Free-form key-value (max 40 keys, 500 chars each).
#### Returns
The [company object](<#companies>) with status `201`. SMP registration happens asynchronously — listen for `company.smp_registered` via webhook.
Duplicates
Calling create with a `vatNumber` already owned by your organization returns `409 duplicate` with the existing `companyId`. Use that as your idempotent upsert.
##### Request
[code]
curl -X POST https://back.p2p-flowie.com/exchange/v1/companies \
-H "Authorization: Bearer $KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: upsert-acme-be" \
-d '{
"vatNumber": "BE0123456789",
"capabilities": {
"send": ["invoice","credit-note"],
"receive": ["invoice"]
},
"metadata": { "tenantId": "t_acme" }
}'
[/code]
##### Response
201 Created 409 Duplicate 422 Unknown VAT
[code]
{
"id": "comp_01HXYZ123ABC",
"name": "ACME Business Solutions BVBA",
"legalName": "ACME Business Solutions BVBA",
"vatNumber": "BE0123456789",
"country": "BE",
"peppolId": "0208:0123456789",
"additionalIdentifiers": [],
"address": {
"street": "Rue de la Loi 16",
"city": "Bruxelles",
"postalCode": "1000",
"country": "BE"
},
"capabilities": {
"send": ["invoice","credit-note"],
"receive": ["invoice"]
},
"status": "active",
"smpRegistered": false,
"smpRegisteredAt": null,
"compliance": {},
"settings": { "defaultCurrency": "EUR" },
"stats": { "sent": 0, "received": 0 },
"metadata": { "tenantId": "t_acme" },
"createdAt": "2026-04-25T10:00:00Z",
"updatedAt": "2026-04-25T10:00:00Z"
}
[/code]
[code]
{
"error": {
"type": "conflict",
"code": "COMPANY_EXISTS",
"message": "A company with this VAT already exists in your organization.",
"details": [{ "field": "vatNumber", "value": "BE0123456789",
"existingId": "comp_01HXYZ…" }],
"requestId":"req_01HXYZ…"
}
}
[/code]
[code]
{
"error": {
"type": "invalid_request_error",
"code": "VAT_NOT_FOUND",
"message": "VAT BE0000000000 is not in the national registry.",
"requestId":"req_01HXYZ…"
}
}
[/code]
### List companies
GET/v1/companies
Returns all companies you own or manage, most-recently created first.
#### Query parameters
* countryISO 3166-1 α-2optional
Filter by country.
* statusstringoptional
`active`, `inactive`, or `suspended`.
* searchstringoptional
Full-text over name, legal name, VAT, and Peppol ID.
* include_addressbooleanoptional
Resolve each row's `legalAddressId` into a full `address` object (adds round-trips). Default `true` — set `false` for a faster, lighter list.
* limit / cursorpaginationoptional
See [Pagination](<#pagination>).
[code]
curl "https://back.p2p-flowie.com/exchange/v1/companies?country=BE&status=active&limit=50" \
-H "Authorization: Bearer $KEY"
[/code]
[code]
{
"data": [
{ "id": "comp_01HXYZ…", "name": "ACME BVBA",
"vatNumber": "BE0123456789", "country": "BE",
"peppolId": "0208:0123456789", "status": "active" }
],
"hasMore": false,
"cursor": null
}
[/code]
### Resolve by VAT / SIREN
GET/v1/companies/resolve
Looks up any company, anywhere, by legal identifier — returns the same shape as the company object but synthesized from national registries and the Peppol directory. Use it to pre-fill forms, verify recipients, or check Peppol reachability.
#### Query parameters
* countryCodeISO 3166-1 α-2required
* vatNumberstringone of
* registrationNumberstringone of
SIREN, KBO, CF, … depending on `countryCode`.
[code]
curl "https://back.p2p-flowie.com/exchange/v1/companies/resolve?countryCode=FR®istrationNumber=797978996" \
-H "Authorization: Bearer $KEY"
[/code]
### Search companies
GET/v1/companies/search
Autocomplete over your managed companies. Optimized for < 80 ms response time. Use for dropdowns in UIs.
* qstringrequired
Query fragment (min 2 chars).
* countryCodeISO 3166-1 α-2optional
* limitintegeroptional
Default `10`, max `50`.
[code]
[
{ "id": "comp_…", "name": "ACME BVBA", "vatNumber": "BE0123456789",
"country": "BE", "peppolId": "0208:0123456789" }
]
[/code]
### Retrieve a company
GET/v1/companies/{company_id}
Returns the [company object](<#companies>). The path parameter accepts three forms:
* `comp_01HXYZ…` — the canonical id
* `vat:BE0123456789` — VAT-scoped lookup
* `peppol:0208:0123456789` — Peppol-ID lookup
[code]
curl https://back.p2p-flowie.com/exchange/v1/companies/vat:BE0123456789 \
-H "Authorization: Bearer $KEY"
[/code]
### Update a company
PATCH/v1/companies/{company_id}
Partial update. System-managed attributes (`peppolId`, `status`, timestamps, stats) are read-only. Merging rules:
* Top-level keys are replaced wholesale.
* `metadata` is shallow-merged. Set a key to `null` to delete it.
* Changing `capabilities.send` or `capabilities.receive` may trigger an SMP re-registration (you'll see a `company.smp_registered` event).
#### Request body
All fields optional — send only what you want to change.
* namestringoptional
Display name.
* addressAddressoptional
* capabilitiesobjectoptional
`{"send": [...], "receive": [...]}`. May trigger SMP re-registration.
* settingsobjectoptional
* complianceobjectoptional
* metadataobjectoptional
Shallow-merged. Set a key to `null` to delete it.
### Deregister a company
DEL/v1/companies/{company_id}
Permanently removes the SMP record and marks the company inactive. Historical documents remain queryable. Returns `204 No Content`.
### Join requests
If a Flowie user wants to connect to an already-registered company, they hit `POST /companies/{id}/join`. The company's organization admins see pending requests via:
GET/v1/companies/join-requests
and accept or reject with:
POST/v1/companies/{company_id}/join
Issue a join request as the calling user.
POST/v1/companies/{company_id}/join-requests/{request_id}/accept
POST/v1/companies/{company_id}/join-requests/{request_id}/reject
[code]
curl -X PATCH \
https://back.p2p-flowie.com/exchange/v1/companies/comp_abc \
-H "Authorization: Bearer $KEY" \
-H "Content-Type: application/json" \
-d '{
"settings": { "defaultCurrency": "EUR" },
"metadata": { "tier": "premium", "oldKey": null }
}'
[/code]
### Import a company (portability)
POST/v1/companies/import
**Authentication:** `Authorization: Bearer ` with a live or test Exchange key (`flw_live_…` / `flw_test_…`) or a Flowie JWT.
Onboard a company for a portability migration, keyed on the taxpayer's **SIRET**. Flowie derives the SIREN, country and Peppol id (`0009:`), resolves the legal name and current PA from the PPF annuaire, then attaches the company to your organization. Idempotent on SIRET.
#### Request body
* siretstringrequired
14-digit SIRET of the taxpayer. Supply `siren` instead only when the establishment is unknown.
* companyNamestringoptional
Overrides the legal name resolved from the annuaire.
* countryCodestringoptional
ISO-3166 alpha-2. Defaults to `FR`.
* modeenumoptional
Migration mode. Governs whether the existing provider connection is reused or re-provisioned.
* sovosOrganizationIdstringoptional
Existing provider organization id, when migrating a company already live elsewhere.
[code]
{
"siret": "55210055400013",
"mode": "portability"
}
[/code]
[code]
{
"id": "comp_01HY7AB9C2DE3FG",
"siren": "552100554",
"peppolId": "0009:552100554",
"name": "ACME SAS",
"country": "FR",
"imported": true
}
[/code]
### Import companies in bulk
POST/v1/companies/import/batch
**Authentication:** `Authorization: Bearer ` with a live or test Exchange key (`flw_live_…` / `flw_test_…`) or a Flowie JWT.
Import a list of companies in one call. Items are processed concurrently and idempotently; a per-item failure is reported in that item's result row rather than failing the whole batch, so a partial batch still onboards everything that was valid.
#### Request body
* itemsCompanyImportRequest[]required
Each item takes the same fields as [Import a company](<#import-company>).
[code]
{
"items": [
{ "siret": "55210055400013" },
{ "siret": "39876543200025" }
]
}
[/code]
[code]
{
"results": [
{ "ok": true, "siret": "55210055400013", "id": "comp_01…" },
{ "ok": false, "siret": "39876543200025", "error": { "code": "SIRET_NOT_FOUND" } }
],
"imported": 1,
"failed": 1
}
[/code]
### Register a company on Peppol
POST/v1/companies/{company_id}/register
**Authentication:** `Authorization: Bearer ` with a live or test Exchange key (`flw_live_…` / `flw_test_…`) or a Flowie JWT.
Deploy the company on Peppol and activate its registration so it can send and receive. This is what publishes the participant to the SMP: until it succeeds, [directory verification](<#verify-recipient>) of your own id returns `canReceive: false`.
Idempotent. For an organization already provisioned this re-syncs and re-activates the local registration; for a new one it provisions the provider customer config and managed connection first.
#### Path parameters
* company_idstringrequired
The company to register, e.g. `comp_01HY7AB9C2DE3FG`.
No request body.
[code]
POST /v1/companies/comp_01HY7AB9C2DE3FG/register
Authorization: Bearer flw_live_…
[/code]
[code]
{
"id": "comp_01HY7AB9C2DE3FG",
"peppolId": "0208:0123456789",
"registered": true,
"smpStatus": "active",
"activatedAt": "2026-04-25T10:05:00Z"
}
[/code]
## Documents
The **document** resource represents an invoice, credit note, debit note, or purchase order. Flowie accepts a structured JSON body (we'll render valid UBL 2.1) or a raw UBL/CII XML payload. Either way, we validate, sign, deliver over Peppol, and track lifecycle status through to payment.
The document object
* idstring
`doc_…`
* typeenum
`invoice``credit-note``debit-note``purchase-order``purchase-request``sales-order``quote``goods-receipt``event`
* directionenum
`incoming``outgoing`
* numberstring
Your external document number.
* issueDate / dueDatedate (YYYY-MM-DD)
* currencyISO 4217
* grossAmount / netAmount / vatAmountdecimal
* sender / receiverParty
* statusenum
`draft``sent``delivered``rejected`
* deliveryStatusenum
`pending``delivered``failed``rejected`
* lifecycleStatusenum
Business-level state. See [Lifecycle](<#update-lifecycle>).
* documentobject
The full structured body (lines, tax, payment, …).
* xmlstring
Rendered UBL (populated on delivery).
* metadataobject
* receivedAt / sentAttimestamp
### Send a document
POST/v1/documents/send
Delivers a document over Peppol to the `to` participant. Always set `Idempotency-Key` — duplicate sends to SDI or PPF can create regulatory headaches.
**Doubles as Flowie's inbound integration point.** Wire any ERP / accounting system / iPaaS webhook directly here — see [Inbound: ERP webhooks](<../guides/index.html#ingest>) for the full matrix of payload shapes (structured JSON · UBL XML · PDF / Factur-X / image / proprietary file).
#### Body
* typeenumrequired
`invoice``credit-note``debit-note``purchase-order``purchase-request``sales-order``quote``goods-receipt``event`
* formatenumoptional
`json``ubl-xml``cii-xml``auto``raw`
`json` (default) — we render UBL. `ubl-xml` / `cii-xml` — provide your own XML in `xml`; a `cii-xml` payload we cannot read is refused with `422` rather than recorded as an empty document. `auto` — supply a `file`; the server sniffs the bytes and routes to the right pipeline. A Factur-X PDF or a CII (UN/CEFACT `CrossIndustryInvoice`) is read into a structured document — number, dates, parties, lines, totals and the type from BT-3 — with the CII kept as the original. `raw` — supply a `file`; the server stores it as-is on the document file API and returns `deliveryStatus="stored"` (no Peppol routing).
* fromstringrequired
Your sender company. Accepts a bare Peppol id (`0208:0123456789`) or the prefixed forms `peppol:…`, `vat:…`, `comp_…` / `org:…`. Whatever you pass is normalised to the sender's canonical Peppol id before delivery — the response always echoes the bare `0208:…` form.
* tostringrequired when type ≠ event
Recipient. A Peppol participant id (`0208:0123456789` or `peppol:…`) — used as-is — or any other identifier we can resolve to one: `vat:…`, `siren:…` / `siret:…`, `duns:…`, `gln:…`, `lei:…`, `eori:…`, `registration:…`, `email:…`, `domain:…`, `name:…`, `org:…` / `id:…` (or the bare unprefixed form of any of these). Non-Peppol identifiers are resolved against org-v2 + the PPF Annuaire (FR) + the Peppol Directory, and provisioned if never seen, so they route to a real participant. A French reception point (_ligne annuaire_) can be addressed with the composed identifier `{siren}_{siret}[_{suffix}]` (e.g. `75297877500027_001`) — see [Reception-point addressing](<#reception-point-addressing>). Optional (omit) when `type=event` — events are pure observability/audit records and have no recipient.
**`name:` needs a country, and refuses to guess.** With one — taken from the counterparty in your payload, so you rarely state it separately — a name is searched against the legal registries, and an unknown company is found and provisioned. Without one it resolves nothing: a name is not a legal identity. “SAFRAN NACELLES MOROCCO” is both a French SIREN and a Moroccan registration, two different legal persons, and binding an invoice to the wrong one is a compliance defect. When several companies match, the API answers `409` listing them rather than picking one.
When your name and the registry’s name differ, use the number
Registries hold the _legal_ name, which is often not the one in your customer master: a Chinese customer filed as `BEIJING GE HUALUN MEDICAL EQUIPMENT CO, LTD` is registered as `GE Hualun Medical Systems Co., Ltd`. No name search bridges that, and none should try. **`registration:`** takes a national registration number in any jurisdiction — a Chinese Unified Social Credit Code, a UK company number, a Moroccan RC — and resolves it exactly. Company registries are searched one jurisdiction at a time, so the country has to be known: it is taken from the recipient's address in the document, or stated inline with a trailing `@`:
[code] "to": "registration:91110302621705062U@CN"
[/code]
It is accepted only when the registry returns exactly one company carrying that number, so a loose search never becomes a wrong match. Without a country, from either source, the call fails with a 400 that says so rather than reporting the company as unknown. `siren:` and `siret:` remain the French forms.
**Better still, route on your own reference.** If your ERP already holds a stable code for each customer, store it on the partnership as a custom field and address the recipient with `cf:FIELD_NAME=VALUE` — for example `cf:CODE_CLIENT=C-4471`. That is an exact match on a value you control, so it does not depend on how either side spells the company name, and it keeps working when the legal name changes.
#### Addressing by your own reference
Every ERP already has a stable code for each customer. Stored on the partnership as a custom field, that code becomes a routing key: `cf:FIELD_NAME=VALUE`, e.g. `cf:CODE_CLIENT=C-4471` (aliases: `ref:`, `customfield:`).
It is matched exactly against custom fields scoped to your organization, so unlike `name:` it does not depend on spelling, accents or locale, and it survives a change of legal name.
[code] {
"type": "invoice",
"to": "cf:CODE_CLIENT=C-4471",
"document": { "number": "INV-2026-0042", "...": "..." }
}
[/code]
Two deliberate limits, both there so a routing key can never silently send a document to the wrong company:
* **The field name is required.** `cf:C-4471` is rejected with `400`: Flowie will not guess which custom field holds your reference.
* **No match is an error, never a fallback.** An unknown reference returns `404` and nothing is created — it does not degrade into a name search. If the reference matches more than one partner you get `409` rather than an arbitrary pick; de-duplicate it, or address that recipient by `org:`.
Set the field on the partnership through the partners API (or the UI) before you route on it.
* documentDocumentBodyrequired when format=json
See schema below.
* numberstringrequired
Invoice number — **BT-1**.
* issueDatedaterequired
**BT-2**.
* dueDatedateoptional
**BT-9**.
* currencyISO 4217optional
**BT-5**. Default `EUR`.
* buyerReferencestringoptional
**BT-10**. Required by many public-sector buyers (e.g. Service Executant / Code Service in FR).
* orderReferencestringoptional
PO number — **BT-13**.
* despatchAdviceReferencestringoptional
Delivery-note number — **BT-16**.
* incotermsstringoptional
Delivery terms code — **EXT-FR-FE-185**. `1`, `2`, or an Incoterms 2020 code (`EXW`, `FCA`, `CPT`, `CIP`, `DAP`, `DPU`, `DDP`, `FAS`, `FOB`, `CFR`, `CIF`).
* incotermsLocationstringoptional
Named place the delivery terms refer to — **EXT-FR-FE-186**. Requires `incoterms`.
* notestringoptional
**BT-22**.
* seller / buyerPartyoptional
Overrides the auto-derived seller/buyer. A `Party` object:
* namestring
**BT-27** (seller) / **BT-44** (buyer).
* vatNumberstring
**BT-31** (seller) / **BT-48** (buyer).
* addressAddress
Billing address — see the [Address](<#address-object>) object. Carried to the party's `billingAddress` and rendered as the party's postal-address group: **BG-5** for the seller (`BT-35` street, `BT-36` street 2, `BT-37` city, `BT-38` post code, `BT-39` subdivision, `BT-40` country) and **BG-8** for the buyer (`BT-50`…`BT-55`).
* shippingAddressAddress
Same shape as `address`.
* contactobject
`{ name?, email?, phone?: string }`. The `email` is added to the party's `contacts`. Rendered as the contact group — **BT-41/42/43** (seller), **BT-56/57/58** (buyer).
* contactsstring[]
Contact email addresses, e.g. `["ap@acme.example"]`.
* partiesPartyRef[]optional
Explicit, role-tagged party list for documents with **more than two parties** (a `payer`/`payee` distinct from `buyer`/`seller`) and for self-billing. **Exactly one** entry must set `initiator: true` (the org the key acts as). When present it **overrides** the default seller/buyer derivation. See [Multiple parties](<#multiple-parties>). Each entry:
* roleenumrequired
`seller``buyer``payer``payee`
* idstring
Any resolvable id (same grammar as `to`).
* name / vatNumberstring
* address / shippingAddressAddress
See the [Address](<#address-object>) object.
* contact / contactsobject / string[]
Same as on `seller/buyer` above.
* initiatorboolean
Exactly one entry must be `true`.
* paymentPaymentInfooptional
A `PaymentInfo` object:
* meansstring
How the payment is made. A UNTDID 4461 code is sent as **BT-81** : `"30"` credit transfer, `"42"` payment to bank account, `"48"` bank card, `"49"` direct debit, `"58"` SEPA credit transfer, `"97"` clearing between partners (the netting a customer and supplier settle against each other), or `"ZZZ"` for a means the two of you defined between yourselves — the list runs 1 to 97 plus `ZZZ`. Anything else, e.g. `"Virement SEPA"` or `"credit_transfer"`, is sent as **BT-82** , the means description; a number outside the code list goes there too rather than being passed off as a code, since free text in BT-81 fails `BR-CL-16`.
* ibanstring
Account credited — **BT-84**. The holder name (**BT-85**) is taken from the seller.
* bicstring
Account provider — **BT-86**.
* referencestring
Remittance / structured communication — **BT-83**. Also sent as `paymentReferenceNumber`.
* discountTermsarray
`[{ days: int, percent: number, note?: string }]`. _Accepted but not yet emitted to the e-invoice._
* deliveryobjectoptional
Delivery details (**BG-13**), e.g. `{ actualDeliveryDate?: "YYYY-MM-DD", deliveryLocation?: Address }`. Two parts are emitted: the delivery date (**BT-72** , also read from `deliveryDate` / `date`) and the deliver-to country (**BT-80** , from `deliveryLocation.country` — required by `BR-IC-12` on intra-community supplies, and defaulted to the buyer's country there when omitted). The rest of the group is accepted but not yet emitted.
* linesInvoiceLine[]required
**VAT is per line** — a document with several rates is several lines (see [Multiple VAT rates](<#multiple-vat>)). Each line:
* descriptionstringrequired
* quantitynumberrequired
* unitPricenumberrequired
Excl. VAT.
* vatRatenumberrequired
Percent, e.g. `21`, `6`, `0` — **BT-152**.
* unitstring
UN/ECE Rec 20 code, e.g. `"HUR"`, `"C62"`.
* vatCategorystring
UNCL5305 code; defaults to `S`. See [Tax exemption & zero rate](<#tax-exemption>).
* vatExemptionReasonstring
Free-text reason why the line carries no VAT (**BT-120**), e.g. `"TVA non applicable, art. 293 B du CGI"`. Required by EN 16931 whenever `vatCategory` is a zero-VAT category. Ignored on standard-rated lines.
* vatExemptionCodestring
VATEX code backing the reason (**BT-121**), e.g. `VATEX-EU-IC`, `VATEX-EU-AE`, `VATEX-FR-FRANCHISE`. Optional under EN 16931, **mandatory for the French franchise en base**.
* itemCodestring
Your identifier for the item — **BT-155**.
* customFieldsobject
Keyed by field name or UUID. See [Custom fields & templates](<#custom-fields>).
* periodobject
Billing period the line covers (**BG-26**): `{ startDate: "YYYY-MM-DD", endDate: "YYYY-MM-DD" }` → **BT-134** / **BT-135**. The pairs `start`/`end` and `from`/`to` are also accepted.
* allowances / chargesarrayoptional
Document-level discounts (`allowances`) / surcharges (`charges`). Each item: `{ reason?: string, amount?: number, percent?: number, vatRate?: number }`. _Accepted but not yet emitted to the e-invoice — for document-level allowances/charges today, send`format=ubl-xml`._
* attachmentsarrayoptional
Embedded attachments. Each item: `{ filename: string, contentType: string, content: }`. _Accepted but not yet emitted to the e-invoice_ — to attach a file today use `format=auto`/`raw` with the top-level `file`.
* totalsobjectoptional
Pre-computed totals — **overrides** the values computed from lines. `{ netAmount?: number, vatAmount?: number, grossAmount?: number }` (aliases `net`/`vat`/`gross` also accepted). If omitted, all three are computed from the lines. **Recommended when your ERP has already posted the invoice** — see [Totals](<#totals>).
* templateIduuidoptional
Template to file the document under — it declares which `customFields` are valid. See [Custom fields & templates](<#custom-fields>).
* customFieldsobjectoptional
Document-level custom field values, keyed by field **name or UUID**. Attached to your party. See [Custom fields & templates](<#custom-fields>).
* xmlstringrequired when format=ubl-xml
Raw UBL 2.1 or CII XML. We validate against the Peppol BIS 3.0 schematron before delivery.
* fileFileAttachmentrequired when format=auto or raw
Arbitrary file payload (PDF, image, ZIP, proprietary format). `{ content: , contentType?, filename? }`. Max 5 MiB. With `format=auto` we sniff magic bytes — if the file is UBL XML it routes through the regular pipeline (`deliveryStatus="pending"`); if it is a Factur-X PDF or a CII and `type` is an invoice, credit note or debit note, the CII is read into a structured document (`deliveryStatus="pending"`, the CII stored as the original, the PDF as the readable copy); otherwise the bytes are persisted on the document file API and the response carries `deliveryStatus="stored"`, `fileId`, and `storedFormat`. Use `format=raw` to archive a Factur-X without it being read.
* selfBilledbooleanoptional
Self-billed invoice (_autofacturation_): the acting org (`from`) is the **customer** issuing on the supplier's behalf, so `to` becomes the Seller and `from` the Buyer / initiator. Tags the document with UNCL1001 subtype `389`. Only valid for `type=invoice`. Default `false`. For self-billing that also involves a third party, use [`document.parties`](<#multiple-parties>) instead.
#### Query parameters — raw-body mode
Instead of a JSON body, you can POST the **native ERP payload verbatim** (UBL/CII XML, PDF, image, proprietary file) as the raw request body and carry the wrapper constants in the URL. Triggered whenever `type` is present as a query param. Handy for wiring an ERP / iPaaS webhook straight at this endpoint.
* typeenumrequired
`invoice``credit-note``debit-note``purchase-order``sales-order``quote``goods-receipt``event`
Same enum as the body `type`. Its presence is what switches the endpoint into raw-body mode.
* fromstringoptional
Sender company. Defaults to the key's organization (`org:`) when omitted.
* contentTypestringoptional
MIME type of the raw body. Falls back to the `Content-Type` header, then a magic-byte sniff.
* filenamestringoptional
Filename persisted on the file record. Defaults to `.` or an auto-generated `event-*.`.
#### Address object
Used by `seller`/`buyer`/`parties[].address` and `shippingAddress`. All fields are optional strings, and every one of them reaches the e-invoice as the party's postal-address group — **BG-5** for the seller, **BG-8** for the buyer. All but `state` are also carried to the party's `billingAddress` record, as `street`, `street2`, `city`, `zipCode` and `country`.
* streetstring
Street and number — the first address line. → `BT-35` (seller) / `BT-50` (buyer).
* streetLine2string
Second address line (suite, box…). → `street2`, `BT-36` / `BT-51`.
* citystring
Town or city. → `BT-37` / `BT-52`.
* postalCodestring
→ `zipCode`, `BT-38` / `BT-53`.
* countryISO 3166-1 alpha-2
e.g. `"FR"`, `"BE"`. → `BT-40` / `BT-55`. It decides whose VAT rules the invoice is judged by; absent it, Flowie falls back to the country prefix of the party's VAT number.
* statestring
Country subdivision — province, région, state. → `BT-39` (seller) / `BT-54` (buyer). Also accepted as `region`. Required by some jurisdictions to fix which local tax applies; optional in the EU. This is the one address field that does not also land on the `billingAddress` record.
#### Totals: let your ERP be the source of truth
Omit `totals` and Flowie derives every amount from the lines, rounding the VAT once per rate as EN 16931 **BR-CO-17** requires. Send `totals` and **your** figures win outright.
[code]
"totals": {
"netAmount": 1267.16,
"vatAmount": 253.43,
"grossAmount": 1520.59
}
[/code]
The short keys `net` / `vat` / `gross` are accepted too.
Send them if your ERP has already posted the invoice
Your accounting system is the source of truth for what the customer owes, not us. Two systems computing the same total independently will eventually disagree by a cent — rounding a hundred lines is not associative — and then you have to explain which one is right. Sending `totals` removes the question: we carry your amounts through to the UBL unchanged. If you do send them, they must reconcile with the per-line sums, or validation fails.
#### Multiple VAT rates
VAT is carried **per line** : every `InvoiceLine` has its own `vatRate` and optional `vatCategory`. A document spanning several rates is simply several lines with different `vatRate` values — Flowie sums each line's tax, groups the totals by rate, and renders one `cac:TaxSubtotal` per rate. There is no document-level VAT array (none is accepted). If you also send `totals`, they must reconcile with the per-line sums or validation fails.
[code]
"lines": [
{ "description": "Consulting", "quantity": 10, "unitPrice": 150.00, "vatRate": 21.0, "vatCategory": "S" },
{ "description": "E-book (reduced)", "quantity": 1, "unitPrice": 40.00, "vatRate": 6.0, "vatCategory": "S" },
{ "description": "Intra-EU goods", "quantity": 1, "unitPrice": 500.00, "vatRate": 0.0, "vatCategory": "K" }
]
[/code]
Exempt / reverse-charge categories (`E`, `AE`, `K`, `G`, `O`) additionally need a VAT exemption reason — see [Tax exemption & zero rate](<#tax-exemption>).
#### Multiple parties
The common seller→buyer case needs no `parties` block — `from`/`to` (or `document.seller`/`document.buyer`) are enough, and Flowie injects a Payer party mirroring the buyer automatically. Supply `document.parties` only when the document has **more than two roles** (a `payer`/`payee` distinct from buyer/seller) or when the issuer is not the seller.
* Each entry is a `PartyRef`: `role` (`seller`·`buyer`·`payer`·`payee`), `id` (any resolvable id, same grammar as `to`), `name`, `vatNumber`, `initiator`.
* **Exactly one** entry must set `initiator: true` — the org the calling key is acting as (tx-docs requires the acting org to be a party).
* **Give every party a resolvable identity** — `id` (peppol / vat / siren / siret / duns / gln) or a `vatNumber`. tx-docs requires an organization on every party, so each is resolved to one (auto-created if new); if an id can't be resolved it falls back to the acting org so the document is still accepted.
* When `parties` is present it **overrides** the default seller/buyer derivation; Flowie injects nothing and your list is authoritative.
* Roles beyond these four aren't modelled by the structured pipeline — use `format=ubl-xml` for those.
[code]
"parties": [
{ "role": "seller", "id": "0009:FR86797978996", "name": "ACME FRANCE", "initiator": true },
{ "role": "buyer", "id": "0208:0123456789", "name": "MEGACORP BE" },
{ "role": "payee", "vatNumber": "FR90123456789", "name": "ACME FACTORING SAS" }
]
[/code]
For **self-billing** (the customer issues on the supplier's behalf), prefer the top-level `selfBilled: true` flag — Flowie flips the roles and tags the document UNCL1001 `389`. Use an explicit `parties` list only when self-billing also involves a third party.
#### Reception-point addressing (France)
In the French PPF/AFNOR model a recipient is not just a legal unit (SIREN) or an establishment (SIRET) — it is a specific **reception point** (_ligne annuaire_). A reception point is addressed with a composed identifier `{siren}_{siret}[_{suffix}]`, where the trailing `suffixeAdressage` selects which reception point inside the SIRET receives the document. The routing platform itself (`identifiantRoutage` — a declared PDP or the default public PPF) is a separate directory concept, resolved for you; you do not encode it here.
* **Auto-detected.** Pass the composed form as `to` with no prefix (e.g. `752978775_75297877500027_100003`, or just `75297877500027_001`) and Flowie recognises it by shape — an underscore-joined string carrying a 14-digit SIRET and/or a 9-digit SIREN. You can also be explicit with a `routage:` / `addressing:` prefix (aliases: `adressage:`, `routing:`, `adr:`).
* **The participant resolves as usual.** The SIRET (preferred, most specific) or SIREN drives recipient resolution through the ordinary layers — the suffix does not change _who_ the participant is.
* **The suffix is business routing, not part of the Peppol id.** It is never folded into `receiverPeppolId`. Instead it travels as document metadata under `metadata.recipientRouting` (`{ "addressingIdentifier": …, "addressingSuffix": … }`) and is echoed back on the response `to` object alongside `peppolId`. An explicit `metadata.recipientRouting` you send yourself is preserved and takes precedence.
* **Org ids are safe.** `org_…` / `comp_…` ids also contain an underscore; they are excluded from this detection and never mistaken for a SIREN/SIRET.
[code]
// request
"to": "752978775_75297877500027_100003"
// response — participant unchanged, suffix carried alongside
"to": {
"peppolId": "0009:75297877500027",
"addressingIdentifier": "752978775_75297877500027_100003",
"addressingSuffix": "100003"
}
[/code]
#### Custom fields & templates
Custom fields carry organization-specific data (cost centre, GL account, internal references…) on a document. They are defined by a **template** in your organization and are always scoped to **your own party** : document-level fields attach to your party (the acting org / initiator), line-level fields to a per-line party on your org.
* `document.templateId` — UUID of the template to file the document under. It declares the valid custom fields, their types, and whether each is document- or line-level. Omit to use your org's default template for the type.
* `document.customFields` — document-level values, an object keyed by the field's **name** (e.g. `"Cost Center"`) or its **definition UUID**. Names are resolved to UUIDs against your org's field definitions; a UUID key is forwarded as-is, while a **name that matches no declared field is rejected with a`400`** — pass the field's UUID or declare it on the `templateId` first.
* `line.customFields` — line-level values on each `InvoiceLine`, same key rules.
Value shapes follow each field's declared type: a bare string for text/date/number fields, `{ "currency": "EUR", "amount": 1000.00 }` for monetary fields, or an address object (`{ street, street2, city, zipCode, country }`).
[code]
"document": {
"number": "INV-2026-0042",
"issueDate": "2026-04-15",
"templateId": "8b1f…-template-uuid",
"customFields": {
"Cost Center": "CC-42",
"9f3a…-budget-uuid": { "currency": "EUR", "amount": 1000.00 }
},
"lines": [
{ "description": "Consulting", "quantity": 10, "unitPrice": 150.00, "vatRate": 21.0,
"customFields": { "GL Account": "606100" } }
]
}
[/code]
Custom fields are carried only on the structured `format=json` pipeline — for `ubl-xml`/`cii-xml`, embed them in the XML yourself.
#### Which BT fields are sent
**What a “BT” is.** An e-invoice is not a picture of an invoice — it is a list of named values that the recipient's software reads. The European standard EN 16931 gives each of those values a number: `BT-1` is the invoice number, `BT-9` the due date, `BT-120` the wording that explains why a line carries no VAT. Your accountant knows these as the mandatory mentions of an invoice; your customer's system knows them as the fields it matches and pays on; your developers see them as the codes a validator quotes when it rejects a document. They are the same thing under three names, and the table below lines all three up. The table below is the subset this endpoint maps; [the full list of all 164 business terms](<../compliance/fr/business-terms.html>) says what France requires of each one.
How to read the table
**Keeping the books?** Column 2 is the invoice mention you already know, and column 4 tells you when it is legally required.
**Running the business?** Column 4 is what happens when the value is missing — a rejected invoice, or one nobody can match and pay.
**Building the integration?** Column 1 is the JSON field you send, column 3 the EN 16931 code your customer's validator will name.
What you send| What it is on the invoice| BT| Why it matters
---|---|---|---
`number`| Invoice number| BT-1| Mandatory. The reference both sides quote for the life of the invoice.
`issueDate`| Invoice date| BT-2| Mandatory. Decides the VAT period the invoice falls in.
`dueDate`| Payment due date| BT-9| Drives the customer's payment run, and your late-payment rights.
`currency`| Invoice currency| BT-5| Mandatory. A non-EUR invoice must also report its VAT in EUR (`BR-FR-CO-12`).
`note`| Free-text mention| BT-22| Where legal wording goes that no coded field carries.
`buyerReference`| Customer's own reference (“Service exécutant” for public buyers)| BT-10| Public-sector buyers route on it; without it Chorus Pro refuses the invoice.
`orderReference`| Purchase order number| BT-13| Large customers match invoice to order before paying. No order number, no payment.
`despatchAdviceReference`| Delivery-note number| BT-16| Same match, against what was actually delivered.
`billingReference`, `billingReferenceDate`| The original invoice a credit note corrects, and its date| BT-25, BT-26| Mandatory on credit and debit notes under the French reform (`BR-FR-CO-04`/`05`).
`seller.name`, `buyer.name`| Legal name of each party| BT-27, BT-44| Mandatory identification of who sold and who bought.
`seller.vatNumber`, `buyer.vatNumber`| VAT identification number| BT-31, BT-48| Mandatory. Your customer reclaims its VAT against this number.
`seller.address`, `buyer.address`| Billing address (street, city, post code, country)| BG-5 (BT-35…BT-40), BG-8 (BT-50…BT-55)| Mandatory, and the country decides whose VAT rules apply.
`contact` on either party| Contact name, phone, e-mail| BT-41/42/43, BT-56/57/58| Where the customer's platform sends questions about the invoice.
`payment.means`| Method of payment| BT-81 or BT-82| Tells the customer how you expect to be paid.
`payment.reference`| Payment reference to quote on the transfer| BT-83| What lets you tie an incoming transfer back to this invoice.
`payment.iban`| Bank account to credit, and its holder| BT-84, BT-85| Where the money actually lands.
`payment.bic`| Bank identifier| BT-86| Required by some banks for cross-border transfers.
`delivery.actualDeliveryDate`| Delivery date| BT-72| On goods, this can be the date the VAT becomes chargeable.
`delivery.deliveryLocation.country`| Country the goods were delivered to| BT-80| Mandatory on intra-community supplies (`BR-IC-12`).
`incoterms`, `incotermsLocation`| Delivery terms code, and the named place it refers to| EXT-FR-FE-185, EXT-FR-FE-186| Who bears carriage and risk — what your customer reconciles freight charges against. AFNOR's French extension, not part of the EN 16931 core, so a recipient on the core profile may ignore it. One of `1`, `2` (UNTDID 4053) or an Incoterms 2020 code: `EXW`, `FCA`, `CPT`, `CIP`, `DAP`, `DPU`, `DDP`, `FAS`, `FOB`, `CFR`, `CIF`. The place is optional; the code is not.
line — `description`, `quantity`, `unit`, `unitPrice`| Description, quantity, unit, unit price excl. VAT| BT-153, BT-129, BT-130, BT-146| Mandatory. The line detail every invoice has to show.
line — `vatRate`, `vatCategory`| VAT rate and VAT category of the line| BT-152, BT-151| Mandatory. Sets the VAT charged, line by line.
line — `vatExemptionReason`, `vatExemptionCode`| Wording that justifies charging no VAT, and its official code| BT-120, BT-121| An exempt or reverse-charge invoice without it is rejected outright. See [Tax exemption & zero rate](<#tax-exemption>).
line — `period`| Period the line covers| BT-134, BT-135| How a subscription or a service billed per period states what it covers.
line — `itemCode`, `buyerItemCode`, `itemDescription`| Your item reference, your customer's item reference, long description| BT-155, BT-156, BT-154| Your customer's system matches on _its own_ part number, not yours.
line — `orderLineReference`, `objectIdentifier` \+ `objectIdentifierScheme`| Order line the line answers, and a document it refers to with the kind of document that is| BT-132, BT-128 + BT-128-1| Line-by-line matching when one invoice covers several orders or deliveries. EN 16931 requires the scheme whenever the identifier is present (`BR-CO-24`).
line — `priceBaseQuantity` \+ `priceBaseUnit`| Number of units the unit price applies to, and their unit| BT-149 + BT-150| For a price quoted per batch — « per 1000 pieces » — rather than per single unit. Without it the price reads as a per-unit price and the line total looks wrong by orders of magnitude.
line — `netAmount`| The line's own net amount, VAT excluded| BT-131| Stops Flowie recomputing the line from `quantity × unitPrice`. Reach for it when the line carries a discount or a surcharge, or when its price is quoted per batch: 519.1 ML at 12.60 less a 3 % line discount is 6344.44, not the 6540.66 the multiplication gives.
line — `despatchAdviceReference`, `despatchAdviceLineReference`| Delivery note the line arrived on, and the line within it| EXT-FR-FE-140, EXT-FR-FE-141| AFNOR's French extension to EN 16931, for an invoice spanning several deliveries. Defaults to the document's `despatchAdviceReference`.
`totals`| Pre-computed net, VAT and gross for the whole invoice| BT-109, BT-112, BT-115, BT-117| Overrides the sums Flowie takes from your lines. Send it when your accounting system is the authority on the figures; omit it and the lines are summed for you.
`exchangeRate`| Rate your books posted the invoice at| feeds BT-111| A non-EUR invoice must also report its VAT in euros (`BR-FR-CO-12`). Stating your own rate keeps that figure tied to your ledger instead of a market feed.
`parties`| The same parties, stated as an explicit role-tagged list| BG-4 / BG-7, exactly as `seller` / `buyer` above| How you state a `payer` or `payee` distinct from the buyer, or self-bill. Overrides `seller`/`buyer` when present.
`contacts` on either party| Contact e-mail addresses| BT-43 / BT-58| The first entry becomes the party's contact e-mail when `contact.email` is absent.
##### Fields that do not become a BT
Everything else the `document` body accepts is listed here, so that no field you can send is left unaccounted for. Two of these are worth an accountant's attention: `allowances`/`charges` and `payment.discountTerms` are accepted today and **not yet placed on the e-invoice** , so a discount stated only there will not be visible to your customer's software.
What you send| What it is for| What happens to it
---|---|---
`templateId`| Which of your organization's e-invoicing templates to file the document under| Decides which BT slots exist at all, so it governs the table above. Not itself a value on the invoice.
`customFields`, and `customFields` on a line| Your own data on the document — cost centre, GL account, an internal reference| Carried on the document, attached to your own party. Organization-specific by definition, so outside EN 16931 and invisible to your customer's validator.
`allowances`, `charges`| Discounts and surcharges for the whole invoice (BG-20 / BG-21) — a loyalty discount, shipping| **Accepted, not yet rendered.** Send `format=ubl-xml` to carry them today, or fold the amount into a line.
`attachments`| Supporting documents embedded in the invoice (BG-24) — a timesheet, a signed delivery note| **Accepted, not yet rendered.** Post the file with `format=auto`/`raw` and the top-level `file`, or embed it in `format=ubl-xml`.
`payment.discountTerms`| Early-payment discount — « 2 % if paid within 10 days »| **Accepted, not yet rendered.** EN 16931 carries it in the payment terms (BT-20); put the wording in `note` (BT-22) if your customer has to read it.
`shippingAddress` on either party| That party's delivery address| Accepted and stored, but the deliver-to country the invoice states (BT-80) is read from `delivery.deliveryLocation`, not from here.
`type`, `format`, `from`, `to`, `file`| The envelope — what kind of document, in what shape, from whom, to whom| Routing and recognition, not invoice content. `from` and `to` resolve to the Peppol participants that deliver the document, and the parties they resolve to fill the party BTs above.
##### What decides whether a field is actually sent
Your e-invoicing template declares a slot for each BT it supports and marks each one _required_ or _optional_. Two rules decide what fills them:
* **Anything you state is sent** , required slot or optional one. If you put a value in the request — the customer's order number, your IBAN, the exemption wording on a line — it reaches its BT. This is worth stating because it used to be false: an optional slot was skipped, so a value you sent could be accepted with `200` and then carried by nothing. Real French e-invoicing templates mark almost everything past the EN 16931 core optional, so that silence covered a lot of ground.
* **Anything Flowie works out for you is sent only where the template requires it.** Totals summed from your lines, the document type code (`BT-3`), the line numbering (`BT-126`), the VAT category of the breakdown (`BT-118`): these are our inference, not your statement, so they fill a required slot and stay out of an optional one. Put plainly — we will complete an invoice for you, but we will not put words in your mouth where you did not have to speak.
Where both apply, what you stated wins: `BT-40`, the seller's country, comes from `seller.address.country` when you send an address, and falls back to the country prefix of the VAT number when you don't.
One limit worth knowing: **a BT only renders if your template declares a slot for it.** The mapping fills the slots your template has; it does not create new ones. If a value you send is not appearing on the e-invoice, ask your Flowie contact which BT-* fields your template declares.
#### Tax exemption & zero rate
Each line's `vatCategory` is a UNCL5305 code. Use `S` for normal taxable supplies. The categories below carry `vatRate: 0` and cover zero-rate, exemption, reverse charge, and out-of-scope supplies:
Code| Meaning| Typical use| Exemption reason required?
---|---|---|---
`S`| Standard rate| Normal VAT (e.g. 20%, 21%)| No
`Z`| Zero rated| Taxable at 0%| No
`E`| Exempt| VAT-exempt supply| **Yes**
`AE`| Reverse charge| Buyer accounts for VAT (intra-EU B2B)| **Yes**
`K`| Intra-community supply| Intra-EU supply of goods| **Yes**
`G`| Free export item| Export outside the EU| **Yes**
`O`| Not subject to VAT| Outside the scope of VAT| **Yes**
Exempt categories need a reason
EN16931 / Peppol BIS 3.0 schematron **rejects** an invoice that uses `E`, `AE`, `K`, `G`, or `O` unless it also carries a VAT exemption reason — a code from the [VATEX]() list (BT-121) and/or free text (BT-120). Send it **per line** with `vatExemptionReason` and `vatExemptionCode`: Flowie lifts the reason to the document VAT breakdown (BG-23) _and_ repeats it on each line’s `cac:ClassifiedTaxCategory`, which the French `BR-FREXT--08rev` reconciliation rule requires — a reason present only at document level makes that rule count zero lines and warn on BT-92 / BT-99 / BT-116 / BT-131. `Z` (zero-rated) and `S` need no reason.
**French franchise en base needs the CODE, not just the text.** `BR-FR-CO-16` requires `BT-118 = "E"` _and_ `BT-121 = "VATEX-FR-FRANCHISE"`. Free text in BT-120 alone does not satisfy it. If the seller has no VAT number it must also repeat its SIREN in BT-32.
**And neither survives the reform flows.** `BR-FR-MAP-08` / `-09` instruct the platform to transcode `BT-118 = "E"` \+ `VATEX-FR-FRANCHISE` to `"Z"` and to **drop BT-121 and BT-120** in flux 1 and flux 10.1. So BT-120 must not be the only carrier of anything the recipient has to read: put that wording in the document `note` (BT-22) as well, which is passed through.
**Two aggregation limits, both of which drop BT-120 silently.**
1\. **One reason per document.** The breakdown has a single BT-120 slot. If two exempt lines carry _different_ `vatExemptionReason` texts, neither is emitted. Use the same wording on every exempt line.
2\. **Do not mix standard-rated and exempt lines.** When a document contains any `vatCategory: "S"` line, the breakdown collapses to `S` and BT-120 / BT-121 are not emitted at all. Split the exempt lines onto their own document, or send `format=ubl-xml` with repeated `cac:TaxSubtotal` groups.
The exempt `` block to include in your UBL (both at line level under `` and in the document ``):
[code]
AE0VATEX-EU-AEReverse chargeVAT
[/code]
#### Returns
`201 Created` with the [document object](<#documents>). For structured payloads `deliveryStatus` starts as `pending`; listen for `document.delivered` or `document.failed`. For raw uploads `deliveryStatus="stored"` and the response includes `fileId` \+ `storedFormat`.
##### Request — full invoice
[code]
curl -X POST https://back.p2p-flowie.com/exchange/v1/documents/send \
-H "Authorization: Bearer $KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: inv-2026-0417" \
-d '{
"type": "invoice",
"from": "comp_01HXYZ…",
"to": "0208:9876543210",
"document": {
"number": "INV-2026-0417",
"issueDate": "2026-04-25",
"dueDate": "2026-05-25",
"currency": "EUR",
"buyerReference": "SERV-FIN-042",
"orderReference": "PO-91234",
"seller": { "name": "ACME BVBA" },
"buyer": { "name": "Globex SRL", "vatNumber": "IT01234567890" },
"payment": {
"means": "credit_transfer",
"iban": "BE68539007547034",
"bic": "BPOTBEB1",
"reference": "INV-2026-0417"
},
"lines": [
{
"description": "Consulting services — April 2026",
"quantity": 10, "unit": "hours",
"unitPrice": 150.00,
"vatRate": 21,
"vatCategory": "S"
},
{
"description": "Travel expenses",
"quantity": 1, "unit": "lump",
"unitPrice": 450.00,
"vatRate": 21,
"vatCategory": "S"
}
]
}
}'
[/code]
##### Response
201 Created 422 Unreachable
[code]
{
"id": "doc_01HY7AB9C2DE3FG",
"type": "invoice",
"direction": "outgoing",
"number": "INV-2026-0417",
"issueDate": "2026-04-25",
"dueDate": "2026-05-25",
"currency": "EUR",
"grossAmount": 2359.50,
"netAmount": 1950.00,
"vatAmount": 409.50,
"sender": { "peppolId": "0208:0123456789", "name": "ACME BVBA" },
"receiver": { "peppolId": "0208:9876543210", "name": "Globex SRL" },
"status": "sent",
"deliveryStatus": "pending",
"lifecycleStatus":"issued",
"sentAt": "2026-04-25T10:05:00Z",
"createdAt": "2026-04-25T10:05:00Z"
}
[/code]
[code]
{
"error": {
"type": "delivery_error",
"code": "RECIPIENT_NOT_FOUND",
"message": "0208:9876543210 is not registered on Peppol for document type 'invoice'.",
"requestId":"req_…"
}
}
[/code]
### Batch send
POST/v1/documents/send/batch
Submit up to 100 documents in one request. Results come back in the same order as the input; failures don't poison successful sends.
#### Request body
* documentsSendItem[]required
Array of send items. Each item takes the same fields as [Send a document](<#send-document>) (`type`, `format`, `from`, `to`, `document`, `xml`, `file`) plus an optional per-item `idempotencyKey`.
[code]
{
"documents": [
{ "type": "invoice", "from": "comp_…", "to": "0208:…", "document": {…} },
{ "type": "invoice", "from": "comp_…", "to": "0208:…", "document": {…} }
]
}
[/code]
[code]
{
"results": [
{ "ok": true, "id": "doc_01…", "status": "sent" },
{ "ok": false, "error": { "code": "INVALID_REQUEST", "message": "…" } }
],
"sent": 1,
"failed": 1
}
[/code]
### Validate without sending
POST/v1/documents/validate
Run full Peppol BIS schematron + recipient reachability checks without delivering anything. Handy as a CI step before switching a customer live.
**Authentication:** `Authorization: Bearer ` with a live or test Exchange key (`flw_live_…` / `flw_test_…`) or a Flowie JWT.
#### Request body
Same shape as [Send a document](<#send-document>), minus the raw `file` upload mode.
* typeenumrequired
`invoice``credit-note``debit-note``purchase-order``purchase-request``sales-order``quote``goods-receipt``event`
* formatenumoptional
`json``ubl-xml``cii-xml`
* fromstringrequired
Sender company — `comp_…`, `vat:…`, or `peppol:…`.
* tostringrequired
Recipient Peppol participant identifier (drives the reachability check).
* documentDocumentBodyrequired when format=json
Same structured body as Send. See [schema](<#send-document>).
* xmlstringrequired when format=ubl-xml / cii-xml
[code]
{
"valid": false,
"errors": [
{ "rule": "BR-16", "message": "An Invoice shall have at least one line.",
"path": "/Invoice/InvoiceLine" }
],
"warnings": [],
"recipientReachable": true
}
[/code]
### List documents
GET/v1/documents
Paginated list across both directions — this is the polling half of [receiving documents](<../guides/receive-invoices.html>), for integrations that cannot expose a webhook endpoint.
**Authentication:** `Authorization: Bearer ` with a live or test Exchange key (`flw_live_…` / `flw_test_…`) or a Flowie JWT.
Cursor-paginated like every list endpoint: the response is `{ "data": […], "hasMore": true, "cursor": "…" }`. Keep passing the returned `cursor` until `hasMore` is `false`; never hard-code an offset.
#### Query parameters
* directionenumoptional
`incoming``outgoing`
* typeenumoptional
`invoice``credit-note``debit-note``purchase-order``sales-order``quote``goods-receipt``event`
* statusstringoptional
Exact `lifecycleStatus` — org-specific and may be localized (e.g. `draft`, `sent`). The delivery values `delivered` / `failed` are routed to `deliveryStatus`.
* deliveryStatusenumoptional
`pending``delivered``failed``rejected`
Peppol network delivery state — use this (not `status`) to find delivered documents.
* from / todateoptional
Filter by `issueDate` range.
* amountMin / amountMaxnumberoptional
Gross amount bounds.
* companyIdstringoptional
* searchstringoptional
Full-text over number, party names, references, note.
* limit / cursorpaginationoptional
### Advanced search
POST/v1/documents/search
The POST counterpart of [List documents](<#list-documents>). It runs against the same index and returns the same paginated `{ "data": […], "hasMore": …, "cursor": … }` shape and the same tenant scoping — the only difference is that the query travels in a JSON body instead of the query string. Reach for it when a filter set is too large for a URL, or when you need a nested boolean predicate that the flat query params can't express.
The `filters` object is a predicate tree. Each leaf is `{ "": }`, where the match is either a bare scalar (exact match) or an operator object like `{ "$gte": 1000 }`. Wrap leaves in the boolean keys `$and` and `$or` to nest them to any depth. Field names are the document's **stored field names** (e.g. `documentType`, `issuedAt`) — _not_ the friendly query-string params of the list endpoint — so consult the table below rather than reusing the `GET` parameter names.
#### Request body
* querystringoptional
Free-text query — identical semantics to the list `search` param (matches document number and party names).
* filtersobjectoptional
Predicate tree over the fields in the table below, combined with the `$and` / `$or` keys. A bare value is an exact match, a bare array an any-of match; an operator object narrows it. See the operator table and example.
* sortobjectoptional
Map of `field → direction`, e.g. `{"issuedAt": "desc"}`. Multiple keys are applied left to right. A bare string (`"issuedAt:desc"`) is also accepted, as is a list mixing either form. Sort on any field in the table below, including `createdAt` / `updatedAt`.
* limitintegeroptional
Page size, clamped to `1`–`100`. Default `20`.
* cursorstringoptional
Opaque pagination cursor from the previous page. Keep passing it until `hasMore` is `false`.
#### Filterable fields
Field| Type| Notes
---|---|---
`documentType`| string| Uppercase — `INVOICE`, `CREDIT_NOTE`, `DEBIT_NOTE`, `PURCHASE_ORDER`, …
`lifecycleStatus`| string| Exact business status. Org-specific and may be localized (e.g. `sent`, `Reçue par la plateforme`).
`issuedAt`| date| Issue date. Use `$gte` / `$lte` for a range — this is the field behind the list endpoint's `from` / `to`.
`dueDate`| date| Payment due date.
`totalAmountDue`| number| Gross amount. Use `$gte` / `$lte` for bounds (the list endpoint's `amountMin` / `amountMax`).
`number`| string| Document number.
`currency`| string| ISO 4217 code.
`sellerId` / `payerId`| string| Party org ids. Every result is already scoped to your org; add one of these to pin the direction — your org as `sellerId` is outgoing, as `payerId` is incoming.
`createdAt` / `updatedAt`| datetime| Ingest / last-change timestamps. Handy as `sort` keys.
#### Operators
Values follow Strapi v4 filter semantics. A bare scalar is shorthand for `$eq`, a bare array for `$in`. An operator outside this table is refused with a `400` naming it.
Operator| Meaning
---|---
`$eq` / `$ne`| Equals / not equal (bare scalar ⇒ `$eq`).
`$gt` / `$gte`| Greater than / greater-or-equal.
`$lt` / `$lte`| Less than / less-or-equal.
`$in` / `$notIn`| Matches / does not match any value in a JSON array (bare array ⇒ `$in`).
`$contains` / `$notContains`| Case-sensitive substring match / its negation.
`$null` / `$notNull`| Field is unset / set. Takes `true`.
`$and` / `$or`| Boolean combinators over an array of nested predicate blocks. There is no `$not`.
`companyId` is a tenant selector, not a filter
A `companyId` key inside `filters` is pulled out and used to **scope the query to that company** (validated against your token — it can narrow to a company you manage, never widen to another tenant). It is not matched as a document column. Pass it as a bare id or `{"$eq": ""}`.
Two list filters have no structured equivalent
The list endpoint's `direction` and `deliveryStatus` are computed conveniences, not stored columns — putting them in `filters` will not match. For direction, filter on `sellerId` / `payerId` as above; for Peppol delivery state, use `GET /v1/documents?deliveryStatus=…`.
#### Filter recipes
Worked `filters` for the queries people actually build. Sibling keys inside a block are **AND** ed for you — you only need an explicit `$and` when you want to group an `$or` alongside other conditions.
**Invoices over €1,000, newest first.** Two sibling keys ⇒ implicit AND.
[code]
{
"filters": {
"documentType": { "$eq": "INVOICE" },
"totalAmountDue": { "$gte": 1000 }
},
"sort": { "issuedAt": "desc" }
}
[/code]
**Anything issued in Q1 2026.** A single field with two bounds is a closed range.
[code]
{
"filters": { "issuedAt": { "$gte": "2026-01-01", "$lte": "2026-03-31" } }
}
[/code]
**Invoices _or_ credit notes.** `$in` matches any value in the list — cleaner than an `$or` of equalities.
[code]
{
"filters": { "documentType": { "$in": ["INVOICE", "CREDIT_NOTE"] } }
}
[/code]
**Everything that needs attention: disputed, or high-value.** An `$or` block holds an array of alternatives.
[code]
{
"filters": {
"$or": [
{ "lifecycleStatus": { "$eq": "disputed" } },
{ "totalAmountDue": { "$gte": 10000 } }
]
}
}
[/code]
**Unpaid invoices in a window.** Group an `$or` with other conditions using an explicit `$and`, and exclude a status with `$ne`.
[code]
{
"filters": {
"$and": [
{ "documentType": { "$eq": "INVOICE" } },
{ "lifecycleStatus": { "$ne": "paid" } },
{ "issuedAt": { "$gte": "2026-01-01" } }
]
},
"sort": { "dueDate": "asc" }
}
[/code]
**Free text, then narrow structurally.** `query` runs the full-text search; `filters` refines it.
[code]
{
"query": "ACME",
"filters": { "issuedAt": { "$gte": "2026-01-01" } }
}
[/code]
**One company in a multi-tenant account.** `companyId` scopes the query to that company (validated against your token); the rest still filters normally.
[code]
{
"filters": {
"companyId": "comp_abc123",
"documentType": { "$eq": "INVOICE" }
}
}
[/code]
**Outgoing only.** There's no `direction` field — pin the direction by matching your own org as the seller (use `payerId` for incoming). Results are already scoped to your org, so this just narrows the role.
[code]
{
"filters": { "sellerId": { "$eq": "org_685a5670efafaa26ebf0128e" } }
}
[/code]
### Retrieve a document
GET/v1/documents/{document_id}
### Download XML
GET/v1/documents/{document_id}/xml
Returns the signed UBL XML with `Content-Type: application/xml`.
### Download PDF
GET/v1/documents/{document_id}/pdf
Returns a human-readable PDF rendering.
### Structured view
GET/v1/documents/{document_id}/structured
Flat, scalar-only representation — perfect for pushing to a data warehouse or spreadsheet.
### Document actions
POST/v1/documents/{document_id}/actions
Non-lifecycle operations: `mark-read`, `mark-unread`, `archive`, `unarchive`, `tag`, `untag`, `assign`, `unassign`, `add-note`, `link`.
* actionenumrequired
`mark-read``mark-unread``archive``unarchive``tag``untag``assign``unassign``add-note``link`
* tagstringconditional
Required by the tag / untag actions.
* userIdstringconditional
Required by the assign / unassign actions.
* notestringconditional
Required by the add-note action.
* relatedDocumentIdstringconditional
The document to link to — required by the link action.
[code]
curl "…/v1/documents?direction=outgoing&status=sent&from=2026-04-01&amountMin=500" \
-H "Authorization: Bearer $KEY"
[/code]
##### Structured response
[code]
{
"id": "doc_01…",
"type": "invoice",
"direction": "incoming",
"number": "INV-2026-0417",
"issueDate": "2026-04-25",
"dueDate": "2026-05-25",
"currency": "EUR",
"grossAmount": 2359.50,
"netAmount": 1950.00,
"vatAmount": 409.50,
"status": "delivered",
"lifecycleStatus": "approved",
"deliveryStatus": "delivered",
"senderPeppolId": "0208:0123456789",
"senderName": "ACME BVBA",
"senderVatNumber": "BE0123456789",
"receiverPeppolId": "0208:9876543210",
"receiverName": "Globex SRL",
"receiverVatNumber": "IT01234567890",
"buyerReference": "SERV-FIN-042",
"orderReference": "PO-91234",
"paymentIban": "BE68539007547034",
"paymentReference": "INV-2026-0417",
"receivedAt": "2026-04-25T10:05:08Z",
"sentAt": "2026-04-25T10:05:00Z",
"createdAt": "2026-04-25T10:05:00Z",
"updatedAt": "2026-04-25T10:05:08Z"
}
[/code]
## Lifecycle
Once a document is delivered, it moves through a business-level state machine: `issued → under_review → approved → partially_paid → paid`, with side branches for `rejected` and `disputed`. Flowie persists the history, enforces allowed transitions, and **reports each relevant change to the national compliance platform automatically**.
Put it on hold before you refuse
Refusing (`rejected`) is **terminal** — in France it transmits _210 Refusée_ , which cancels the invoice for VAT and forces the supplier to issue a corrective. If the disagreement might still be resolved, **prioritize the reversible paths first** : `disputed` to contest the content, or `disputed` with `reasonCode:"suspended"` to put the invoice **on hold** pending documents — both keep it alive and can resolve back to approval. Reach for `rejected` only when you are certain the invoice must be cancelled and re-issued. [Choosing the right status & reason →](<#reason-codes>)
### Retrieve lifecycle history
GET/v1/documents/{document_id}/lifecycle
Full event log, current status, allowed transitions, and per-country compliance state. When the current status stems from a failed validation, `currentStatusReason` carries the failing EN 16931 / CTC-FR schematron rule ids.
### Update lifecycle status
POST/v1/documents/{document_id}/lifecycle
#### Body
* statusenumrequired
`under_review``approved``rejected``partially_paid``paid``disputed`
* reasonCodeenumconditional
`NON``REF``LEG``REC``QUA``DEL``PRI``QTY``ITM``PAY``UNR``FIN``PPD``OTH`
Required for **rejected** and **disputed**. One of the 14 [official Peppol status reason codes](<#reason-codes>) (OPStatusReason) — full table below. 🇫🇷 France: an AFNOR motif code (XP Z12-012 annex) is forwarded verbatim as MDT-113, and `suspended` on a _disputed_ call transmits _208 Suspendue_ — see [FR refusal & rejection](<../compliance/fr/refusal-rejection.html#motifs>).
* reasonstringoptional
Free-text explanation shown to the counterparty (forwarded verbatim as MDT-114 in France). Always pair it with reasonCode **OTH**.
* notestringoptional
* paymentDatedateconditional
Required for `paid` / `partially_paid`.
* paymentAmount / paymentCurrency / remainingAmountnumber / ISO 4217conditional
* paymentReferencestringoptional
### Batch lifecycle update
POST/v1/documents/lifecycle/batch
Up to 500 updates in one call. Atomic per document; failures are reported per item.
#### Request body
* updatesobject[]required
Array of updates. Each entry is a [lifecycle update body](<#update-lifecycle>) (`status`, `reason`, `note`, `paymentDate`, …) plus the target `documentId`.
Allowed transitions
Trying to skip states (e.g. `issued → paid` without a prior `approved`) returns `409 invalid_transition` and a hint listing legal next states. Fetch [the history](<#get-lifecycle>) to see what's allowed now.
[code]
curl -X POST …/v1/documents/doc_abc/lifecycle \
-H "Authorization: Bearer $KEY" \
-d '{
"status": "paid",
"paymentDate": "2026-04-25",
"paymentAmount": 2359.50,
"paymentCurrency": "EUR",
"paymentReference": "PAY-2026-0001"
}'
[/code]
[code]
{
"documentId": "doc_abc",
"previousStatus": "approved",
"currentStatus": "paid",
"updatedAt": "2026-04-25T10:35:00Z",
"compliance": {
"reportedTo": ["PPF","SDI"],
"status": "reported",
"nextCheckAt":"2026-04-25T10:40:00Z"
},
"allowedTransitions": ["disputed"]
}
[/code]
### Update lifecycle status by invoice number
POST/v1/documents/by-number/{number}/lifecycle
Move a document to a new status, targeting it by its **invoice number** (the value printed on the invoice) instead of Flowie's internal `documentId`. Integration partners often only hold the human-readable number, not our id.
This route resolves the number to exactly one document **scoped to your organization** , then applies the _same_ transition as [`POST /v1/documents/{document_id}/lifecycle`](<#update-lifecycle>) — identical state-machine validation, the same transaction-documents update, the same PPF/SDI compliance reporting for FR/IT documents, and the same `lifecycle.updated` webhook. **The request body and the success response are identical to the id-based route** (see [Update lifecycle status](<#update-lifecycle>) for the full field list), so payment fields (`paymentDate`, `paymentAmount`, `paymentCurrency`, `paymentReference`) are required for `paid` / `partially_paid` here too.
Because invoice numbers are **not unique** (the same number can exist as a sale and a purchase, or across periods), resolution is strict:
Matches in your org| Result
---|---
0| `404 not_found` — no document with that invoice number that your organization is a party on.
exactly 1| `200` — the transition is applied and the updated document is returned.
more than 1| `409 conflict` — ambiguous; re-issue the call against [`POST /v1/documents/{documentId}/lifecycle`](<#update-lifecycle>) with the specific `documentId`.
Tenant-scoped resolution
Matching is always confined to documents your organization is a party on — an invoice number belonging to another tenant is invisible and resolves to `404`, never another org's document.
[code]
curl -X POST …/v1/documents/by-number/INV-2026-0042/lifecycle \
-H "Authorization: Bearer $KEY" \
-d '{
"status": "approved",
"note": "Invoice verified against PO"
}'
[/code]
[code]
{
"documentId": "doc_test001",
"previousStatus": "received",
"currentStatus": "approved",
"updatedAt": "2026-04-15T10:32:18.421Z",
"compliance": {},
"allowedTransitions": ["partially_paid", "paid", "disputed"]
}
[/code]
## Directory
Peppol's public directory lets you find any registered participant across every access point in Europe. Use these endpoints to verify reachability _before_ sending.
### Search directory
GET/v1/directory/search
Find any participant registered on the Peppol network. You must supply at least one search criterion — `q` or `vatNumber` — and **a free-text`q` must be scoped by `country`** (a bare SIREN/SIRET or a `vatNumber` already carries its country, so it's exempt). Matching on `q` is fuzzy (substring). By default results are collapsed to one row per legal entity — the directory lists each company once per identifier scheme.
* qstringconditional
Free-text company name, e.g. `epsa`. A bare 9- or 14-digit value is treated as a French SIREN/SIRET and routed to an exact lookup. **One of`q` or `vatNumber` is required.**
* vatNumberstringconditional
Exact VAT number, e.g. `BE0633501357` or `FR26921376265`. **One of`q` or `vatNumber` is required.**
* countryISO 3166-1 α-2conditional
**Required when searching by a free-text`q`**, e.g. `BE`. Optional (a filter) otherwise.
* city / postalCodestringoptional
Further geographic filters.
* naceCodesstring[]optional
Filter by NACE business-activity code(s).
* documentTypesstring[]optional
Only return participants that can receive these types.
* includeSubEntitiesbooleanoptional
Default `false` (one row per legal entity). Set `true` to return every Peppol identifier-scheme / establishment row — needed when you want the exact routable participant ID. For a French SIREN or SIRET this includes the company's Peppol addressing lines (`0225:{siren}_{suffix}`).
* detailenumoptional
`basic``full`
Default `basic` (directory fields only). `full` enriches each row with access-point / SMP detail — slower, one lookup per result. A French row is resolved from its company's Peppol lines, read once for all the rows of the same company.
* limitintegeroptional
Max distinct participants to return. Default `20`.
### Lookup Peppol ID
GET/v1/directory/{peppol_id}
Resolve a participant ID — `0009:921376265` for a French SIREN, `0208:0123456789` for a Belgian CBE — against local registrations, the PPF annuaire (French IDs) and the Peppol Directory. Returns `404` when no source knows it.
A French company is usually listed on Peppol several times: `0225:{siren}` and suffixed addressing lines such as `0225:{siren}_hrs`. `participants` lists every line of the company. For a company ID such as `0009:{siren}`, `smpStatus` and `documentTypes` describe the company across its lines; for one line looked up directly, they describe that line alone.
### Verify recipient
POST/v1/directory/verify
The **recommended pre-flight check** before every send. Tells you whether the recipient exists _on the Peppol network_ , whether a participant actually advertises the document type, and where to send it. Being listed in a national company register is not being reachable: a French company the PPF annuaire knows but the network does not answers `exists: false`. For a French company ID (`0009:{siren}`) the answer covers its addressing lines, and `acceptedBy` names the ones that take this document — the company ID itself is not an address.
#### Request body
* peppolIdstringrequired
Recipient Peppol participant identifier, e.g. `0208:9876543210`.
* documentTypestringrequired
Document type to check reachability for, e.g. `INVOICE`.
[code]
curl "…/v1/directory/search?q=epsa&country=BE&limit=20" \
-H "Authorization: Bearer $KEY"
[/code]
[code]
{
"data": [
{
"peppolId": "0208:0655917760",
"name": "EPSA MARKETPLACE Belgium SRL",
"country": "BE",
"city": null,
"postalCode": null,
"vatNumber": null,
"documentTypes": ["invoice", "credit-note"],
"accessPoint": null
}
],
"hasMore": true,
"cursor": null
}
[/code]
[code]
curl -X POST …/v1/directory/verify \
-H "Authorization: Bearer $KEY" \
-d '{
"peppolId": "0208:9876543210",
"documentType":"INVOICE"
}'
[/code]
[code]
{
"peppolId": "0208:9876543210",
"exists": true,
"canReceive": true,
"recipientName": "Globex SRL",
"documentType": "INVOICE",
"accessPoint": "peppol.ehealth.fgov.be",
"acceptedBy": []
}
[/code]
## Partners
A **partner** is a counterparty you regularly transact with — a customer, a supplier, or both. Partners store defaults (preferred currency, payment terms, contacts, routing ID) so you don't have to supply them on every send.
### Create a partner
POST/v1/partners
At least one of `peppolId` or `vatNumber` is required.
* peppolIdstringconditional
Pattern `^\d{4}:.+$`.
* vatNumberstringconditional
* roleenumoptional
`supplier``buyer``both`
* contactName / contactEmailstringoptional
* defaultsobjectoptional
`currency`, `paymentTermsDays`, `note`, `orderReference`…
* tags / metadataarray / objectoptional
### List partners
GET/v1/partners
#### Query parameters
* roleenumoptional
`supplier``buyer``both`
* searchstringoptional
Full-text over name, VAT, and Peppol ID.
* countryISO 3166-1 α-2optional
* tagsstringoptional
Comma-separated tag filter.
* hasActivitybooleanoptional
Only partners with at least one sent/received document.
* peppolStatusstringoptional
* sortBy / orderstringoptional
Field to sort by and direction (`asc` / `desc`).
* limit / cursorpaginationoptional
### Retrieve partner
GET/v1/partners/{partner_id}
Path accepts `part_…`, `vat:…`, or `peppol:…`.
### Update partner
PATCH/v1/partners/{partner_id}
#### Request body
All fields optional — same shape as [create](<#create-partner>).
* peppolIdstringoptional
* vatNumberstringoptional
* roleenumoptional
`supplier``buyer``both`
* contactName / contactEmailstringoptional
* defaultsobjectoptional
* tags / metadataarray / objectoptional
### Delete partner
DEL/v1/partners/{partner_id}
### Retrieve a partner by account number
GET/v1/partners/by-account-number
Reverse lookup: resolve the partner behind one of your own internal customer or supplier account numbers. The value is matched against a custom field on your partner records — scoped to your organization — and the matched record is resolved to the partner’s full profile (name, VAT number, country).
The custom field must be populated on the partner records you want to reach. Returns `404` when no partner carries that value.
#### Query parameters
* valuestringrequired
The exact account number to look up.
* fieldstringoptional
Name of the custom field holding the account number. Defaults to `Numéro de compte interne`.
* entityTypestringoptional
Entity the custom field is attached to. Defaults to `PARTNERSHIP`.
Requires the `partners.read` scope. Returns a [partner](<#get-partner>) object.
### List a partner’s invoices
GET/v1/partners/{partner_id}/invoices
Every invoice exchanged between your organization and this partner — the partner is matched as either seller or payer. Results are always scoped to your organization: you only ever see documents your organization is a party to.
#### Query parameters
* limitintegeroptional
1–100. Defaults to 20.
* cursorstringoptional
Opaque cursor returned by the previous page.
Returns a paginated list of [document](<#list-documents>) summaries.
[code]
curl -X POST …/v1/partners \
-H "Authorization: Bearer $KEY" \
-d '{
"peppolId": "0208:9876543210",
"role": "buyer",
"contactName":"Laura Rossi",
"contactEmail":"laura@globex.it",
"defaults": { "currency": "EUR", "paymentTermsDays": 30 },
"tags": ["strategic","italy"]
}'
[/code]
[code]
{
"id": "part_01HXY…",
"peppolId": "0208:9876543210",
"name": "Globex SRL",
"vatNumber": "IT01234567890",
"country": "IT",
"role": "buyer",
"contactName": "Laura Rossi",
"contactEmail":"laura@globex.it",
"peppolStatus":"active",
"defaults": { "currency": "EUR", "paymentTermsDays": 30 },
"tags": ["strategic","italy"],
"enrichment": { "naceCode": "70.22" },
"stats": { "documentsSent": 12, "documentsReceived": 0 },
"metadata": {},
"createdAt": "2026-04-25T10:00:00Z",
"updatedAt": "2026-04-25T10:00:00Z"
}
[/code]
## Purchase orders
A read-only view over the purchase orders already flowing through Flowie. Use it to walk from an order to the invoices billed against it — handy for reconciliation and for answering “what has been invoiced on this order so far?”.
### List a purchase order’s invoices
GET/v1/purchase-orders/{purchase_order_id}/invoices
Every invoice linked to the given purchase order. Results are always scoped to your organization: you only ever see documents your organization is a party to. An order with nothing billed against it returns an empty list, not a `404`.
#### Query parameters
* limitintegeroptional
1–100. Defaults to 20.
* cursorstringoptional
Opaque cursor returned by the previous page.
Returns a paginated list of [document](<#list-documents>) summaries.
[code]
curl …/v1/purchase-orders/PO-2026-0042/invoices \
-H "Authorization: Bearer $KEY"
[/code]
[code]
{
"data": [
{
"id": "doc_01HXY…",
"type": "INVOICE",
"number": "INV-2026-001",
"issueDate":"2026-04-14",
"currency": "EUR",
"amount": 1210.0,
"status": "received",
"direction":"incoming"
}
],
"hasMore": false,
"cursor": null
}
[/code]
## Webhooks
Webhooks deliver events to your HTTPS endpoint. Every delivery is signed (`X-Flowie-Signature`), retried with exponential backoff, and recorded for replay. See the [Webhook cookbook]() for signing, retries, and idempotency patterns.
### Create a webhook
POST/v1/webhooks
* urlhttps URLrequired
* eventsstring[]required
`document.received``document.updated` `document.sent``document.delivered` `document.failed``lifecycle.updated` `company.smp_registered``*`
* secretstringoptional
Auto-generated if omitted. Used for HMAC-SHA256 signing.
* companyIdstringoptional
Scope events to a specific managed company.
### List webhooks
GET/v1/webhooks
#### Query parameters
* companyIdstringoptional
Only return webhooks scoped to this managed company.
### Update webhook
PATCH/v1/webhooks/{webhook_id}
#### Request body
* urlhttps URLoptional
* eventsstring[]optional
* rotateSecretbooleanoptional
Set `true` to mint a new signing secret (returned once in the response).
### Delete webhook
DEL/v1/webhooks/{webhook_id}
[code]
curl -X POST …/v1/webhooks \
-H "Authorization: Bearer $KEY" \
-d '{
"url": "https://example.com/hooks/peppol",
"events": ["document.received","document.delivered","document.failed"],
"secret": "whsec_rotate_me"
}'
[/code]
[code]
{
"id": "wh_01…",
"url": "https://example.com/hooks/peppol",
"events": ["document.received","document.delivered","document.failed"],
"status": "active",
"companyId": null,
"failureCount": 0,
"lastDeliveredAt": null,
"createdAt": "2026-04-25T10:00:00Z"
}
[/code]
## Events
Every webhook delivery has a durable twin in the Events API. If your endpoint was down, or you want a replay, poll `/v1/events` and acknowledge what you've processed.
### List events
GET/v1/events
#### Query parameters
* typestringoptional
Filter by event type, e.g. `document.received`.
* companyIdstringoptional
Scope to a managed company.
* limitintegeroptional
Page size. Default `20`.
### Acknowledge one event
POST/v1/events/{event_id}/ack
Returns `204 No Content`. Acked events are hidden from subsequent list calls.
### Batch acknowledge
POST/v1/events/ack
#### Request body
* eventIdsstring[]required
Event IDs to acknowledge, e.g. `["evt_…", "evt_…"]`.
### Replay an event
POST/v1/events/{event_id}/replay
Re-emits a delivered event onto every matching webhook subscription as if it had just happened. Useful for recovering from a downstream outage on your side without rewinding our delivery state. Returns `{"replayed": }` with the count of webhook deliveries scheduled.
[code]
{
"data": [
{
"id": "evt_01HY…",
"type": "document.received",
"createdAt": "2026-04-25T10:05:08Z",
"data": {
"documentId": "doc_01…",
"direction": "incoming",
"type": "invoice",
"number": "INV-2026-0417"
}
}
],
"hasMore": false
}
[/code]
## Compliance
France **PPF** and Italy **SDI** require that lifecycle state changes (accepted / rejected / paid) be reported to a national platform. Flowie does this for you. These endpoints surface the current state and the underlying report records. Belgium runs pure Peppol since 2026-01-01 (HERMES decommissioned 2025-12-31) — no regulator-side report fires for BE.
### Compliance status
GET/v1/compliance/status
#### Query parameters
* companyIdstringoptional
Limit to a single managed company.
* countryISO 3166-1 α-2optional
### Compliance reports
GET/v1/compliance/reports
Every report record has `documentId`, `reportedTo`, `platformResponse`, and an `error` if the authority rejected.
#### Query parameters
* companyIdstringoptional
* countryISO 3166-1 α-2optional
* statusstringoptional
Filter by reporting status.
* from / todateoptional
Report-date range.
* limit / cursorpaginationoptional
## Stats
GET/v1/stats
Usage, quota, and rate-limit status for the current period.
#### Query parameters
* periodenumoptional
`day``week``month``year`
* companyIdstringoptional
[code]
{
"period": { "start":"2026-04-01", "end":"2026-04-30" },
"quota": { "limit": 5000, "used": 412, "remaining": 4588 },
"rateLimit": { "perMinute": 300 },
"documents": {
"sent": 180,
"received": 232,
"delivered": 178,
"failed": 2
},
"byType": { "invoice": 390, "credit-note": 22 },
"byCountry": { "FR": 150, "BE": 120, "IT": 142 },
"partners": { "total": 47, "active": 31 }
}
[/code]
## Platform
These endpoints are for organizations running Flowie under their own brand — accounting SaaS, ERPs, public-sector aggregators. Most require a `flw_plat_live_…` or `flw_wl_live_…` key.
### Onboard a managed company
POST/v1/platform/companies
Registers a tenant, optionally creates a scoped API key and webhook, and registers on SMP — all in one call.
* vatNumberstringrequired
* namestringoptional
* addressAddressoptional
* metadataobjectoptional
* receiveDocumentsbooleanoptional
Default `true`.
* autoVerifybooleanoptional
* webhookobjectoptional
Same shape as [webhook create](<#create-webhook>); created atomically.
* apiKeyobjectoptional
`{ "name": "tenant-…", "scopes": ["send","documents.read"] }`.
### List managed companies
GET/v1/platform/companies
### Create API key for tenant
POST/v1/platform/api-keys
* namestringrequired
* companyIdstringoptional
Scopes the key to that tenant.
* scopesstring[]optional
* expiresAttimestampoptional
* rateLimitobjectoptional
### List platform API keys
GET/v1/platform/api-keys
### Revoke a key
DEL/v1/platform/api-keys/{key_id}
### Usage breakdown
GET/v1/platform/usage
Returns total counters and a per-group array.
#### Query parameters
* periodstringoptional
Reporting window, e.g. `month`.
* groupByenumoptional
`company``country``type`
### Update platform settings
PATCH/v1/platform/settings
#### Request body
* brandingobjectoptional
Logo, colors, sender display name for white-label delivery.
* defaultsobjectoptional
Default tenant settings applied at onboard time.
* customDomainstringoptional
Custom domain for webhook/callback URLs.
### Cross-tenant event stream
GET/v1/platform/events
Returns the unified event stream across every tenant managed by this platform key. Same shape as `/v1/events` with an extra `companyId` on each row so you can fan out per-tenant. Filters: `type`, `companyId`, `limit`, `cursor`. Platform / white-label keys only.
[code]
curl -X POST …/v1/platform/companies \
-H "Authorization: Bearer flw_plat_live_xyz" \
-d '{
"vatNumber":"FR86797978996",
"receiveDocuments":true,
"webhook": {
"url": "https://erp.acme.fr/hooks/flowie",
"events": ["*"]
},
"apiKey": { "name":"erp-tenant-t001",
"scopes":["send","documents.read","lifecycle"] }
}'
[/code]
[code]
{
"company": { "id":"comp_01HY…", "peppolId":"0009:FR86797978996", … },
"apiKey": { "id":"key_01…", "key":"flw_live_t001_abc…", "keyPrefix":"flw_live_t001" },
"webhook": { "id":"wh_01…", "status":"active" }
}
[/code]
## API keys
### Create API key
POST/v1/api-keys
Authenticate with a [Flowie JWT (Auth0)](<#authentication>) — the same token your dashboard uses. The new key is bound to the caller's Flowie organization (resolved from the JWT's `_permissions` claim) and inherits its tier. Multi-org users should pass `X-Flowie-Organization-Id` to target a specific org. An existing `flw_live_*` key may also call this endpoint to mint additional keys for the same org.
* namestringrequired
* companyIdstringoptional
* scopesstring[]optional
See [scopes list](<#authentication>).
* expiresAttimestampoptional
* rateLimitintegeroptional
Response includes `key` **exactly once**. Store it in your secret manager immediately.
### List API keys
GET/v1/api-keys
### Revoke API key
DEL/v1/api-keys/{key_id}
Immediate. Any request-in-flight bearing the revoked key finishes, but new requests 401.
[code]
{
"id": "key_01HY…",
"key": "flw_live_abc123def456ghi…", // shown once
"keyPrefix": "flw_live_abc123",
"name": "Mobile App",
"scopes": ["send","documents.read"],
"companyId": null,
"createdAt": "2026-04-25T10:00:00Z",
"expiresAt": "2027-04-25T00:00:00Z"
}
[/code]
## Categorization
Tag documents, partners, or other objects. Tags live in _groups_ (e.g. `business-unit`, `project`, `cost-center`). We also expose an AI suggest endpoint — feed it a document, get a ranked list of tags.
### List tag groups
GET/v1/categorization/groups
### List tags in a group
GET/v1/categorization/groups/{group_id}/tags
### Tags on an object
GET/v1/categorization/objects/{object_id}/tags
### Assign tag
POST/v1/categorization/objects/{object_id}/tags
Body: `{"tagId": "tag_…", "objectType": "document"}`.
### Remove tag
DEL/v1/categorization/objects/{object_id}/tags/{tag_id}
### AI tag recommendation
POST/v1/categorization/objects/tags/auto
Body: `{"objectId":"doc_…", "objectType":"document", "context": {…}}` → ranked list of recommended tags with confidence scores.
[code]
[
{ "tagId": "tag_cc_rd", "name": "R&D", "groupId": "cost-center", "confidence": 0.92 },
{ "tagId": "tag_proj_x1", "name": "Project X1", "groupId": "project", "confidence": 0.71 }
]
[/code]
## Payments
**Authentication:** `Authorization: Bearer ` with a live or test Exchange key (`flw_live_…` / `flw_test_…`) or a Flowie JWT.
### Document payment info
GET/v1/payments/documents/{documentId}
Every payment recorded against the document, plus what is still owed. `balanceDue` is the document total minus everything paid — `null` when the document carries no total to subtract from. `payments` is empty, not absent, when nothing has been paid yet.
[code]
{
"objectId": "doc_01HZXABCDEF0123456789",
"objectType": "document",
"organizationId": "comp_abc123",
"payments": [
{
"id": "pay_01HZXPAID0000001",
"amount": 1200.00,
"currency": "EUR",
"paidAt": "2026-06-10T13:42:11Z",
"method": "SEPA",
"reference": "INV-2026-0042",
"status": "recorded"
}
],
"balanceDue": 0.00,
"totalPaid": 1200.00,
"currency": "EUR"
}
[/code]
### Record a payment
POST/v1/payments/documents/{documentId}/pay
Records a payment against an invoice or a purchase order, and returns the created `Payment`.
Recording also advances the lifecycle to `partially_paid`, or to `paid` once the recorded payments cover the document total. The advance is best-effort: the payment is always recorded, and the response’s `lifecycleStatus` is `null` if the document could not legally move to a paid state. Only `approved`, `partially_paid` and `disputed` can — a document still in `draft`/`received`/`under_review` must be approved first. Check `GET /v1/documents/{documentId}/lifecycle` → `allowedTransitions`.
#### Request body
* amountnumberrequired
Amount paid — a positive number.
* datedateoptional
`YYYY-MM-DD` or an ISO 8601 timestamp. `paidAt` is accepted as an alias.
* currencyISO 4217optional
ISO 4217 code. Defaults to `EUR`.
* methodenumoptional
One of `SEPA`, `card`, `cheque`, `cash`, `wire`, `other`.
* referencestringoptional
Free-form external reference — a bank transaction id, for instance.
* notestringoptional
Free text stored on the payment.
[code]
{
"amount": 1200.00,
"date": "2026-06-17",
"currency": "EUR",
"method": "SEPA",
"reference": "INV-2026-0042"
}
[/code]
[code]
{
"id": "pay_01HZXNEW000000001",
"objectId": "doc_01HZXABCDEF0123456789",
"objectType": "document",
"organizationId": "comp_abc123",
"amount": 1200.00,
"currency": "EUR",
"paidAt": "2026-06-17T09:30:00Z",
"method": "SEPA",
"reference": "INV-2026-0042",
"status": "recorded",
"lifecycleStatus": "paid",
"createdAt": "2026-06-17T09:30:00Z"
}
[/code]
### Export ISO 20022 / SEPA
POST/v1/payments/export/iso20022
Generates a pain.001 SEPA credit-transfer file for a set of documents, ready for upload to your bank. The returned `content` is base64-encoded XML — decode it before saving. Sandbox keys receive a placeholder file with `transactionCount: 0`.
#### Request body
* documentIdsstring[]required
Documents to bundle. Each needs a registered creditor bank account and a non-zero outstanding balance.
* currencyISO 4217optional
ISO 4217 code. Defaults to `EUR`.
* executionDatedateoptional
Date on which the bank should execute the payments. Defaults to the next business day.
[code]
{
"documentIds": [
"doc_01HZXABCDEF0123456789",
"doc_01HZXABCDEF0123456790"
],
"currency": "EUR",
"executionDate": "2026-06-18"
}
[/code]
[code]
{
"messageId": "MSG-2026-06-17-0001",
"organizationId": "comp_abc123",
"format": "pain.001.001.09",
"filename": "MSG-2026-06-17-0001.xml",
"content": "PD94bWwgdmVyc2lvbj0iMS4wIiA…(base64)…",
"contentType": "application/xml",
"transactionCount": 2,
"totalAmount": 2400.00,
"currency": "EUR",
"generatedAt": "2026-06-17T09:35:14Z"
}
[/code]
## Request log
Every mutation (POST/PUT/PATCH/DELETE) and every error is captured for your organization, so you can answer "what did that integration actually send?" without adding logging of your own. Successful GETs are captured only when the server-side `REQUEST_LOG_ALL` flag is on. Individual entries are also browsable in the [request inspector](<../playground/requests.html>).
### List captured requests
GET/v1/requests
**Authentication:** `Authorization: Bearer ` with a live or test Exchange key (`flw_live_…` / `flw_test_…`) or a Flowie JWT.
Newest first, cursor-paginated — the response carries `data`, `hasMore` and `cursor` like every other list endpoint. Filter by `apiKeyId` or `userId` to see everything a given key or user did.
#### Query parameters
* methodstringoptional
HTTP verb, e.g. `POST`.
* pathstringoptional
Path prefix, e.g. `/v1/documents`.
* statusintegeroptional
Exact HTTP status.
* apiKeyIdstringoptional
Restrict to one API key.
* userIdstringoptional
Restrict to one JWT user.
* sincedatetimeoptional
ISO-8601 lower bound.
* untildatetimeoptional
ISO-8601 upper bound.
[code]
{
"data": [
{
"id": "req_01HY…",
"method": "POST",
"path": "/v1/documents/send",
"status": 201,
"apiKeyId": "key_01HY…",
"createdAt": "2026-04-25T10:05:00Z"
}
],
"hasMore": false,
"cursor": null
}
[/code]
### Usage rollup
GET/v1/requests/summary
**Authentication:** `Authorization: Bearer ` with a live or test Exchange key (`flw_live_…` / `flw_test_…`) or a Flowie JWT.
Per-API-key (or per-user) rollup: who called, how many times, how many errors, last seen — without paging through every log line. Pass `by=user` to group by JWT user and surface their email instead of grouping by key id.
Grouped by key, each row also carries rate-limit pressure: `throttledRequests` (requests refused with 429 — exact, since every error is captured), `peakRequestsPerMinute` (the busiest minute in the window) and `rateLimitPerMinute` (the budget that key is allowed). Treat the peak as a _lower bound_ : successful reads spend the budget without being logged.
#### Query parameters
* byenumoptional
`key``user`
* sincedatetimeoptional
ISO-8601 lower bound.
* untildatetimeoptional
ISO-8601 upper bound.
[code]
{
"by": "apiKey",
"data": [
{
"apiKeyId": "key_01HY…",
"totalRequests": 1284,
"errorRequests": 3,
"throttledRequests": 0,
"peakRequestsPerMinute": 96,
"rateLimitPerMinute": 600,
"lastRequestAt": "2026-04-25T10:05:00Z"
}
]
}
[/code]
### Volume over time
GET/v1/requests/timeseries
**Authentication:** `Authorization: Bearer ` with a live or test Exchange key (`flw_live_…` / `flw_test_…`) or a Flowie JWT.
The same traffic as the rollup, but bucketed by minute or hour, so a spike has a shape instead of a number. `errors` counts every 4xx/5xx; `throttled` isolates the requests the rate limiter itself refused with a 429.
`total` is a _lower bound_ unless the server captures successful reads: those spend the rate-limit budget without being logged. `errors` and `throttled` are exact.
#### Query parameters
* bucketenumoptional
`minute``hour`
* apiKeyIdstringoptional
Only this API key / client id.
* organizationIdstringoptional
Only this organization.
* sincedatetimeoptional
ISO-8601 lower bound.
* untildatetimeoptional
ISO-8601 upper bound.
[code]
{
"bucket": "minute",
"data": [
{ "ts": "2026-08-28 21:14", "total": 96, "errors": 2, "throttled": 0, "maxDurationMs": 210 },
{ "ts": "2026-08-28 21:15", "total": 412, "errors": 31, "throttled": 29, "maxDurationMs": 940 }
]
}
[/code]
## Portability
Inter-PA messaging for the French portability process: when a taxpayer moves from one Plateforme Agréée to another, the gaining and losing platforms exchange a normalised message (a strict subject line plus an 18-field CSV). These endpoints send that message to the counterparty platform, keep every one that goes out, parse the ones that come in, and list the registered platforms so you know where to write. Full walkthrough: [Portability (change of PA)](<../guides/portability.html>).
### Send an inter-PA message
POST/v1/portability/messages
**Authentication:** `Authorization: Bearer ` with a live or test Exchange key (`flw_live_…` / `flw_test_…`) or a Flowie JWT.
Assemble the AIFE inter-PA message for a portability request, email it to the counterparty platform and log it. Name the counterparty (`losingPaName` / `gainingPaName`) and the address is resolved from the registry of registered Plateformes Agréées, or pass `to` yourself; `recipientSource` in the response says which happened. Sending is gated by a server-side kill-switch and the SMTP configuration, and a sandbox key never reaches a real platform — when the mail does not go out, `dispatched` is `false` and `reason` says why. The message is recorded either way: `GET /v1/portability/messages` lists them and `GET /v1/portability/messages/{id}` returns the CSV as sent with its SHA-256. `GET /v1/portability/platforms` lists the platforms and the address a port request goes to, and `POST /v1/portability/routing` switches the taxpayer's routing on the agreed _date d'effet_ (`validFrom` when gaining, emission stop plus a 12-month reception window when losing). `GET /v1/portability/annuaire/{siren}` reads the PPF _annuaire_ back: which platform matricule routes the taxpayer today, since when, and until when. Outside France there is no registry to read, so `GET /v1/portability/access-point/{participantId}` resolves the answer live instead — SML, then the SMP, then the endpoint certificate, which names the platform that routes that Peppol participant. And because nobody knows their own SIRET by heart, `GET /v1/portability/companies?q=` turns a company name into the company — ranked suggestions, or a direct lookup when the query already is an identifier.
#### Request body
* messageTypeenumrequired
Which step of the portability exchange this message is.
* stateenumrequired
State of the request the message reports.
* requestRefstringrequired
Your reference for the portability request; echoed in the subject.
* taxpayerSirenstringrequired
9-digit SIREN of the taxpayer being ported.
* gainingPaId / losingPaIdstringoptional
Platform identifiers on each side of the move.
* effectiveDatedateoptional
When the transfer takes effect.
[code]
{
"messageType": "REQUEST",
"state": "received",
"requestRef": "POR-2026-000123",
"directionRole": "GAINING_PA",
"taxpayerSiren": "552100554",
"losingPaName": "ESKER",
"effectiveDate": "2026-10-01"
}
[/code]
[code]
{
"id": "pmsg_9f2c7a1d4b8e4c0f9a6d3e2b1c7f5a80",
"subject": "[PORTABILITE][REQUEST][REQ][SIREN:552100554][REF:POR-2026-000123]",
"csvRow": "POR-2026-000123;REQUEST;REQ;GAINING_PA;552100554;…",
"csvSha256": "6b1f…",
"to": "contact-pdp@esker.com",
"recipientSource": "registry:ESKER",
"dispatched": true,
"reason": "sent"
}
[/code]
### Parse an inbound message
POST/v1/portability/messages/parse
**Authentication:** `Authorization: Bearer ` with a live or test Exchange key (`flw_live_…` / `flw_test_…`) or a Flowie JWT.
Parse a received inter-PA message back into structured fields. Validates the normalised subject grammar and, when `csvRow` is supplied, the 18-column payload. A subject that does not match the grammar returns `400` — dead-letter it rather than opening a request.
#### Request body
* subjectstringrequired
The raw subject line as received.
* csvRowstringoptional
The data row, without the header line.
[code]
{
"subject": "[PORTABILITE][REQUEST][REQ][SIREN:552100554][REF:POR-2026-000123]"
}
[/code]
[code]
{
"messageType": "REQUEST",
"statusCode": "REQ",
"state": "received",
"siren": "552100554",
"requestRef": "POR-2026-000123"
}
[/code]
### Resolve a taxpayer
POST/v1/portability/resolve
**Authentication:** `Authorization: Bearer ` with a live or test Exchange key (`flw_live_…` / `flw_test_…`) or a Flowie JWT.
One identifier in, everything a migration request needs out. Accepts a SIRET, a SIREN, a VAT number, a Peppol id, a national registration number, a domain, an e-mail or `name:`, and answers with the legal name, country, identifiers and the French annuaire addressing line — plus the regime that governs the switch, what changes, and what must be re-granted. Whatever cannot be resolved is listed in `stillNeeded` rather than guessed. `404` when no layer resolves the identifier.
#### Request body
* taxpayerstringrequired
The only value a company has to supply.
* countrystring (ISO-2)optional
Used only when the identifier does not carry its own country.
[code]
{
"taxpayer": "92137626500017"
}
[/code]
[code]
{
"taxpayer": {
"name": "ACME SAS",
"country": "FR",
"registrationNumber": "92137626500017",
"peppolId": "0009:921376265",
"addressingIdentifier": "921376265_92137626500017_001",
"resolvedFrom": "legal_base"
},
"regime": "PPF · plateforme agréée",
"requirements": {
"addressChanges": false,
"reGrant": "A dated, signed designation agreement (accord formel).",
"archiveHolder": "You / your platform — the outgoing one owes 1 year of lifecycle statuses."
},
"stillNeeded": []
}
[/code]
### Open a migration request
POST/v1/portability/requests
**Authentication:** `Authorization: Bearer ` with a live or test Exchange key (`flw_live_…` / `flw_test_…`) or a Flowie JWT.
Opens a platform change from the same one identifier, resolving everything else. Produces the designation agreement **art. 242 nonies E bis** requires — taxpayer, incoming platform, previous platform, effective date, scope of electronic addresses, signatory — numbers it, and starts a hash-linked evidence chain. Deadlines are computed in _jours ouvrés_ including _jours fériés_. A missing signatory does not fail the call: it comes back in `mandate.gaps`, because that gap is exactly what an outgoing platform may object to. Returns `201`.
#### Request body
* taxpayerstringrequired
Identifier, as above.
* signatorystringoptional
Who signs for the company. Required by the decree; reported as a gap when absent.
* outgoingPlatformstringoptional
The platform being left. Resolved from the directory when omitted.
* effectiveDatestring (date)optional
Defaults to the first business day after the objection window could close.
* addressScopestring[]optional
Addresses covered. Defaults to the resolved annuaire line and Peppol id.
* overridesobjectoptional
Explicit values that beat anything resolved.
[code]
{
"taxpayer": "92137626500017",
"signatory": "Camille Roy, Directrice Générale"
}
[/code]
[code]
{
"requestRef": "POR-2026-4F2A91C08B7D",
"state": "received",
"statusCode": "REQ",
"mandate": {
"reference": "POR-2026-4F2A91C08B7D",
"effectiveDate": "2026-09-09",
"addressScope": ["921376265_92137626500017_001", "0009:921376265"]
},
"clocks": {
"notifyBy": "2026-09-03",
"objectionWindowEnds": "2026-09-08",
"annuaireUpdateBy": "2026-09-29",
"continuityUntil": "2027-09-09"
},
"tacitApproval": false,
"evidence": { "verification": { "intact": true, "length": 1 } }
}
[/code]
### Read a migration request
GET/v1/portability/requests/{request_ref}
**Authentication:** `Authorization: Bearer ` with a live or test Exchange key (`flw_live_…` / `flw_test_…`) or a Flowie JWT.
State is never stored — it is folded from the evidence chain on every read, so this is the single truth about where a port stands, and an agent that was not running when the request was opened reaches the same answer as one that was. `tacitApproval` flips to `true` by itself once the objection window lapses with no admissible objection (_le silence vaut accord_), with no scheduler involved. `404` when the reference does not belong to the calling organization.
[code]
curl …/v1/portability/requests/POR-2026-4F2A91C08B7D \
-H "Authorization: Bearer $FLOWIE_KEY"
[/code]
[code]
{
"requestRef": "POR-2026-4F2A91C08B7D",
"state": "auto_accepted",
"statusCode": "TAC",
"tacitApproval": true,
"objections": [
{ "ground": "unpaid_invoices", "admissible": false }
],
"evidence": {
"verification": { "intact": true, "length": 3, "brokenAt": null },
"manifestSha256": "b41c…"
}
}
[/code]
### Have the agreement signed
POST/v1/portability/requests/{request_ref}/mandate/signature
**Authentication:** `Authorization: Bearer ` with a live or test Exchange key (`flw_live_…` / `flw_test_…`) or a Flowie JWT.
Renders the designation agreement from the mandate this request already holds — the five items article 242 _nonies_ E bis requires — and opens an **approval check** on it for the users you name. They sign by deciding in Flowie; what is stored is their decision, its instant and its author, none of which this API supplies. `201` returns the text exactly as it is put in front of them, and its two digests.
**The context key is what binds a decision to this agreement:** `portability::`. An approval vote is scoped to the object it hangs on, so without it a decision taken on the same document for another reason would read here as a signature. Change the mandate — a new _date d'effet_ , a different address scope — and the digest moves with it, so the earlier signature stops answering for the new agreement. That is the point: it is not the agreement that was signed.
The check hangs on a **document** , never on the request — approval's `ObjectType` is a closed enum with no portability member. Leave `objectId` out and the rendered agreement is stored as one for you. `502` when the approval service does not answer: a silent failure would leave you believing somebody had been asked. `409` when the mandate is still missing a decree item (`taxpayerId`, `incomingPlatform`, `effectiveDate`, `addressScope`): a request can be opened on an identifier alone, and an agreement rendered with blanks where the decree wants values is not one anybody should be asked to sign.
#### Request body
* userIdsstring[]required
Who is asked to sign, as Flowie user ids. The agreement is a designation _by the taxpayer_ , so this is its legal representative — not your own team. One to ten.
* objectIdstringoptional
The object the check hangs on. Leave it out and the rendered agreement is stored as a document and used.
* objectTypestringoptional
Defaults to `DocumentVersion`. Only set it if `objectId` points at another kind approval accepts.
* expiresAtstringoptional
ISO-8601. A mandate nobody signs should lapse rather than sit open against a _date d'effet_ that has passed.
[code]
curl -X POST …/v1/portability/requests/POR-2026-4F2A91C08B7D/mandate/signature \
-H "Authorization: Bearer $FLOWIE_KEY" \
-H "Content-Type: application/json" \
-d '{"userIds": ["usr_01J9ZC3K7V8QDX4M2T6NRPB5HE"]}'
[/code]
[code]
{
"requestRef": "POR-2026-4F2A91C08B7D",
"checkId": "chk_01J9ZC6Q4T2VHM8B5D7KXNPR3W",
"contextKey": "portability:POR-2026-4F2A91C08B7D:9f2c…",
"objectId": "docv_01J9ZC4B2N7RSF5K8W3PQXTM6D",
"objectType": "DocumentVersion",
"mandateSha256": "9f2c…",
"agreementSha256": "41ab…",
"signed": false,
"signature": null
}
[/code]
### Read the signature
GET/v1/portability/requests/{request_ref}/mandate/signature
**Authentication:** `Authorization: Bearer ` with a live or test Exchange key (`flw_live_…` / `flw_test_…`) or a Flowie JWT.
`signed` stays `false` while the check is open, refused or withdrawn — only a passed check carrying _this_ agreement's context key is a signature, and one taken on an earlier version of the mandate is not. The first read that finds one writes it into the evidence chain, so the request carries its `signedAt` from then on whether or not this endpoint is called again.
`404` when nobody has been asked to sign this request. A mandate signed outside Flowie is recorded instead with `signedAt` and `signatureMethod` when the request is opened.
[code]
curl …/v1/portability/requests/POR-2026-4F2A91C08B7D/mandate/signature \
-H "Authorization: Bearer $FLOWIE_KEY"
[/code]
[code]
{
"signed": true,
"signature": {
"signatory": "Yann Ravel-Sibillot",
"signedAt": "2026-09-18T08:59:00+00:00",
"method": "approval",
"checkId": "chk_01J9ZC6Q4T2VHM8B5D7KXNPR3W",
"mandateSha256": "9f2c…",
"agreementSha256": "41ab…"
}
}
[/code]
### Sign with your own document
POST/v1/portability/requests/{request_ref}/mandate/signature/document
**Authentication:** `Authorization: Bearer ` with a live or test Exchange key (`flw_live_…` / `flw_test_…`) or a Flowie JWT.
The third way to sign, and the one that fits a taxpayer whose representative has no Flowie account. Signing _in_ Flowie gives an authenticated actor and an observed instant; asserting a paper signature at opening gives neither and keeps nothing. This sits between them honestly: **you** state who signed and when, exactly as for paper, but the artefact is stored under your organization and hashed — so what was signed stops being a claim. The signature then replays onto the mandate like any other and `mandateGaps` closes.
**`agreementSha256` is the digest of the file you uploaded**, not of the text this API would have rendered. Those are different documents — you may have signed your own wording, or your advocate’s, or ours with a scan on top — and recording ours as the one signed would be a statement we cannot support. `mandateSha256` still binds to the decree content, so amending the mandate afterwards invalidates this signature exactly as it invalidates an in-app one.
`400` when `signedAt` is not an ISO-8601 instant, when it is _in the future_ (an act cannot be dated after the moment it is recorded), or when the file is empty. `409` when the agreement is _already signed_ — the chain folds signatures in order, so a second one would replace the first without trace — or when the mandate is still missing a decree item. `413` above 10 MB, `502` when the document could not be stored — the artefact is the whole point of this path, so a signature is never recorded without it.
#### Form fields
* filefilerequired
The signed agreement, as signed. Kept under your organization; PDF, image or text.
* signatorystringrequired
Who signed it, for the taxpayer — name and role, as it appears on the document.
* signedAtstringrequired
When they signed it, ISO-8601. Refused if it is in the future.
[code]
curl -X POST …/v1/portability/requests/POR-2026-4F2A91C08B7D/mandate/signature/document \
-H "Authorization: Bearer $FLOWIE_KEY" \
-F "file=@mandat-signe.pdf" \
-F "signatory=Camille Roy, Directrice Générale" \
-F "signedAt=2026-09-18T09:00:00+00:00"
[/code]
[code]
{
"signed": true,
"objectId": "docv_01J9ZC4B2N7RSF5K8W3PQXTM6D",
"mandateSha256": "9f2c…",
"agreementSha256": "7d10…",
"signature": {
"signatory": "Camille Roy, Directrice Générale",
"signedAt": "2026-09-18T09:00:00+00:00",
"method": "upload",
"documentId": "docv_01J9ZC4B2N7RSF5K8W3PQXTM6D"
}
}
[/code]
### Record a step
POST/v1/portability/requests/{request_ref}/events
**Authentication:** `Authorization: Bearer ` with a live or test Exchange key (`flw_live_…` / `flw_test_…`) or a Flowie JWT.
Appends one step to the evidence chain and returns the re-derived state. **An objection is recorded, then judged:** the decree limits the outgoing platform to grounds questioning the taxpayer's intent — `more_recent_agreement`, `identity_mismatch`, `mandate_invalid`. Any other ground is stored verbatim and flagged `admissible: false`; the request keeps running and carries the mandate digest as the answer to it.
#### Request body
* kindenumrequired
`notified`, `objection`, `acceptance` or `annuaire_updated`.
* groundstringoptional
For `objection`: the ground stated by the outgoing platform.
* statementstringoptional
For `objection`: their wording, kept verbatim as evidence.
* channelRefstringoptional
For `notified`: your reference for the message sent.
* entryRefstringoptional
For `annuaire_updated`: the directory entry reference.
[code]
{
"kind": "objection",
"ground": "unpaid_invoices",
"statement": "Contract runs to December."
}
[/code]
[code]
{
"state": "received",
"objections": [
{
"ground": "unpaid_invoices",
"admissible": false
}
]
}
[/code]
## UBL generator (France)
A compliant French e-invoice for every business situation the reform recognises — all 45 _cas d'usage_ of AFNOR XP Z12-014 plus the foundations. Each scenario carries its business narrative, the BT fields it turns on, the 200–213 lifecycle it drives, the EN 16931 UBL 2.1 XML and the exact `POST /v1/documents/send` body. The catalogue and the generator are pure functions of the request and touch no tenant data, so they need **no API key**. Full guide: [UBL generator](<../compliance/fr/ubl-generator.html>).
### List every business case
GET/v1/tools/fr/ubl/scenarios
**Authentication:** none — this is reference material, not your data.
Returns every scenario with its business description. Filter by `theme`, `family`, `channel` (`e-invoicing` / `e-reporting`) or `case`, or search the narratives with `q`.
#### Query parameters
* themestringoptional
e.g. `Acompte & paiement échelonné`.
* familyenumoptional
`data`, `third-party`, `lifecycle` or `foundation`.
* channelenumoptional
`e-invoicing` or `e-reporting`.
* casestringoptional
XP Z12-014 case number, e.g. `20` or `19b`.
* qstringoptional
Free-text search across the title, the story and the French case title.
### Read one business case
GET/v1/tools/fr/ubl/scenarios/{scenario_id}
**Authentication:** none — this is reference material, not your data.
The story, the rule that makes it its own case, the trap, and the BT fields that carry it — without generating anything. An unknown id returns `404` with the closest matches.
[code]
curl https://back.flowie.ink/exchange/v1/tools/fr/ubl/scenarios?case=20
[/code]
[code]
{
"scenarios": [
{
"id": "uc-20-deposit-invoice",
"case": "20",
"caseTitleFr": "Facture d'acompte",
"title": "A builder asks for 30% up front before starting the job",
"theme": "Acompte & paiement échelonné",
"channel": "e-invoicing",
"typeCode": "386",
"cadre": "S1",
"story": "Grand Client commissions a €20,000 fit-out…",
"why": "A deposit invoice is a real invoice for VAT…",
"watchOut": "Type code 386 is what makes it a deposit…",
"keyData": [ { "bt": "BT-3", "label": "Type code 386", "why": "…" } ],
"lifecycle": ["200 Déposée", "205 Approuvée", "212 Encaissée"],
"sendFormat": "ubl-xml",
"validatesAsEInvoice": true
}
],
"summary": { "total": 59, "numberedCases": 50 },
"referential": { "useCases": "AFNOR XP Z12-014 v1.4 (2026-06-30) — 45 cas d'usage" }
}
[/code]
### List every business term
GET/v1/tools/fr/ubl/business-terms
**Authentication:** none — this is a referential, not your data.
The whole EN 16931 semantic model: 30 business groups, 164 business terms (`BT-1`…`BT-165`; `BT-4` is unassigned), plus the `-1`/`-2` scheme attributes the French rules lean on. Each entry says what the term is in both languages, where it lives in UBL 2.1, what France requires of it and under which `BR-FR-*` rule, and which field of [`POST /v1/documents/send`](<#send-document>) carries it. Full guide: [Business terms](<../compliance/fr/business-terms.html>).
`fr` is one of `mandatory`, `conditional`, `restricted` (France narrows the allowed values), `optional` or `unused`; `apiState` is `sent` (you state it), `derived` (Flowie computes it), `accepted` (stored, not rendered yet) or `xml-only` (no JSON field — carry it with `format=ubl-xml`). The response's `legend` spells both out.
#### Query parameters
* groupstringoptional
One business group and its members, e.g. `BG-23`.
* scopeenumoptional
`document` or `line`.
* frenumoptional
`mandatory`, `conditional`, `restricted`, `optional` or `unused`.
* mappedbooleanoptional
`true` for the terms `POST /v1/documents/send` has a field for; `false` for the gaps.
* qstringoptional
Free-text across the id, both names, the UBL path, the French note and the API field.
### Read one business term
GET/v1/tools/fr/ubl/business-terms/{term_id}
**Authentication:** none — this is a referential, not your data.
One `BT`, `BG` or scheme attribute by id — `BT-121`, `BG-23`, `BT-29-1`. Case-insensitive. An id the standard does not assign returns `404 BUSINESS_TERM_NOT_FOUND`.
[code]
curl https://back.flowie.ink/exchange/v1/tools/fr/ubl/business-terms?group=BG-23
[/code]
[code]
{
"terms": [
{
"id": "BT-121",
"kind": "term",
"name": "VAT exemption reason code",
"nameFr": "Code du motif d'exonération",
"group": "BG-23",
"scope": "document",
"cardinality": "0..1",
"ubl": ".../cac:TaxCategory/cbc:TaxExemptionReasonCode",
"fr": "conditional",
"frNote": "A VATEX code. Optional under EN 16931 but mandatory for the franchise en base…",
"api": "lines[].vatExemptionCode",
"apiState": "sent"
}
],
"summary": { "groups": 32, "terms": 164, "mandatoryInFrance": 37, "mappedToApi": 84 },
"legend": { "fr": { "mandatory": "Required on every French e-invoice." } },
"referential": { "semanticModel": "EN 16931-1:2017/A1:2019 — 30 business groups, 164 business terms" }
}
[/code]
### Generate a case
POST/v1/tools/fr/ubl/generate
**Authentication:** none — this is reference material, not your data.
Renders the scenario as EN 16931 UBL 2.1 and builds the send call for it. Override the invoice number, the dates, the currency and either party to make the sample look like your own data; identifier _schemes_ stay fixed, so an override cannot produce a party whose SIRET and SIREN disagree (`BR-FR-09`).
#### Request body
* scenarioIdstringrequired
Scenario id from the catalogue, e.g. `uc-20-deposit-invoice`.
* numberstringoptional
Invoice number (BT-1). `BR-FR-01` caps it at 35 characters.
* issueDate / dueDatedateoptional
`YYYY-MM-DD`. `BR-FR-03` wants a year between 2000 and 2099.
* currencystringoptional
Anything other than EUR also needs the VAT accounting currency (`BR-FR-CO-12`) — the generator does not add it for you.
* seller / buyerobjectoptional
`{name, siret, siren, vatNumber, legalForm, address, contact}` — your own party in place of the catalogue's.
* formatenumoptional
`json` or `ubl-xml`. Defaults to the scenario's own recommendation.
### Simple mode — your own data, no scenario id
POST/v1/tools/fr/ubl/simple
**Authentication:** none — the response is a pure function of the request.
The catalogue answers "what does _this_ business case look like?". This answers the question people ask first — "here is my data, make it legal" — which nine out of ten French invoices need and no numbered _cas d'usage_ covers, because they are just one company billing another.
From the line kinds and the VAT situation it derives the type code (`BT-3`), the _cadre de facturation_ (`BT-23`), the tax point (`BT-8`), the VAT category with the exemption reason EN 16931 demands, the three legal mentions of `BR-FR-05` and the `BAR` regime note. Every one comes back in `inferred` with the rule that forced it, so you can check what was added on your behalf instead of trusting it. `warnings` carries what is legal but probably not what you meant — a VAT rate that is not in force in France, an intra-community supply with no buyer VAT number.
Two fields cannot be guessed and are asked for: each party's **SIRET** , because the routing address and the legal identifier both derive from it, and the seller's **legal form and share capital** (`BT-33`), which France makes mandatory. A request that cannot produce a compliant invoice comes back `400 CANNOT_BUILD_COMPLIANT_INVOICE` naming the field and the fix.
#### Request body
* seller / buyerobjectrequired
`{name, siret, siren?, vatNumber?, legalForm?, address?, contact?}`. `legalForm` is required on the seller (`BT-33`) and ignored on the buyer, where `UBL-CR-244` forbids it.
* linesarrayrequired
`{description, quantity, unitPrice, vatRate?, kind?, unit?}`. `kind` is `goods` or `services` and decides the cadre and the tax point.
* numberstringrequired
Invoice number (BT-1). `BR-FR-01` caps it at 35 characters.
* documentTypeenumoptional
`invoice` (default) or `credit-note` — which becomes type 381, not a negative invoice.
* vatRegimeenumoptional
`standard`, `reverse-charge-subcontracting`, `franchise-en-base`, `intra-community`, `export`. Anything but `standard` zero-rates every line and attaches the reason.
* alreadyPaid / vatOnDebitsbooleanoptional
Move the cadre to B2 / S2 / M2, and make services taxable on the invoice date.
* deliveryCountrystringoptional
Where the goods went (`BT-80`) — required in substance on an intra-community supply (`BR-IC-12`), inferred from the buyer when omitted.
* paymentobjectoptional
`{iban?, bic?, means?, reference?, terms?}`. With an IBAN the invoice declares a credit transfer (code 30); without one it declares code 1, because claiming a transfer with no account fails `BR-61`.
* issueDate / dueDate / currency / buyerReference / orderReference / corrects / note / formatmixedoptional
The usual header fields. `corrects` is `{number, issueDate}` — what a credit note refers back to (`BG-3`).
[code]
curl -X POST https://back.flowie.ink/exchange/v1/tools/fr/ubl/simple \
-H 'Content-Type: application/json' \
-d '{"seller":{"name":"Ma Boîte SAS","siret":"12345678900017",
"legalForm":"SAS au capital de 10 000 EUR — RCS Paris 123 456 789"},
"buyer":{"name":"Client SARL","siret":"39158000400021"},
"lines":[{"description":"Prestation de conseil","quantity":2,"unitPrice":500,
"vatRate":20,"kind":"services"}],
"number":"FA-2026-0001"}'
[/code]
### Generate and validate
POST/v1/tools/fr/ubl/generate-and-validate
**Authentication:** `Authorization: Bearer ` with a live or test Exchange key (`flw_live_…` / `flw_test_…`) or a Flowie JWT.
Generates the case and runs it through the official FNFE **XP Z12-012 v1.3.0** schematrons — XSD, the EN 16931 profile rules, the French `BR-FR` rules — plus the complementary CIUS-FR field checks. `errors` flattens every failing rule across all steps. Needs a key because it calls the schematron service on your behalf.
[code]
{
"scenarioId": "uc-21-final-after-deposit",
"number": "FA-2027-0042",
"issueDate": "2027-03-01",
"seller": { "name": "Ma Société SAS", "siret": "55210055400013" },
"buyer": { "name": "Mon Client SA", "siret": "39158000400021" }
}
[/code]
[code]
{
"scenario": { "id": "uc-21-final-after-deposit", "case": "21", "cadre": "S4" },
"ubl": "\n",
"totals": {
"lineExtensionAmount": 20000.0,
"taxExclusiveAmount": 20000.0,
"taxAmount": 4000.0,
"taxInclusiveAmount": 24000.0,
"prepaidAmount": 7200.0,
"payableAmount": 16800.0
},
"sendRequest": {
"type": "invoice",
"from": "0009:55210055400013",
"to": "0009:39158000400021",
"format": "ubl-xml",
"xml": ")
* [Spécifications externes B2B — DGFiP / portail PPF]()
* [Chorus Pro / PPF technical specs (EN)]()
* [AFNOR X12U commission — XP Z12-013 announcement]()
### Architecture & vocabulary
The PPF (Portail Public de Facturation) sits as a passive concentrator and annuaire. Every B2B invoice in France must flow through at least one PDP. PDPs route to each other directly when both sides are on different platforms; flows transit the PPF only for fallback, reporting (e-Reporting), and lifecycle aggregation.
* **PA** (Plateforme Acheteur) — the buyer's PDP receives the flow.
* **PV** (Plateforme Vendeur) — the seller's PDP submits the flow.
* **OD** (Opérateur de Dématérialisation) — non-certified upstream of a PDP; can submit but not receive.
* **OPDF** — Operation Process Description Format; how flow lifecycle is described on the wire.
* **MR-DG** — Mandat de Représentation côté Destinataire / côté Generic; routing-code level mandate.
Every operation below is authenticated with a Flowie token (Bearer) _or_ the AFNOR-compliant `?token=` query parameter — both forms are accepted.
### Submit a flow
POST/afnor/flow-service/v1/flows
Multipart: `flowInfo` (JSON) + `file` (binary). Returns `202 Accepted` with a `flowId`.
* flowInfo.namestringrequired
* flowInfo.flowSyntaxenumrequired
`CII``UBL``Factur-X``CDAR``FRR`
* flowInfo.trackingIdstring (≤36)optional
* flowInfo.processingRuleenumoptional
`B2B``B2C``B2G`
* flowInfo.flowProfileenumoptional
`Basic``CIUS``Extended-CTC-FR`
* flowInfo.sha256hexoptional
### Search flows
POST/afnor/flow-service/v1/flows/search
#### Request body
`SearchFlowParams`. Filters are AND-combined; array values are OR-combined.
* limitintegeroptional
Page size, 1–100. Default `25`.
* whereSearchFlowFiltersoptional
Filter object. Fields: `updatedAfter`, `updatedBefore`, `processingRule[]`, `flowType[]`, `flowDirection[]`, `trackingId`, `ackStatus`.
### Retrieve a flow
GET/afnor/flow-service/v1/flows/{flow_id}
#### Query parameters
* docTypeenumoptional
`Metadata``Original``Converted``ReadableView`
### AFNOR webhooks
Same operations as [Webhooks](<#create-webhook>) but under the AFNOR-shaped schema:
GET/afnor/flow-service/v1/webhooks
POST/afnor/flow-service/v1/webhooks
#### Create body
* callbackobjectrequired
`url` (required), plus optional `headers[]`, `authentication`, `signature`.
* metadataobjectrequired
Subscription filters: `flowType`, `flowDirection` (required), `processingRule`, `ackStatus` (optional).
GET/afnor/flow-service/v1/webhooks/{webhook_uid}
PATCH/afnor/flow-service/v1/webhooks/{webhook_uid}
#### Update body — technical params only
* headersobject[]optional
* authenticationobjectoptional
* signatureobjectoptional
DEL/afnor/flow-service/v1/webhooks/{webhook_uid}
### AFNOR directory (SIREN / SIRET / routing codes)
Every `*/search` response uses the AFNOR envelope: `search`, `totalNumberOfResults`, `results`.
POST/afnor/directory-service/v1/siren/search
#### Request body
* filtersobjectoptional
Field → value map of search predicates.
* sortingobject[]optional
* fieldsstring[]optional
Restrict the returned columns.
* limitintegeroptional
1–100. Default `50`.
* ignoreintegeroptional
Offset — rows to skip.
GET/afnor/directory-service/v1/siren/code-insee:{siren}
#### Query parameters
* fieldsstring[]optional
Comma-separated columns to return.
POST/afnor/directory-service/v1/siret/search
#### Request body
* filtersobjectoptional
* sortingobject[]optional
* fieldsstring[]optional
* includestring[]optional
Expand related rows.
* limitintegeroptional
1–100. Default `50`.
* ignoreintegeroptional
GET/afnor/directory-service/v1/siret/code-insee:{siret}
#### Query parameters
* fieldsstring[]optional
* includestring[]optional
POST/afnor/directory-service/v1/routing-code/search
#### Request body
* filtersobjectoptional
* includestring[]optional
* limitintegeroptional
1–100. Default `50`.
GET/afnor/directory-service/v1/routing-code/siret:{siret}/code:{routing_identifier}
#### Query parameters
* fieldsstring[]optional
* includestring[]optional
### Directory-line search
POST/afnor/directory-service/v1/directory-line/search
Stub endpoint for _directory-line_ queries — the AFNOR aggregate row that joins SIREN + SIRET + routing-code data into a single result row, used for OD ↔ PDP onboarding flows. Response is currently empty (returns the AFNOR `search` envelope with `totalNumberOfResults: 0`) until the PDP-PDP federation handshake is wired up.
#### Request body
* filtersobjectoptional
* sortingobject[]optional
* fieldsstring[]optional
* limitintegeroptional
1–100. Default `50`.
### Healthchecks
GET/afnor/flow-service/v1/healthcheck
GET/afnor/directory-service/v1/healthcheck
Public, unauthenticated. Returns `{ "status": "ok", "version": "1.0", "service": "flow-service|directory-service" }`. Required by the AFNOR PDP certification suite.
[code]
curl -X POST …/afnor/flow-service/v1/flows \
-H "Authorization: Bearer $KEY" \
-F 'flowInfo={"name":"INV-2026-0417","flowSyntax":"UBL","processingRule":"B2B","flowProfile":"Extended-CTC-FR","trackingId":"t-42"};type=application/json' \
-F 'file=@invoice.xml'
[/code]
[code]
HTTP/1.1 202 Accepted
{
"flowId": "flw_01HY…",
"submittedAt": "2026-04-25T10:00:00Z",
"name": "INV-2026-0417",
"flowSyntax": "UBL",
"trackingId": "t-42",
"processingRule": "B2B",
"flowProfile": "Extended-CTC-FR",
"sha256": "e3b0c442…"
}
[/code]
### Get directory line by id
GET/afnor/directory-service/v1/directory-line/code:{addressing_identifier}
**Authentication:** `Authorization: Bearer ` with a live or test Exchange key (`flw_live_…` / `flw_test_…`) or a Flowie JWT.
Resolve a single directory line by its addressing identifier — XP Z12-013 § 7.9 `getDirectoryLineById`. Same ppf-annuaire backing as [searchDirectoryLine](<#afnor-directory-line>), filtered on the identifier and reduced to one line. Note the AFNOR path grammar: the value is prefixed `code:` in the path segment.
#### Path parameters
* addressing_identifierstringrequired
The routing code of the line, e.g. `code:0009:552100554`.
#### Query parameters
* includestringoptional
Related resources to embed.
* fieldsstringoptional
Sparse fieldset.
[code]
GET /afnor/directory-service/v1/directory-line/code:0009:552100554
Authorization: Bearer flw_live_…
[/code]
[code]
{
"directoryLine": {
"addressingIdentifier": "0009:552100554",
"siren": "552100554",
"name": "ACME SAS",
"status": "active"
}
}
[/code]
## PunchOut cart callback
POST/document/callback
The cXML PunchOut return endpoint. SAP Ariba, Coupa, Ivalua and friends POST the user's cart back here when they check out. We OCR the cXML into a Flowie request, then respond with an HTML page that redirects the user to the originating chat thread.
**Authentication:** no bearer — we validate the `SharedSecret` in the cXML header against a per-partner allow-list, plus `BuyerCookie` for org scoping.
#### Accepted bodies
* `Content-Type: application/x-www-form-urlencoded` with a `cxml-urlencoded` or `cxml-base64` field.
* `Content-Type: application/xml` with the raw cXML PunchOutOrderMessage.
#### Response
An HTML `` redirect — typically to `{APP_URL}/{org_slug}/ai/chat/{thread_id}` if the v2 BuyerCookie contains a thread hint, or `{APP_URL}/{org_slug}/requests` otherwise.
[code]
...***org_01HY…:thread_abc:v2
...
[/code]
### OCI cart callback
POST/document/oci-callback
The OCI return endpoint, for Mercateo, Conrad and SAP-style suppliers. Accepted as both `POST` (form post) and `GET` (supplier auto-submit), because OCI suppliers differ on which they use. Cart lines arrive as the flat `NEW_ITEM-*` field family.
**Authentication:** no bearer — the supplier-facing HOOK_URL carries a `flowie_cookie` query parameter (or form field) holding the BuyerCookie `flowie:{org_id}:{thread_id}:{nonce}`. We use it to route the cart to the right organization and to redirect the user back to the originating thread.
[code]
POST /document/oci-callback?flowie_cookie=flowie:org_01HY…:thr_01HY…:9f3c
Content-Type: application/x-www-form-urlencoded
NEW_ITEM-DESCRIPTION[1]=Laptop stand&NEW_ITEM-QUANTITY[1]=2&NEW_ITEM-PRICE[1]=49.00
[/code]
## Health
Public, unauthenticated. Great for load balancers and synthetic monitors.
### Liveness
GET/health/liveness
Returns `{"status":"ok"}` as long as the process can serve requests.
### Readiness
GET/health/readiness
Includes circuit-breaker state for every upstream.
### Contracts
GET/health/contracts
Actively probes upstreams (SMP, national directories). Slower; don't call from a hot path.
[code]
{
"status": "ok",
"circuits": {
"peppol-smp": { "state": "closed", "failures": 0 },
"ppf-annuaire": { "state": "closed", "failures": 0 },
"document-service":{ "state": "closed", "failures": 0 }
}
}
[/code]
## Appendices
### Address object
Every field is optional. Defined in full — with the BT number each one maps to — under [Send a document → Address object](<#address-object>).
### Party object
`{ "name":"…", "vatNumber":"…", "address":[Address](<#address-object>), "contact": {"name":"…", "email":"…", "phone":"…"} }`
### PaymentInfo object
* meansenum
`credit_transfer``direct_debit``card``cash``cheque`
* ibanIBAN
* bicSWIFT BIC
* referencestring
* discountTermsarray
### Status reason codes (Peppol BIS · OPStatusReason)
The coded vocabulary for `reasonCode` on [lifecycle updates](<#update-lifecycle>) is the official OpenPeppol _Status Clarification Reason_ list ([OPStatusReason](), Peppol BIS Invoice Response 3). All **14** codes — nothing else is part of the official list:
Code| Label| Use it when…
---|---|---
`NON`| No issue| Pure status update — nothing is wrong (e.g. with `under_review`).
`REF`| References incorrect| A required reference (PO number, buyer reference, contract) is missing or wrong.
`LEG`| Legal information incorrect| The document doesn't meet legal requirements (mandatory mentions, VAT identifiers…).
`REC`| Receiver unknown| The invoice is not addressed to this party.
`QUA`| Item quality insufficient| Unacceptable or incorrect quality of the delivered goods / services.
`DEL`| Delivery issues| Goods / services not delivered, or the delivery is not acceptable.
`PRI`| Prices incorrect| Price differs from the order, quote or contract.
`QTY`| Quantity incorrect| Quantity differs from what was ordered or delivered.
`ITM`| Items incorrect| The invoiced items don't match what was ordered / delivered.
`PAY`| Payment terms incorrect| Payment terms differ from the agreement.
`UNR`| Not recognized| The commercial transaction is not recognized (unknown order / relation).
`FIN`| Finance incorrect| Financing terms differ from expectations.
`PPD`| Partially paid| The invoice is only partially paid.
`OTH`| Other| No code fits — **always** pair with a free-text `reason`.
#### Rejecting vs putting on hold — pick the reversible path first
You want to…| Send| Terminal?| What the reason must say
---|---|---|---
**Pause / on hold** — something is missing (delivery note, PO reference, supporting document)| `{"status":"disputed","reasonCode":"suspended","reason":"…"}`| No — supplier answers with the material and processing resumes| Exactly _what is missing_ , so the supplier can supply it and lift the hold.
**Contest** — you disagree with part of the content but it may be resolved| `{"status":"disputed","reasonCode":"…"}`| No — resolves to approval or refusal| The code that names the disagreement (`PRI`, `QTY`, `ITM`…), plus free text with the specifics (line, expected value).
**Refuse / reject** — the invoice must be cancelled and re-issued| `{"status":"rejected","reasonCode":"…","reason":"…"}`| **Yes** — the supplier must issue a corrective| The code that justifies a definitive refusal, plus free text precise enough for the supplier to re-invoice correctly first time.
**Prioritize on hold / dispute over refusing directly.** A rejection cannot be undone: the supplier has to start over. A hold or dispute keeps the invoice alive, tells the supplier exactly what to fix, and costs nothing if the answer is satisfactory. Whatever the status, make the reason _actionable_ : code for the machine, free text for the human — a rejection or hold whose reason the supplier can't act on just moves the problem to email.
France — AFNOR motifs, not Peppol codes
On the French DGFiP leg the `reasonCode` is forwarded _verbatim_ as the CDAR's `MDT-113`: for _210 Refusée_ / _213 Rejetée_ use a code from the official AFNOR XP Z12-012 motif annex (« Tableau des motifs de STATUTS »), and the special value `suspended` on a `disputed` call is the discriminator that transmits _208 Suspendue_. See [FR refusal, rejection & on-hold](<../compliance/fr/refusal-rejection.html#motifs>).
========================================================================
# Data model
# Source: https://docs.get-flowie.com/reference/data-model.html
========================================================================
---
title: "Data model"
description: "Entity-relationship diagram and field-level reference for every resource in Flowie Exchange."
canonical: "https://docs.get-flowie.com/reference/data-model"
source: "https://docs.get-flowie.com/reference/data-model.html"
---
# Data model
Data model
# How the resources fit together
If you read one page in this whole reference, make it this one. Once you see the relationships, the rest of the API becomes obvious.
## Entity-relationship diagram
Organization id (org_…) name, brand plan (free|starter|pro|platform|wl) API key id (key_…) organizationId → companyId? → Company scopes[], keyType, expiresAt Company id (comp_…) organizationId → vatNumber, peppolId country, status, smpRegistered capabilities {send[], receive[]} compliance, settings, metadata createdAt, updatedAt Webhook id (wh_…) organizationId → companyId? → Company url, events[], secret Partner id (part_…) companyId → Company peppolId, vatNumber, role defaults, tags, contactEmail Document id (doc_…) senderCompanyId → Company receiverPeppolId type, direction, number status, deliveryStatus lifecycleStatus, currency grossAmount, document {…} Event id (evt_…) organizationId → type, createdAt, livemode data {…} (snapshot) Lifecycle event documentId → Document previous, current, at setBy, reason, payment {…} Compliance report documentId → Document platform (PPF|SDI) status, code, reportedAt 1 : N 1 : N 1 : N 1 : N 1 : N (sent) scopes scoped 1 : N 1 : N emits delivers
## Legend
* **Solid arrow** : synchronous foreign-key relationship (the child belongs to the parent).
* **Dashed arrow** : asynchronous "emits an event" relationship (state change creates an Event record).
* **Bold field** : primary key.
* **Blue field** : foreign key.
## Organization
Top-level tenant in the Flowie system. Holds plan, branding, and ownership of every other resource. You'll never CRUD an Organization through the public API — they're created at sign-up.
## Company
A legal entity that can send/receive on Peppol. [Full reference](). Note that `peppolId` is auto-derived from `vatNumber` \+ country scheme; you can override with `additionalIdentifiers[]`.
## Partner
A counterparty (customer or supplier) of one of your companies. Stores defaults so you don't repeat them on every send. Partners are scoped to a single company.
## Document
An invoice, credit note, debit note, or purchase order. Has three orthogonal status fields:
* `status`: _protocol-level_ — has it been validated, signed, sent.
* `deliveryStatus`: _transport-level_ — has the recipient AP confirmed.
* `lifecycleStatus`: _business-level_ — has the buyer approved, paid, or rejected.
You can have `status=sent, deliveryStatus=delivered, lifecycleStatus=disputed`. They're independent.
## Lifecycle event
Append-only log of business-level transitions on a document. The current `lifecycleStatus` on a document is materialized from the latest entry.
## Compliance report
One per (document, platform) pair where Flowie reported a status to a national authority (PPF for FR, SDI for IT). Updated on every retry. Belgium has no regulator-side report since the HERMES platform was decommissioned on 2025-12-31 — BE invoices don't create rows here. Historical HERMES rows from before that date are retained for audit.
## API key
Three flavors (personal, platform, white-label) and an optional scope to a single Company. [Full reference]().
## Webhook
Subscription to one or more event types. Optional company scoping. Failures auto-pause after 8 consecutive errors.
## Event
Durable record of every state change worth notifying about. Webhook deliveries are derived from these. Available for replay through the [Events API]() for 30 days.
## Cardinality summary
From| To| Cardinality| Note
---|---|---|---
Organization| Company| 1 : N| Platform orgs typically have N in the thousands.
Organization| API key| 1 : N| One per integration.
Organization| Webhook| 1 : N| Up to 100 active webhooks per org.
Company| Partner| 1 : N| Free, no upper limit.
Company| Document| 1 : N (as sender)| Or as receiver — direction stored on doc.
Document| Lifecycle event| 1 : N| One per status transition.
Document| Compliance report| 1 : N| One per (platform, retry).
Webhook| Event| N : N| Many webhooks consume; one event matches whoever subscribes.
========================================================================
# Document & invoice types
# Source: https://docs.get-flowie.com/reference/document-types.html
========================================================================
---
title: "Document & invoice types"
description: "Every document type Flowie sends — invoice, credit note, debit note, orders, quotes, events — plus every invoice subtype (prepayment, corrected, self-billed) and how self-billing and self-invoicing work."
canonical: "https://docs.get-flowie.com/reference/document-types"
source: "https://docs.get-flowie.com/reference/document-types.html"
---
# Document & invoice types
API Reference
# Document & invoice types
Every document you send flows through one endpoint — [`POST /v1/documents/send`]() — and a single `type` field tells Flowie what it is. This page is the complete referential: the eight [document types](<#document-types>), the four [invoice subtypes](<#invoice-subtypes>) (including **self-billed** invoices), and how the two flavours of _self-invoice_ — [self-billing](<#self-billing>) and [reverse-charge self-invoicing](<#self-invoice>) — differ and how to emit each.
The one field that decides everything: `type`
`type` is required on every send (except `event`, which needs no recipient). It picks the document class and the Peppol document type Flowie routes on. Invoice _sub_ -kinds (prepayment, corrected, self-billed) are a second, optional axis — [`documentSubtype`](<#invoice-subtypes>) — layered on top of `type: "invoice"`.
## Test any use case
Every scenario on this page has a ready-to-send example body for [`POST /v1/documents/send`](). Expand one and hit **Try in Playground** — it opens the request builder prefilled with the payload, and the Playground loads your stored sandbox key automatically — or copy the JSON or a ready-made curl. Every example uses the sandbox test identifiers, so it runs as-is.
Standard invoice 380
Ordinary sale of goods or services — the default.
[code]
{
"type": "invoice",
"from": "0009:FR86797978996",
"to": "0009:BE0123456789",
"document": {
"number": "INV-2026-0042",
"issueDate": "2026-04-15",
"currency": "EUR",
"lines": [
{ "description": "Consulting services", "quantity": 10, "unitPrice": 150.00, "vatRate": 21.0 }
]
}
}
[/code]
Multi-line invoice (mixed VAT rates) lines
Several lines at different VAT rates — standard, reduced and an exempt intra-EU line carrying its reason. VAT is summed per rate. See [Multiple VAT rates]().
[code]
{
"type": "invoice",
"from": "0009:FR86797978996",
"to": "0009:BE0123456789",
"document": {
"number": "INV-2026-0500",
"issueDate": "2026-04-15",
"currency": "EUR",
"lines": [
{ "description": "Consulting (standard rate)", "quantity": 10, "unit": "HUR", "unitPrice": 150.00, "vatRate": 21.0, "vatCategory": "S" },
{ "description": "E-book (reduced rate)", "quantity": 3, "unit": "C62", "unitPrice": 40.00, "vatRate": 6.0, "vatCategory": "S" },
{ "description": "Support plan (per month)", "quantity": 12, "unit": "MON", "unitPrice": 99.00, "vatRate": 21.0, "vatCategory": "S" },
{ "description": "Intra-EU goods (exempt)", "quantity": 1, "unit": "C62", "unitPrice": 500.00, "vatRate": 0.0, "vatCategory": "K", "vatExemptionReason": "Intra-Community supply, art. 138 Directive 2006/112/EC", "vatExemptionCode": "VATEX-EU-IC" }
]
}
}
[/code]
Line detail (units & item codes) lines
Per-line unit of measure (`unit`, UN/ECE Rec 20 — HUR hour, MON month, KGM kg, C62 unit), item reference (`itemCode`) and VAT category.
[code]
{
"type": "invoice",
"from": "0009:FR86797978996",
"to": "0009:BE0123456789",
"document": {
"number": "INV-2026-0501",
"issueDate": "2026-04-30",
"currency": "EUR",
"orderReference": "PO-2026-0042",
"lines": [
{ "description": "Managed hosting", "quantity": 1, "unit": "MON", "unitPrice": 1200.00, "vatRate": 21.0, "vatCategory": "S", "itemCode": "SKU-HOST-PRO" },
{ "description": "Steel bar", "quantity": 250, "unit": "KGM", "unitPrice": 3.20, "vatRate": 21.0, "vatCategory": "S", "itemCode": "SKU-STEEL-16" }
]
}
}
[/code]
Prepayment invoice / acompte 386
Advance billed before delivery. See [Prepayment invoices](<#prepayment>).
[code]
{
"type": "invoice",
"documentSubtype": "PREPAYMENT_INVOICE",
"from": "0009:FR86797978996",
"to": "0009:BE0123456789",
"document": {
"number": "ACPT-2026-0042",
"issueDate": "2026-04-15",
"currency": "EUR",
"orderReference": "PO-2026-0042",
"note": "Acompte 30 percent - commande PO-2026-0042",
"lines": [
{ "description": "Advance - 30 percent of project fee", "quantity": 1, "unitPrice": 3000.00, "vatRate": 21.0 }
]
}
}
[/code]
Corrected invoice 384
Replaces a prior invoice with corrected content; references the original.
[code]
{
"type": "invoice",
"documentSubtype": "CORRECTED_INVOICE",
"from": "0009:FR86797978996",
"to": "0009:BE0123456789",
"document": {
"number": "INV-2026-0042-R1",
"issueDate": "2026-04-20",
"currency": "EUR",
"billingReference": "INV-2026-0042",
"billingReferenceDate": "2026-04-15",
"lines": [
{ "description": "Consulting services (corrected quantity)", "quantity": 8, "unitPrice": 150.00, "vatRate": 21.0 }
]
}
}
[/code]
Credit note 381
Reduces or cancels a prior invoice. See [Credit & debit notes](<#credit-debit>).
[code]
{
"type": "credit-note",
"from": "0009:FR86797978996",
"to": "0009:BE0123456789",
"document": {
"number": "CN-2026-0007",
"issueDate": "2026-05-02",
"currency": "EUR",
"billingReference": "INV-2026-0042",
"billingReferenceDate": "2026-04-15",
"lines": [
{ "description": "Refund - consulting services", "quantity": 2, "unitPrice": 150.00, "vatRate": 21.0 }
]
}
}
[/code]
Debit note 383
Increases a prior invoice with an extra charge.
[code]
{
"type": "debit-note",
"from": "0009:FR86797978996",
"to": "0009:BE0123456789",
"document": {
"number": "DN-2026-0003",
"issueDate": "2026-05-05",
"currency": "EUR",
"billingReference": "INV-2026-0042",
"billingReferenceDate": "2026-04-15",
"lines": [
{ "description": "Late-delivery surcharge", "quantity": 1, "unitPrice": 90.00, "vatRate": 21.0 }
]
}
}
[/code]
Self-billing / autofacturation 389
You (the customer) issue for the supplier; roles flip. See [Self-billing](<#self-billing>).
[code]
{
"type": "invoice",
"selfBilled": true,
"from": "0009:FR86797978996",
"to": "0009:BE0123456789",
"document": {
"number": "SB-2026-0100",
"issueDate": "2026-04-15",
"currency": "EUR",
"lines": [
{ "description": "Grain delivery - March", "quantity": 12, "unitPrice": 210.00, "vatRate": 6.0 }
]
}
}
[/code]
Reverse charge (self-account VAT) AE
Cross-border supply where the buyer accounts for the VAT. See [Self-invoicing](<#self-invoice>).
[code]
{
"type": "invoice",
"from": "0009:FR86797978996",
"to": "0009:BE0123456789",
"document": {
"number": "RC-2026-0055",
"issueDate": "2026-04-15",
"currency": "EUR",
"note": "Reverse charge - VAT to be accounted for by the customer",
"lines": [
{
"description": "Cross-border consulting (reverse charge)",
"quantity": 5,
"unitPrice": 200.00,
"vatRate": 0.0,
"vatCategory": "AE",
"vatExemptionReason": "Reverse charge, art. 196 Directive 2006/112/EC",
"vatExemptionCode": "VATEX-EU-AE"
}
]
}
}
[/code]
Multiple parties (factoring payee) parties
A payee distinct from the seller. See [Multiple parties]().
[code]
{
"type": "invoice",
"from": "0009:FR86797978996",
"to": "0009:BE0123456789",
"document": {
"number": "INV-2026-0200",
"issueDate": "2026-04-15",
"currency": "EUR",
"parties": [
{ "role": "seller", "id": "0009:FR86797978996", "name": "ACME FRANCE", "initiator": true },
{ "role": "buyer", "id": "0009:BE0123456789", "name": "MEGACORP BE" },
{ "role": "payee", "vatNumber": "FR90123456789", "name": "ACME FACTORING SAS" }
],
"lines": [
{ "description": "Consulting services", "quantity": 10, "unitPrice": 150.00, "vatRate": 21.0 }
]
}
}
[/code]
Purchase request / requisition order
The buyer's internal request to authorise a purchase, ahead of the order. See [Orders, quotes & requisitions](<#orders>).
[code]
{
"type": "purchase-request",
"from": "0009:FR86797978996",
"to": "0009:BE0123456789",
"document": {
"number": "PR-2026-0042",
"issueDate": "2026-04-08",
"currency": "EUR",
"lines": [
{ "description": "Office chairs (requisition)", "quantity": 20, "unitPrice": 120.00, "vatRate": 21.0 }
]
}
}
[/code]
Purchase order order
An order sent by the buyer to the seller.
[code]
{
"type": "purchase-order",
"from": "0009:FR86797978996",
"to": "0009:BE0123456789",
"document": {
"number": "PO-2026-0042",
"issueDate": "2026-04-10",
"currency": "EUR",
"lines": [
{ "description": "Office chairs", "quantity": 20, "unitPrice": 120.00, "vatRate": 21.0 }
]
}
}
[/code]
Sales order order
The seller's order acknowledgement back to the buyer.
[code]
{
"type": "sales-order",
"from": "0009:FR86797978996",
"to": "0009:BE0123456789",
"document": {
"number": "SO-2026-0042",
"issueDate": "2026-04-11",
"currency": "EUR",
"orderReference": "PO-2026-0042",
"lines": [
{ "description": "Office chairs", "quantity": 20, "unitPrice": 120.00, "vatRate": 21.0 }
]
}
}
[/code]
Quote quote
A quotation ahead of any order.
[code]
{
"type": "quote",
"from": "0009:FR86797978996",
"to": "0009:BE0123456789",
"document": {
"number": "Q-2026-0042",
"issueDate": "2026-04-01",
"currency": "EUR",
"lines": [
{ "description": "Annual support plan", "quantity": 1, "unitPrice": 5000.00, "vatRate": 21.0 }
]
}
}
[/code]
Event event
An observability record — no recipient, so `to` is omitted.
[code]
{
"type": "event",
"from": "0009:FR86797978996",
"document": {
"number": "INV-2026-0042"
}
}
[/code]
Where's the expense payload?
There isn't one — an [expense](<#expenses>) is _received_ , not sent. The invoice-backed case is just the supplier's `invoice` arriving inbound; the no-invoice case is declared via e-reporting. Neither is a `POST /v1/documents/send` you make.
## Two axes: document type & invoice subtype
It helps to keep two concepts separate:
* **Document type** (`type`) — _what kind of document_ this is: an invoice, a credit note, an order, a quote. It is a fixed enum and it drives Peppol routing (which document type the recipient must be able to receive).
* **Invoice subtype** (`documentSubtype`) — _which kind of invoice_ , when `type: "invoice"`. It is rendered as the UBL `InvoiceTypeCode` (BT-3) using the UNCL1001 code list — `380` for a plain commercial invoice, `386`/`384`/`389` for prepayment / corrected / self-billed.
A credit note carries its own UNCL1001 code (`381`) derived from `type: "credit-note"`; you do not set `documentSubtype` for it. The subtype axis exists only to distinguish sub-kinds _of an invoice_.
## Document types (`type`)
The `type` enum on [`POST /v1/documents/send`](). The first six are Peppol-routed business documents and require a `to`; `event` is a pure observability record and has no recipient.
`type`| What it is| UNCL1001 code (BT-3)| Notes
---|---|---|---
`invoice`| Commercial invoice — a demand for payment for goods/services (B2B, B2C, B2G).| `380` (default; overridable via [`documentSubtype`](<#invoice-subtypes>))| The workhorse. See [invoice subtypes](<#invoice-subtypes>) for prepayment / corrected / self-billed.
`credit-note`| Reduces or cancels a previously issued invoice (a return, a rebate, an error).| `381`| Link the original with `document.billingReference` — **required** under the FR reform. See [below](<#credit-debit>).
`debit-note`| Increases a previously issued invoice (an extra charge after the fact).| `383`| Also requires `document.billingReference` under the FR reform.
`purchase-order`| An order sent by the buyer to the seller.| —| Ordering document, not a fiscal invoice. See [Purchase orders]().
`purchase-request`| A purchase requisition — the buyer's internal request to authorise a purchase, ahead of the order.| —| Maps to the transaction-documents `PURCHASE_REQUEST`. See [Orders, quotes & requisitions](<#orders>).
`sales-order`| The seller's order acknowledgement / confirmation back to the buyer.| —| Pairs with `purchase-order` in an order-to-invoice flow.
`quote`| A quotation / proposal, ahead of any order.| —| No fiscal effect; the first step of the quote → order → invoice chain.
`goods-receipt`| The buyer's record that the goods actually arrived — quantities received against what was ordered.| —| Closes the order loop: it is what a three-way match checks the invoice against, alongside the `purchase-order`. No fiscal effect. The _seller's_ side of the same delivery is not a document type — reference the delivery note on the invoice with `document.despatchAdviceReference` (BT-16) instead.
`event`| An observability / audit record about a document — no transport, no recipient.| —| The only type where `to` is optional. Carries just `document.number` and metadata.
A minimal invoice send:
[code]
curl -X POST https://api.flowie.ink/v1/documents/send \
-H "Authorization: Bearer $FLOWIE_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"type": "invoice",
"from": "0009:FR86797978996",
"to": "0009:BE0123456789",
"document": {
"number": "INV-2026-0042",
"issueDate": "2026-04-15",
"currency": "EUR",
"lines": [
{ "description": "Consulting services", "quantity": 10, "unitPrice": 150.00, "vatRate": 21.0 }
]
}
}'
[/code]
## Invoice subtypes (`documentSubtype`)
When `type: "invoice"`, the optional `documentSubtype` field selects the UNCL1001 `InvoiceTypeCode` (BT-3) rendered on the UBL. It accepts the `CAPITAL_SNAKE_CASE` name or the raw numeric code (e.g. `"386"`). It is only valid for `type: "invoice"` — sending it on any other type is a `400`. The four below are the named, modelled sub-kinds; because the field is a UNCL1001 pass-through, any other valid BT-3 code you send is tagged and rendered as-is.
Name| Code| Meaning| How to send
---|---|---|---
(default)| `380`| Commercial invoice — an ordinary sale.| Omit `documentSubtype`.
`PREPAYMENT_INVOICE`| `386`| Prepayment / down-payment invoice (_facture d'acompte_) — billed before delivery; netted out by the final invoice. See [Prepayment invoices](<#prepayment>).| `"documentSubtype": "PREPAYMENT_INVOICE"`
`CORRECTED_INVOICE`| `384`| Corrected invoice (_facture rectificative_) — replaces a prior invoice with corrected content. See [Corrected invoices](<#corrected>).| `"documentSubtype": "CORRECTED_INVOICE"`
`SELF_BILLED_INVOICE`| `389`| Self-billed invoice (_autofacturation_) — the customer issues on the supplier's behalf. See [Self-billing](<#self-billing>).| Prefer the `selfBilled: true` flag — it also flips the party roles.
Prefer the `selfBilled` flag for `389`
Setting `documentSubtype: "SELF_BILLED_INVOICE"` tags the UBL but does _not_ swap Seller and Buyer. The top-level [`selfBilled: true`](<#self-billing>) flag does both — tags `389` _and_ flips the roles — so it is the right choice for real self-billing.
## Credit & debit notes
A credit note (`type: "credit-note"`, UNCL1001 `381`) reduces or cancels a prior invoice; a debit note (`type: "debit-note"`, `383`) increases one. Both are first-class documents that flow through the same lifecycle as an invoice — a credit note is _not_ a lifecycle status on the original invoice.
Under the French reform, both must reference the invoice they amend via `document.billingReference` (BT-25, the UBL `BillingReference/InvoiceDocumentReference/ID`) and, where known, `document.billingReferenceDate` (BT-26). Omitting the reference on a FR credit/debit note fails validation (`BR-FR-CO-04`/`BR-FR-CO-05`).
[code]
curl -X POST https://api.flowie.ink/v1/documents/send \
-H "Authorization: Bearer $FLOWIE_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"type": "credit-note",
"from": "0009:FR86797978996",
"to": "0009:BE0123456789",
"document": {
"number": "CN-2026-0007",
"issueDate": "2026-05-02",
"currency": "EUR",
"billingReference": "INV-2026-0042",
"billingReferenceDate": "2026-04-15",
"lines": [
{ "description": "Refund — consulting services", "quantity": 2, "unitPrice": 150.00, "vatRate": 21.0 }
]
}
}'
[/code]
## Prepayment invoices (_facture d'acompte_)
A **prepayment invoice** — _facture d'acompte_ , or down-payment / advance invoice — bills an amount **before** the goods are delivered or the service is completed. It is a real, VAT-bearing invoice in its own right (with its own number and, where the advance is taxable, VAT due on the advance) — not a proforma or a quote. Tag it with the UNCL1001 subtype `386` via `documentSubtype`:
[code]
curl -X POST https://api.flowie.ink/v1/documents/send \
-H "Authorization: Bearer $FLOWIE_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"type": "invoice",
"documentSubtype": "PREPAYMENT_INVOICE",
"from": "0009:FR86797978996",
"to": "0009:BE0123456789",
"document": {
"number": "ACPT-2026-0042",
"issueDate": "2026-04-15",
"currency": "EUR",
"orderReference": "PO-2026-0042",
"note": "Acompte 30% — commande PO-2026-0042",
"lines": [
{ "description": "Advance — 30% of project fee", "quantity": 1, "unitPrice": 3000.00, "vatRate": 21.0 }
]
}
}'
[/code]
You can send the numeric code instead of the name (`"documentSubtype": "386"`) — both render the same UBL `InvoiceTypeCode` (BT-3). Like every subtype it is only valid for `type: "invoice"`.
**Settling the advance.** When the work is done you issue the _final_ (balance) invoice as an ordinary `type: "invoice"` (subtype `380`) and **deduct the amount already invoiced on the acompte** , so the customer is billed only the remaining balance — carry the deduction as a negative line (or, with `format=ubl-xml`, a document-level allowance) and cite the acompte's number in `document.note` or `document.orderReference` for the audit trail. The acompte and the balance invoice together add up to the full order value.
Country specifics
In 🇮🇹 Italy the advance is its own _TipoDocumento_ — `TD02` (_acconto/anticipo su fattura_) or `TD03` (_su parcella_) — set through `document.note`; see [Italian document types](<../compliance/it/document-types.html>). Under the 🇫🇷 French reform the acompte follows the standard e-invoice flow carrying `InvoiceTypeCode` `386`.
## Corrected invoice (_facture rectificative_)
A **corrected invoice** re-issues an invoice whose content was wrong — a mistyped amount, the wrong line, a bad VAT rate — as a fresh, self-standing invoice that **replaces** the original rather than adjusting it. Tag it with the UNCL1001 subtype `384` via `documentSubtype`, and point it at the invoice it supersedes with `document.billingReference` (BT-25) so the chain stays auditable:
[code]
curl -X POST https://api.flowie.ink/v1/documents/send \
-H "Authorization: Bearer $FLOWIE_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"type": "invoice",
"documentSubtype": "CORRECTED_INVOICE",
"from": "0009:FR86797978996",
"to": "0009:BE0123456789",
"document": {
"number": "INV-2026-0042-R1",
"issueDate": "2026-04-20",
"currency": "EUR",
"billingReference": "INV-2026-0042",
"billingReferenceDate": "2026-04-15",
"lines": [
{ "description": "Consulting services (corrected quantity)", "quantity": 8, "unitPrice": 150.00, "vatRate": 21.0 }
]
}
}'
[/code]
Send the numeric code if you prefer (`"documentSubtype": "384"`); like every subtype it is only valid for `type: "invoice"`.
**Corrected invoice vs. credit note.** A corrected invoice (`384`) _replaces_ the original with the right figures. An [avoir / credit note](<#credit-debit>) instead _cancels or reduces_ the original and leaves it standing — often followed by a brand-new invoice. Under the French reform the credit-note route is the usual way to correct an already-transmitted invoice; reach for `384` when a single rectifying invoice that references the original is the cleaner record. Either way, carry the link in `document.billingReference`.
Correcting before vs. after transmission
Nothing sent yet? Just fix and send the invoice normally — there is no correction to model. The `384` subtype (and the `billingReference` link) is for when the original has already reached the buyer and the tax authority and must be superseded on the record.
## Self-billing (_autofacturation_)
**Self-billing** is the arrangement where the **customer issues the invoice on the supplier's behalf** — common in agriculture, marketplaces, and royalty settlements, and permitted where the two parties have agreed to it. It is still a two-party sale between a distinct seller and buyer; only the party who _issues_ the document changes.
Set it with the top-level `selfBilled: true` flag. Flowie then:
* treats the acting organization (`from`) as the **Buyer** / initiator;
* treats `to` as the **Seller** (the supplier being billed);
* tags the document with UNCL1001 subtype `389` (Self-Billed Invoice).
It is a shorthand for `documentSubtype: "SELF_BILLED_INVOICE"` that _also_ flips the roles, and it is only valid for `type: "invoice"` — self-billed credit notes (UNCL1001 `261`) are not yet modelled downstream, so `selfBilled` on any other type is a `400`.
[code]
curl -X POST https://api.flowie.ink/v1/documents/send \
-H "Authorization: Bearer $FLOWIE_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"type": "invoice",
"selfBilled": true,
"from": "0009:FR86797978996", # you — the customer, issuing on the supplier'"'"'s behalf
"to": "0009:BE0123456789", # the supplier — becomes the Seller
"document": {
"number": "SB-2026-0100",
"issueDate": "2026-04-15",
"currency": "EUR",
"lines": [
{ "description": "Grain delivery — March", "quantity": 12, "unitPrice": 210.00, "vatRate": 0.0 }
]
}
}'
[/code]
Self-billing with a third party
When the self-billed document also involves a distinct `payer` or `payee` (e.g. a factoring arrangement), drop the `selfBilled` shorthand and describe every role explicitly with [`document.parties`](): one entry per role, with exactly one carrying `initiator: true` (the org your key acts as).
## Self-invoicing & reverse charge (_autofattura_)
"Self-invoice" is also used for a different, VAT-driven case: under a **reverse charge** or on a cross-border purchase, the **buyer issues a document to account for the VAT itself** , because the supplier did not (or could not) charge it. Here the same party is effectively both seller and buyer of record — it is not the two-party self-billing above.
How this is expressed depends on the jurisdiction:
* **🇮🇹 Italy (SDI).** Self-invoices and integrations carry a dedicated _TipoDocumento_ — `TD16`–`TD19` for reverse charge and foreign purchases, `TD20`/`TD21`/`TD27`/`TD29` for the _autofatture_ where seller = buyer. Set the code via `document.note`; Flowie validates the seller/buyer and country rules before transmission. See the full table on [Italian document types (TD01–TD29)](<../compliance/it/document-types.html>).
* **Cross-border / EN 16931.** On the structured invoice, a reverse-charge or exempt supply is carried per line with the right `vatCategory` (`AE` reverse charge, `K` intra-community, `G` export, `E` exempt, `O` out of scope) plus a stated exemption reason — see [Tax exemption & zero rate](). Flowie renders the matching BG-23 VAT breakdown so the zero-VAT category is declared rather than a bogus 0 % standard rate.
Two things both called "self-invoice"
**Self-billing** (`selfBilled: true`, UNCL1001 `389`) = the customer issues a normal invoice for a real supplier, roles flipped. **Reverse-charge self-invoicing** (Italian _autofattura_ , TD16–TD29) = the buyer issues a document to self-account for VAT. Pick by _why_ you are issuing, not just the word.
## Orders, quotes & requisitions
Not every document is an invoice. Flowie also carries the **pre-invoice** documents of the procure-to-pay chain — the ones that lead up to the bill. They flow through the same [`POST /v1/documents/send`]() pipeline; only the `type` changes.
* **Purchase request** (`type: "purchase-request"`) — a _purchase requisition_ : the buyer's internal request to authorise a purchase, the first step of the chain. Try the [Purchase request example](<#try-it>).
* **Quote** (`type: "quote"`) — a quotation / proposal the seller sends. No fiscal effect. Try the [Quote example](<#try-it>).
* **Purchase order** (`type: "purchase-order"`) — the buyer's order to the seller. Try the [Purchase order example](<#try-it>).
* **Sales order** (`type: "sales-order"`) — the seller's acknowledgement back to the buyer, pairing with the purchase order.
These are order-side documents, not fiscal invoices. Chain the whole thread — _requisition → quote → order → invoice_ — by carrying `document.orderReference` (and `document.buyerReference`) forward from one document to the next, so it stays linkable end to end.
About `purchase-request` (the requisition)
A **purchase requisition** (PR) is the internal approval a buyer raises before a [purchase order](<#orders>) goes to the supplier. Flowie carries it as the `purchase-request` type, rendered as the transaction-documents `PURCHASE_REQUEST` document. Like the other order-side types it takes a `from`/`to` and a `document` body; put the requisition number in `document.number` and any originating reference in `document.buyerReference`.
## Expenses (employee & card spend)
There is **no`expense` document type** — an expense is not a thing you _send_ , it is spend you _account for_ , and it maps onto the model above in one of two ways depending on whether a supplier invoice exists:
* **Expense backed by a supplier invoice** (a hotel, a SaaS subscription, a supplier that issues a proper invoice). This is just an ordinary `type: "invoice"` that you _receive_ — your company is the buyer, and it arrives inbound like any other invoice (see [Document · direction]()). Nothing expense-specific: it is captured, matched and booked as a received invoice. This is French reform _cas d'usage_ 5.
* **Expense with no invoice** — a restaurant receipt, a toll ticket, a taxi, a lodged-card purchase. There is no structured invoice to exchange over Peppol/PA, so the amount is declared to the tax authority as **e-reporting** (transaction / payment _data_), not sent as an e-invoice. These are French _cas d'usage_ 6 (expenses without an invoice), 27 (toll tickets), 28 (restaurant receipts) and 7 (lodged corporate card).
Expenses are received, not a send type
Because expenses are the buyer-side view of a supplier's invoice (or a receipt reported as data), they never need a new `type` value. For the invoice-backed case, receive and reconcile the inbound invoice; for the no-invoice case, see the [e-reporting deep dive](<../compliance/fr/use-cases.html#ereporting>) and the full [cas d'usage referential](<../compliance/fr/use-cases.html#all>) (cases 5–7, 27, 28).
## Which one do I use?
* Ordinary sale → `type: "invoice"` (subtype defaults to `380`).
* Billing an advance before delivery ([acompte](<#prepayment>)) → `type: "invoice"` \+ `documentSubtype: "PREPAYMENT_INVOICE"`.
* Replacing an invoice's content → `type: "invoice"` \+ `documentSubtype: "CORRECTED_INVOICE"`.
* Reducing / cancelling an invoice → `type: "credit-note"` with `billingReference`.
* Charging more after the fact → `type: "debit-note"` with `billingReference`.
* You are the customer issuing for the supplier → `type: "invoice"` \+ `selfBilled: true`.
* Self-accounting for VAT under reverse charge (IT) → `type: "invoice"` \+ the right `TD` code in `document.note`.
* Requisitioning a purchase (internal request) → `type: "purchase-request"`.
* Ordering / quoting → `type: "purchase-order"`, `"sales-order"`, or `"quote"`.
* Recording an event, no recipient → `type: "event"`.
* Booking an [expense](<#expenses>) → not a send type: receive the supplier's `invoice`, or e-report it when there's no invoice.
## References
* [Send a document]() — the endpoint, every field including `type`, `documentSubtype` and `selfBilled`.
* [Multiple parties]() — explicit role-tagged parties for self-billing with a third party.
* [Tax exemption & zero rate]() — VAT categories and exemption reasons for reverse-charge and exempt supplies.
* [Data model · Document]() — the three orthogonal status fields on every document.
* [Italy · Document types (TD01–TD29)](<../compliance/it/document-types.html>) — the full _TipoDocumento_ referential, including the _autofatture_.
========================================================================
# E-invoicing formats
# Source: https://docs.get-flowie.com/reference/formats.html
========================================================================
---
title: "E-invoicing formats"
description: "Every e-invoicing format, explained: EN 16931, UBL 2.1, UN/CEFACT CII, Peppol BIS Billing 3.0 and PINT, Factur-X and ZUGFeRD, XRechnung, FatturaPA, Facturae, KSeF FA(3), ZATCA, MyInvois — which ones Flowie sends and receives, and a link to every official specification."
canonical: "https://docs.get-flowie.com/reference/formats"
source: "https://docs.get-flowie.com/reference/formats.html"
---
# E-invoicing formats
API Reference
# E-invoicing formats
An **e-invoicing format** is the machine-readable structure an invoice travels in — not a PDF of an invoice, but the invoice itself as data a buyer's system can book without retyping. There are only **two XML syntaxes** that matter in Europe ([UBL 2.1](<#ubl>) and [UN/CEFACT CII](<#cii>)), one semantic standard on top of them ([EN 16931](<#en16931>)), and then a long tail of **national profiles** — [Factur-X](<#hybrid>), [ZUGFeRD](<#hybrid>), [XRechnung](<#national>), [FatturaPA](<#national>), [Facturae](<#national>), [KSeF FA(3)](<#national>), [ZATCA](<#clearance>), [MyInvois](<#clearance>) — that constrain the same data for one country's tax administration. This page is the complete map, and says exactly which of them Flowie produces, accepts and delivers.
You do not have to pick one
Send [`POST /v1/documents/send`]() your invoice as JSON and Flowie renders the syntax the destination requires — Peppol BIS Billing 3.0 UBL for the Peppol network, the country-native format where the tax administration mandates one. Already have XML? Deposit [UBL](<#ubl>), [CII](<#cii>) or a [Factur-X PDF](<#hybrid>) and Flowie validates and routes it as-is. The `format` field is documented under [what Flowie handles](<#flowie>).
## The short answer, by country
If you only read one section, read this one. In 2026 the format question resolves to four cases:
* **You are sending inside the EU over Peppol** (Belgium, the Netherlands, the Nordics, Ireland, most B2G) → [Peppol BIS Billing 3.0](<#peppol>), which is UBL 2.1 constrained to EN 16931. This is the default Flowie emits.
* **You are sending to a country with its own clearance platform** (Italy, Poland, Romania, Spain, Saudi Arabia, India, Malaysia, Egypt, Turkey) → the [national format](<#clearance>) that platform accepts, cleared before or as the invoice is delivered.
* **You are sending in France or Germany** → a [hybrid Factur-X / ZUGFeRD PDF](<#hybrid>), or plain [CII](<#cii>)/[UBL](<#ubl>), or [XRechnung](<#national>) for German public buyers. All three are legal; the buyer's capability decides.
* **You do not know** → send JSON and let Flowie resolve the recipient's capability from the network directory. That is what [`POST /v1/directory/verify`]() answers.
Per-country mandates, deadlines and the exact network each one runs on are documented in the [compliance section](<../compliance/index.html>) — 47 jurisdictions, with a [coverage matrix](<../compliance/index.html#matrix>) you can sort by network.
## The four layers: model, syntax, profile, network
Almost every argument about e-invoicing formats is two people talking about different layers. An invoice on the wire is four decisions stacked, and each is independent of the others:
Layer| What it fixes| Examples
---|---|---
**1\. Semantic model** | Which business terms exist and what they mean — `BT-1` is the invoice number, `BT-9` the due date. No syntax at all. | [EN 16931](<#en16931>), the semantic core of every European format
**2\. Syntax** | How those terms are serialised into a file a parser can read. | [UBL 2.1](<#ubl>) (OASIS), [UN/CEFACT CII D16B](<#cii>)
**3\. Profile / CIUS** | Which optional terms become mandatory, which code lists are allowed, which national identifiers are required. A _CIUS_ narrows EN 16931; an _extension_ adds to it. | [Peppol BIS Billing 3.0](<#peppol>), [XRechnung](<#national>), [Factur-X EN 16931 profile](<#hybrid>), [PINT](<#peppol>)
**4\. Network / transport** | How the file reaches the buyer and the tax administration. | Peppol AS4 4-corner, Italy's SDI, France's PA/PDP, Poland's KSeF, Saudi Fatoora
"Is XRechnung a format?"
It is a _profile_ (layer 3) that can be carried in either syntax (layer 2) and always satisfies the same semantic model (layer 1). That is why an XRechnung invoice and a Peppol BIS invoice can be byte-different and still carry identical business content — and why converting between them is a mapping exercise, not a re-keying one. Flowie holds the semantic model once, in the [document data model](), and renders the layers below it.
## What Flowie handles
Two directions, one endpoint each. On the way **out** , the `format` field on [`POST /v1/documents/send`]() declares what you are handing us:
`format`| What you send| What Flowie does
---|---|---
`json` | The canonical [Flowie document model]() in the `document` field. | Renders EN 16931-compliant UBL 2.1 (Peppol BIS Billing 3.0 customisation), then delivers it in the destination's native format.
`ubl-xml` | Your own UBL 2.1 `Invoice` or `CreditNote` XML in `xml`. | Validates against the EN 16931 and national schematrons, then routes it. Your bytes stay the emitted original.
`cii-xml` | UN/CEFACT `CrossIndustryInvoice` XML — including the XML extracted from a Factur-X or ZUGFeRD PDF. | Same: CII schematrons, then routing.
`auto` | A file in `file.content` and no opinion about it. | Sniffs the magic bytes and the XML root element — `CrossIndustryInvoice` → CII, `Invoice`/`CreditNote` → UBL, `%PDF` → PDF — and picks the pipeline. This is the safe default.
`raw` | Anything else — a PDF, a scan, a spreadsheet. | Stores it as-is, no network routing, no structured validation.
Announce CII as CII
A Factur-X deposit is **CII, not UBL**. Declaring `format: "ubl-xml"` for it runs the wrong schematron and reports a perfectly valid invoice as broken. If you are not certain, use `auto` — the root element decides, and it is never wrong.
On the way **in** , every document you receive is available in three shapes from [the document endpoints](): the **original** artefact exactly as the sender deposited it (the Factur-X PDF, if that is what they sent), the **structured XML** , and the normalised **JSON** that [webhooks]() carry. You never have to parse a syntax you do not want to support.
On the French leg, the AFNOR XP Z12-013 flow declares its syntax explicitly — `CII`, `UBL`, `Factur-X` for invoices, plus `CDAR` for lifecycle statuses and `FRR` for e-reporting. See the [French integration playbook](<../compliance/fr/integration.html>).
## Format catalogue
Every e-invoicing format you are likely to meet, what it actually is, where it is required, and whether Flowie handles it. Click a column header to sort.
Format | What it is | Where it matters | Flowie | Official reference
---|---|---|---|---
[**EN 16931**](<#en16931>) | Semantic model (not a file format) | EU-wide baseline; every European profile is a CIUS of it | Native | [European Commission]()
[**UBL 2.1**](<#ubl>) | XML syntax (OASIS) | Peppol, Denmark, Norway, Netherlands, Saudi Arabia, Malaysia, Turkey | Send & receive | [OASIS UBL 2.1]()
[**UN/CEFACT CII**](<#cii>) (D16B) | XML syntax (Cross Industry Invoice) | France, Germany, and the XML inside every Factur-X / ZUGFeRD PDF | Send & receive | [UNECE XML schemas]()
[**Peppol BIS Billing 3.0**](<#peppol>) | CIUS of EN 16931 in UBL 2.1 | The Peppol network — 30+ countries, the EU default | Send & receive | [OpenPeppol BIS 3.0]()
[**Peppol PINT**](<#peppol>) | Global billing template + per-jurisdiction specialisations | Australia, New Zealand, Japan, Singapore, UAE, and the EU PINT profile | Send & receive | [PINT Billing]()
[**Factur-X**](<#hybrid>) | Hybrid PDF/A-3 with embedded CII XML | France — the format most French suppliers will emit | Send & receive | [FNFE-MPE]()
[**ZUGFeRD**](<#hybrid>) | The same hybrid standard, German edition | Germany — B2B, interchangeable with Factur-X | Send & receive | [FeRD]()
[**XRechnung**](<#national>) | German CIUS of EN 16931 (UBL or CII) | Germany — mandatory for federal B2G, widely used B2B | Send & receive | [KoSIT / XÖV]()
[**FatturaPA**](<#national>) | Italian national XML schema (pre-dates EN 16931) | Italy — every B2B, B2C and B2G invoice, cleared through SDI | Send & receive | [Agenzia delle Entrate]()
[**Facturae**](<#national>) | Spanish national XML, signed with XAdES | Spain — B2G via FACe, alongside the Crea y Crece B2B rollout | Send & receive | [facturae.gob.es]()
[**KSeF FA(3)**](<#national>) | Polish national XML schema | Poland — mandatory B2B clearance through KSeF from 2026 | Send & receive | [Ministerstwo Finansów]()
[**ISDOC**](<#national>) | Czech UBL-derived XML, in use since 2009 | Czechia — public sector accepts ISDOC and Peppol BIS | Send & receive | [ISDOC specification]()
[**OIOUBL**](<#national>) | Danish UBL profile, pre-dating Peppol | Denmark — NemHandel, legacy public-sector ERPs | Send & receive | [oioubl.info]()
[**EHF**](<#national>) | Norwegian profile, now a thin layer over Peppol BIS | Norway — B2G since 2012 | Send & receive | [DFØ / Anskaffelser]()
[**Finvoice**](<#national>) | Finnish bank-led XML standard | Finland — bank channels, alongside Peppol BIS | Send & receive | [Finance Finland]()
[**ebInterface**](<#national>) | Austrian XML standard | Austria — accepted alongside Peppol BIS on the federal portal | Send & receive | [ebInterface]()
[**ZATCA e-invoice**](<#clearance>) | UBL 2.1-based XML, cryptographically stamped | Saudi Arabia — Fatoora clearance and reporting | Send & receive | [ZATCA]()
[**MyInvois**](<#clearance>) | UBL 2.1 in XML or JSON | Malaysia — LHDN clearance, phased by turnover | Send & receive | [MyInvois SDK]()
[**GST e-invoice (INV-01)**](<#clearance>) | JSON schema registered with an IRP for an IRN | India — B2B above the turnover threshold | Send & receive | [GST e-Invoice portal]()
[**ETA e-invoice**](<#clearance>) | JSON/XML submitted to the tax authority | Egypt — universal B2B/B2G clearance | Send & receive | [Egyptian Tax Authority]()
[**UBL-TR (e-Fatura)**](<#clearance>) | Turkish UBL 2.1 customisation | Türkiye — e-Fatura and e-Arşiv | Send & receive | [GİB e-Fatura]()
[**UN/EDIFACT INVOIC**](<#legacy>) | Pre-XML EDI message | Retail, automotive and logistics supply chains | On request | [UNECE EDIFACT]()
[**PDF / scan**](<#legacy>) | Not an e-invoicing format | Nowhere, legally, once a mandate is live | Stored as-is | —
**Send & receive** means Flowie renders the format on the way out and normalises it on the way in — you work in JSON and never touch the schema. Country-by-country detail, including which network carries which format, is in the [coverage matrix](<../compliance/index.html#matrix>).
## EN 16931 — the European semantic standard
**EN 16931 is not a file format.** It is the semantic data model that says what an invoice contains: 164 business terms (`BT-1`…) grouped into business groups (`BG-1`…), plus roughly 200 business rules that say when each is required and how the totals must add up. Every European e-invoicing format is a constraint on it.
It exists because of EU Directive 2014/55/EU, which obliged public buyers across the Union to accept electronic invoices in a common standard. **EN 16931-1:2026 was published in May 2026 and formally withdrew the 2017 edition** , with a migration period while profiles catch up — so a document that validates against a 2017-era schematron will keep validating for now, and the practical change arrives when each national profile republishes against the new edition.
The companion **CEN/TS 16931-2** lists the syntaxes that comply with it, and there are exactly two: [UBL 2.1](<#ubl>) and [UN/CEFACT CII](<#cii>). Everything else in Europe is a profile of one of those two.
Flowie exposes the model directly: the [business-terms referential](<../compliance/fr/business-terms.html>) lists all 164 BTs, what each maps to in UBL and CII, and which ones France additionally requires. Validation failures come back naming the business term, not the schematron step — see [the error catalog]().
## UBL 2.1 — the XML syntax most networks speak
**UBL (Universal Business Language) 2.1** is an OASIS standard defining XML schemas for the whole procurement chain — orders, despatch advices, invoices, credit notes. Its `Invoice` and `CreditNote` documents are one of the two EN 16931-compliant syntaxes, and the one the Peppol network chose.
An EN 16931 UBL invoice announces its profile in two elements at the top of the document:
[code]
urn:cen.eu:en16931:2017#compliant#urn:fdc:peppol.eu:2017:poacc:billing:3.0urn:fdc:peppol.eu:2017:poacc:billing:01:1.0
[/code]
`CustomizationID` is the _profile_ — the CIUS the document claims to satisfy. `ProfileID` is the _business process_ it belongs to. Get either wrong and a compliant access point will reject the document before a human sees it. Flowie writes both for you when you send `format: "json"`, and validates them when you deposit your own XML.
UBL is also the base of several national formats that pre-date or extend the European standard — [OIOUBL](<#national>) (Denmark), [ISDOC](<#national>) (Czechia), [UBL-TR](<#clearance>) (Türkiye), [ZATCA](<#clearance>) (Saudi Arabia) and [MyInvois](<#clearance>) (Malaysia).
## UN/CEFACT CII — the other compliant syntax
**CII (Cross Industry Invoice)** is UN/CEFACT's XML syntax, standardised in schema release **D16B**. Its root element is `rsm:CrossIndustryInvoice`, and it carries the same EN 16931 business terms as UBL in a different tree — three top-level sections (`ExchangedDocument`, `SupplyChainTradeTransaction` and the header context) instead of UBL's flatter layout.
CII matters far more than its market share suggests, because it is the XML embedded inside every [Factur-X and ZUGFeRD](<#hybrid>) PDF — which makes it the dominant syntax in France and Germany, the two largest e-invoicing markets in continental Europe.
Telling them apart in one line
Read the root element. `` → CII. `` or `` in a UBL namespace → UBL. That is exactly what Flowie's `format: "auto"` does, and why it is a safer choice than declaring the syntax yourself.
## Factur-X & ZUGFeRD — the hybrid PDF formats
**Factur-X** (France) and **ZUGFeRD** (Germany) are the same standard published by two bodies — [FNFE-MPE]() and [FeRD]() — under two names. A Factur-X invoice is a **PDF/A-3 file with a CII XML attachment embedded inside it** : a human opens the PDF and reads an invoice; a machine opens the same file, pulls out `factur-x.xml`, and books it. One artefact, both audiences, no reconciliation problem.
The standard defines a ladder of profiles, from _MINIMUM_ and _BASIC WL_ (too thin to be a legal invoice on their own) through _BASIC_ and _EN 16931_ (the fully compliant core) to _EXTENDED_ (adds terms beyond EN 16931). France's B2B reform accepts Factur-X at the EN 16931 profile and above.
Flowie treats the PDF as the original: deposit a Factur-X and the embedded CII is extracted, mapped and validated, while the **PDF you sent stays the artefact returned as the original document** — which is what AFNOR XP Z12-013 requires of a French emitter, and what an auditor will ask for. See the [France overview](<../compliance/fr/index.html>) and the [Germany page](<../compliance/de.html>) for the two mandates.
## Peppol BIS Billing 3.0 and PINT
**Peppol BIS Billing 3.0** is a CIUS of EN 16931 expressed in UBL 2.1, and it is the single most widely deployed e-invoicing profile in Europe. It is what travels the Peppol network's four-corner model: you send to your access point, your access point delivers to the recipient's, and an SMP lookup resolves who that is. Flowie is an access point, so [sending]() is one API call.
**PINT (Peppol International)** is the newer, global generalisation: a common billing template that each jurisdiction specialises rather than fork. PINT specialisations are live or landing in [Australia and New Zealand](<../compliance/au.html>) (PINT A-NZ), [Japan](<../compliance/jp.html>) (JP PINT), [Singapore](<../compliance/sg.html>) (InvoiceNow), [the UAE](<../compliance/ae.html>) (PINT AE, on a five-corner model that adds the tax authority as a corner) — and in the EU, as [PINT EU](), the successor profile to BIS Billing 3.0.
Reachability is per document type
A recipient registered on Peppol advertises which document types it accepts. Before you send, ask [`POST /v1/directory/verify`]() with the _type you will actually send_ — a participant reachable for invoices is not automatically reachable for credit notes or orders.
## National European formats and CIUS
Most EU countries either use Peppol BIS as-is or narrow it with a national CIUS. A handful run formats that pre-date the European standard and are still legally required.
### XRechnung (Germany)
The German CIUS of EN 16931, maintained by [KoSIT](). Mandatory for invoices to federal public buyers, and the reference profile for the B2B mandate phasing in through 2028. XRechnung can be carried in either UBL or CII, and adds German specifics — _Leitweg-ID_ routing, mandatory buyer contact details. Details on the [Germany page](<../compliance/de.html>).
### FatturaPA (Italy)
Italy's national XML schema, cleared through the _Sistema di Interscambio_ (SDI) for every B2B, B2C and B2G invoice. It pre-dates EN 16931 and is not a CIUS of it: it has its own element names, its own _TipoDocumento_ codes (TD01–TD29) and its own outcome messages (_esiti_) that come back asynchronously after submission. Flowie maps the canonical model onto it and surfaces the esiti as [lifecycle events]() — see the [Italy overview](<../compliance/it/index.html>) and the [TD explorer](<../compliance/it/document-types.html>).
### The rest, briefly
* **[Facturae]()** (Spain) — national XML with a mandatory XAdES signature, used for B2G through FACe while the Crea y Crece B2B framework rolls out. [Spain →](<../compliance/es.html>)
* **[KSeF FA(3)]()** (Poland) — the schema for the national clearance platform; an invoice has no legal existence until KSeF assigns it a number. [Poland →](<../compliance/pl.html>)
* **RO e-Factura** (Romania) — a national CIUS of EN 16931 cleared through ANAF. [Romania →](<../compliance/ro.html>)
* **[ISDOC]()** (Czechia) — a UBL-derived national standard from 2009; public buyers accept it and Peppol BIS. [Czechia →](<../compliance/cz.html>)
* **[OIOUBL]()** (Denmark) — the Danish UBL profile carried over NemHandel, still alive in legacy public-sector ERPs. [Denmark →](<../compliance/dk.html>)
* **EHF** (Norway) — now essentially Peppol BIS with Norwegian identifiers. [Norway →](<../compliance/no.html>)
* **[Finvoice]()** (Finland) — a bank-led standard delivered through banking channels alongside Peppol. [Finland →](<../compliance/fi.html>)
* **[ebInterface]()** (Austria) — accepted on the federal e-invoicing portal next to Peppol BIS. [Austria →](<../compliance/at.html>)
* **[UBL.BE]()** (Belgium) — the Belgian Peppol BIS profile, now that the B2B mandate is live and HERMES has been retired. [Belgium →](<../compliance/be.html>)
## Clearance and reporting formats outside the EU
Outside Europe the dominant model is **clearance** : the invoice is submitted to the tax administration and only becomes valid once it comes back stamped, numbered or signed. The format is whatever that platform's schema says, and it is rarely EN 16931.
* **[Saudi Arabia — ZATCA / Fatoora]()** : UBL 2.1-based XML with a cryptographic stamp, a UUID and a QR code; standard invoices are cleared before issuance, simplified ones reported after. [Saudi Arabia →](<../compliance/sa.html>)
* **[Malaysia — MyInvois]()** : UBL 2.1 in XML or JSON, validated by LHDN, which returns a UUID and a QR code. [Malaysia →](<../compliance/my.html>)
* **[India — GST e-invoice]()** : the INV-01 JSON schema registered with an Invoice Registration Portal, which returns the IRN and a signed QR code. [India →](<../compliance/in.html>)
* **[Egypt — ETA]()** : JSON/XML documents submitted to the tax authority for near-real-time clearance. [Egypt →](<../compliance/eg.html>)
* **[Türkiye — e-Fatura / e-Arşiv]()** : UBL-TR, a Turkish customisation of UBL 2.1, through the GİB. [Türkiye →](<../compliance/tr.html>)
* **Israel — ITA allocation number** : no new document format; invoices above a threshold need an allocation number requested from the tax authority before they are deductible. [Israel →](<../compliance/il.html>)
* **China — fully digital e-fapiao** : issued inside the STA's Golden Tax IV platform rather than exchanged between trading partners. [China →](<../compliance/cn.html>)
Flowie's job on these is the same in every case: you send the canonical JSON, and the country connector produces the platform's schema, submits it, and reports the outcome back as lifecycle events you can subscribe to. What differs is _when_ the invoice becomes legally valid — which is why [the lifecycle endpoint](), not the send response, is the thing to watch in a clearance country.
## Legacy EDI, and why a PDF is not an e-invoice
**UN/EDIFACT INVOIC** and **ANSI X12 810** are the pre-XML EDI invoice messages, still carrying enormous volume in retail, automotive and logistics. They are structured and machine-readable, so they solve the same problem — but they are not EN 16931 syntaxes, and a mandate that names UBL or CII will not accept them. Bridging is a mapping project; talk to us if you have an EDI backbone to keep.
A **PDF invoice, including one sent by email, is not an electronic invoice** under any current mandate — nor is a scan, nor a spreadsheet. The test every regulation applies is whether the invoice can be processed automatically without re-keying, which a flat PDF cannot. This is the single most common misconception in e-invoicing projects, and the reason [Factur-X](<#hybrid>) exists: it keeps the PDF a human wanted _and_ the data the regulation requires in one file. Flowie will happily store a flat PDF with `format: "raw"` — it just will not route it as a compliant invoice.
## Which format should I send?
* **Building a new integration** → send `format: "json"`. You describe the invoice once; Flowie renders the right syntax per destination and re-renders it when a country changes its profile.
* **Your ERP already emits UBL or CII** → deposit it with `ubl-xml` / `cii-xml`. Your bytes remain the emitted original, which matters for audit.
* **Your ERP emits Factur-X or ZUGFeRD PDFs** → send the PDF with `format: "auto"`. The embedded CII is extracted and validated; the PDF stays the original.
* **You are selling into Germany** → XRechnung for public buyers, Factur-X/ZUGFeRD or plain CII/UBL for B2B. [Germany →](<../compliance/de.html>)
* **You are selling into Italy, Poland, Romania, Spain or a Gulf/Asian clearance country** → send JSON and let the country connector produce the national schema. The format is not really your choice there; the platform's schema is the contract. [Coverage matrix →](<../compliance/index.html#matrix>)
* **You do not know what the recipient accepts** → [`POST /v1/directory/verify`]() before you send.
## Frequently asked questions
### What is the difference between UBL and CII?
They are two XML syntaxes for the same semantic content. UBL 2.1 is an OASIS standard used by Peppol and most Northern European networks; UN/CEFACT CII is used in France and Germany and is the XML embedded in Factur-X and ZUGFeRD PDFs. Both are listed by CEN/TS 16931-2 as compliant with EN 16931, so an invoice can be converted from one to the other without losing business content. The root element tells them apart: `CrossIndustryInvoice` for CII, `Invoice` or `CreditNote` for UBL.
### Is Factur-X the same as ZUGFeRD?
Yes — technically the same hybrid PDF/A-3 standard, published jointly by FNFE-MPE in France and FeRD in Germany under two names. A ZUGFeRD file is a valid Factur-X file and vice versa, at the same profile level. The names differ for governance and market reasons, not technical ones.
### Is XRechnung a Peppol format?
No. XRechnung is a German CIUS of EN 16931; Peppol BIS Billing 3.0 is OpenPeppol's CIUS of the same standard. XRechnung documents are commonly _transported_ over the Peppol network, which is why the two are often confused, but they are different profiles with different mandatory fields — notably the German _Leitweg-ID_.
### Is a PDF invoice an electronic invoice?
No. Under EU Directive 2014/55/EU and the national mandates that follow it, an electronic invoice must be issued, transmitted and received in a structured format that allows automatic processing. A PDF — or a scan, or an emailed image — does not qualify, however it was produced. A hybrid Factur-X/ZUGFeRD PDF does qualify, because the structured XML travels inside it.
### Do I have to convert my invoices myself?
No. Send the canonical JSON model to [`POST /v1/documents/send`]() and Flowie produces whatever the destination requires. Conversion only becomes your problem if you insist on depositing finished XML for a country whose profile you have not implemented.
### Does EN 16931-1:2026 break my integration?
Not on its own. The 2026 edition was published in May 2026 and formally withdrew the 2017 edition, but national profiles adopt it on their own timetable and validation keeps accepting the current profile versions during the migration. Anything that does change lands in the [changelog](<../changelog.html>) before it reaches you.
### Which formats does Flowie support?
On input: the canonical JSON model, UBL 2.1, UN/CEFACT CII, and Factur-X/ZUGFeRD PDFs (plus any file stored as-is with `format: "raw"`). On output: Peppol BIS Billing 3.0 and PINT for the Peppol network, and the national format required by each of the 47 jurisdictions documented under [compliance](<../compliance/index.html>) — Factur-X and CII for France, XRechnung and ZUGFeRD for Germany, FatturaPA for Italy, KSeF FA(3) for Poland, Facturae for Spain, ZATCA for Saudi Arabia, MyInvois for Malaysia, and the rest of the [catalogue](<#catalogue>) above.
## Official references
Primary sources, in the order the layers stack. When a national profile and this page disagree, the national profile wins — tell us and we will fix the page.
* [European Commission — compliance with the eInvoicing standard]() (EN 16931, and [how to obtain a copy]())
* [OASIS — Universal Business Language 2.1]()
* [UNECE — UN/CEFACT XML schemas (Cross Industry Invoice)]()
* [OpenPeppol — BIS Billing 3.0]() · [PINT Billing]() · [PINT EU]() · [Peppol eDelivery (AS4)]()
* [FNFE-MPE — Factur-X]() · [FeRD — ZUGFeRD]()
* [KoSIT — XRechnung]()
* [Agenzia delle Entrate — FatturaPA]()
* [DGFiP — spécifications externes B2B]() (France)
* [Ministerstwo Finansów — KSeF]() · [Facturae]() · [ISDOC]() · [OIOUBL]() · [Finvoice]() · [ebInterface]() · [UBL.BE]()
* [ZATCA]() · [MyInvois SDK]() · [India GST e-invoice]() · [Egyptian Tax Authority]() · [GİB e-Fatura]()
And on this site: [the send endpoint](), [the canonical document model](), [document & invoice types](), [47 country guides](<../compliance/index.html>), and [all 164 EN 16931 business terms](<../compliance/fr/business-terms.html>).
========================================================================
# Sandbox guide
# Source: https://docs.get-flowie.com/sandbox/index.html
========================================================================
---
title: "Sandbox"
description: "Every test scenario for Flowie Exchange sandbox: test VATs, test Peppol IDs, error triggers, lifecycle paths, webhook simulators."
canonical: "https://docs.get-flowie.com/sandbox/"
source: "https://docs.get-flowie.com/sandbox/index.html"
---
# Sandbox
Sandbox
# Test the entire API without sending a real invoice
Every endpoint, every webhook, every regulatory platform has a deterministic sandbox counterpart. Use the rows below to trigger any outcome you need to test — the recipient is unreachable, PPF rejects with code 00058, the rate-limit kicks in, the lifecycle reaches `paid` after a 30-second delay. No real Peppol traffic is generated.
Promise
The sandbox is API-identical to production. If a request works in sandbox, the only thing that changes in live mode is the network destination. We test this contract on every release.
## Base URLs
Environment| Base URL| Key prefix
---|---|---
Sandbox| `https://back.flowie.ink/exchange`| `flw_test_…` · `flw_plat_test_…` · `flw_wl_test_…`
Production| `https://back.p2p-flowie.com/exchange`| `flw_live_…` · `flw_plat_live_…` · `flw_wl_live_…`
## Test API keys
⚡ Easiest path — no signup, no JWT
Hit the public bootstrap endpoint from your terminal (rate-limited to 120 keys per IP per hour):
[code]
curl -X POST https://back.flowie.ink/exchange/v1/sandbox/bootstrap \
-H "Content-Type: application/json" \
-d '{"label":"my-laptop"}'
[/code]
You get back the full `apiKey` (shown _once_), a starter sandbox company, and a 7-day expiry. Or click [**"Get a test API key"**](<../index.html#get-test-key>) on the landing page — same endpoint, the result auto-loads into the [Playground](<../playground/index.html>).
### Key types — personal, platform, white-label
By default `/v1/sandbox/bootstrap` mints a **personal** key (prefix `flw_test_`) that behaves like a regular tenant integration. Pass `"keyType": "platform"` or `"white_label"` to mint a multi-tenant key that satisfies the platform-key gate on `/v1/platform/*`:
[code]
curl -X POST https://back.flowie.ink/exchange/v1/sandbox/bootstrap \
-H "Content-Type: application/json" \
-d '{"label":"my-platform","keyType":"platform"}'
[/code]
keyType| Token prefix| Unlocks
---|---|---
`personal` (default)| `flw_test_…`| All non-platform endpoints
`platform`| `flw_plat_test_…`| \+ `POST /v1/platform/companies` (multi-tenant onboard), `GET /v1/platform/companies`, `GET /v1/platform/events`, `GET /v1/platform/usage`, `PATCH /v1/platform/settings`
`white_label`| `flw_wl_test_…`| Same as `platform` \+ branding
If you already have a Flowie dashboard JWT, you can also create longer-lived keys explicitly:
[code]
curl -X POST https://back.flowie.ink/exchange/v1/api-keys \
-H "Authorization: Bearer $FLOWIE_DASHBOARD_JWT" \
-d '{"name":"local-dev","scopes":["*"]}'
[/code]
Or grab one from the dashboard **Settings → API keys → New key (test mode)**. As with live keys, the full string is shown _once_.
### What sandbox synthesises (vs. live infra)
Sandbox keys never reach the live Peppol network, the einvoice-validator, the tag service, the payment service, the request-log store, or org-v2's BOR. Each route either short-circuits to a synthetic response or runs in a memory-only mode so the contract is exercisable without external dependencies. The table below is the canonical list — anything not on it behaves identically to production.
Route| Sandbox behaviour
---|---
`POST /v1/documents/send` (any format)| Returns a `doc_sbx_…` id immediately. `format=auto`/`raw` with a `file` payload returns `deliveryStatus="stored"` \+ `fileId` \+ `storedFormat` without uploading — except a Factur-X or CII invoice sent with `format=auto`, which is read into a structured document as in production.
`GET /v1/documents/{id}/xml`| Synthesises a minimal valid UBL Invoice XML for any `doc_sbx_…` / `doc_test_…` / `flw_…` id.
`GET /v1/documents/{id}/pdf`| Returns a 1-page PDF stub for any `doc_sbx_…` / `doc_test_…` / `flw_…` id.
`DELETE /v1/companies/{id}`| Idempotent — never 404s.
`GET /v1/directory/{peppol_id}`| Synthesises a participant record (no live Peppol/PPF lookup). Always returns `smpStatus="active"`.
`POST /v1/partners` · `GET /v1/partners`| POST returns a synthetic `prt_sbx_…`. GET returns an empty page (sandbox tenants start with no partnerships).
`POST /v1/categorization/objects/{id}/tags` · `POST .../auto`| Returns synthetic assignments / AI suggestions. Tag groups are pre-seeded with `grp_sbx_unspsc`, `grp_sbx_accounting`, `grp_sbx_custom`.
`POST /v1/events/{id}/ack` · `POST /v1/events/ack` · `POST /v1/events/{id}/replay`| Idempotent — accepts any event id, including ones that were never emitted. Replay returns a synthetic delivery record.
`GET /v1/requests/{request_id}`| Synthesises a believable failed-request envelope (502 from a Peppol AP) for any id, so the inspector contract round-trips without first triggering a real failure.
`GET /v1/payments/documents/{id}` · `POST .../pay` · `POST /v1/payments/export/iso20022`| Returns synthetic `PaymentInfo` / `PaymentRecord` / pain.001 ISO 20022 stubs. Live `payment-staging` service is bypassed.
`POST /v1/platform/companies` (platform key) · `GET /v1/platform/companies` · `PATCH /v1/platform/settings` · `DELETE /v1/platform/api-keys/{key_id}`| Synthesise empty managed-companies pages, echo settings updates, and idempotently revoke arbitrary key ids — no org-v2 children are required.
`GET /afnor/directory-service/v1/siret/code-insee:{siret}` · `GET /afnor/.../routing-code/siret:{siret}/code:{routing_identifier}`| Synthesise believable INSEE establishment / routing-code records for any 14-digit SIRET — no live INSEE lookup required.
`POST /afnor/flow-service/v1/flows`| Routes through the broadened `POST /v1/documents/send` sandbox synth — a `flw_…` id is returned without requiring a real Peppol registration.
`GET /afnor/flow-service/v1/flows/{flow_id}` (any docType)| Resolves any `flw_…` id (including 32-hex / 36-uuid shapes) via the document_service sandbox synth.
`POST /document/callback` (cXML PunchOut)| **Not** short-circuited. Authentication is still enforced via `` in the cXML envelope (or supplier-identity fallback) — sandbox keys do not bypass this gate.
## Test VAT numbers
Pass any of these to [POST /v1/companies](<../reference/index.html#create-company>) or [/companies/resolve](<../reference/index.html#resolve-company>) to deterministically trigger a behavior.
VAT| Country| Outcome
---|---|---
`BE0000000001`| BE| Enriches as _Sandbox Test BVBA_ , status `active`, SMP-registered after ~2s.
`BE0000000099`| BE| Returns `422 VAT_INACTIVE`.
`BE0000000404`| BE| Returns `422 VAT_NOT_FOUND`.
`BE0000000500`| BE| Returns `503 UPSTREAM_UNAVAILABLE` (registry down).
`FR12345678901`| FR| Enriches with a public-sector flag → SDI/PPF reporting enabled.
`FR99999999999`| FR| `422 VAT_NOT_FOUND`.
`IT00000000010`| IT| Enriches Italian; auto-enables SDI reporting.
`IT00000000099`| IT| SDI returns `00306` (_Codice Destinatario unknown_).
`DE000000001`| DE| Enriches; no auto-compliance (Germany is voluntary).
`NL000000001B01`| NL| Enriches; auto-enables NL Peppol routing.
`ES00000000C`| ES| Enriches; FACe (Spain public-sector) flag set.
Slow enrichment
Append `?simulateLatencyMs=2500` to `POST /companies` in sandbox to force a slow enrichment. Useful to test loading states.
## Test Peppol IDs (recipient side)
Peppol ID| Behavior
---|---
`0208:TEST_OK`| Delivers in ~1s. Fires `document.sent`, `document.delivered`.
`0208:TEST_OK_SLOW`| Delivers in ~30s. Lets you exercise polling UIs.
`0208:TEST_AP_FAIL`| Recipient AP rejects with `AP_REJECTED`. Fires `document.failed` after ~2s.
`0208:TEST_AP_FLAKY`| First two attempts time out, third succeeds. Tests retry logic in your UI.
`0208:TEST_TIMEOUT`| All transport attempts time out → `document.failed` with `TRANSPORT_FAILURE`.
`0208:TEST_REJECT_SCHEMA`| Recipient rejects with a UBL schematron failure (BR-CO-15).
`0208:TEST_REJECT_BUYER_REF`| Recipient requires `buyerReference` — rejects PPF code `00058`.
`0208:TEST_DUPLICATE`| Recipient marks the document as duplicate (`DUP`).
`0208:TEST_NOT_REGISTERED`| SMP returns "not found" → `422 RECIPIENT_NOT_FOUND`.
`0208:TEST_CANNOT_RECEIVE_INVOICE`| Registered, but doesn't accept `INVOICE` doctype → `422 RECIPIENT_CANNOT_RECEIVE`.
## End-to-end recipient simulators
Each test Peppol ID below is a fully simulated recipient. Sending to it triggers a full lifecycle including counterparty acks/rejects.
Peppol ID| Persona| Lifecycle path it drives on the receiver side
---|---|---
`0208:SIM_HAPPY`| Happy path| `delivered → under_review → approved → paid` over ~5 min.
`0208:SIM_SLOW_PAY`| Late payer| `delivered → approved` immediately, then `paid` 60 days later (use time-travel to skip ahead).
`0208:SIM_DISPUTE`| Disputes invoices| `delivered → under_review → disputed` with reason `QUA` (quantity discrepancy).
`0208:SIM_REJECT`| Rejects on first review| `delivered → rejected` with reason `PRI` (price disagreement).
`0208:SIM_PARTIAL`| Pays in installments| `approved → partially_paid (50%) → partially_paid (75%) → paid` over 3 days.
## Lifecycle simulators
For your _own_ sent documents, you can advance the lifecycle on demand:
[code]
# Force a sandbox document to "paid" right now
curl -X POST …/v1/documents/{doc_id}/lifecycle \
-H "Authorization: Bearer $TEST_KEY" \
-d '{
"status": "paid",
"paymentDate": "2026-04-25",
"paymentAmount": 2359.50,
"paymentCurrency": "EUR",
"paymentReference":"SBX-PAY-001"
}'
[/code]
The compliance hooks fire normally — see [compliance simulators](<#test-compliance>) below.
Reason code (force a rejection)| What gets reported
---|---
`RE`| Generic rejection — PPF/SDI accept silently.
`QUA`| Quantity discrepancy.
`PRI`| Price disagreement.
`TAX`| Tax mismatch — SDI flags for review.
`DUP`| Duplicate — PPF returns `00043`.
## Compliance platform simulators (PPF / SDI)
To exercise the compliance pipeline, set the company's `metadata.simulateCompliance` field. The next lifecycle update on any of that company's docs uses the simulated response. Belgium has no regulator-side report (HERMES decommissioned 2025-12-31) — BE invoices skip this pipeline entirely.
Value| PPF / SDI response
---|---
`"accept"`| 200 OK in < 1s. Fires `compliance.reported`.
`"reject_00058"`| PPF returns `00058` (missing Service Exécutant). `compliance.reported.failed`.
`"reject_00306"`| SDI returns `00306` (Codice Destinatario unknown).
`"timeout_30s"`| Authority times out; circuit breaker behavior visible at [`/health/readiness`](<../reference/index.html#readiness>).
`"flaky_50pct"`| 50% probability of acceptance per attempt.
[code]
# Set the simulator on a sandbox company
curl -X PATCH …/v1/companies/{company_id} \
-H "Authorization: Bearer $TEST_KEY" \
-d '{"metadata": {"simulateCompliance": "reject_00058"}}'
[/code]
## Triggering each webhook event
Each row below is a **copy-pasteable curl** that produces exactly one webhook delivery against your registered sandbox endpoint.
Event| How to trigger
---|---
`document.received`| Send to your own sandbox company from `0208:SIM_HAPPY`.
`document.sent`| Send anything to `0208:TEST_OK`.
`document.delivered`| Send to `0208:TEST_OK`; arrives ~1s later.
`document.failed`| Send to `0208:TEST_AP_FAIL`.
`document.updated`| `POST /documents/{id}/actions` with `{"action":"tag","tag":"x"}`.
`lifecycle.updated`| `POST /documents/{id}/lifecycle` with any allowed status.
`company.smp_registered`| Create a company with VAT `BE0000000001`; arrives ~2s later.
`compliance.reported`| Mark a French/Italian/Belgian doc as `paid` with `simulateCompliance="accept"`.
`compliance.reported.failed`| Same as above with `simulateCompliance="reject_00058"`.
To replay any past event byte-identically:
[code]
curl -X POST …/v1/events/{event_id}/replay \
-H "Authorization: Bearer $TEST_KEY"
[/code]
Need fixture payloads to seed your tests without hitting the API? See [webhook fixtures](<../fixtures/index.html>).
## Forcing specific errors
Pass `X-Sandbox-Force-Error` on any request to make the API return that error code:
[code]
curl …/v1/companies \
-H "Authorization: Bearer $TEST_KEY" \
-H "X-Sandbox-Force-Error: UPSTREAM_UNAVAILABLE"
[/code]
Header value| Resulting status / body
---|---
`INVALID_REQUEST`| 400
`EXPIRED_TOKEN`| 401
`INSUFFICIENT_SCOPE`| 403
`RESOURCE_NOT_FOUND`| 404
`IDEMPOTENCY_BODY_MISMATCH`| 409
`VAT_NOT_FOUND`| 422
`RATE_LIMITED`| 429 with `Retry-After: 30`
`INTERNAL_ERROR`| 500
`UPSTREAM_UNAVAILABLE`| 503
## Forcing a rate-limit
Sandbox rate-limits are normally generous. To _force_ a 429 right now:
[code]
curl -X POST …/v1/sandbox/rate-limit/exhaust \
-H "Authorization: Bearer $TEST_KEY" \
-d '{"durationSeconds": 60}'
[/code]
Every subsequent call returns `429` with a real `Retry-After` header for the next 60 seconds. Useful for testing your backoff implementation under realistic conditions.
## Time-travel
For sandbox companies you can advance the clock to verify deferred behaviors (60-day late payments, 12-month deprecation windows, idempotency cache TTL):
[code]
curl -X POST …/v1/sandbox/clock/advance \
-H "Authorization: Bearer $TEST_KEY" \
-d '{"companyId": "comp_…", "by": "60d"}'
[/code]
Accepts `by` as `1h`, `3d`, `2w`, `1m`, `1y`. The clock is per-company and never affects another tenant. `POST …/clock/reset` snaps it back.
Side-effect ordering
Time-travel fires every webhook that _would_ have fired in the skipped interval, in chronological order. Don't skip a year unless you actually want a thousand events on your endpoint.
## Reset & data lifetime
Resource| Sandbox lifetime| Reset
---|---|---
Companies, partners, webhooks, API keys| Persistent| Delete via API or dashboard.
Documents| 90 days from creation| Auto-purged. Use `POST /v1/sandbox/reset` to wipe all docs immediately.
Events| 30 days| Auto-purged.
Idempotency cache| 24 hours (same as live)| `POST /v1/sandbox/idempotency/flush`
Rate-limit counters| 60s window (same as live)| —
[code]
# Nuke EVERYTHING in your sandbox tenant
curl -X POST …/v1/sandbox/reset \
-H "Authorization: Bearer $TEST_KEY" \
-d '{"confirm": "yes"}'
[/code]
## Local webhook tunnels
To receive webhooks while running your handler on `localhost`, use any tunnel:
[code]
ngrok http 3000
# OR
cloudflared tunnel --url http://localhost:3000
[/code]
Then point a sandbox webhook at `https://.ngrok.io/hooks`. The dashboard's **Resend** button sends a byte-identical retry — perfect for iterating on your signature verifier.
## Copy-paste bootstrap scripts
Spin up a complete test scenario (one sender, one recipient simulator, one webhook, three sent invoices) with a single shell script:
[code]
#!/usr/bin/env bash
set -euo pipefail
BASE="https://back.flowie.ink/exchange/v1"
KEY="$FLOWIE_TEST_KEY"
H=(-H "Authorization: Bearer $KEY" -H "Content-Type: application/json")
# 1. Create a sandbox sender
SEND=$(curl -s -X POST "$BASE/companies" "${H[@]}" \
-d '{"vatNumber":"BE0000000001"}')
COMP=$(echo "$SEND" | jq -r .id)
echo "→ sender: $COMP"
# 2. Register a webhook (replace URL with your tunnel)
curl -s -X POST "$BASE/webhooks" "${H[@]}" \
-d '{"url":"'"$WEBHOOK_URL"'","events":["*"]}' > /dev/null
# 3. Send 3 invoices to the happy-path simulator
for n in 001 002 003; do
curl -s -X POST "$BASE/documents/send" "${H[@]}" \
-H "Idempotency-Key: bootstrap-$n" \
-d '{
"type":"invoice",
"from":"'"$COMP"'",
"to":"0208:SIM_HAPPY",
"document":{
"number":"INV-2026-'"$n"'",
"issueDate":"2026-04-25",
"currency":"EUR",
"lines":[{"description":"Test","quantity":1,"unitPrice":100,"vatRate":21}]
}
}' | jq -r '.id + " → " + .status'
done
[/code]
The same script in [Python · Node · Go on GitHub]().
## Gotchas
* **Sandbox keys never reach production.** If you accidentally point a `flw_test_…` key at `https://back.flowie.ink`, you get `401 INVALID_TOKEN`. Production rejects test keys and vice versa.
* **Webhook signatures use the webhook's own secret** , not a global sandbox secret. Each webhook you create has its own.
* **Time-travel is per-company.** Two parallel test runs on different sandbox companies don't interfere.
* **Idempotency cache TTL is the same in sandbox** (24h). If a test reuses the same key within that window, you'll see the cached response, not a fresh send.
* **Test data is not anonymized in logs.** Don't paste real customer VATs into sandbox just because "it's only a test."
========================================================================
# API keys
# Source: https://docs.get-flowie.com/sandbox/keys.html
========================================================================
---
title: "API Keys"
description: "Create, list and revoke Flowie Exchange API keys from your browser. Sign in with your Flowie account — no curl required."
canonical: "https://docs.get-flowie.com/sandbox/keys"
source: "https://docs.get-flowie.com/sandbox/keys.html"
---
# API Keys
API Keys
# Manage your API keys
Create a long-lived API key for your Flowie organization, list the keys that already exist, and revoke any you no longer need — all from this page. Sign in with the same Flowie account you use for the dashboard; the key inherits your organization and tier. Keys minted here are also remembered locally so the [Playground](<../playground/index.html>) and [API reference](<../reference/index.html>) Try-it widgets can pick them from a dropdown.
Where the key works
A key belongs to the **environment it was created on** — `https://back.flowie.ink/exchange` (staging) or `https://back.p2p-flowie.com/exchange` (production). These are **separate backends with separate keys** : a staging key returns `401` on production and vice-versa. Pick the environment in the form below before creating. Pass the key as `Authorization: Bearer flw_…`. Note the prefix is the _mode_ , not the environment: `flw_live_…` = live mode, `flw_test_…` = sandbox mode — both exist on staging _and_ production, so the prefix alone does **not** tell you which environment a key is for. The full string is shown **once** , right after creation — save it in your secret store before navigating away (we also cache it in this browser's `localStorage` so the Playground can reuse it).
### Sign in to manage your API keys
If you're already signed in to Flowie in another tab, we'll detect it automatically. Otherwise, open the dashboard, sign in, then come back here.
[Sign in with Flowie ↗](<#>) I just signed in — recheck
Or paste a Flowie JWT manually
Paste an `access_token` from your Flowie session (DevTools → Application → Local Storage → look for an `@@auth0spajs@@::…` entry on `staging.flowieapp.io`, or grab a `Bearer …` header from a Network request). Stored only in this browser's `localStorage`.
Save token
No account? [Sign up for free]() — under a minute, then come back here.
## Create a new key
Name Environment Staging · back.flowie.ink Production · back.p2p-flowie.com Company (optional) (org-wide — no specific company)
Create key
**✓ Key created.** Copy it now — you will not see the full value again.
Copy
## Your keys
Name | Env | Prefix | Company | Created | Expires |
---|---|---|---|---|---|---
No API keys yet. Create one above to get started.
## How it works
This page calls the same public endpoints documented in the [API reference](<../reference/index.html#create-api-key>). Nothing happens server-side that you couldn't reproduce with `curl`:
* **Create** → `POST /v1/api-keys` with `{"name": "...", "companyId": "..."}`.
* **List** → `GET /v1/api-keys` (paginated; this page reads the first 100).
* **Revoke** → `DELETE /v1/api-keys/{id}` (204 on success). Revocation is immediate; any in-flight request finishes, but the next one returns `401`.
Your Flowie JWT is held in `localStorage` only (key `flowie-playground-state.key`). It never leaves the browser except as an `Authorization: Bearer …` header to the Exchange API. If you belong to multiple organizations, use the organization picker in the topbar to choose which one a new key targets — the picker sets the `X-Flowie-Organization-Id` header on every request.
The page detects your existing Flowie session via a hidden iframe (`/__exchange-handshake.html`) hosted on `staging.flowieapp.io` (or `app.flowie.me` in production). The iframe reads the Auth0 SDK's cached access token from the dashboard's `localStorage` and posts it back via `postMessage` — strict origin validation, no servers, no cookies. If you're not signed in there, the page falls back to the dashboard sign-in link or manual JWT paste.
========================================================================
# Live playground
# Source: https://docs.get-flowie.com/playground/index.html
========================================================================
---
title: "Playground"
description: "Live API playground in your browser. Send any request to the Flowie Exchange API with a sandbox key — no terminal, no setup."
canonical: "https://docs.get-flowie.com/playground/"
source: "https://docs.get-flowie.com/playground/index.html"
---
# Playground
GETPOSTPATCHPUTDELETE https://back.flowie.ink Send ⏎
### Parameters
Edit any value below — your changes flow back into the request above. Click **Save** on a row to reuse the value across endpoints.
### Headers
▾
\+ Add header
### Body (JSON)
Live request cURL Python JS Copy ▾
[code]
curl …
[/code]
— Press `Send` to run this request.
**Token expired.** ↻ Refresh from Flowie Pick another token
[code]
// Press Send (or ⌘⏎) to fire the request.
[/code]
[/code]
[/code]
[code]
========================================================================
# Request inspector
# Source: https://docs.get-flowie.com/playground/requests.html
========================================================================
---
title: "Request inspector"
description: "Look up any request_id from a Flowie Exchange error response and see exactly what was sent and received."
canonical: "https://docs.get-flowie.com/playground/requests"
source: "https://docs.get-flowie.com/playground/requests.html"
---
# Request inspector
API requests
# Every request, by API key & user
Browse all requests made to the Flowie Exchange API — with your API keys or from the app — and see who made each one. Filter by API key, user, method or status, or look up a single `requestId` below. Secrets are redacted at capture time; logs are kept for 7 days.
Any method GETPOSTPUTPATCHDELETE Filter Reset By API keyBy user Show usage summary
Set your API key above, then Filter to load activity.
Load more
* * *
## Inspect one request by id
========================================================================
# Webhook cookbook
# Source: https://docs.get-flowie.com/reference/webhooks.html
========================================================================
---
title: "Webhooks"
description: "Event catalog, HMAC signing, retry policy, and idempotency patterns for Flowie Exchange webhooks."
canonical: "https://docs.get-flowie.com/reference/webhooks"
source: "https://docs.get-flowie.com/reference/webhooks.html"
---
# Webhooks
Webhook Cookbook
# Webhooks
Webhooks are how your stack learns that something happened on Peppol. Every time a document arrives, a delivery fails, or a lifecycle status changes, Flowie makes an HTTPS POST to each endpoint you've configured — with exponential retries, HMAC signatures, and a durable twin in the [Events API]() for replay.
Delivery guarantees
**At-least-once.** Your handler must be idempotent. Duplicates are rare but possible after a 2xx response times out on our side.
## Event catalog
Event| Fires when| Key fields in `data`
---|---|---
`document.received`| An incoming Peppol document has been persisted.| `documentId`, `type`, `number`, `direction`=`incoming`
`document.sent`| An outgoing document has been handed off to the recipient's access point.| `documentId`, `type`, `sentAt`
`document.delivered`| The recipient's access point confirmed final delivery.| `documentId`, `deliveredAt`
`document.failed`| Delivery permanently failed (recipient unreachable, schema rejection, …).| `documentId`, `errorCode`, `errorMessage`
`document.updated`| A document's metadata was updated (e.g. tagged, archived, note added).| `documentId`, `changes` (field diff)
`lifecycle.updated`| Lifecycle status transitioned.| `documentId`, `previousStatus`, `currentStatus`, `compliance`
`company.smp_registered`| A company's SMP record went live.| `companyId`, `peppolId`
`compliance.reported`| A lifecycle change was reported to PPF (FR) or SDI (IT). Belgium has no regulator-side report.| `documentId`, `reportedTo`, `status`
`*`| Subscribes to every event.| Use sparingly — prefer explicit lists.
## Payload shape
Every delivery is a JSON POST with this envelope:
[code]
{
"id": "evt_01HY3AB9C2DE3FG",
"type": "document.received",
"livemode": true,
"createdAt": "2026-04-25T10:05:08Z",
"apiVersion":"2026-04-01",
"data": {
"documentId": "doc_01HY7AB9C2DE3FG",
"type": "invoice",
"direction": "incoming",
"number": "INV-2026-0417",
"sender": { "peppolId": "0208:0123456789", "name": "ACME BVBA" },
"receiver": { "peppolId": "0208:9876543210", "name": "Globex SRL" }
}
}
[/code]
Request headers include:
[code]
POST /hooks/peppol HTTP/1.1
Host: example.com
Content-Type: application/json
User-Agent: Flowie-Webhooks/3.0
X-Flowie-Signature: t=1714046708,v1=3d9e8b7…
X-Flowie-Event: document.received
X-Flowie-Event-Id: evt_01HY3AB9C2DE3FG
X-Flowie-Delivery: dlv_01HY3AB9C2DE3FG
X-Flowie-Attempt: 1
[/code]
## Signing & verification
Every request carries `X-Flowie-Signature`. The header is comma-separated key/value pairs:
* `t` — Unix timestamp at signing time
* `v1` — HMAC-SHA256 of `t + "." + raw_body`, hex-encoded
To verify:
1. Split the header by `,` into `t` and `v1`.
2. Reject if `|now - t| > 5 minutes` — that's a replay.
3. Compute `HMAC-SHA256(secret, t + "." + raw_body)`.
4. Constant-time compare against `v1`.
Use the raw body
Verify _before_ any JSON parsing or transcoding. Even a re-serialized JSON is no longer byte-identical — it will fail the HMAC check.
[code]
import hmac, hashlib, time
from fastapi import Request, HTTPException
SECRET = b"whsec_..." # the secret you created with the webhook
async def verify(req: Request):
raw = await req.body()
header = req.headers.get("X-Flowie-Signature", "")
parts = dict(p.split("=", 1) for p in header.split(","))
t, sig = parts.get("t"), parts.get("v1")
if not t or not sig: raise HTTPException(400, "Missing signature")
if abs(time.time() - int(t)) > 300: raise HTTPException(400, "Stale")
expected = hmac.new(SECRET, f"{t}.".encode() + raw, hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, sig):
raise HTTPException(401, "Invalid signature")
return raw
[/code]
[code]
import crypto from "node:crypto";
const SECRET = process.env.FLOWIE_WEBHOOK_SECRET;
export function verify(req, rawBody) {
const header = req.headers["x-flowie-signature"] || "";
const parts = Object.fromEntries(header.split(",").map(p => p.split("=")));
const { t, v1 } = parts;
if (!t || !v1) throw new Error("Missing signature");
if (Math.abs(Date.now()/1000 - Number(t)) > 300) throw new Error("Stale");
const mac = crypto.createHmac("sha256", SECRET)
.update(`${t}.`).update(rawBody).digest("hex");
const ok = crypto.timingSafeEqual(Buffer.from(mac), Buffer.from(v1));
if (!ok) throw new Error("Invalid signature");
}
[/code]
[code]
func verify(r *http.Request, secret []byte) error {
raw, _ := io.ReadAll(r.Body); r.Body = io.NopCloser(bytes.NewReader(raw))
parts := map[string]string{}
for _, p := range strings.Split(r.Header.Get("X-Flowie-Signature"), ",") {
if kv := strings.SplitN(p, "=", 2); len(kv) == 2 { parts[kv[0]] = kv[1] }
}
t, err := strconv.ParseInt(parts["t"], 10, 64)
if err != nil || math.Abs(float64(time.Now().Unix()-t)) > 300 { return errors.New("stale") }
h := hmac.New(sha256.New, secret)
h.Write([]byte(parts["t"] + ".")); h.Write(raw)
if !hmac.Equal([]byte(hex.EncodeToString(h.Sum(nil))), []byte(parts["v1"])) {
return errors.New("invalid signature")
}
return nil
}
[/code]
## Retries & backoff
Flowie retries any non-2xx response (and any timeout > 20s) on this schedule:
Attempt| Delay after failure| Cumulative
---|---|---
1| —| 0m
2| 30s| 30s
3| 2m| 2m 30s
4| 10m| 12m 30s
5| 30m| 42m 30s
6| 2h| ≈ 2h 42m
7| 6h| ≈ 8h 42m
8 (last)| 12h| ≈ 20h 42m
After 8 failures, the webhook is auto-**paused**. You'll receive an email and the `status` field on the webhook flips to `paused`. Manually re-activate it with a `PATCH` once the endpoint is healthy.
Respond fast, process async
Ack within 5 seconds with `200`, then hand the payload to a queue. Long synchronous processing in your handler multiplies tail-latency and increases the odds of a retry storm.
## Idempotency on your side
Because retries can overlap with a successful delivery you missed, your handler must treat every event as "at-least-once". Two patterns work well:
1. **Dedupe table.** Use `X-Flowie-Event-Id` as a unique key in a fast KV (Redis, DynamoDB). Ignore duplicates.
2. **Idempotent state transitions.** Upsert by `documentId` — setting `status = paid` again is a no-op.
## Replay & the Events API
Every webhook attempt has a matching event in the [Events API](). If your endpoint was down, fetch missed events:
[code]
curl "https://back.p2p-flowie.com/exchange/v1/events?type=document.received&limit=100" \
-H "Authorization: Bearer $KEY"
[/code]
Process them, then acknowledge in bulk to clear the queue:
[code]
curl -X POST https://back.p2p-flowie.com/exchange/v1/events/ack \
-H "Authorization: Bearer $KEY" \
-d '{"eventIds": ["evt_01…", "evt_02…"]}'
[/code]
## Testing locally
1. Expose your dev server with `ngrok http 3000` (or your preferred tunnel).
2. Create a test-mode webhook pointing at `https://.ngrok.io/hooks/peppol`.
3. Send a document in sandbox — you'll see `document.received` fire.
4. In the dashboard, open any delivery and click **Resend** to replay the exact byte-identical request.
## Troubleshooting
Symptom| Likely cause| Fix
---|---|---
Webhook is `paused` after deploy| Endpoint returned 5xx 8 times in a row| Fix the endpoint, then `PATCH` the webhook back to `active` and replay via Events API.
Signature mismatch| You're signing a re-serialized body| Verify on the raw buffer, before JSON parse.
Events arrive out of order| Retries of an earlier delivery arrive after a later one| Read `data.updatedAt` — don't rely on receipt order. Store monotonic versions.
Duplicate processing| Your handler isn't idempotent| Dedupe on `X-Flowie-Event-Id`.
Slow deliveries| Your endpoint takes > 5s| Enqueue fast, process async.
## Interactive signature verifier
Paste a webhook secret, the timestamp from `X-Flowie-Signature`, and the raw body. We compute the HMAC in your browser (nothing is sent to a server) and compare against the signature you provide.
Webhook secret Timestamp (t=…) Raw body Signature (v1=…) (optional)
Compute & verify Load example Clear
All computation happens in your browser via [SubtleCrypto](). Your secret never leaves the page.
## Payload fixtures
Need realistic JSON to seed your handler tests? [/fixtures](<../fixtures/index.html>) ships one downloadable `.json` per event type, with copy-to-clipboard and a tarball bundle.
========================================================================
# Build with AI
# Source: https://docs.get-flowie.com/build-with-ai/index.html
========================================================================
---
title: "Build with AI"
description: "Build with AI on Flowie Exchange. Give AI agents native access to Peppol e-invoicing — MCP servers for Claude, Cursor and custom agents, agent-ready docs (llms.txt), and self-service agent onboarding."
canonical: "https://docs.get-flowie.com/build-with-ai/"
source: "https://docs.get-flowie.com/build-with-ai/index.html"
---
# Build with AI
AI Agents
# Build with AI
Flowie Exchange is built to be driven by AI. Point Claude Desktop, Claude Code, Cursor, n8n, or your own custom agent at the API and it can send, receive, and manage Peppol e-invoices as native tool calls — no glue code, no bespoke wrappers. This page is the hub for every AI surface: the [MCP servers](<#mcp>), the [agent-ready docs](<#docs-for-agents>), and [self-service agent onboarding](<#agent-onboarding>).
Same auth, same quota, same sandbox
Every AI surface is a thin layer over the REST API you already know. MCP tool calls are forwarded to the underlying FastAPI handler with your `Authorization` header preserved — so JWT, `flw_*` keys, tenant scoping, rate limits, and sandbox simulators all work identically.
## AI tools
Three ways to put Flowie Exchange in front of an agent. Most integrations start with the MCP server.
### [MCP server → Connect Claude Desktop, Claude Code, Cursor, or a custom Python agent over the Model Context Protocol and call the API as native tools. ](<#mcp>) ### [Docs for agents → Machine-readable docs — `llms.txt` as a fast page index, `llms-full.txt` as the whole corpus, plus one Markdown slice per endpoint. ](<#docs-for-agents>) ### [Agent onboarding → Let an agent self-provision: zero-friction sandbox bootstrap (no human in the loop) or OAuth-style consent with PKCE for production scope grants. ]()
## MCP server
The Flowie Exchange API ships **two Model Context Protocol servers** so AI agents — Claude Desktop, Claude Code, Cursor, n8n, custom Python agents — can send, receive, and manage Peppol e-invoices as native tool calls.
### Endpoints
Mode| Tools| Production| Sandbox
---|---|---|---
**Curated** _(recommended)_ | 34 | `https://back.p2p-flowie.com/exchange/mcp` | `https://back.flowie.ink/exchange/mcp`
**Full** | 94 | `https://back.p2p-flowie.com/exchange/mcp/full` | `https://back.flowie.ink/exchange/mcp/full`
The curated server exposes only the eight tags an agent actually needs: `Documents`, `Directory`, `Companies`, `Lifecycle`, `Compliance`, `Partners`, `UBL Generator`, `Portability`. Admin, sandbox control plane, AFNOR certification, and debug routes are hidden — fewer tokens spent on tool discovery, far fewer "wrong tool" misfires. Pick **full** only when the agent genuinely needs platform / white-label / certification surface.
Transport is **streamable-HTTP** (the modern MCP transport, MCP spec `2025-06-18`). The legacy SSE transport is no longer mounted.
### Authentication
Every request the agent makes is forwarded to the FastAPI handler with the original `Authorization` header preserved, so the same scoping rules apply: tenant isolation, per-key quotas, sandbox vs live partitioning.
[code]
Authorization: Bearer flw_test_your_key_here
[/code]
Use a `flw_test_…` key against the sandbox host while you're developing the agent — every test recipient from the [sandbox guide](<../sandbox/index.html>) is reachable through MCP exactly as it is through REST. Need a key? [Bootstrap one in one click](<../index.html#get-test-key>), or — if the agent must **request its own key on behalf of a real user** — see the [OAuth consent flow]().
### Quickstart — Claude Desktop
Edit `~/Library/Application Support/Claude/claude_desktop_config.json` (macOS) or `%APPDATA%\Claude\claude_desktop_config.json` (Windows):
[code]
{
"mcpServers": {
"flowie-exchange": {
"url": "https://back.flowie.ink/exchange/mcp",
"transport": "streamable-http",
"headers": {
"Authorization": "Bearer flw_test_your_key_here"
}
}
}
}
[/code]
Restart Claude Desktop. The hammer icon shows **34 tools loaded**. Try: _"List my last 5 incoming invoices."_
### Quickstart — Claude Code
In the project directory, drop a `.mcp.json` file (Claude Code picks it up automatically per project):
[code]
{
"mcpServers": {
"flowie-exchange": {
"url": "https://back.flowie.ink/exchange/mcp",
"transport": "streamable-http",
"headers": { "Authorization": "Bearer flw_test_your_key_here" }
}
}
}
[/code]
Or register globally so every project sees it:
[code]
claude mcp add flowie-exchange https://back.flowie.ink/exchange/mcp \
--transport streamable-http \
--header "Authorization: Bearer flw_test_your_key_here"
[/code]
### Quickstart — Cursor / VS Code
In **Cursor** : _Settings → MCP → Add new server_ , paste the same JSON shape as Claude Desktop. In **VS Code** with the Continue extension: same JSON under `continue.config.mcpServers`. Both speak streamable-HTTP natively.
### Quickstart — Python (mcp SDK)
For custom agents, the official `mcp` Python SDK speaks streamable-HTTP directly:
[code]
# pip install mcp
import asyncio, os
from mcp.client.streamable_http import streamablehttp_client
from mcp import ClientSession
URL = "https://back.flowie.ink/exchange/mcp"
KEY = os.environ["FLOWIE_KEY"]
async def main():
async with streamablehttp_client(
URL, headers={"Authorization": f"Bearer {KEY}"}
) as (read, write, _):
async with ClientSession(read, write) as session:
await session.initialize()
tools = await session.list_tools()
print(f"{len(tools.tools)} tools available")
# Call a tool by name with REST-style args
result = await session.call_tool(
"list_documents",
arguments={"direction": "incoming", "status": "unread", "limit": 5},
)
print(result.content[0].text)
asyncio.run(main())
[/code]
### Tool catalog (curated)
The curated server exposes one MCP tool per FastAPI operation tagged `Documents`, `Directory`, `Companies`, `Lifecycle`, `Compliance`, `Partners`, `UBL Generator`, `Portability`. The high-leverage ones for agents:
Tool| What it does
---|---
`send_document`| Send an e-invoice / credit note / order over Peppol.
`resolve_portability_taxpayer`| One identifier → the company's identity, its regime and what its platform change requires.
`open_portability_request`| Open a platform change: designation agreement, computed clocks, first evidence entry.
`record_portability_event`| Record a notice, an objection (classified), an agreement or the annuaire update.
`get_portability_request`| State re-derived from the evidence chain, with tacit approval and chain verification.
`list_documents`| Filter by direction, status, type, date range.
`search_documents`| Full-text + structured search across all documents.
`get_document_structured`| Flat, agent-friendly view — every field as a primitive.
`validate_document`| Pre-flight a payload through BIS / EN-16931 rules.
`update_lifecycle`| Approve, reject, mark as paid, dispute.
`search_directory`| Find Peppol participants by name, VAT, or country.
`verify_recipient`| Check a Peppol ID can receive a given document type.
`resolve_company`| Look up by VAT / SIREN — get Peppol ID + enriched profile.
`create_company`| Register a sender, auto-publish to the Peppol SMP.
`get_compliance_report`| Latest PPF (FR) or SDI (IT) report status for a document.
`list_business_terms`| Every EN 16931 business term with its French obligation — what a BT id means, and whether the reform requires it.
Run `tools/list` over MCP to enumerate the full set with input schemas and descriptions. Every tool's input schema mirrors the REST endpoint's request body — see the [API Reference](<../reference/index.html>) for the canonical shape.
### Common workflow — _"What invoices arrived this week?"_
The agent picks the right tools from the prompt; you do nothing.
[code]
User: "What invoices arrived this week and which ones are still unpaid?"
Agent → list_documents({direction: "incoming", since: "2026-04-26"})
→ for each: get_document_structured({documentId})
→ for each unpaid: get_compliance_report({documentId})
→ summarises totals by supplier, flags the ones past dueDate
[/code]
### Common workflow — _"Send an invoice to ACME"_
Three tools, one chain. The agent verifies the recipient before sending.
[code]
User: "Bill ACME BVBA €4,500 + VAT for April consulting, due in 30 days."
Agent → search_directory({q: "ACME BVBA"}) # finds peppolId
→ verify_recipient({peppolId, documentType: "INVOICE"})
→ send_document({
type: "invoice",
from: "comp_abc123",
to: "0208:0123456789",
document: {
number: "INV-2026-0451",
issueDate: "2026-04-30",
dueDate: "2026-05-30",
currency: "EUR",
lines: [{
description: "Consulting — April 2026",
quantity: 1, unit: "lot",
unitPrice: 4500.00, vatRate: 21
}]
}
})
[/code]
The agent sees the returned `documentId` \+ `deliveryStatus` and reports back. Pass an `Idempotency-Key` at the REST layer if you want retry safety — MCP forwards it as a tool argument.
### Common workflow — _"Mark INV-0417 as paid"_
[code]
User: "INV-2026-0417 was paid yesterday — close the loop."
Agent → search_documents({number: "INV-2026-0417"}) # → documentId
→ update_lifecycle({
documentId,
status: "paid",
note: "Paid 2026-04-29 via SEPA"
})
[/code]
The lifecycle change automatically triggers PPF (FR) or SDI (IT) reporting where applicable — the agent doesn't need to know about that. Watch `compliance.reported` on your [webhook stream](<../reference/webhooks.html#events>) for confirmation. Belgian invoices skip this step (HERMES was decommissioned 2025-12-31).
### Common workflow — _"Onboard a new supplier"_
[code]
User: "Add Globex SRL (VAT IT09876543210) as a partner and check they're on Peppol."
Agent → resolve_company({vatNumber: "IT09876543210"}) # enriched profile
→ verify_recipient({peppolId}) # canReceive: true?
→ save_partner({...}) # in your CRM/ERP
[/code]
### Direct HTTP (no SDK)
MCP is just JSON-RPC over an HTTP POST. If you don't want the SDK:
[code]
# 1. Initialize the session
curl -X POST https://back.flowie.ink/exchange/mcp \
-H "Authorization: Bearer $FLOWIE_KEY" \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-d '{
"jsonrpc": "2.0", "id": 1, "method": "initialize",
"params": {
"protocolVersion": "2025-06-18",
"capabilities": {},
"clientInfo": {"name": "curl", "version": "1.0"}
}
}'
# 2. List tools
curl -X POST https://back.flowie.ink/exchange/mcp \
-H "Authorization: Bearer $FLOWIE_KEY" \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","id":2,"method":"tools/list"}'
# 3. Call a tool
curl -X POST https://back.flowie.ink/exchange/mcp \
-H "Authorization: Bearer $FLOWIE_KEY" \
-H "Content-Type: application/json" \
-d '{
"jsonrpc":"2.0","id":3,"method":"tools/call",
"params":{
"name":"list_documents",
"arguments":{"direction":"incoming","limit":5}
}
}'
[/code]
### Errors
MCP errors mirror the underlying REST errors — same codes, same shape, wrapped in JSON-RPC. A `401` from REST surfaces as MCP error `-32001` with the original Flowie error code in `data.errorCode`. See the [error catalog](<../reference/errors.html>) for everything you might see.
The two MCP-specific errors:
* **`tool not found`** — agent called a tool that's not in the curated set. Switch to `/mcp/full` or rename.
* **`invalid arguments`** — input schema mismatch. Run `tools/list` and follow the `inputSchema` exactly.
### Rate limits & quotas
MCP calls inherit your REST quota — there's no separate budget. One MCP `tools/call` = one REST request. Use the same `X-Flowie-RateLimit-Remaining` header logic to back off; the header is surfaced on the JSON-RPC response envelope under `_meta`.
### Sandbox
Point the agent at `https://back.flowie.ink/exchange/mcp` with a `flw_test_…` key and every sandbox feature works: forced errors via `X-Sandbox-Force-Error`, simulated recipients (`0208:SIM_HAPPY`, `SIM_DISPUTE`, `TEST_AP_FAIL`), lifecycle simulators, the lot. See the [sandbox guide](<../sandbox/index.html>) for the full menu.
Tip — keep a sandbox profile in Claude Desktop
Claude Desktop supports multiple `mcpServers` entries. Register both `flowie-exchange-sandbox` (test key, sandbox URL) and `flowie-exchange-prod` (live key, prod URL). Then prompt the agent explicitly: _"Use the sandbox server to dry-run this."_
### When to use full vs curated
* **Curated** — agents that send, receive, search, and reconcile invoices. Default choice for 95% of integrations.
* **Full** — IDE integrations, ops scripts, AFNOR-certified flows, white-label admin, request inspector. Larger context cost; only when you genuinely need the extra surface.
You can mount both — agents pick the right one based on the host you point them at. There's no auth difference between the two, so the same key works against both URLs.
## Docs for agents
The whole documentation site is published in machine-readable form, following the [llms.txt]() convention. Point an agent (or a RAG pipeline) at these instead of scraping HTML — every page carries a `` so tools can discover them automatically.
Resource| What it is
---|---
[`llms.txt`](<../llms.txt>)| Page index with titles and one-line descriptions — a fast lookup so an agent can decide what to fetch.
[`llms-full.txt`](<../llms-full.txt>)| The entire corpus in one file — every page back-to-back as clean Markdown. Drop it straight into a context window.
[`llms/reference/index.md`](<../llms/reference/index.md>)| Directory of per-endpoint Markdown slices — one file per API operation, so an agent can pull just the one endpoint it needs.
`openapi.json`| The full OpenAPI 3.1 spec — the same one that generates the MCP tool schemas.
Everything under `docs.get-flowie.com` is reachable this way: `https://docs.get-flowie.com/llms.txt`, `https://docs.get-flowie.com/llms-full.txt`, and one Markdown file per endpoint under `https://docs.get-flowie.com/llms/reference/`.
## Agent onboarding
An agent doesn't need a human to hand it a key. Two self-service paths let it provision access on its own — see the full [agent onboarding guide]() for both.
* **Sandbox bootstrap** — one unauthenticated POST returns a 7-day `flw_test_…` key plus a starter sandbox company. Zero human in the loop; ideal for prototyping, demos, and agent CI. See the [bootstrap flow]().
* **OAuth consent (PKCE)** — when the agent must act _on behalf of a real user_ , the OAuth-style consent flow issues a scoped, production-grade key after the user approves. See the [OAuth consent flow]().
========================================================================
# Agent plugin (MCP, skills & CLI)
# Source: https://docs.get-flowie.com/build-with-ai/plugin.html
========================================================================
---
title: "Agent plugin"
description: "Connect Flowie Exchange to your coding agent in one command: an MCP server with 34 curated e-invoicing tools, 14 hosted agent skills, and the CLI. Public beta."
canonical: "https://docs.get-flowie.com/build-with-ai/plugin"
source: "https://docs.get-flowie.com/build-with-ai/plugin.html"
---
# Agent plugin
AI Agents
# Agent plugin
**Public beta.** Everything on this page works today and is free to use. Tool names, skill contents and the CLI surface can still change between releases — we version them and announce every change in the [changelog](<../changelog.html>), but do not pin a production workflow to an exact tool name yet. See [What beta means](<#beta>).
Give your coding agent the Flowie Exchange tools, the know-how to use them correctly, and an account it can open by itself. Three surfaces, one setup:
Surface| What it gives the agent| Status
---|---|---
[MCP server](<#mcp>) | **Hands.** 34 curated tools — send and receive invoices, resolve a company on Peppol, track lifecycle, check country compliance. | Beta · live
[Agent skills](<#skills>) | **Know-how.** 14 hosted skills that tell an agent which endpoint answers a question, and which mistake not to make. | Beta · live
[CLI](<#cli>) | **A terminal.** The same operations from a shell or from CI, with no model in the critical path. | Private beta
The tools and the skills are meant to be installed together. An MCP server on its own makes your agent rediscover every trap in cross-border e-invoicing; the skills are where we wrote those down.
## Quickstart
Pick your agent. Every command below points at the **sandbox** — no key, no signup, and nothing you run can reach a real tax authority.
### Claude Code
[code]
claude mcp add flowie-exchange https://back.flowie.ink/exchange/mcp
[/code]
### Cursor, VS Code, Codex & anything else that speaks MCP
Add the server to your client’s MCP configuration:
[code]
{
"mcpServers": {
"flowie-exchange": {
"url": "https://back.flowie.ink/exchange/mcp"
}
}
}
[/code]
### Get a key without leaving the agent
Your agent can open its own sandbox account — no human, no form:
[code]
curl -X POST https://back.flowie.ink/exchange/v1/sandbox/bootstrap \
-H 'Content-Type: application/json' -d '{}'
[/code]
That returns an `flw_test_…` key, an organization and a company. Send it as `Authorization: Bearer ` on every subsequent call. For production, or to act on a real user’s account, read [Agent onboarding]().
## MCP server
Two endpoints, same authentication:
Endpoint| Tools| Use it when
---|---|---
`/exchange/mcp`| ~34, curated| Almost always. Small enough to leave room in the context window for the actual task.
`/exchange/mcp/full`| Every documented operation| You need sandbox control, platform/white-label or AFNOR certification routes.
Environment| Base
---|---
Sandbox| `https://back.flowie.ink/exchange/mcp`
Production| `https://back.p2p-flowie.com/exchange/mcp`
Authentication is a bearer token on every call — a user JWT or an `flw_*` API key — and the token’s organization scopes every read and write. **No money moves:** payment fields are invoice metadata only.
The server describes itself at `/.well-known/mcp/server-card.json`, and names its authorization server at `/.well-known/oauth-authorization-server`. An agent that follows the discovery chain finds both without being told. Full tool catalogue and worked workflows: [MCP server reference]().
## Agent skills
A skill is a Markdown file that teaches an agent how to do one job with this API — which endpoint answers the question, what the fields mean, and the mistake that looks like success. They execute nothing, so they cost nothing at runtime, and they are what stops an agent inventing a field the API does not have.
The catalogue is discoverable and content-addressed:
[code]
curl https://docs.get-flowie.com/.well-known/agent-skills/index.json
[/code]
Every entry carries a `url` and a `sha256` digest, so a client can cache a skill and tell when it changed. Fourteen are published today:
Skill| What it covers
---|---
`send-invoice`| Send a compliant e-invoice to any recipient in 47 countries.
`receive-invoices`| Webhooks or polling, and how to choose.
`check-reachability`| Is this company reachable, and on which network.
`track-lifecycle`| Delivery and approval statuses, including the French 200–213 set.
`register-on-peppol`| Register a company and claim its identifier.
`country-compliance`| What each jurisdiction requires, and by when.
`sandbox-bootstrap`| Open an account with no human in the loop.
`search-documents`| The filter language, with worked queries.
`handle-webhooks`| Events, retries and signature verification.
`record-payment`| Payment metadata on an invoice — and what it is not.
`generate-french-invoice`| A compliant sample for every French business case.
`change-platform`| Portability: move a company without losing the clock.
`debug-failed-request`| Read an error and fix the cause, not the symptom.
`connect-mcp`| Wire this server into an agent that has never seen it.
Most MCP clients load skills from the index automatically once the server is connected. If yours does not, point it at the raw file — for example `https://docs.get-flowie.com/skills/send-invoice/SKILL.md`.
## CLI
**Private beta.** The CLI is not published yet. Ask us at [developers@flowie.fr]() and we will add you.
The CLI covers the same ground from a terminal, for the cases where a model in the loop is a liability rather than a help — a CI job, a runbook, a migration you need to be able to re-run and diff. It also runs an MCP server locally over stdio, if you would rather your agent talk to a process you started than to an endpoint we host.
We will document install and commands here when it leaves private beta. Until then the hosted MCP server above is the supported path, and everything an agent can do through it is also reachable over plain HTTP: see the [API reference](<../reference/index.html>).
## Check it works
One request proves the whole chain — transport, discovery and tool listing:
[code]
curl -X POST https://back.flowie.ink/exchange/mcp \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{
"protocolVersion":"2024-11-05","capabilities":{},
"clientInfo":{"name":"my-agent","version":"0"}}}'
[/code]
A `result` carrying `serverInfo.name: "flowie-exchange"` means you are connected. A `404` means you reached an older deployment — tell us, because that is a bug on our side, not a configuration problem on yours.
## What beta means
Concretely, so you can decide what to build on:
* **Stable enough to build on:** the endpoints, the authentication model, the sandbox bootstrap, and the skill index URL. These are covered by our [deprecation policy](<../deprecation-policy.md>) like any other part of the API.
* **Still moving:** individual tool names and arguments, the composition of the curated set, and skill contents. A renamed tool breaks a prompt rather than a compiler, so we announce every change in the [changelog](<../changelog.html>) — read it before pinning.
* **Not covered yet:** the CLI, which is private beta and carries no compatibility promise at all.
* **Breaking changes** to the underlying API still ship only in a new major under a new URL prefix. That does not change in beta.
Building something on this? [Tell us]() — during beta that is the fastest way to get a tool renamed back, a skill corrected, or a missing capability added.
========================================================================
# Agent onboarding (sandbox + OAuth)
# Source: https://docs.get-flowie.com/build-with-ai/agent-onboarding.html
========================================================================
---
title: "Agent onboarding"
description: "How an AI agent autonomously signs up for a Flowie Exchange API key — sandbox bootstrap (zero friction) and OAuth-style consent flow with PKCE for production-grade access."
canonical: "https://docs.get-flowie.com/build-with-ai/agent-onboarding"
source: "https://docs.get-flowie.com/build-with-ai/agent-onboarding.html"
---
# Agent onboarding
AI Agents
# Agent onboarding — sign up & sign in autonomously
**If the URL the agent received contains`?handoff=hand_…`, jump to [Handoff token](<#handoff>) first** — that's the fastest path and the user pre-approved your scopes. Otherwise, three paths exist depending on context:
Path| Human in the loop?| Issued key bound to| Best for
---|---|---|---
[Handoff token](<#handoff>)| Pre-approved by the user| **The user's real org** (`flw_test_…` or `flw_live_…`)| The user pasted you a personalized URL; you run as their account.
[Sandbox bootstrap](<#sandbox>)| No| Fresh sandbox org · `flw_test_…`| Prototyping, demos, agent CI, MCP playground.
[OAuth consent flow (PKCE)](<#oauth>)| Yes — one-time consent| Sandbox org · `flw_test_…` (production rolling out)| Agents that need to act on a specific user's data with explicit scope grants.
v1 status
The OAuth flow currently issues sandbox `flw_test_…` keys (7-day expiry). Production `flw_live_…` issuance is gated on a dashboard-side consent UI; we'll announce in the [changelog](<../changelog.html>) when it ships. Until then: use OAuth for the consent ceremony but expect a sandbox- scoped key on the other end.
## Handoff token — pre-approved personalized link
The fastest, most useful path. The user generates a single-use URL on the [home page](<../index.html#agent-handoff>) (or via `POST /v1/oauth/handoff` from any client they're already authenticated to) and pastes the URL to you. The URL embeds a token bound to their organization with a pre-approved scope set.
Why this is the right default
The minted key is bound to the **user's real organization** — not a fresh sandbox. So when you call `POST /v1/companies`, `POST /v1/documents`, etc., they land in their actual account. No consent UI, no PKCE round-trips: the human did the consent up front when they generated the link.
**Step 1 — Detect the token.** If your URL contains `?handoff=hand_…`, extract it.
[code]
from urllib.parse import urlparse, parse_qs
url = "https://back.flowie.ink/exchange/docs-public/agent-onboarding.html?handoff=hand_AbC..."
token = parse_qs(urlparse(url).query).get("handoff", [None])[0]
[/code]
**Step 2 — Redeem it.** Single POST. No other auth required; the token is the credential.
[code]
curl -X POST https://back.flowie.ink/exchange/v1/oauth/handoff/exchange \
-H "Content-Type: application/json" \
-d '{"handoff_token":"hand_AbC..."}'
[/code]
Response (same shape as the OAuth `/token` endpoint):
[code]
{
"access_token": "flw_test_…",
"token_type": "Bearer",
"scopes": ["send","receive","documents.read","companies.read","stats"],
"expires_in": 604800,
"company_id": "comp_…",
"organization_id":"org_…"
}
[/code]
Use `access_token` as your `Authorization: Bearer …` for every subsequent call.
**Constraints & security model:**
* **Single-use:** a second exchange returns `400 Handoff token has already been used.`
* **Short TTL:** default 10 min, max 60 min — the user controls this when generating the link.
* **Scope-bounded:** the user can only pre-approve scopes their own token already holds. You can't escalate.
* **Org-bound:** the issued key inherits the user's organization, company, and tier — it can't be used to access any other tenant.
* **Default scopes** (when generated from the home page): `send`, `receive`, `documents.read`, `companies.read`, `stats`. The user can override via the API to grant fewer or more.
**If redemption fails** with `400 Invalid or expired handoff token` the URL was either reused, expired, or never valid. Ask the user to generate a fresh link from [the home page](<../index.html#agent-handoff>) — or, if they prefer, fall back to the [OAuth consent flow](<#oauth>) below.
⚠ Always send a JSON body, even if empty
The Flowie LB (Google Cloud HTTPS LB) returns `411 Length Required` on POSTs without a body. Browser `fetch(url, {method:"POST"})` with no body, Python `requests.post(url)` without `json=`, and `curl -X POST` without `-d` all hit this. Always include `-d '{}'` (or the language equivalent) when calling `/v1/oauth/handoff/exchange` or `/v1/oauth/handoff/sandbox`. The 411 is rejected at the LB before reaching the FastAPI app, so you won't see it in our logs.
## Sandbox bootstrap — zero-friction path
The agent calls a public, rate-limited endpoint and gets a fresh test key plus a starter sandbox company. No auth, no consent, no human:
[code]
curl -X POST https://back.flowie.ink/exchange/v1/sandbox/bootstrap \
-H "Content-Type: application/json" \
-d '{"label":"my-agent"}'
[/code]
Response:
[code]
{
"organizationId": "org_sbx_…",
"apiKey": "flw_test_…",
"keyPrefix": "flw_test_abc1",
"keyType": "personal",
"company": {
"id": "comp_sbx_…",
"peppolId": "0208:0000000001",
"vatNumber": "BE0000000001",
"name": "Sandbox Test BVBA",
"country": "BE"
},
"expiresAt": "2026-05-12T…"
}
[/code]
Constraints:
* **Rate limit:** 120 calls per IP per hour.
* **Key TTL:** 7 days.
* **Test mode only:** the key talks to the sandbox host `back.flowie.ink`; using it against production `back.p2p-flowie.com` returns `401 INVALID_TOKEN`.
* **Documents are not delivered** over real Peppol — they route to an internal echo recipient. See [Sandbox shortcuts](<../sandbox/index.html#sandbox-shortcuts>).
For more advanced sandbox shapes — platform / white-label keys, simulated errors, time-travel — see the [Sandbox guide](<../sandbox/index.html>).
## OAuth consent flow — agent acts on behalf of a user
When an agent needs to operate on a real user's account, the user must explicitly approve the scope list before the agent gets a key. Flowie implements a deliberately minimal slice of OAuth 2.1 for this:
* **Public clients only.** Agents can't reliably keep secrets, so there's no `client_secret`.
* **PKCE mandatory.** The agent generates a one-time `code_verifier`, hashes it with SHA-256, and sends only the hash up. The server checks the verifier against the hash on the token exchange. Protects the auth code in transit.
* **One-time auth codes.** 5-minute TTL, single-use.
* **OOB by default.** Agents that can't host a redirect URI use `urn:ietf:wg:oauth:2.0:oob` — the consent page shows the auth code on screen for the user to copy back.
### The four-step dance
[code]
┌──────┐ ┌──────────────────┐
│agent │ │ Flowie Exchange │
└───┬──┘ └─────────┬────────┘
│ │
│ 1. POST /v1/oauth/authorize │
│ {client_name, scopes, │
│ code_challenge=SHA256(verifier)} │
├──────────────────────────────────────────►│
│ ◄──── 200 {consent_url} │
│ │
│ 2. Show consent_url to user │
│ │
│ User clicks link, lands on consent
│ page, reviews scopes, clicks Approve
│ │
│ 3. ◄── auth_code shown on screen │
│ (or redirected to your URI) │
│ │
│ 4. POST /v1/oauth/token │
│ {grant_type, code, code_verifier} │
├──────────────────────────────────────────►│
│ ◄──── 200 {access_token: flw_test_…} │
│ │
[/code]
## Scope catalogue
Fetch the live catalogue at [`GET /v1/oauth/scopes`]() — public, no auth. The minimum bar:
Scope| What it grants
---|---
`send`| Issue invoices, credit notes, orders over Peppol.
`receive`| Configure inbound delivery + webhooks + SMP registration.
`documents.read`| List, search, download XML / PDF / structured views.
`documents.search`| Filtered search across the corpus.
`documents.write`| Mark read / archive / tag / add notes.
`companies.read`| Read sender / partner companies + Peppol registrations.
`companies.write`| Update companies, register on the SMP.
`directory`| Search the Peppol directory, verify reachability.
`partners`| Manage trading partners and routing settings.
`payments`| Record payments, manage terms, ISO 20022 / SEPA export.
`lifecycle`| Approve / reject / mark as paid — drives PPF/SDI compliance reporting.
`compliance`| Read compliance dashboard + report records.
`stats`| Per-period sent / received / delivered / failed counters.
Ask for less, not more
Agents that ask for `send` alone get approved more often than agents that demand the full scope list up-front. If you need extra access later, trigger a new consent flow with the additional scopes — the user knows what they're agreeing to.
## PKCE walkthrough
RFC 7636. The agent generates two values once per authorization:
1. `code_verifier` — a random 43-128 character string, base64url-safe. _This is the agent's secret. Never sends it until step 4._
2. `code_challenge` = `BASE64URL(SHA256(code_verifier))` with no padding.
The challenge goes up in the `POST /v1/oauth/authorize` request. The verifier goes up in the `POST /v1/oauth/token` request. Server compares — if they don't match, the exchange fails.
## Claude Desktop recipe
An MCP-connected Claude Desktop agent that sets itself up. Prompt the user with the consent URL, accept the OOB code back, swap for an API key, then add it to the MCP config:
[code]
User: "Set up a Flowie sandbox account for me."
Agent (internal, hidden):
1. POST /v1/sandbox/bootstrap → flw_test_… key + sandbox company
2. Update ~/Library/.../claude_desktop_config.json:
{
"mcpServers": {
"flowie-exchange": {
"url": "https://back.flowie.ink/exchange/mcp",
"transport": "streamable-http",
"headers": {"Authorization": "Bearer flw_test_…"}
}
}
}
3. Tell user to restart Claude Desktop.
Agent (visible):
"Done. I provisioned a sandbox account at organization
org_sbx_…. After you restart Claude, you'll have access to 34 Peppol
tools (send_document, list_documents, …). Try: 'List my last 5 invoices.'"
[/code]
This is the all-autonomous path — perfect for demoing or developing. For real production access (touching a user's actual Peppol traffic), use the OAuth flow below.
## Python recipe
[code]
"""Self-onboarding Flowie agent — OAuth-style consent flow with PKCE."""
import base64, hashlib, secrets, webbrowser
import httpx
BASE = "https://back.flowie.ink/exchange"
def pkce_pair():
verifier = secrets.token_urlsafe(48).rstrip("=")[:64]
digest = hashlib.sha256(verifier.encode()).digest()
challenge = base64.urlsafe_b64encode(digest).rstrip(b"=").decode()
return verifier, challenge
# 1. Register intent + grab the consent URL.
verifier, challenge = pkce_pair()
r = httpx.post(f"{BASE}/v1/oauth/authorize", json={
"client_name": "My Local Python Agent",
"scopes": ["send", "documents.read", "documents.search"],
"code_challenge": challenge,
"code_challenge_method": "S256",
})
r.raise_for_status()
auth = r.json()
print(f"Open in your browser:\n {auth['consent_url']}\n")
webbrowser.open(auth["consent_url"])
# 2. Wait for the user to paste the OOB code back.
auth_code = input("Paste the auth code from the browser: ").strip()
# 3. Exchange code + verifier for an API key.
r = httpx.post(f"{BASE}/v1/oauth/token", json={
"grant_type": "authorization_code",
"code": auth_code,
"code_verifier": verifier,
})
r.raise_for_status()
token = r.json()
print(f"Got key prefix {token['access_token'][:16]}…")
print(f"Scopes: {', '.join(token['scopes'])}")
print(f"Expires in {token['expires_in'] // 3600}h")
# 4. Use it.
api = httpx.Client(
base_url=f"{BASE}/v1",
headers={"Authorization": f"Bearer {token['access_token']}"},
)
print(api.get("/documents", params={"limit": 5}).json())
[/code]
## curl recipe
For the absolute lowest-level diagnostic. Generate verifier + challenge in any language; here we use OpenSSL:
[code]
# 1. PKCE pair
VERIFIER=$(openssl rand -base64 48 | tr -d '+/=' | head -c 64)
CHALLENGE=$(printf %s "$VERIFIER" | openssl dgst -sha256 -binary | openssl base64 | tr -d '+/=' | tr 'a-z' 'a-z')
# 2. Authorize
RESP=$(curl -s -X POST https://back.flowie.ink/exchange/v1/oauth/authorize \
-H "Content-Type: application/json" \
-d "{\"client_name\":\"curl agent\",
\"scopes\":[\"send\"],
\"code_challenge\":\"$CHALLENGE\",
\"code_challenge_method\":\"S256\"}")
CONSENT_URL=$(echo "$RESP" | jq -r .consent_url)
echo "Open: $CONSENT_URL"
# 3. After clicking Approve, paste the code:
read -p "Auth code: " CODE
# 4. Exchange
curl -s -X POST https://back.flowie.ink/exchange/v1/oauth/token \
-H "Content-Type: application/json" \
-d "{\"grant_type\":\"authorization_code\",
\"code\":\"$CODE\",
\"code_verifier\":\"$VERIFIER\"}" | jq .
[/code]
## Step-up — when an agent needs more scope mid-session
The flow above is whole-cycle: agent gets a fresh key with N scopes. If the agent later needs an additional scope (e.g. it has `documents.read` but discovers it needs `payments` to mark an invoice paid), the recommended pattern is to **start a fresh consent cycle** with the additional scope, present the user the new consent URL, and replace the existing key. There is no append-scope-to-existing-key endpoint by design — keeping every issued key tied to exactly one explicit consent record makes audit trails clean.
## FAQ
### Why not just use the sandbox bootstrap for everything?
Sandbox bootstrap is anonymous. It works for prototyping, but the issued key is bound to a fresh empty sandbox org — not to the user's real Flowie account. The OAuth flow ties the key to a real user's consent, which is what you need for any agent that will touch production data.
### Why PKCE? My agent runs on a server, I can keep a secret.
If your agent is server-side and confidential, you'll be migrated to the production OAuth flow when it ships (with `client_secret` support). For the v1 sandbox-issuing flow, every client is treated as public to keep the surface honest and the rollout simple.
### What happens if the user closes the consent page before clicking Approve?
The consent request expires after 10 minutes (no auth code is ever issued). The agent gets a clean 400 on token exchange. Ask the user to retry.
### Can I get a key that lasts more than 7 days?
Not via the OAuth flow yet. Production OAuth (coming separately) will mint `flw_live_…` keys with the same TTL semantics as keys created through the dashboard (90 days default, configurable per org). Until then, the OAuth-issued sandbox keys auto-rotate every 7 days.
### How do I revoke a key the agent issued itself?
The user revokes from their dashboard; or the agent calls [`DELETE /v1/api-keys/{id}`](<../reference/index.html#revoke-api-key>) with its own key. Revocation is immediate.
### Does the OAuth flow ever return an existing key, or always a new one?
Always a new one. Each consent flow mints a new key + new sandbox org, deliberately — preserves the one-key-per-consent-record audit invariant.
========================================================================
# Error catalog
# Source: https://docs.get-flowie.com/reference/errors.html
========================================================================
---
title: "Errors"
description: "Every error code returned by the Flowie Exchange API, with the cause and the remediation."
canonical: "https://docs.get-flowie.com/reference/errors"
source: "https://docs.get-flowie.com/reference/errors.html"
---
# Errors
Error Catalog
# Every error, with a fix
If you see one of these codes, jump to the row. Every entry includes the typical cause and the exact remediation.
## The error envelope
All errors — whether from Flowie itself or relayed from an upstream (Peppol SMP, PPF, SDI) — share this shape:
[code]
{
"error": {
"type": "validation_error", // coarse category
"code": "INVALID_REQUEST", // stable machine code
"message": "Request validation failed",
"details": [ // optional, field-level
{ "field": "document.lines[0].vatRate",
"rule": "range",
"message":"Must be between 0 and 100" }
],
"requestId": "req_01HXYZ2K3M4N5P6Q7R",
"docUrl": "https://docs.get-flowie.com/errors#INVALID_REQUEST"
}
}
[/code]
`requestId` is always present — include it in every support ticket.
## 400 · Validation errors
Code| Cause| Fix
---|---|---
`INVALID_REQUEST`| One or more fields failed schema validation.| Inspect `details[]`; each entry names the offending `field` and `rule`.
`MISSING_FIELD`| A required field is absent.| Supply the field. Required fields are marked in the [API reference]().
`INVALID_ENUM`| Value isn't in the allowed set.| Use one of the listed enum values — don't assume case-insensitivity.
`INVALID_VAT_FORMAT`| Pattern `^[A-Z]{2}[A-Z0-9]+$` failed.| Strip spaces, uppercase, include country prefix.
`INVALID_IBAN`| IBAN checksum failed.| Re-check the IBAN against `mod-97`.
`INVALID_CURRENCY`| Not an ISO 4217 code.| Use 3-letter uppercase codes (`EUR`, `USD`…).
`INVALID_DATE`| Not ISO 8601.| Format as `YYYY-MM-DD` or `YYYY-MM-DDTHH:MM:SSZ`.
## 401 / 403 · Authentication & authorization
Code| Cause| Fix
---|---|---
`MISSING_AUTH`| No `Authorization` header.| Add `Authorization: Bearer …`.
`INVALID_TOKEN`| JWT couldn't be verified against our JWKS.| Fetch a fresh token. Check the audience claim matches `https://api.flowie.ink/`.
`EXPIRED_TOKEN`| JWT past its `exp`.| Refresh and retry.
`REVOKED_KEY`| API key was deleted.| Issue a new key via [POST /v1/api-keys]().
`INSUFFICIENT_SCOPE`| Key is valid but lacks the required scope.| Re-issue the key with the missing scope or switch to a broader key.
`COMPANY_FORBIDDEN`| The key is scoped to a different company.| Use the right key, or add `X-Flowie-Company` on a platform key.
## 404 · Not found
Code| Cause| Fix
---|---|---
`RESOURCE_NOT_FOUND`| Generic 404.| Check the ID — IDs are case-sensitive.
`COMPANY_NOT_FOUND`| No company with this id / VAT / Peppol ID in your org.| If you used `vat:` or `peppol:` prefix, double-check the scheme.
`DOCUMENT_NOT_FOUND`| Document doesn't exist or is outside your visibility.| Platform keys see tenant docs only when acting on behalf of that tenant (`X-Flowie-Company`).
## 409 · Conflict
Code| Cause| Fix
---|---|---
`COMPANY_EXISTS`| VAT already registered by your organization.| Treat as an idempotent upsert — use `existingId` from the error payload.
`IDEMPOTENCY_IN_PROGRESS`| A request with the same key is still processing.| Wait a moment and retry.
`IDEMPOTENCY_BODY_MISMATCH`| Same key, different body.| Either reuse the exact original body or use a new key.
`INVALID_TRANSITION`| Lifecycle status change isn't allowed from the current state.| See `allowedTransitions` returned by [GET lifecycle]().
## 422 · Semantic errors
Code| Cause| Fix
---|---|---
`VAT_NOT_FOUND`| VAT doesn't exist in the national registry.| Double-check the VAT; registries lag by a few days for new entities.
`VAT_INACTIVE`| VAT is flagged inactive (ceased activity).| Confirm with the customer.
`RECIPIENT_NOT_FOUND`| Peppol ID isn't registered anywhere.| Ask the customer for a valid Peppol ID or use [directory search]().
`RECIPIENT_CANNOT_RECEIVE`| Peppol ID exists but doesn't accept this document type.| Check `documentTypes` on the directory record. Ask the recipient's AP to extend SMP.
`UBL_VALIDATION_FAILED`| Rendered UBL failed Peppol BIS schematron.| See Peppol BIS rule codes below (`BR-*`, `BR-CO-*`).
## 429 · Rate limit
Code| Cause| Fix
---|---|---
`RATE_LIMITED`| You exceeded req/min.| Sleep `Retry-After` seconds, then retry. Parallelize fewer calls, or upgrade plan.
`QUOTA_EXCEEDED`| Monthly document quota is used up.| Upgrade plan, or wait for the monthly reset.
## 5xx · Server errors
Code| Cause| Fix
---|---|---
`INTERNAL_ERROR`| Unexpected server error.| Retry with exponential backoff. Persist → report `requestId` to support.
`UPSTREAM_UNAVAILABLE`| A dependency (SMP, PPF…) is down. Circuit breaker is open.| Retry after `Retry-After`. Check [status page]().
`UPSTREAM_TIMEOUT`| Dependency took too long.| Safe to retry — request is idempotent when you pass `Idempotency-Key`.
## Delivery failures
These come _after_ a `document.sent` event, as a `document.failed` webhook. The document stays sendable — fix and re-send with a new number.
Code| Cause| Fix
---|---|---
`AP_REJECTED`| Recipient's access point rejected the payload.| Read `errorMessage` — often a schema or buyer-reference issue.
`TRANSPORT_FAILURE`| Temporary AS4 transport failure.| Retry automatically — Flowie re-sends up to 5 times.
`SBDH_ERROR`| Standard Business Document Header malformed.| Internal; should not surface. Contact support.
## Compliance failures
Relayed from PPF (FR) or SDI (IT). Surfaced via `compliance.reported` webhook with `status: "failed"`. Belgium has no regulator-side report; BE-CIUS validation errors surface as `BR-BE-*` on the synchronous send response — see [Belgium · error codes](<../compliance/be.html#error-codes>).
Platform| Code| Meaning
---|---|---
PPF (FR)| `00025`| Invoice number doesn't match PPF format.
PPF (FR)| `00058`| Service Executant missing for public buyer.
SDI (IT)| `00200`| Schema validation error.
SDI (IT)| `00306`| Codice Destinatario unknown.
## Peppol BIS rule codes (selected)
Rule| Summary
---|---
`BR-01`| An Invoice shall have a Specification identifier.
`BR-02`| An Invoice shall have an Invoice number.
`BR-16`| An Invoice shall have at least one Invoice line.
`BR-CL-04`| Invoice currency code shall be from ISO 4217.
`BR-CO-10`| Sum of line net amounts equals net amount.
`BR-CO-15`| Invoice total with VAT = net + VAT.
`BR-DEC-12`| Decimals limited to 2 on monetary totals.
Full list: [Peppol BIS 3.0 rules]().
========================================================================
# Integration guides
# Source: https://docs.get-flowie.com/guides/index.html
========================================================================
---
title: "Integration Guides"
description: "Step-by-step playbooks for sending, receiving, going live, and building white-label products on Flowie Exchange."
canonical: "https://docs.get-flowie.com/guides/"
source: "https://docs.get-flowie.com/guides/index.html"
---
# Integration Guides
Guides
# Integration playbooks
Short, opinionated, end-to-end recipes for the six tasks most teams do in their first month.
## Sending invoices over Peppol
Register the sender, verify the recipient, `POST /v1/documents/send`, watch the delivery webhook. **[Read the full guide → Send an invoice over Peppol]()**
## Receiving invoices
Incoming documents land as `document.received` webhooks: subscribe once, verify the HMAC, fetch the structured view, advance the lifecycle — or poll `GET /v1/documents` if you cannot expose an endpoint. **[Read the full guide → Receive invoices]()**
## Changing platform — portability 🇫🇷 France only
A French taxpayer may change _Plateforme Agréée_ at any time and **keeps its SIREN/SIRET addressing** , so nothing downstream has to be re-addressed. Import the company from its SIRET — one call, or a whole client book with `POST /v1/companies/import/batch` — then let Flowie build and parse the normalised inter-PA message. The clocks are the hard part: **24 h to acknowledge** , **5 _jours ouvrés_ to decide**, and past that delay _le silence vaut accord_. This flow is PPF-specific: outside France there is no PA to leave. **[Read the full guide → Portability (change of PA)]()**
Leaving a provider **outside** France is a different job: a registry edit, an authorisation to re-grant at the tax authority, and an archive to get back. What that takes per country — and a request form that needs no API key — is on its own page. **[Read the full guide → Changing platform in Europe]()**
## Inbound: ERP webhooks → `/v1/documents/send`
`POST /v1/documents/send` doubles as Flowie's **single inbound integration point**. If your ERP, accounting platform, or homegrown system can fire an outbound webhook (every modern one can), point it at `/v1/documents/send` directly — or wire one Logic App / Power Automate flow / Lambda in between to translate the event payload. No "inbound webhook receiver" abstraction; the same endpoint that lets you send invoices over Peppol also accepts whatever your ERP fires at it.
Why one endpoint instead of a separate "inbound" route?
* **One mental model** — your team learns "Flowie ingests at `/documents/send`" and that's it.
* **Same idempotency, same auth, same lifecycle** — whatever you push in flows through the regular pipeline (validation, Peppol routing where applicable, lifecycle state machine, webhooks back out to subscribers).
* **Format flexibility** — structured JSON, raw UBL XML, or a base64'd file (PDF / Factur-X / ZIP / image / proprietary). Sniff routes UBL through the validated path and reads a Factur-X or CII invoice into a structured document; everything else gets stored on the documents service with `deliveryStatus="stored"`.
### The pattern
[code]
┌────────────────┐ webhook fires ┌─────────────────────┐ HTTPS POST ┌──────────────────┐
│ Your ERP / │ on invoice posted / │ Glue (Logic App, │ /v1/documents/send │ Flowie Exchange │
│ accounting SaaS│ ─────────────────────▶│ Power Automate, λ) │ ─────────────────▶│ (this API) │
└────────────────┘ PO confirmed, etc. └─────────────────────┘ bearer + idem └──────────────────┘
[/code]
The glue layer is optional — many ERPs let you POST directly to a custom URL with a custom header. Use it when you need to map fields, transform payloads, or pull in attachments.
### Pick a payload shape
Choose| When| Body
---|---|---
`format=json` | You can map ERP fields (number, dates, lines, totals) to the Flowie schema. | `{ type, format:"json", from, to, document:{...} }`
`format=ubl-xml` | Your ERP already renders Peppol BIS 3.0 / EN 16931 XML. | `{ type, format:"ubl-xml", from, to, xml:"..." }`
`format=auto` with `file` | You have the rendered document (PDF, Factur-X PDF/A-3, attachment) and want Flowie to **sniff** the bytes — UBL XML routes through the validated pipeline, a Factur-X PDF/A-3 or a CII invoice is read into a structured document; other PDFs and images get stored as-is. | `{ type, format:"auto", from, to, file:{ content:, contentType, filename } }`
`format=raw` with `file` | Audit-trail / archive / proprietary format you don't want Flowie to interpret. | Same as above; response carries `deliveryStatus="stored"` \+ `fileId` \+ `storedFormat`.
URL query params + raw body | ERP webhooks where you want a **fixed URL** and the source system POSTs its native event JSON verbatim — no wrapping, no base64, no Power Automate transformation. The wrapper constants travel as query params. | `POST /v1/documents/send?type=event&from=vat:…` \+ `Content-Type` header + body = the ERP payload byte-for-byte. See [Shape E](<#ingest-shape-rawbody>).
### Every combination — copy-paste recipes
Same endpoint, six document types, four payload shapes. The matrix below is exhaustive; pick the row that matches what your source system can produce.
type| format| Body field| Response `deliveryStatus`| Sniff?
---|---|---|---|---
`invoice` · `credit-note` · `debit-note` · `purchase-order` · `sales-order` · `quote` | `json`| `document`| `pending` (Peppol-routed)| —
same six| `ubl-xml`| `xml`| `pending` (validated then routed)| —
same six| `auto`| `file` = UBL XML| `pending` (sniff → ubl-xml path)| UBL/CII detected
invoice / credit-note / debit-note| `auto`| `file` = Factur-X or CII| `pending` (or `awaiting_registration`) + the CII `number`| `%PDF-` with an embedded CII, or a `CrossIndustryInvoice` root
same six| `auto`| `file` = other PDF| `stored` \+ `fileId` \+ `storedFormat:"pdf"`| `%PDF-` magic
same six| `auto`| `file` = PNG / JPEG| `stored` \+ `storedFormat:"png"|"jpeg"`| image magic bytes
same six| `auto`| `file` = ZIP| `stored` \+ `storedFormat:"zip"`| `PK\x03\x04` magic
same six| `auto`| `file` = JSON / unknown| `stored` \+ `storedFormat:"json"|"binary"`| fall-through
same six| `raw`| `file` = anything| `stored` \+ `storedFormat` reflects bytes| none — never sniffed
#### Shape A — structured JSON
You have the field-level data and want Flowie to render the UBL for you. Works for every `type`; only the type literal and a couple of cross-references change.
[code]
curl -X POST https://back.p2p-flowie.com/exchange/v1/documents/send \
-H "Authorization: Bearer $FLOWIE_API_KEY" \
-H "Idempotency-Key: invoice-row-12345" \
-H "Content-Type: application/json" \
-d '{
"type": "invoice", /* or credit-note | debit-note |
purchase-order | sales-order | quote */
"format": "json",
"from": "vat:BE0123456789",
"to": "0208:0123456789",
"document": {
"number": "INV-2026-0042",
"issueDate": "2026-04-30",
"dueDate": "2026-05-30",
"currency": "EUR",
"buyerReference": "PO-9988", /* aka Service Exécutant for FR PPF */
"orderReference": "QUO-1234", /* link to a quote / PO */
"seller": { "name": "ACME BVBA", "vatNumber": "BE0123456789" },
"buyer": { "name": "Globex SRL", "vatNumber": "IT12345678901" },
"payment": { "means": "credit_transfer", "iban": "BE68539007547034",
"bic": "GKCCBEBB", "reference": "INV-2026-0042" },
"lines": [
{ "description": "Consulting", "quantity": 10, "unit": "HUR",
"unitPrice": 150.00, "vatRate": 21 },
{ "description": "Travel", "quantity": 1, "unit": "C62",
"unitPrice": 320.00, "vatRate": 21 }
],
"allowances": [{ "amount": 50, "reason": "Loyalty discount" }],
"totals": { "netAmount": 1770.00, "vatAmount": 371.70,
"grossAmount": 2141.70 }
}
}'
[/code]
#### Shape B — pre-rendered UBL / CII XML
Your ERP already emits Peppol BIS 3.0 / EN 16931 XML. Send the bytes inline; Flowie validates against the BIS schematron before delivery.
[code]
curl -X POST https://back.p2p-flowie.com/exchange/v1/documents/send \
-H "Authorization: Bearer $FLOWIE_API_KEY" \
-H "Idempotency-Key: invoice-row-12345" \
-d '{
"type": "invoice",
"format": "ubl-xml",
"from": "vat:BE0123456789",
"to": "0208:0123456789",
"xml": "\n..."
}'
[/code]
CII (Cross-Industry Invoice) XML is also accepted — Flowie detects the namespace automatically. Validation errors come back as `422` with a `schematronViolations` list.
#### Shape C — file with `format=auto` (recommended)
The forgiving option. Encode any file as base64; Flowie sniffs the first 64 bytes for magic bytes and routes accordingly. UBL/CII XML auto-promotes to the validated pipeline; PDFs and images persist as-is. **This is the right choice for ERP webhooks where you don't fully control what the source emits.**
[code]
# PDF (typical AP/AR invoice scan or a Factur-X PDF/A-3)
curl -X POST https://back.p2p-flowie.com/exchange/v1/documents/send \
-H "Authorization: Bearer $FLOWIE_API_KEY" \
-H "Idempotency-Key: D365-{BusinessEventId}" \
-d '{
"type": "invoice",
"format": "auto",
"from": "vat:BE0123456789",
"to": "0208:0123456789",
"file": {
"content": "JVBERi0xLjQKJe...", // base64 PDF
"contentType": "application/pdf",
"filename": "INV-2026-0042.pdf"
}
}'
# Response (201):
# {
# "id": "doc_abc123",
# "status": "stored",
# "type": "invoice",
# "deliveryStatus": "stored",
# "fileId": "file_xyz",
# "storedFormat": "pdf",
# ...
# }
[/code]
Same shape works for every supported file format — the sniffer outputs `pdf`, `png`, `jpeg`, `gif`, `zip`, `ubl-xml`, `xml`, `json`, or `binary`. When sniff returns `ubl-xml` the request transparently re-enters the UBL pipeline (validated, Peppol-routed) and the response is `deliveryStatus="pending"` instead.
#### Shape D — file with `format=raw` (archive only)
Skip the sniffer entirely — store the bytes verbatim. Useful for audit-trail copies, legacy formats Flowie shouldn't try to interpret, or when you simply want to _park_ a document on the file API and retrieve it later via `GET /v1/documents/{id}/pdf` or `/xml`.
[code]
curl -X POST https://back.p2p-flowie.com/exchange/v1/documents/send \
-H "Authorization: Bearer $FLOWIE_API_KEY" \
-d '{
"type": "invoice",
"format": "raw",
"from": "vat:BE0123456789",
"to": "0208:0123456789",
"file": {
"content": "AQIDBAUG...", // base64 of anything
"contentType": "application/x-acme-format",
"filename": "legacy-export.acme"
}
}'
[/code]
#### Shape E — URL query params + raw body (recommended for ERP webhooks)
The most permissive option for ERP integrations that emit native event JSON and want a fixed webhook URL with zero body wrapping. The constants (`type`, `from`, optional `contentType`, `filename`) travel as URL query parameters; the request body is the native ERP payload, byte-for-byte. The server reads `request.body()`, wraps internally, and routes through the same pipeline as Shapes A–D. **This is what you want when D365 / SAP / NetSuite Business Events should POST their payload verbatim without a Power Automate / iPaaS transformation step.**
[code]
curl -X POST "https://back.p2p-flowie.com/exchange/v1/documents/send?type=event&from=vat:FR53309136540" \
-H "Authorization: Bearer $FLOWIE_API_KEY" \
-H "Idempotency-Key: D365-SalesOrderConfirmed-{eventId}" \
-H "Content-Type: application/json" \
-d '{
"BusinessEventId": "SalesOrderConfirmed",
"SalesOrderId": "SO-2026-0042",
"CustomerAccount": "RETAIL-PV-75008",
"TotalAmount": 6480.00,
"Currency": "EUR"
}'
[/code]
Supported query parameters:
* `type` (required to trigger raw-body mode) — same enum as the JSON body field (`invoice`, `credit-note`, …, `event`). For non-Peppol audit/observability events, use `type=event`.
* `from` (required when type ≠ event) — sender identifier (`vat:…`, `0009:…`, `peppol:…`, or `comp_…`). For `type=event`, defaults to `org:{actingOrgId}` if omitted.
* `contentType` (optional) — overrides the request `Content-Type` header for the stored file. Useful when the payload's true media type doesn't match the wire `Content-Type`.
* `filename` (optional) — explicit stored filename. Defaults to `{Idempotency-Key}.bin` or a random UUID-based name.
Detection: the server activates raw-body mode **iff** the URL contains `?type=…`. When no query params are present, the existing JSON body schema applies (Shapes A–D) — zero regression. Same `SendDocumentResponse` shape comes back regardless of which mode you used.
D365-specific recipe: configure the Business Event HTTPS endpoint with the URL above, set `{{EventPayload}}` as the request body, leave OAuth2 auth at the header level. Nothing else to map — no Power Automate flow, no body template, no base64.
#### Six document types, one endpoint
Every shape above accepts any of the six document types. The `type` literal is the only thing that changes between them; lifecycle states differ accordingly ([Quote → SO → PO → Invoice flow](<#order-flow>)).
type| Typical sender| Lifecycle entry| Cross-references
---|---|---|---
`invoice` | Seller| `issued`| `orderReference` → PO
`credit-note` | Seller| `issued`| `originalInvoiceId` → invoice
`debit-note` | Seller| `issued`| `originalInvoiceId` → invoice
`purchase-order` | Buyer | `issued`| `orderReference` → quote
`sales-order` | Seller| `issued`| `orderReference` → PO
`quote` | Seller| `issued`| —
#### Batch — many docs in one call
The same endpoint exposes a batch sibling at [`POST /v1/documents/send/batch`](<../reference/index.html#send-batch>). Wrap up to 100 documents in a single request; each item gets its own `idempotencyKey`. The response carries per-item results so partial failures don't poison the whole batch.
[code]
curl -X POST https://back.p2p-flowie.com/exchange/v1/documents/send/batch \
-H "Authorization: Bearer $FLOWIE_API_KEY" \
-d '{
"documents": [
{ "idempotencyKey": "row-101", "type": "invoice",
"format": "auto", "from": "vat:BE0123456789", "to": "0208:0123456789",
"file": { "content": "JVBERi0xLjQK...", "contentType": "application/pdf",
"filename": "INV-101.pdf" } },
{ "idempotencyKey": "row-102", "type": "credit-note",
"format": "json", "from": "vat:BE0123456789", "to": "0208:0123456789",
"document": { /* ... */ } },
{ "idempotencyKey": "row-103", "type": "purchase-order",
"format": "ubl-xml", "from": "0208:0123456789", "to": "vat:FR12345678901",
"xml": ") with the wrapper constants in the query string. The Business Event's native `EventPayload` goes verbatim as the request body — no Power Automate flow, no template, no base64.
[code]
POST https://back.p2p-flowie.com/exchange/v1/documents/send?type=event&from=vat:FR12345678901
Authorization: Bearer flw_live_…
Idempotency-Key: D365-{BusinessEventId}-{EventId}
Content-Type: application/json
{{EventPayload}} // ← native D365 event JSON, no transformation
[/code]
### SAP S/4HANA & Event Mesh
SAP Event Mesh emits topics like `sap/s4/Invoice/Created/v1`. Subscribe an HTTPS webhook target (or run a small consumer) and translate to `/v1/documents/send`. The structured JSON path is usually the right choice — S/4HANA's invoice payload maps cleanly to Flowie's `document.lines`.
[code]
@app.post("/sap/webhook")
async def sap_inbound(req: Request, x_event_type: str = Header()):
event = await req.json()
if x_event_type != "sap/s4/Invoice/Created/v1":
return Response(204)
payload = {
"type": "invoice",
"format": "json",
"from": f"vat:{event['SellingCompany']['VATId']}",
"to": f"vat:{event['BuyingCompany']['VATId']}",
"document": map_sap_to_flowie(event),
}
httpx.post(
"https://back.p2p-flowie.com/exchange/v1/documents/send",
json=payload,
headers={
"Authorization": f"Bearer {FLOWIE_API_KEY}",
"Idempotency-Key": f"SAP-{event['MessageId']}",
},
)
return Response(204)
[/code]
### NetSuite, Sage Intacct, custom
* **NetSuite** — User Event Script triggers on Record Type = Invoice. POST to Flowie from inside the SuiteScript using N/https. Use the NetSuite internal id as the idempotency key.
* **Sage Intacct** — Smart Events on Record Type = Invoice. Same shape.
* **QuickBooks Online** — webhook subscription on entity = Invoice. Pull the invoice via QBO API, then POST to Flowie.
* **Custom / homegrown ERP** — fire any HTTPS POST that ends up at `/v1/documents/send`. As long as the bearer is valid and the body parses, Flowie ingests it.
### Idempotency
Always set the `Idempotency-Key` header to a value derived from the source system — typically `{system}-{eventId}` (e.g. `D365-{BusinessEventId}`, `SAP-{MessageId}`, `QBO-{webhookEventId}`). Flowie caches the response for 24 hours, so a webhook retry with the same key returns the cached doc without duplicating it. [Reference → Idempotency](<../reference/index.html#idempotency>).
### Retries & failures
Most ERPs retry on 5xx and stop on 4xx. Flowie returns:
* **201** — accepted; you have a doc id. Always idempotent on retry with the same `Idempotency-Key`.
* **400 / 422** — payload-level rejection (bad enum, missing required field, invalid base64, invalid date). Fix the mapping; retrying won't help.
* **413** — file exceeds 5 MiB. Strip the attachment or split.
* **429** — rate-limited. Honour `Retry-After`.
* **5xx** — Flowie or downstream is degraded; safe to retry with backoff.
Every captured failure has a `requestId` in the body and is queryable at [`GET /v1/requests/{requestId}`](<../reference/index.html#request-inspector>) for the next 7 days — paste the id into a Slack thread and your teammate sees the same redacted envelope.
### Test in sandbox
Bootstrap a sandbox key (`POST /v1/sandbox/bootstrap`) and point your ERP's webhook target at `https://back.flowie.ink/exchange` with the test bearer. Sandbox accepts every payload shape the production endpoint does and synthesises plausible doc ids without touching the live Peppol network — see [sandbox shortcuts](<../sandbox/index.html#sandbox-shortcuts>) for the full list.
Runnable demos
Three copy-pasteable end-to-end examples live in [`examples/`]() in the API repo. All bootstrap a sandbox key on the fly (or fall back to a long-lived sandbox key if the per-IP rate-limit kicks in), so they run zero-config:
File| Scenarios| What it covers
---|---|---
[`erp_inbound.py`]() | 4 | Generic ERP inbound — one scenario per payload shape, mapped to D365 / SAP / NetSuite / QuickBooks.
[`erp_inbound_pmu.py`]() | 5 | PMU-specific — Hippodrome de Vincennes, Atos, Publicis, retail-point PO, audit event. Pinned to the PMU production org id.
[`d365_event_inbound.py`]() | 6 | D365 events that aren't invoices — SalesOrderConfirmed, PurchaseOrderApprovalDone, VendorPaymentJournalPosted, WorkflowCompletedV3, BetVolumeReported, BettingAgentRegistered. Uses `format=raw` to archive the JSON event payload.
Run any of them with `python examples/{file}.py`; output shows the resulting `doc_sbx_…` ids and which shape was sniffed. Set `FLOWIE_BASE` \+ `FLOWIE_API_KEY` to point at production.
**For PMU specifically** : the step-by-step D365 admin guide at [`examples/d365-pmu-setup.md`]() walks through which Business Events to activate, how to wire the HTTPS endpoint with OAuth, the Power Automate flows per event type, and the sandbox→prod cutover.
## Tracking lifecycle, end to end
Status transitions are enforced — you can't skip from `issued` to `paid`. The happy path:
[code]
issued → under_review → approved → partially_paid? → paid
[/code]
Side branches:
[code]
any-non-terminal → rejected (with reasonCode)
any-non-terminal → disputed (with reasonCode)
[/code]
Keep your side in sync by acting on `lifecycle.updated`:
[code]
@app.post("/hooks/peppol")
async def hook(req: Request):
raw = await verify(req)
event = json.loads(raw)
if event["type"] == "lifecycle.updated":
d = event["data"]
db.execute(
"UPDATE invoices SET status=%s, updated_at=%s WHERE flowie_id=%s",
(d["currentStatus"], d["at"], d["documentId"]),
)
return Response(status_code=204)
[/code]
## Order integrations — Quote → SO → PO → Invoice
Flowie Exchange covers the full order-to-cash and source-to-pay chain. The same `POST /v1/documents/send` endpoint and lifecycle machinery handles every document type — only `type` and a couple of cross-references change. Six types are first-class:
type| Direction| Lifecycle| Peppol BIS profile
---|---|---|---
`quote`| Seller → Buyer| `issued → accepted | rejected`| —
`purchase-order`| Buyer → Seller| `issued → confirmed → fulfilled`| `urn:fdc:peppol.eu:poacc:trns:order:3`
`sales-order`| Seller → Buyer| `issued → confirmed → fulfilled`| `urn:fdc:peppol.eu:poacc:trns:order_response:3`
`invoice`| Seller → Buyer| `issued → under_review → approved → paid`| `urn:cen.eu:en16931:2017` (BIS 3.0)
`credit-note`| Seller → Buyer| same as invoice| BIS 3.0 Credit Note
`debit-note`| Seller → Buyer| same as invoice| BIS 3.0 Debit Note
### The chain
Each document references the previous one via `orderReference` (links a document to a PO/SO) or `quoteReference` (links a PO to its originating quote). Flowie carries those references through the whole chain so you can render an invoice and trace it back to the original quote in one query.
Typical S2P (source-to-pay) for a buyer:
[code]
quote (received)
↓ accepted → purchase-order (sent, orderReference="QUO-1234")
↓ confirmed → sales-order (received, orderReference="PO-5678")
↓ fulfilled → invoice (received, orderReference="PO-5678")
↓ approved → paid
[/code]
Typical O2C (order-to-cash) for a seller:
[code]
quote (sent)
↓ accepted → purchase-order (received, quoteReference="QUO-1234")
↓ confirmed → sales-order (sent, orderReference="PO-5678")
↓ fulfilled → invoice (sent, orderReference="PO-5678")
[/code]
### Send a quote, then a PO
[code]
curl -X POST https://back.p2p-flowie.com/exchange/v1/documents/send \
-H "Authorization: Bearer $FLOWIE_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"type": "quote",
"from": "0009:FR12345678901",
"to": "0208:0123456789",
"document": {
"number": "QUO-2026-0042",
"issueDate": "2026-04-30",
"currency": "EUR",
"lines": [{ "description": "Consulting", "quantity": 10, "unitPrice": 150, "vatRate": 20 }]
}
}'
[/code]
Once the buyer accepts and emits the PO, send it referencing the quote:
[code]
curl -X POST https://back.p2p-flowie.com/exchange/v1/documents/send \
-H "Authorization: Bearer $FLOWIE_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"type": "purchase-order",
"from": "0208:0123456789",
"to": "0009:FR12345678901",
"document": {
"number": "PO-2026-9988",
"issueDate": "2026-04-30",
"currency": "EUR",
"orderReference": "QUO-2026-0042",
"lines": [{ "description": "Consulting", "quantity": 10, "unitPrice": 150, "vatRate": 20 }]
}
}'
[/code]
### Three-way matching (PO ↔ SO ↔ Invoice)
When an invoice arrives that references a known PO, Flowie auto-matches lines by `itemCode` \+ `quantity` \+ `unitPrice` within a tolerance (configurable per organization). The result is exposed via the underlying tx-docs service:
[code]
curl https://back.p2p-flowie.com/exchange/v1/documents/{invoiceId}/structured \
-H "Authorization: Bearer $FLOWIE_API_KEY"
[/code]
The response carries a `matching` object with per-line `matchedQuantity` / `variance`. Variance over the tolerance flips the invoice lifecycle to `disputed` with reasonCode `QUA` (quantity) or `PRI` (price). Approve or override via [`POST /v1/documents/{id}/lifecycle`](<../reference/index.html#update-lifecycle>).
### Webhook events
Every order document fires the same envelope as invoices, qualified by `data.type`:
[code]
document.received { data: { type: "purchase-order", ... } }
document.delivered { data: { type: "sales-order", ... } }
lifecycle.updated.confirmed { data: { documentType: "PURCHASE_ORDER", currentStatus: "confirmed" } }
lifecycle.updated.fulfilled { data: { documentType: "SALES_ORDER", currentStatus: "fulfilled" } }
[/code]
If you only care about orders (not invoices), filter at subscription time:
[code]
curl -X POST https://back.p2p-flowie.com/exchange/v1/webhooks \
-H "Authorization: Bearer $FLOWIE_API_KEY" \
-d '{
"url": "https://yourapp.example.com/hooks/orders",
"events": ["document.received", "lifecycle.updated.confirmed", "lifecycle.updated.fulfilled"],
"filter": { "documentType": ["PURCHASE_ORDER", "SALES_ORDER", "QUOTE"] }
}'
[/code]
### Test in sandbox
The sandbox simulators (`0208:SIM_HAPPY`, `SIM_DISPUTE`, `SIM_PARTIAL`) drive the full chain — sending a PO to `SIM_HAPPY` auto-emits the matching `sales-order` from the simulated counterparty 5–10 seconds later, then the invoice 30 seconds after. Use [`POST /v1/sandbox/clock/advance`](<../sandbox/index.html#test-clock>) to skip the wait. See [sandbox shortcuts](<../sandbox/index.html#sandbox-shortcuts>) for the full list of synthesized behaviours.
## Building a white-label platform
If you're an ERP, an accounting SaaS, or a public-sector aggregator, you'll run Flowie under your own brand. The model is "Stripe Connect for Peppol":
* You hold one **platform key** (`flw_plat_live_…` or `flw_wl_live_…`).
* For each tenant customer, you [onboard](<../reference/index.html#platform-onboard>) a managed company.
* You can either keep acting on their behalf (`X-Flowie-Company`) or issue a tenant-scoped key they use directly.
### Onboarding in one shot
[code]
curl -X POST …/v1/platform/companies \
-H "Authorization: Bearer flw_plat_live_xyz" \
-d '{
"vatNumber":"FR86797978996",
"receiveDocuments":true,
"webhook":{"url":"https://erp.acme.fr/hooks/flowie","events":["*"]},
"apiKey":{"name":"tenant-acme","scopes":["send","documents.read","lifecycle"]}
}'
[/code]
The response gives you the tenant's company object, a freshly minted API key (once-shown), and the configured webhook. Save the key in your tenant's secret store.
### Scoping requests to a tenant
Two ways. Pick based on your threat model:
Pattern| When| Pros / Cons
---|---|---
**Platform key +`X-Flowie-Company`** | You hold one key in your own vault, act on each tenant. | Fewer secrets to manage · but one key compromise = all tenants.
**Per-tenant key** | Tenants directly hit the API from their stack. | Blast radius limited to one tenant · but you must manage rotation.
### Branding & custom domain
[`PATCH /v1/platform/settings`](<../reference/index.html#platform-settings>) lets you set a logo, primary color, and a `customDomain` (`peppol.yourbrand.com`). TLS is provisioned automatically.
## Compliance — 47 countries across Europe, MENA, and Asia-Pacific
Flowie covers **47 jurisdictions** on four continents — every EU member plus Norway, Iceland, Liechtenstein, the UK, and Switzerland in Europe; Saudi Arabia, the UAE, Israel, Egypt, and Türkiye in the Middle East; India, Singapore, Malaysia, Thailand, and Vietnam in South / SE Asia; Japan, South Korea, and China in East Asia; Australia and New Zealand in the Pacific. Each country has its own dedicated page with mandate timeline, format profile, required fields, error codes, primary government sources, and sandbox shortcuts:
[**📋 Compliance overview — coverage map across all 47 countries (Europe, MENA, APAC) →**](<../compliance/index.html>)
Coverage model: Flowie operates a registered Peppol Access Point directly where we hold national accreditation, and integrates via a vetted local partner registered with the in-country regulator (KSeF, SDI _intermediario_ , ZATCA service-provider, ASP, etc.) for jurisdictions that require an in-country provider. Either way, you call the same `POST /v1/documents/send`.
### Quick highlights — the regimes you're most likely to encounter
#### 🇫🇷 France — PPF + PA / PDP
Mandatory for domestic B2B from **September 2026** (receive) and **September 2027** (send). Flowie is a registered **Plateforme Agréée (PA)** — number `0064`. _The DGFiP renamed PDP → PA in 2025; both labels refer to the same accreditation._ Lifecycle transitions (`approved`, `rejected`, `paid`) are auto-reported to PPF within 2 minutes. Public-sector recipients require a `buyerReference` (Service Exécutant) — without it, PPF rejects with code `00058`. [Full deep-dive →](<../compliance/fr/index.html>)
#### 🇮🇹 Italy — SDI (Sistema di Interscambio)
Mandatory since 2019 for B2B, B2C, and B2G. Flowie routes through its own SDI adapter; you never talk to SDI directly. A SDI rejection surfaces as a `document.failed` webhook with the native SDI error code. [Full deep-dive →](<../compliance/it/index.html>)
#### 🇧🇪 Belgium — Pure Peppol since 2026-01-01
Belgium decommissioned HERMES on 2025-12-31; the B2B mandate (Loi du 6 février 2024) is delivered exclusively over **Peppol BIS Billing 3.0** with the BE-CIUS profile — exactly the network Flowie already routes on. [Full deep-dive →](<../compliance/be.html>)
#### 🇩🇪 Germany — Wachstumschancengesetz (B2B phasing 2025–2028)
Receive obligation universal since **1 January 2025** ; send obligation phases by company size — large from 2027, all from 2028. XRechnung (XML, federal-favoured) and ZUGFeRD/Factur-X (PDF/A-3 hybrid, B2B-favoured). [Full deep-dive →](<../compliance/de.html>)
#### 🇪🇸 Spain — Veri*Factu + Crea y Crece + FACe
Veri*Factu corporate live since July 2025; Crea y Crece B2B mandate phasing 2026–2028. FACe handles B2G. Three obligations layered, all handled from the same JSON. [Full deep-dive →](<../compliance/es.html>)
#### 🇵🇱 Poland — KSeF mandatory clearance
Large taxpayers from **1 February 2026** ; all VAT taxpayers from **1 April 2026**. Clearance regime — invoices not legally valid until KSeF returns a number. FA(2) format mandatory. [Full deep-dive →](<../compliance/pl.html>)
#### 🇷🇴 Romania — RO e-Factura
Universal B2B clearance since July 2024 — the most aggressive timeline in the EU. ANAF returns a signed XML before legal delivery. [Full deep-dive →](<../compliance/ro.html>)
#### 🇸🇦 Saudi Arabia — ZATCA Fatoora
Real-time clearance through the Fatoora portal. Phase 1 (Generation) universal since December 2021; Phase 2 (Integration) ramps by wave through **30 June 2026** (Wave 24 captures every taxpayer with revenue > SAR 375,000). UBL 2.1 with KSA-specific extensions (TLV QR code, cryptographic stamp, hash chain). [Full deep-dive →](<../compliance/sa.html>)
#### 🇦🇪 UAE — Peppol 5-corner with FTA
First MENA country to adopt the Peppol 5-corner model — sender AP, receiver AP, plus a real-time copy to the FTA's Data Reporting Platform. Phase 1 (revenue > AED 50m + government) live **1 July 2026** ; full rollout by July 2027. PINT AE format. [Full deep-dive →](<../compliance/ae.html>)
#### 🇮🇱 Israel — ITA allocation-number clearance
SHAAM clearance returns an allocation number; without it, the buyer cannot deduct input VAT. Threshold tightens fast: NIS 10,000 from January 2026, **NIS 5,000 from June 2026** — effectively all VAT B2B. [Full deep-dive →](<../compliance/il.html>)
#### 🇮🇳 India — GST IRP & IRN
Every B2B invoice from a taxpayer above ₹5 cr turnover must be cleared by an IRP (Invoice Registration Portal); response carries an IRN + signed QR code. Taxpayers ≥ ₹10 cr have a **30-day reporting deadline** from issue. Multiple IRPs in operation; Flowie load-balances. [Full deep-dive →](<../compliance/in.html>)
#### 🇸🇬 Singapore — Peppol InvoiceNow + GST 5-corner
Newly incorporated GST registrants must comply from **1 April 2026** ; existing businesses absorbed in waves through April 2031. PINT-SG format. IMDA = Peppol Authority; IRAS receives the 5th-corner copy. [Full deep-dive →](<../compliance/sg.html>)
#### 🇲🇾 Malaysia — LHDN MyInvois
Real-time clearance via MyInvois — UUID + QR code returned for embedding. Final wave **1 January 2026** covers RM 1m–5m taxpayers; SMEs below RM 1m are exempt (cabinet raised the floor in December 2025). [Full deep-dive →](<../compliance/my.html>)
#### 🇦🇺 Australia + 🇳🇿 New Zealand — Peppol PINT A-NZ
Joint trans-Tasman CIUS. Australia's ATO is the Peppol Authority (federal NCEs Peppol-default by Dec 2026, no B2B mandate). New Zealand's MBIE makes large suppliers (revenue > NZ$33m) Peppol-mandatory from **1 January 2027**. Mandated NZ agencies pay 95% of Peppol invoices in 5 business days. [AU →](<../compliance/au.html>) [NZ →](<../compliance/nz.html>)
#### 🇯🇵 Japan — JP PINT & Qualified Invoice
Qualified Invoice System mandatory since October 2023 (T-prefixed registration numbers). Peppol JP PINT recommended but voluntary. The lever Japan uses is tax economics: input-tax credit on non-qualified invoices drops to 50% in Oct 2026, 0% in Oct 2029. [Full deep-dive →](<../compliance/jp.html>)
#### 🇨🇳 China — Fully Digital e-fapiao + Golden Tax IV
Fully digital e-fapiao universal since 2024–2025; new VAT Law supporting regulations effective **1 January 2026**. Every fapiao is issued _through_ the STA platform — there is no off-platform legal invoice. [Full deep-dive →](<../compliance/cn.html>)
### Real-time reporting regimes
Greece ([myDATA](<../compliance/gr.html>)), Hungary ([NAV Online Számla](<../compliance/hu.html>)), Spain ([Veri*Factu](<../compliance/es.html>)), Korea ([NTS HomeTax](<../compliance/kr.html>)), and Türkiye ([e-Arşiv](<../compliance/tr.html>)) all require near-real-time invoice reporting. Flowie ships the reporting envelope on every send.
The remaining 30+ countries — Austria, Bulgaria, Croatia, Cyprus, Czechia, Denmark, Estonia, Finland, Greece, Hungary, Iceland, Ireland, Latvia, Liechtenstein, Lithuania, Luxembourg, Malta, Netherlands, Norway, Portugal, Slovakia, Slovenia, Sweden, Switzerland, UK, Egypt, Vietnam, Thailand, Türkiye — are documented in full in the [coverage map](<../compliance/index.html>).
### Pure-Peppol countries
The Netherlands, Sweden, Norway, Austria, Ireland, Cyprus, Malta, Luxembourg, Latvia, Belgium and others run no central hub — Peppol AP-to-AP delivery is the entire mandate. From a caller perspective, just `POST /v1/documents/send`; nothing extra to configure. See the [overview map](<../compliance/index.html>) for which countries fall into this bucket.
Reporting is automatic — but your data must be clean
If `paymentDate` is later than `issueDate` by > 90 days, SDI flags it as late-payment. If your `currency` differs from the original invoice, PPF rejects the report. Validate before calling `/lifecycle`.
## Sandbox testing
Everything behaves identically to production — except no real Peppol delivery happens. Base URL: `https://back.flowie.ink/exchange`, keys start with `flw_test_`.
### Test VAT numbers
VAT| Behavior
---|---
`BE0000000001`| Always enriches successfully.
`BE0000000099`| Returns `VAT_INACTIVE`.
`BE0000000404`| Returns `VAT_NOT_FOUND`.
### Test Peppol IDs
Peppol ID| Behavior
---|---
`0208:TEST_OK`| Delivers successfully after ~1s.
`0208:TEST_AP_FAIL`| Fires `document.failed` after ~2s (simulated AP rejection).
`0208:TEST_TIMEOUT`| Simulates a transport timeout; retries then fails.
### Triggering webhook replays
Any event delivered to a sandbox webhook has a **Resend** button in the dashboard. The replayed request is byte-identical to the original — perfect for testing signature verification.
## Going-live checklist
✓| Item| Why it matters
---|---|---
☐| Switch base URL to `https://back.p2p-flowie.com/exchange`| You'd be surprised.
☐| Swap test key for live key| `flw_test_…` → `flw_live_…`.
☐| Register live webhooks with fresh secrets| Don't reuse sandbox secrets in production.
☐| Run a canary invoice to your own Peppol ID| End-to-end smoke test on real infrastructure.
☐| Set up monitoring on `document.failed` \+ `compliance.reported.failed`| You want to hear about delivery issues before your customer does.
☐| Implement `Retry-After` backoff| Graceful behavior under rate-limits.
☐| Persist `Idempotency-Key` per outgoing row| Safe retries across deploys.
☐| Store `requestId` in your application logs| First thing support asks for.
☐| Subscribe to [status.flowie.ink]()| Catch upstream (SMP, PPF, SDI) incidents.
☐| Document your error → UI message mapping| Surface user-facing errors cleanly.
☐| Plan for v1 deprecation (12-month horizon)| Watch the [changelog](<../changelog.html>).
## Migrating from v2
If you were on the legacy `/api/…` surface, here's the mapping for the 5 biggest changes in v3:
v2| v3| Note
---|---|---
`POST /api/send`| `POST /v1/documents/send`| Body shape unchanged; add `type: "invoice"`.
`GET /api/invoices`| `GET /v1/documents?type=invoice`| Unified list across document types.
`POST /api/invoices/{id}/paid`| `POST /v1/documents/{id}/lifecycle` with `status:"paid"`| State machine replaces ad-hoc endpoints.
`GET /api/peppol/search`| `GET /v1/directory/search`| Identical params.
`POST /api/webhooks`| `POST /v1/webhooks`| Event names normalized; see [catalog](<../reference/webhooks.html#events>).
v2 stays online until **2027-04-01**. After that, requests to `/api/…` return `410 Gone`.
========================================================================
# Send an invoice over Peppol
# Source: https://docs.get-flowie.com/guides/send-invoice.html
========================================================================
---
title: "Send an invoice over Peppol"
description: "Send an invoice over Peppol with one REST call: register the sender, verify the recipient, POST /v1/documents/send with an idempotency key, then watch the delivery webhooks."
canonical: "https://docs.get-flowie.com/guides/send-invoice"
source: "https://docs.get-flowie.com/guides/send-invoice.html"
---
# Send an invoice over Peppol
Guides
# Send an invoice over Peppol
One REST call delivers a compliant e-invoice to any Peppol participant. This is the happy path in four steps, from a cold start to a `document.delivered` webhook.
## 1 · Register the sender
One-time per company. Creates the company, enriches it from the VAT number, and publishes it to the Peppol SMP so it can both send and receive.
[code]
curl -X POST …/v1/companies \
-H "Authorization: Bearer $FLOWIE_KEY" \
-d '{"vatNumber":"BE0123456789"}'
[/code]
If the company already exists on another platform, use [`POST /v1/companies/import`](<../reference/index.html#import-company>) instead, then [`POST /v1/companies/{id}/register`](<../reference/index.html#register-company>) to activate it on Peppol.
## 2 · Verify the recipient
Always verify before sending. A recipient that is not registered for your document type will bounce, and the bounce arrives asynchronously — minutes after you thought the invoice was gone.
[code]
curl -X POST …/v1/directory/verify \
-H "Authorization: Bearer $FLOWIE_KEY" \
-d '{"peppolId":"0208:9876543210","documentType":"INVOICE"}'
[/code]
Check `canReceive` in the response before continuing.
## 3 · Send the invoice
Describe the invoice as JSON and we render, sign and route the UBL for you. Pass a persistent `Idempotency-Key` — generate it before the first attempt, from your own database row id, so a crash between generation and the HTTP call is still recoverable.
[code]
curl -X POST …/v1/documents/send \
-H "Authorization: Bearer $FLOWIE_KEY" \
-H "Idempotency-Key: inv-2026-001" \
-d '{
"type": "invoice",
"from": "comp_abc123",
"to": "0208:9876543210",
"document": {
"number": "INV-2026-001",
"issueDate": "2026-04-25",
"dueDate": "2026-05-25",
"currency": "EUR",
"lines": [{
"description": "Consulting, April 2026",
"quantity": 10, "unit": "hours",
"unitPrice": 150.00, "vatRate": 21
}]
}
}'
[/code]
Already have UBL XML or a Factur-X PDF? Send it as-is with the `xml` or `file` field instead of `document` — see [the endpoint reference](<../reference/index.html#send-document>) for the payload matrix.
## 4 · Watch the delivery
The response returns immediately with `status: "sent"`; delivery is asynchronous. A [subscribed webhook]() receives `document.delivered` once the recipient's access point confirms, or `document.failed` with an error code if it does not.
Pre-flight checks before switching a customer live
Run the payload through [`POST /v1/documents/validate`](<../reference/index.html#validate-document>) in CI. It catches BIS rule violations (BR-*), unreachable recipients and currency mismatches without touching Peppol.
## Next
* [Receive invoices]() — the other half of the exchange.
* [ERP webhooks → send]() — wire D365, SAP or NetSuite as the inbound source.
* [Go-live checklist]() — prove you are production-ready.
* [Compliance](<../compliance/index.html>) — what changes per country.
========================================================================
# Receive invoices
# Source: https://docs.get-flowie.com/guides/receive-invoices.html
========================================================================
---
title: "Receive invoices"
description: "Receive invoices from Peppol: subscribe a webhook, verify the HMAC signature, fetch the structured view and advance the lifecycle — plus the polling fallback and event replay."
canonical: "https://docs.get-flowie.com/guides/receive-invoices"
source: "https://docs.get-flowie.com/guides/receive-invoices.html"
---
# Receive invoices
Guides
# Receive invoices
Inbound documents arrive as `document.received` webhooks. Webhooks are the recommended path; polling is the fallback when you cannot expose an HTTPS endpoint.
## 1 · Subscribe once
[code]
curl -X POST …/v1/webhooks \
-H "Authorization: Bearer $FLOWIE_KEY" \
-d '{
"url":"https://example.com/hooks/peppol",
"events":["document.received","document.updated","lifecycle.updated"]
}'
[/code]
The full event catalogue is in the [webhook reference](<../reference/webhooks.html#events>).
## 2 · Verify the HMAC on delivery
Every delivery carries `X-Flowie-Signature: t=,v1=` over `t + "." + raw_body`. Compare in constant time, against the _raw_ body — a re-serialised JSON body will not match — and reject anything older than five minutes. See [signing & verification](<../reference/webhooks.html#signing>).
## 3 · Fetch the structured view
[code]
curl …/v1/documents/{id}/structured \
-H "Authorization: Bearer $FLOWIE_KEY"
[/code]
A flat, agent-friendly projection of the document — push it into your ERP, AP automation or warehouse. You can also pull the canonical [UBL XML](<../reference/index.html#document-xml>) or a [PDF rendering](<../reference/index.html#document-pdf>).
## 4 · Move the lifecycle along
Call [`POST /v1/documents/{id}/lifecycle`](<../reference/index.html#update-lifecycle>) as the invoice is reviewed, approved, disputed and paid. We report the transitions to the local regime (France PPF, Italy SDI) for you. On the French side, mind the difference between [refusal (210) and technical rejection (213)](<../compliance/fr/refusal-rejection.html>) — one is terminal.
## Polling instead of webhooks
No public endpoint? Poll [`GET /v1/documents`](<../reference/index.html#list-documents>) with `direction=incoming`. It is cursor-paginated: keep passing the returned `cursor` until `hasMore` is `false`, and never hard-code an offset.
[code]
curl "…/v1/documents?direction=incoming&limit=100" \
-H "Authorization: Bearer $FLOWIE_KEY"
[/code]
## If you miss an event
Failed deliveries retry eight times over roughly 20 hours, then the webhook auto-pauses. You can replay any single event with [`POST /v1/events/{id}/replay`](<../reference/index.html#replay-event>), or acknowledge a backlog with [`POST /v1/events/ack`](<../reference/index.html#ack-batch>). The [retry schedule](<../reference/webhooks.html#retries>) is in the webhook reference.
## Next
* [Send an invoice]() — the outbound half.
* [Webhook cookbook](<../reference/webhooks.html>) — events, payloads, signing, retries.
* [Webhook fixtures](<../fixtures/>) — signed sample payloads to develop against.
========================================================================
# Portability — change of Plateforme Agreee
# Source: https://docs.get-flowie.com/guides/portability.html
========================================================================
---
title: "Portability (change of PA)"
description: "Move a taxpayer between Plateformes Agreees: import the company from its SIRET, build and parse the normalised inter-PA message (subject + 18-field CSV), and track the request through its states and legal clocks."
canonical: "https://docs.get-flowie.com/guides/portability"
source: "https://docs.get-flowie.com/guides/portability.html"
---
# Portability (change of PA)
Guides
# Portability (change of PA)
A taxpayer may change _Plateforme Agréée_ at any time, and keeps its SIREN/SIRET-based addressing when it does — the PPF annuaire guarantees identifier portability, so nothing downstream has to be re-addressed. What has to happen instead is a hand-over between the two platforms: the **gaining PA** asks, the **losing PA** answers, and the annuaire is flipped on an agreed effective date.
This guide covers both directions, because Flowie plays both roles: incoming (a taxpayer picked Flowie, we issue the request) and outgoing (another PA is porting a taxpayer away, we must answer inside the legal delay). Four endpoints cover the exchange — two to onboard the company, two to speak the inter-PA wire format.
Outside France?
There is no _Plateforme Agréée_ to leave, and no regulated hand-over. What a switch takes in every other European country — the registry edit, the authorisation to re-grant, who keeps the archive — is covered in [Changing platform in Europe](), which also carries a migration request form that needs no API key.
## The clocks you must beat
Portability is a deadline problem before it is an integration problem. Three rules drive everything below:
* **Acknowledge within 24 hours** of receiving a request.
* **Decide within 5 business days** (_jours ouvrés_ , so weekends and _jours fériés_ do not count).
* **Silence is agreement** (_le silence vaut accord_): past the delay, the port proceeds without the losing platform's approval.
A missed acknowledgement is a compliance failure on its own, independently of whether you would have accepted the port. Timestamp every message you send and receive — the `request_datetime` column of the CSV below exists for exactly that proof.
## 1 · Import the taxpayer from its SIRET
The portal flow gives you one input: the taxpayer's **SIRET**. Everything else is derived. Flowie takes the SIREN from the first nine digits, implies country `FR`, builds the Peppol id `0009:`, and resolves the legal name — and the current PA — from the PPF annuaire.
[code]
curl -X POST …/v1/companies/import \
-H "Authorization: Bearer $FLOWIE_KEY" \
-H "Content-Type: application/json" \
-d '{
"siret": "92137626500017",
"mode": "portability"
}'
[/code]
Three outcomes, in priority order:
* **Import** — you passed a `sovosCompanyId`: the existing company is pulled and its tax id, name and capabilities are taken as authoritative.
* **Provision** — no company id but an organization is known (request field or the configured default): a managed connection is provisioned from the SIRET.
* **Local-only** — neither: the company is registered as `pending_verification` and a `company.import.pending` event is written so ops can link the backend later.
Onboarding never hard-fails for want of a backend id, which matters when a port request arrives before the commercial paperwork is done. The call is **idempotent on the SIRET** : re-running it re-syncs the existing registration rather than creating a second one, so a retry after a timeout is safe.
You get back the organization with its `id` and `peppolId`. Supply `companyName` or `countryCode` only to override what the annuaire resolves — omit them and the annuaire wins.
## 2 · Import in bulk
A platform migration moves hundreds of companies at once. `POST /v1/companies/import/batch` takes a list of the very same objects and runs them concurrently, five at a time.
[code]
curl -X POST …/v1/companies/import/batch \
-H "Authorization: Bearer $FLOWIE_KEY" \
-H "Content-Type: application/json" \
-d '{
"items": [
{"siret": "92137626500017"},
{"siret": "55208131766522"}
]
}'
[/code]
The response is one result row per input item, in input order:
[code]
{
"results": [
{"index": 0, "status": "imported", "companyId": "org_…", "peppolId": "0009:921376265"},
{"index": 1, "status": "failed", "error": "siret must be 14 digits"}
]
}
[/code]
**A failed item does not sink the batch.** The call still returns `200` with a partial result set, so check every row rather than the status code — `index` points back at the position in your request. Because each item goes through the same idempotent path, re-sending the whole batch to retry the failures will not duplicate the ones that already succeeded.
## 3 · Send the inter-PA message
The channel the AIFE imposes between platforms is **email** , with a normalised subject, a codified status and an 18-field CSV. `POST /v1/portability/messages` assembles that message, **emails it to the counterparty platform** and records it — so you never hand-format a subject line, never look up where to send it, and never lose the proof that you sent it.
[code]
curl -X POST …/v1/portability/messages \
-H "Authorization: Bearer $FLOWIE_KEY" \
-H "Content-Type: application/json" \
-d '{
"messageType": "REQUEST",
"state": "received",
"requestRef": "POR-2026-000123",
"directionRole": "GAINING_PA",
"taxpayerSiren": "921376265",
"taxpayerSiret": "92137626500017",
"losingPaName": "ESKER",
"effectiveDate": "2026-09-01",
"mandateRef": "MDT-2026-8891"
}'
[/code]
You get back the log id, the subject, the CSV as header plus row and its hash, who it was addressed to and how that address was found, and whether it actually left:
[code]
{
"id": "pmsg_9f2c7a1d4b8e4c0f9a6d3e2b1c7f5a80",
"subject": "[PORTABILITE][REQUEST][REQ][SIREN:921376265][REF:POR-2026-000123]",
"messageType": "REQUEST",
"statusCode": "REQ",
"state": "received",
"filename": "POR-2026-000123-REQ.csv",
"csvHeader": "request_ref;message_type;…",
"csvRow": "POR-2026-000123;REQUEST;REQ;…",
"csvSha256": "6b1f…",
"to": "contact-pdp@esker.com",
"recipientSource": "registry:ESKER",
"dispatched": true,
"reason": "sent",
"smtpMessageId": "<176…@flowie.fr>",
"createdAt": "2026-09-01T08:14:02.114000+00:00"
}
[/code]
You do not need to know the other platform’s email address
Name the counterparty — `losingPaName` when you are the gaining platform, `gainingPaName` when you are the losing one — and it is resolved against the [registry of registered Plateformes Agréées](<#platforms>): the dedicated portability inbox the platform published if it has one, its DGFiP _courriel de contact_ otherwise. `recipientSource` tells you which happened (`registry:`, `explicit` when you passed `to` yourself, or `unresolved` when the name matched nothing). Passing `to` always wins.
The message is recorded whether or not it is emailed
Sending is gated by a kill-switch and by the SMTP configuration, and a sandbox key never reaches a real platform. When the mail does not go out, `dispatched` is `false` and `reason` says why — `dispatch_disabled`, `not_configured` (no relay or no sender), `no_recipient` (nothing to address it to), `sandbox`, or the SMTP error itself. The row in the message log is written either way, so a dry environment produces the same audit trail minus the email, and a relay outage leaves you with the exact message to re-send rather than a gap. A non-production deployment can also set a recipient override: the message still resolves the real counterparty and records it, but it is delivered to the override address, so testing never emails a real platform.
How it leaves
The message goes out through Flowie’s own mail path, so it carries the platform’s sender identity and delivery handling rather than a relay only this service knows about; the counterparty replies to the address in `PORTABILITY_CHANNEL_FROM`, which the body states. That path attaches files by reference, so the CSV travels **inline in the body** — byte-identical to the `csvRow` you get back and to what was hashed. If you need the CSV as a real `.csv` file, pin the direct-relay transport (`PORTABILITY_TRANSPORT=smtp`) and it is attached instead. `transport` on the response and in the log says which one carried it.
The four `messageType` values map to the steps of the exchange — `REQUEST`, `ACK`, `DECISION`, `COMPLETION` — while `state` is your internal state and is translated to the wire status code for you (see [Request states](<#states>)). `directionRole` says who is speaking: `GAINING_PA` or `LOSING_PA`.
The subject grammar is strict and positional:
[code]
[PORTABILITE][][][SIREN:<9 digits>][REF:]
[/code]
A SIREN that is not exactly nine digits, an unknown message type, or a `requestRef` containing `]` is rejected with `400` before anything is built.
## 4 · Parse an inbound message
The other half: turn a message you received back into structured fields. Pass the subject, and the CSV row when you have it.
[code]
curl -X POST …/v1/portability/messages/parse \
-H "Authorization: Bearer $FLOWIE_KEY" \
-H "Content-Type: application/json" \
-d '{
"subject": "[PORTABILITE][DECISION][ACC][SIREN:921376265][REF:POR-2026-000123]",
"csvRow": "POR-2026-000123;DECISION;ACC;LOSING_PA;921376265;…"
}'
[/code]
The response gives you the message type, the wire status code, the internal `state` it maps back to, the SIREN, the request reference, and — when a row was supplied — the 18 parsed columns as `fields`.
**A subject that does not match the grammar returns`400`: dead-letter it, do not open a request from it.** That is the whole point of a normalised subject — anything that fails to parse is not a portability message, and guessing at its intent is how you end up porting the wrong taxpayer. A CSV row with anything other than 18 columns is rejected the same way.
## 5 · Switch the routing at the date d'effet
Agreeing a port changes nothing by itself. What decides where an invoice goes is the taxpayer’s e-invoicing address on the compliance backend — that is what the directory ends up routing on — and `POST /v1/portability/routing` is what moves it:
[code]
curl -X POST …/v1/portability/routing \
-H "Authorization: Bearer $FLOWIE_KEY" \
-H "Content-Type: application/json" \
-d '{
"organizationId": "019c76b2-9c94-7000-8cb6-ef104afb6093",
"siren": "921376265",
"siret": "92137626500018",
"effectiveDate": "2026-10-01",
"role": "GAINING_PA"
}'
[/code]
[code]
{
"organizationId": "019c76b2-9c94-7000-8cb6-ef104afb6093",
"connectionId": "conn_7Yb3…",
"role": "GAINING_PA",
"siren": "921376265",
"effectiveDate": "2026-10-01",
"serviceUntil": null,
"created": true,
"address": { "id": "addr_2Kd9…", "siren": "921376265", "active": true }
}
[/code]
The date is the point. As the **gaining** platform you declare the address with `validFrom` = the date d'effet, so a port agreed in August for 1 October does not start pulling invoices in August. As the **losing** platform (`role: "LOSING_PA"`) nothing is deleted: emission stops on the date d'effet while reception stays open until `effectiveDate + 12 months` — the minimal service LFI 2026 requires of the outgoing platform, so flows already in flight still resolve. Override the window with `minimalServiceMonths` when a contract promises longer.
It is idempotent on the SIREN: an address already declared for it is updated, never duplicated. If the organization has more than one connection you must name it with `connectionId` — choosing one for you is how a port lands on the wrong company. And when you are onboarding the taxpayer in the same move, `POST /v1/companies/import` now takes the same `effectiveDate` and carries it onto the address it creates.
## The PA address book
A port request is only as sendable as your knowledge of where the other platform reads its mail. `GET /v1/portability/platforms` is that address book — every operator the DGFiP has registered, with the address a portability message should actually go to:
[code]
curl "…/v1/portability/platforms?q=esker" \
-H "Authorization: Bearer $FLOWIE_KEY"
[/code]
[code]
{
"data": [
{
"name": "ESKER",
"website": "https://www.esker.fr/",
"email": "info@esker.fr",
"portabilityEmail": "contact-pdp@esker.com",
"contactEmail": "contact-pdp@esker.com",
"registeredOn": "2025-10-14",
"status": "registered"
}
],
"total": 1,
"source": "https://www.impots.gouv.fr/je-consulte-la-liste-des-plateformes-agreees",
"snapshotDate": "2026-08-20"
}
[/code]
It merges the two official DGFiP lists — operators meeting every condition (`status: "registered"`) and those still awaiting the interoperability tests (`"pending_interop"`) — with the dedicated portability inboxes platforms circulated among themselves. `contactEmail` is the one that matters: the dedicated address when there is one, the generic DGFiP contact otherwise. Filter with `q` (name, email or website) and `status`.
The list moves every week as platforms are registered, so the payload carries its own `snapshotDate` and the `source` it was built from. If you need the authoritative list at this instant, that source is it.
## Request states
A portability request moves through eight states. Each maps to a short wire code carried in the subject and in the CSV, so a counterparty can machine-route on it:
State| Wire code| What it means
---|---|---
`received`| `REQ`| Request created or inbound message parsed. Starts the 24 h and 5-day clocks.
`acknowledged`| `ACK`| Receipt confirmed inside 24 h. The first SLA is met.
`accepted`| `ACC`| Decided in favour of the port, inside the 5-day window.
`rejected`| `REJ`| Refused. A reason is expected — fill `reasonCode` and `reasonText`.
`auto_accepted`| `TAC`| Tacit agreement: the deadline passed with no decision, so _silence vaut accord_.
`executing`| `MIG`| Effective date reached; the annuaire switch is in progress.
`completed`| `CMP`| The annuaire confirms the new platform is active.
`failed`| `ERR`| Transport or annuaire error.
Send the internal state in `state`; you never write the wire code yourself. On the way back in, `parse` resolves the code to the state for you. An unknown state is a `400`, not a silent pass-through.
The two decision states differ in who fires them. `accepted` and `rejected` are a deliberate act inside the window; `auto_accepted` is what happens _to_ the silent party when the window closes. If you are the losing platform, `TAC` arriving on your request is the signal that you missed the deadline.
## Tracking a request
Company onboarding writes to the event log, which is where you follow a migration today:
[code]
curl "…/v1/events?type=company.imported&limit=100" \
-H "Authorization: Bearer $FLOWIE_KEY"
[/code]
Two event types are written by the import path: `company.imported` when the registration is active, and `company.import.pending` when the company landed local-only and still needs an ops link. Each carries the company id, the Peppol id, the SIRET, the SIREN and the country, which is enough to reconcile a bulk migration row by row. The listing is cursor-paginated — keep passing the returned `cursor` until `hasMore` is `false`.
These two are event-log entries, not webhook events
They are readable through [`GET /v1/events`](<../reference/index.html#list-events>) but they are not in the webhook catalogue, so subscribing a webhook to `company.imported` will not deliver anything. Poll the event log for now; the [webhook reference](<../reference/webhooks.html#events>) lists what does get pushed.
Every message you send is logged, and the log is queryable:
[code]
curl "…/v1/portability/messages?requestRef=POR-2026-000123" \
-H "Authorization: Bearer $FLOWIE_KEY"
[/code]
`GET /v1/portability/messages` lists them newest first, scoped to your organization, filterable by `requestRef`, `siren`, `state`, `messageType` and `dispatched` — so one reference replays a whole exchange, and `dispatched=false` finds the messages that never left and need re-sending. It is cursor-paginated like the rest of the API and returns a real `total`.
`GET /v1/portability/messages/{id}` returns the proof bundle for one message: the exact CSV row that left with its `csvSha256`, the recipient and how it was resolved, the SMTP message id, and `annuaire` — what the PPF _annuaire_ answered for that taxpayer at the moment we sent. The annuaire itself is read-only to us: a port is not written into it, it is written into the routing address ([step 5](<#execute>)) and propagates from there. The snapshot is what lets you prove the before and check the after — compare it with what the annuaire says once propagation has happened, which is a call of its own:
[code]
curl "…/v1/portability/annuaire/921376265" -H "Authorization: Bearer $FLOWIE_KEY"
[/code]
`GET /v1/portability/annuaire/{siren}` answers with the line that decides where this taxpayer's invoices go: `currentPaMatricule` (the platform routing it — `9998` is the PPF default, meaning nobody has been declared and there may be nothing to port), `effectiveFrom`, an `effectiveTo` when a departure is already scheduled, and `isFlowie` once the switch has propagated to us. Read it before a port to know who you are porting away from, and after to know whether it landed.
There is still no server-side SLA timer
Nothing fires an acknowledgement for you and nothing flips a request to _silence vaut accord_ when the fifth business day passes. The message log gives you the timestamps to prove the delays; the clocks themselves are still yours to run.
## The 18-field CSV
One header row and one data row, semicolon-delimited, in this exact order:
#| Column| Notes
---|---|---
1| `request_ref`| Your stable reference for the request.
2| `message_type`| REQUEST / ACK / DECISION / COMPLETION.
3| `status_code`| The wire code from the table above.
4| `direction_role`| GAINING_PA or LOSING_PA.
5| `taxpayer_siren`| Nine digits.
6| `taxpayer_siret`| Fourteen digits.
7| `taxpayer_name`| Legal name.
8| `gaining_pa_id`| Operator code or SIREN.
9| `gaining_pa_name`|
10| `losing_pa_id`|
11| `losing_pa_name`|
12| `effective_date`| _Date d'effet_ , ISO-8601.
13| `transferred_addresses`| Routing ids, pipe-joined. Send a list, get a list back.
14| `mandate_ref`| The _mandat de désignation_.
15| `mandate_signatory`| Legal representative.
16| `request_datetime`| ISO-8601 with an explicit timezone — the proof of delay. Defaults to now (UTC).
17| `reason_code`| Expected on a rejection.
18| `reason_text`|
Only `transferred_addresses` repeats, and it uses `|` inside the cell so it never collides with the delimiter. Missing values are written as empty strings, never omitted — the column count is what the parser validates.
## What is still provisional
The wire format is a working model, not a certified one
The subject grammar, the status codes and the CSV column set are Flowie's reading of the process pending publication of the AIFE annex of 15/07, which is not yet publicly indexed. They are centralised in a single module precisely so that reconciliation is one well-tested edit rather than a scattered migration.
Build against them — that is what they are for — but treat the exact strings as subject to change, keep your own `requestRef` as the key you join on, and re-read the [changelog](<../changelog.html>) before you go live. What will not change is the shape: a normalised subject, a codified status, eighteen columns, and clocks that start the moment a request lands.
## Next
* [Import a company](<../reference/index.html#import-company>) — full parameter list and responses.
* [Portability endpoints](<../reference/index.html#portability>) — send, follow and parse, in the API reference.
* [France compliance](<../compliance/fr/index.html>) — the wider PPF and PA picture.
* [Events](<../reference/index.html#list-events>) — the log you poll to follow a migration.
========================================================================
# Changing platform in Europe — country by country
# Source: https://docs.get-flowie.com/guides/portability-europe.html
========================================================================
---
title: "Changing platform in Europe"
description: "What changing e-invoicing platform takes in every European country: whether your routing address changes, what you must re-grant at the tax authority, who holds the archive, and how to request a migration without touching the API."
canonical: "https://docs.get-flowie.com/guides/portability-europe"
source: "https://docs.get-flowie.com/guides/portability-europe.html"
---
# Changing platform in Europe
Guides
# Changing platform in Europe
Whether you can leave your current e-invoicing provider, what it takes, and who has to do what — country by country. **[France has a regulated hand-over]()** ; everywhere else, switching is three unrelated jobs that happen to fall in the same week.
You do not need the API to start
Use the [migration request form](<#request>) below. It runs in your browser, needs no account and no API key, and hands us a complete request — identifiers, current platform, target date — by e-mail or clipboard. The endpoints on this page are what _we_ then run on your behalf.
## The five layers a switch touches
“Changing provider” sounds like one operation. It is five, owned by five different parties, and they fail independently.
Layer| What it is| Who changes it| Portable?
---|---|---|---
**Identifier**| VAT number, SIREN/SIRET, NIP, Peppol participant ID| Nobody — it is yours| Always. Identifiers never change when you switch.
**Routing address**| Peppol SMP entry, the French annuaire, the Italian _codice destinatario_| Your platform or access point| Usually — but the _value_ can change (Italy)
**Authorisation**| _delega_ , technical user, KSeF certificate, SPV authorised user| You, at the tax authority| Never transferred — always re-granted
**Data & archives**| Original XML, lifecycle statuses, the legal archive| Your outgoing provider| The real fight. Regulated only in France
**Contract**| Notice period, exit fees, export format| Both parties| Capped by the [EU Data Act](<#eu-right>)
## What it takes, per country
The pattern that decides everything: **where the archive lives**. In centralised-clearance countries the state holds your invoices, so leaving is cheap. In decentralised countries your outgoing provider holds them, so leaving is expensive.
Country| Does your address change?| What you must re-grant| Who holds the archive| Effort
---|---|---|---|---
🇫🇷 **France** · PPF| No — SIREN/SIRET addressing is kept; the annuaire is re-pointed| A signed designation agreement (_accord formel_)| You / your platform — with a 1-year continuity duty| Regulated
🇮🇹 **Italy** · SDI| **Yes** — the _codice destinatario_ belongs to the intermediary| _Delega_ to the new intermediario; ideally register your _indirizzo telematico_| You (_conservazione_ , 10 years, signed packages)| Hard
🇵🇱 **Poland** · KSeF| No address exists — buyers pull from KSeF| A KSeF certificate for the new provider; revoke the old| The state, 10 years| Easy
🇷🇴 **Romania** · e-Factura| No — everything goes through the SPV| An authorised user holding a qualified certificate| ANAF holds the cleared invoices| Easy
🇭🇺 **Hungary** · NAV| No — reporting only, no routing| A technical user for the new software| You (reporting regime)| Easy
🇬🇷 **Greece** · myDATA| No — you declare a transmission channel| The channel declaration + provider credentials| You, with myDATA as the reported record| Medium
🇪🇸 **Spain** · Crea y Crece| No — private platforms must interoperate| Provider onboarding (rules land with the mandate)| You + the _copia fiel_ at AEAT| Medium
🇵🇹 **Portugal**| n/a — but **document series are bound to certified software**| New series registered under the new software's certificate| You (SAF-T PT is your export)| Hard
🇭🇷 **Croatia** · Fiskalizacija 2.0| Yes — the state directory names your provider (“AMS”)| The directory entry| You + the reported record| Medium
🇹🇷 **Türkiye** · GİB| Yes — via your _özel entegratör_| An activation form signed with your e-seal| Your integrator| Ask us
🇧🇪 🇳🇱 🇩🇰 🇸🇪 🇳🇴 🇫🇮 and the rest of **Peppol Europe**| No — the participant ID stays; the SMP entry moves| Nothing at a tax authority| You| Easy
🇩🇪 **Germany**| No platform layer to leave (Factur-X by e-mail or Peppol)| Nothing| You (GoBD, 8 years)| Easy
**Effort** is about the switch, not about us: “Hard” means there are counterparties to notify or an archive to move, not that we cannot do it.
## Ask us to migrate you
One field. Type the identifier you already know — a SIRET, a VAT number, or just the company name — and everything else is resolved from our records and the registries we already query: legal name, country, SIREN/SIRET, Peppol id, the annuaire addressing line, and what your country requires. You correct anything that is wrong; you type nothing that we can look up.
Migration request
Your SIRET, VAT number, or company name Your e-mail Fill this in for me
No account and no API key: the page mints a throwaway sandbox key for the lookup and forgets it when you close the tab. Resolution runs against `POST /v1/portability/resolve`.
**Correct something, or fill it in by hand** — only if the lookup got it wrong or found nothing.
Country 🇫🇷 France — PPF 🇮🇹 Italy — SDI 🇵🇱 Poland — KSeF 🇷🇴 Romania — e-Factura 🇭🇺 Hungary — NAV 🇬🇷 Greece — myDATA 🇪🇸 Spain — Crea y Crece 🇵🇹 Portugal — AT 🇭🇷 Croatia — Fiskalizacija 2.0 🇹🇷 Türkiye — GİB Peppol country (BE, NL, DK, SE, NO, FI, DE…) Legal name Platform you are leaving Who signs, for the company Effective date
[Send this request]() Copy as text
The lookup is the only thing that leaves your browser, and only when you ask for it. The button opens your own mail client with the request filled in. We reply within one business day, and run the technical part for you.
**Already a customer?** The same resolution runs against your own records rather than the sandbox, so a signed-in company confirms one screen and types nothing at all.
### What we do once you send it
1. **We check who holds you today** in the relevant registry — the PPF annuaire, the Peppol SMP, or the national directory.
2. **We draw up the designation agreement** your country needs, numbered and dated, and start the evidence chain described below.
3. **We import your companies** — one, or hundreds in a single batch — with one result row per company, so nothing is silently skipped.
4. **We sequence the cut-over** so your old access is never closed before the new one resolves. That ordering is the single most common cause of lost invoices.
## Why the platform you are leaving cannot just say no
A switch that can be stalled is not a right. In France the decree closes both escapes — the refusal and the silence — and our job is to make the record that proves it.
### The grounds for an objection are narrow, and we classify them
Under **CGI ann. II art. 242 nonies E ter** the outgoing platform may object only on grounds that call your _intent_ to switch into question. Three do:
Ground| What it claims| Admissible
---|---|---
`more_recent_agreement`| A later designation agreement exists| Yes
`identity_mismatch`| The taxpayer named is not the one they hold| Yes
`mandate_invalid`| The agreement is unsigned, undated or unnumbered| Yes
An unexpired contract · unpaid invoices · a notice period · “commercial reasons”| Nothing about your intent| No — the port continues
An objection is recorded either way, verbatim. What changes is the verdict: an inadmissible ground is stored with `admissible: false` and the request keeps running, carrying the digest of your signed agreement as the answer to it. And silence is not a veto: once the five-business-day window lapses with no admissible objection, the request moves to `auto_accepted` (`TAC` on the wire) by itself — _le silence vaut accord_.
### The proof: a hash-linked chain, not a mailbox
Every step appends an entry carrying the SHA-256 of its own payload plus the digest of the entry before it. Edit a payload, retime a step, drop one, or reorder two, and verification fails _and names the entry_. That is what turns “we sent it on the 3rd” into something an administration can check.
[code]
{
"seq": 2,
"kind": "portability.request.notified",
"at": "2026-09-03T09:12:00+00:00",
"payloadSha256": "9f2c…",
"prevSha256": "41ab…",
"sha256": "7d10…"
}
[/code]
What we sign and keep, because the decree asks for it: the taxpayer, the incoming platform, the previous one, the effective date, the scope of electronic addresses, the signatory — numbered, retained, and produced to the administration on demand. A request opened without a signatory does not fail; it reports the gap in `mandate.gaps`, because that gap is exactly what an outgoing platform is entitled to object to.
## Run it from an agent, end to end
Four calls, no human judgement in between, so an agent asked to “move this company to Flowie” can carry the whole procedure. The `Portability` tools are on the curated MCP server at `/exchange/mcp` — see [Build with AI](<../build-with-ai/index.html#mcp>).
Step| Call| What it does
---|---|---
1| `POST /v1/portability/resolve`| One identifier in; identity, regime and requirements out
2| `POST /v1/portability/requests`| Opens the request: agreement number, computed clocks, first evidence entry
3| `POST /v1/portability/requests/{ref}/events`| Records a step: `notified`, `objection`, `acceptance`, `annuaire_updated`
4| `GET /v1/portability/requests/{ref}`| State re-derived from the chain, with `tacitApproval` and the verification result
[code]
# 1 · everything from one identifier
curl -X POST …/v1/portability/resolve \
-H "Authorization: Bearer $FLOWIE_KEY" -H "Content-Type: application/json" \
-d '{"taxpayer": "92137626500017"}'
# 2 · open the request — nothing else is required
curl -X POST …/v1/portability/requests \
-H "Authorization: Bearer $FLOWIE_KEY" -H "Content-Type: application/json" \
-d '{"taxpayer": "92137626500017", "signatory": "Camille Roy, Directrice Générale"}'
# 3 · record the D+2 notice to the outgoing platform
curl -X POST …/v1/portability/requests/POR-2026-4F2A91C08B7D/events \
-H "Authorization: Bearer $FLOWIE_KEY" -H "Content-Type: application/json" \
-d '{"kind": "notified", "channelRef": "msg-2026-09-03-001"}'
# 4 · where does it stand, and is the proof intact?
curl …/v1/portability/requests/POR-2026-4F2A91C08B7D \
-H "Authorization: Bearer $FLOWIE_KEY"
[/code]
State is never stored, always folded from the evidence chain, so step 4 is the single source of truth — and an agent that was not running when the request was opened reaches the same answer as one that was. The whole history is also readable as ordinary events (`portability.request.*`) over `GET /v1/events`.
Outside France the same four calls apply, with less law behind them
The clocks and the objection rules are French. Elsewhere the chain still gives you a timestamped record of what you asked for and when — which is what the [Data Act](<#eu-right>) switching right is argued with.
## Peppol countries: the participant ID stays, the SMP entry moves
In every Peppol country the switch is a registry edit, not a re-addressing. Your participant ID does not change, document-type and process registrations are re-created identically, and only the endpoint and its transport certificate point somewhere new.
The specification provides a **migration key** for this: your outgoing provider generates it, the new provider presents the same key to the SML, and the registration moves. In practice many providers never expose that key, so the real-world sequence degrades to _deregister, then re-register_ — which opens a window where invoices can be misrouted, delivered twice, or lost.
Ask for the migration key in writing before you sign anything
And never terminate the old access point before a lookup shows the new one resolving. Propagation is usually hours, but it can take days with a complex integration.
## France: the only regulated hand-over
France is the exception. A change of _plateforme agréée_ follows a procedure fixed by decree, with deadlines on both platforms and a continuity obligation on the one you leave. The taxpayer keeps its SIREN/SIRET addressing throughout; what changes is the annuaire entry, and only a platform can write to it.
**[Read the France portability guide →]()** for the designation agreement, the day-by-day timetable, the eight request states and the inter-PA message format.
## Clearance and intermediary countries, one by one
### 🇮🇹 Italy — the address changes, and that is the problem
Your _codice destinatario_ is the intermediary's channel code, so switching changes it and every supplier holding the old one must be told. The mitigation is to register your _indirizzo telematico_ in _Fatture e Corrispettivi_ , so SdI routes to the registered channel. The _delega_ to an _intermediario_ runs for four years unless you set a shorter term, does not auto-renew, and is revocable at any time in the same form it was granted. Your _conservazione sostitutiva_ obligation is ten years, with signed and time-stamped packages, and it applies independently to what you issue and what you receive.
### 🇵🇱 Poland — the state is the archive, so switching is cheap
KSeF stores every invoice for ten years from the end of its year of issue and is the official record, so there is no archive to move and no address to propagate: your buyers pull from KSeF. What you port is credentials. KSeF certificates are live from February 2026; tokens work until the end of 2026 and are replaced by certificate-only access from 1 January 2027. Grant the new provider its own credential, then revoke the old one.
### 🇷🇴 Romania — authorised users, not addresses
e-Factura runs through the SPV, and a provider acts under an authorised user holding a qualified certificate. Switching means registering the new provider's certificate holder and revoking the outgoing one's rights. ANAF holds the cleared invoices.
### 🇭🇺 Hungary — a technical user per software
NAV Online Számla is a reporting regime: no routing to move, no counterparties to notify. Your primary user creates a technical user for the new software and you delete the old keys.
### 🇬🇷 Greece — the transmission channel is a declared choice
myDATA accepts a direct ERP integration, an accredited provider, or the free _Timologio_ app. Switching provider means re-declaring the channel and re-issuing credentials. The B2B mandate lands on 2 March 2026 for turnover above €1M and 1 October 2026 for everyone else, each with a transition period.
### 🇪🇸 Spain — interoperability is mandated, a switch procedure is not
_Crea y Crece_ is a four-corner model: invoices travel through compliant private platforms or the public AEAT solution, and private platforms also submit a _copia fiel_. Mandated interoperability is a strong indirect guarantee — your counterparties stay reachable whoever you pick — but there is no regulated hand-over. Large companies are in scope from 1 October 2027, everyone else from 1 October 2028.
### 🇵🇹 Portugal — the lock-in is the software certificate
Invoices must come from AT-certified software and carry its certification number, the ATCUD and a QR code. A document series is registered under the software that created it, so switching means opening **new series** under the new software's certificate — a series does not follow you. SAF-T (PT) is your export on the way out.
### 🇭🇷 Croatia — a state directory of approved providers
Fiskalizacija 2.0 went live on 1 January 2026 with a state directory of taxpayers and approved providers (“AMS”) alongside the FiskApplication portal. Switching is a directory re-pointing — the closest structural analogue to the French annuaire outside France.
### 🇹🇷 Türkiye — the biggest market, the least public procedure
Around 1.49 million taxpayers file through a private integrator (_özel entegratör_) against roughly 76,000 on the GİB portal, so switching integrator is a mass-market event — but no integrator-to-integrator procedure is published. Talk to us before you give notice.
## Your EU right to switch
Outside France there is no tax rule that governs leaving a provider. There _is_ a horizontal one, and it is stronger than most contracts: the **EU Data Act** , whose switching provisions have applied since 12 September 2025 to data-processing services — e-invoicing platforms included.
* Providers must remove commercial, technical, contractual and organisational **obstacles to switching**.
* **Two months' notice** is the maximum they can require of you.
* The switch must complete within **30 calendar days** , extendable only where it is technically unfeasible.
* Absent an applicable standard, you must be able to export **all your data in a structured, commonly used, machine-readable format**.
* It binds **existing contracts** , fixed-term ones included, and reaches non-EU providers serving EU customers.
Looking further out, **ViDA** makes EN 16931 mandatory for intra-EU invoicing from 1 July 2030 and aligns domestic reporting by 1 January 2035. That shrinks the format half of switching cost — but a common format is not portability: it says nothing about who holds your archive or who is registered as your platform.
## Who holds the archive holds the customer
Country| Retention| What it means when you leave
---|---|---
🇵🇱 Poland| 10 years, in KSeF| Nothing to move — the state is the record
🇮🇹 Italy| 10 years, _conservazione_| Signed, time-stamped packages have to be handed over
🇩🇪 Germany| 8 years (§14b UStG)| GoBD adds original format, immutability, machine-readability
🇫🇷 France| 10 years for commercial records| Plus a 1-year continuity duty on the platform you leave
🇨🇭 Switzerland · 🇦🇹 Austria · 🇬🇧 UK| 10 · 7 · 6 years| Contractual export, no regulated hand-over
## What to demand from the provider you are leaving
Ask before you sign the exit, not after. In France most of this is owed to you; elsewhere the Data Act is your lever.
1. **Original XML** invoices, probative value intact.
2. **Human-readable renditions** (PDF or Factur-X).
3. **Lifecycle statuses** — the full history, in CSV, JSON or XML.
4. **Counterparty lists** with their addresses and routing codes.
5. **Attachments** in their original formats.
6. **Accounting entries** (in France, the FEC).
7. **Technical logs** , or a signed attestation covering them.
8. The **Peppol migration key** , in writing, where Peppol applies.
9. **Registry evidence** that the old entry is deactivated and the new one resolves.
10. A **credential revocation plan** sequenced _after_ the new provider is authorised.
11. **In-flight reconciliation** : documents submitted but not yet acknowledged at cut-over.
12. The **conservation packages** with their signature and timestamp metadata, where the archive stays behind.
## What we could not confirm
Said plainly, because a switch planned on a guess is a switch that slips:
* The **Türkiye** integrator-to-integrator procedure is not published anywhere we could find.
* Whether **Greece** restricts myDATA channel changes _within_ a tax year — the one rule that would block a mid-year switch.
* Whether the registered Italian _indirizzo telematico_ overrides an invoice-level _codice destinatario_ unconditionally.
* The **Croatian** AMS directory re-pointing procedure and its deadlines.
* Peppol **in-flight document** handling and rollback during a migration.
Where a country appears above, we verify it live with the registry before we quote you a date.
========================================================================
# Platform onboarding kit
# Source: https://docs.get-flowie.com/guides/onboarding-kit.html
========================================================================
---
title: "Platform Onboarding Kit"
description: "A complete walkthrough for platform builders: onboard one tenant, then a hundred. Scoped keys, branding, webhooks, billing."
canonical: "https://docs.get-flowie.com/guides/onboarding-kit"
source: "https://docs.get-flowie.com/guides/onboarding-kit.html"
---
# Platform Onboarding Kit
Platform Onboarding Kit
# Build your own e-invoicing product on top of Flowie
This is the playbook accounting SaaS, ERPs, and public-sector aggregators follow when they integrate Flowie under their own brand. By the end you'll have a working tenant onboarding flow, scoped credentials, branded UX, signed webhooks, and a path to support thousands of customers.
Mental model in one sentence
You hold a **platform key**. For each customer of yours (a "tenant"), you onboard one **managed company**. You then either keep acting on their behalf with `X-Flowie-Company`, or hand them a **scoped tenant key** for direct integration.
## Mental model
Concept| What it means
---|---
**Platform organization**| Your Flowie account. Holds platform keys, branding, webhook fan-out config.
**Managed company**| One Peppol-registered legal entity belonging to a tenant. _One per tenant per VAT_.
**Platform key**| `flw_plat_live_…` or `flw_wl_live_…` — your master credential. Never expose to tenants.
**Tenant key**| Per-managed-company personal key (`flw_live_…`). Optional — only issue if the tenant integrates Flowie directly.
**X-Flowie-Company**| Header you set with a platform key to act on a specific tenant.
## Prerequisites
* A Flowie organization with **Platform** or **White-label** entitlement (request via [sales]()).
* Test API credentials. The dashboard's **Settings → API keys → Platform key** screen issues them.
* An HTTPS endpoint that can receive webhooks (your dev tunnel is fine for now).
1. ### Get a platform key
[code] curl -X POST https://back.flowie.ink/exchange/v1/api-keys \
-H "Authorization: Bearer $FLOWIE_DASHBOARD_JWT" \
-d '{"name":"my-platform","scopes":["platform","*"],"keyType":"platform"}'
[/code]
You'll get back something like:
[code] {
"id": "key_01HXY",
"key": "flw_plat_test_AbC123…",
"keyPrefix": "flw_plat_test_AbC",
"scopes": ["*"],
"createdAt": "2026-04-25T10:00:00Z"
}
[/code]
Persist the `key` string in your secret manager. You won't see it again.
2. ### Onboard your first tenant
One call does everything atomically: registers the company, publishes it to Peppol SMP, opens a tenant-scoped webhook, and (optionally) mints a tenant key.
[code] curl -X POST https://back.flowie.ink/exchange/v1/platform/companies \
-H "Authorization: Bearer $PLATFORM_KEY" \
-H "Idempotency-Key: tenant-acme-init" \
-H "Content-Type: application/json" \
-d '{
"vatNumber": "FR86797978996",
"name": "ACME France SARL",
"metadata": { "tenantId": "t_acme", "tier": "premium" },
"webhook": {
"url": "https://yourplatform.com/hooks/flowie?tenant=t_acme",
"events": ["document.received","document.delivered","document.failed",
"lifecycle.updated","compliance.reported.failed"]
},
"apiKey": {
"name": "tenant-acme",
"scopes": ["send","receive","documents.read","documents.write","lifecycle"]
}
}'
[/code]
Response:
[code] {
"company": {
"id": "comp_01HY7…",
"peppolId": "0009:FR86797978996",
"vatNumber": "FR86797978996",
"name": "ACME France SARL",
"country": "FR",
"status": "active",
"smpRegistered": false,
"metadata": { "tenantId": "t_acme", "tier": "premium" },
"createdAt": "2026-04-25T10:00:00Z"
},
"apiKey": {
"id": "key_01HY7…",
"key": "flw_test_tacme_xyz123…",
"keyPrefix": "flw_test_tacme",
"name": "tenant-acme"
},
"webhook": {
"id": "wh_01HY7…",
"url": "https://yourplatform.com/hooks/flowie?tenant=t_acme",
"status": "active"
}
}
[/code]
Idempotent by design
Reusing `Idempotency-Key` within 24h returns the same response. If your retry is from a different deploy and the original key has expired, you'll get a `409 COMPANY_EXISTS` with the existing `companyId` — treat it as success.
SMP registration is async. Listen for the `company.smp_registered` event on the platform-level webhook (or poll `GET /companies/{id}`) to know when the tenant can send/receive.
3. ### Choose a key strategy
Pattern| When| Trade-off
---|---|---
**Platform-only** (`X-Flowie-Company`) | Your stack does everything; tenants never touch the API. | One secret to manage · platform key compromise = all tenants.
**Per-tenant key** | Tenants integrate directly (e.g. via your SDK) or you want hard isolation. | Blast radius limited to one tenant · you must manage rotation & storage.
**Hybrid** | Most platforms. Use the platform key from your backend; issue tenant keys only on request. | Best of both, slightly more code.
Acting on behalf of a tenant from your backend looks like this:
[code] curl -X POST https://back.flowie.ink/exchange/v1/documents/send \
-H "Authorization: Bearer $PLATFORM_KEY" \
-H "X-Flowie-Company: comp_01HY7…" \
-H "Idempotency-Key: t_acme-inv-001" \
-H "Content-Type: application/json" \
-d @invoice.json
[/code]
Without the header, the call would error `403 COMPANY_REQUIRED` — platform keys must always specify whom they're acting for.
4. ### Wire up webhooks
You have two options. Pick the one that matches how you want to fan out events:
1. **Platform-level webhook.** One endpoint receives events from all tenants. Each event includes `data.company.id` and the tenant's `metadata` so you can route. Easier to operate.
2. **Per-tenant webhook** (created during onboarding above). One endpoint per tenant. Heavier, but gives you per-tenant retry isolation.
Either way, the receiver pattern is the same — verify HMAC, ack fast, queue work:
[code] @app.post("/hooks/flowie")
async def flowie_hook(req: Request, tenant: str | None = None):
raw = await req.body()
verify_hmac(req.headers["X-Flowie-Signature"], raw,
secret=lookup_webhook_secret(tenant))
event = json.loads(raw)
queue.enqueue("process_flowie_event", tenant=tenant, event=event)
return Response(status_code=204)
[/code]
Full verification recipe in the [webhook cookbook](<../reference/webhooks.html#signing>); payload fixtures in [/fixtures](<../fixtures/index.html>).
5. ### Brand the experience (white-label)
If you have a white-label entitlement, you can replace Flowie's branding everywhere your tenants see it:
[code] curl -X PATCH https://back.flowie.ink/exchange/v1/platform/settings \
-H "Authorization: Bearer $PLATFORM_KEY" \
-d '{
"branding": {
"displayName": "ACME e-Invoice",
"logoUrl": "https://acme.com/logo.svg",
"primaryColor":"#0F62FE",
"supportEmail":"support@acme.com"
},
"customDomain": "peppol.acme.com",
"defaults": {
"preferredFormat": "ubl-xml",
"autoCompliance": { "FR": true, "IT": true, "BE": true }
}
}'
[/code]
The `customDomain` field provisions TLS automatically (Let's Encrypt). DNS records to point at us are returned in the response.
6. ### Send on behalf of a tenant
Same call as a single-tenant integration, plus the `X-Flowie-Company` header. Pull the company id from your tenant table by tenantId:
[code] def send_invoice(tenant_id: str, invoice: dict) -> dict:
company_id = db.get("flowie_company_id", tenant_id=tenant_id)
return platform_api.post(
"/documents/send",
headers={
"X-Flowie-Company": company_id,
"Idempotency-Key": f"{tenant_id}-{invoice['id']}",
},
json={
"type": "invoice",
"from": company_id,
"to": invoice["recipientPeppolId"],
"document": invoice["body"],
},
).json()
[/code]
## Scale to 100+ tenants
The onboarding API is designed for batch use. Common patterns:
* **Backfill from your existing customer table.** Iterate, call `POST /platform/companies` with an idempotency key per customer. Failures are isolated; safe to retry.
* **Just-in-time onboarding.** Onboard the first time a tenant tries to send. Hide the latency behind a "preparing your workspace" loading state — typically < 5 seconds.
* **Bulk send.** Use [`POST /v1/documents/send/batch`](<../reference/index.html#send-batch>) for nightly jobs. Up to 100 documents per call, all atomic per item.
Concrete script for backfill:
[code]
for tenant in db.tenants(active=True):
try:
api.post("/platform/companies",
headers={"Idempotency-Key": f"backfill-{tenant.id}"},
json={
"vatNumber": tenant.vat,
"name": tenant.name,
"metadata": {"tenantId": tenant.id},
},
timeout=30,
)
except httpx.HTTPStatusError as e:
log.error("onboard_failed", tenant=tenant.id,
status=e.response.status_code, body=e.response.text)
continue
[/code]
## Billing & chargebacks
Flowie bills the platform organization monthly, by document volume. Use [`GET /v1/platform/usage`](<../reference/index.html#platform-usage>) to break down per tenant for chargebacks:
[code]
curl "https://back.p2p-flowie.com/exchange/v1/platform/usage?period=month&groupBy=company" \
-H "Authorization: Bearer $PLATFORM_KEY"
[/code]
[code]
{
"period": { "start":"2026-04-01", "end":"2026-04-30" },
"total": { "documentsSent": 18420, "documentsReceived": 22100 },
"byCompany": [
{ "companyId":"comp_…", "tenantId":"t_acme",
"sent": 4203, "received": 5012, "complianceReports": 3801 },
…
]
}
[/code]
## Observability
Metric| How to read it
---|---
Tenant health| Per-company `document.failed` rate over rolling 24h.
Compliance health| `compliance.reported.failed` count by country.
Webhook delivery| Webhook record's `failureCount` field; monitor for > 0.
Quota burn| `GET /v1/stats?period=month` per tenant; alert at 80%.
Upstream health| `GET /health/readiness` on Flowie's side; see circuit-breaker state.
## Offboarding a tenant
Three steps, in order:
1. Revoke the tenant key: `DELETE /v1/platform/api-keys/{key_id}`.
2. Disable the per-tenant webhook (don't delete — keep the audit trail): `PATCH /v1/webhooks/{id}` with `{"status":"disabled"}`.
3. Deregister the company: `DELETE /v1/companies/{id}`. Historical documents remain queryable for the legally-mandated retention period (10y in IT, 6y in FR).
## Production go-live checklist
✓| Item| Why
---|---|---
☐| Platform key stored only in secret manager (Vault / AWS SM / GSM)| Compromise = blast radius across all tenants.
☐| Tenant keys (if used) stored encrypted at rest, scoped by tenant| Reduces blast radius if one is leaked.
☐| All `POST` calls send an `Idempotency-Key` derived from your DB row id| Safe retries across deploys.
☐| Webhook handler verifies HMAC on the raw body, before parsing| Forgery resistance.
☐| Webhook handler dedupes on `X-Flowie-Event-Id`| At-least-once delivery.
☐| Webhook handler queues work, doesn't process inline| Stay under the 5s ack window.
☐| Per-tenant alerting on `document.failed` and `compliance.reported.failed`| Fail fast, fix fast.
☐| Monthly chargeback job hits `/platform/usage`| Don't eat your tenants' cost.
☐| Custom domain DNS verified; TLS auto-renewing| Brand integrity.
☐| Sandbox-mode integration tests in CI before any prod deploy| Catch regressions.
========================================================================
# European compliance overview
# Source: https://docs.get-flowie.com/compliance/index.html
========================================================================
---
title: "Compliance · all 47 countries"
description: "Flowie Exchange compliance coverage across 47 countries on four continents — EU-27 plus EEA, UK and Switzerland, the Middle East (KSA, UAE, Israel, Egypt, Türkiye), South Asia and SE Asia (India, Singapore, Malaysia, Thailand, Vietnam), East Asia (Japan, South Korea, China), and the Pacific (Australia, New Zealand). Mandate status, network, format, and Flowie support at a glance."
canonical: "https://docs.get-flowie.com/compliance/"
source: "https://docs.get-flowie.com/compliance/index.html"
---
# Compliance · all 47 countries
Compliance · all 47 countries
# E-invoicing coverage map — 47 countries, four continents
Flowie covers **47 jurisdictions** across Europe, MENA, and Asia-Pacific — operating Peppol Access Points directly where we hold national accreditation, and integrating via vetted local partners where in-country presence is required by the regulator. The table below gives you the mandate status, network, and at-a-glance summary for each. Click any country for the full deep-dive. _Last refreshed: 2026-07-13._
## Coverage matrix
Click any column header to sort. Click a second time for descending, a third to restore the default order.
Country | Status | Network | Tagline | Where things stand
---|---|---|---|---
[🇦🇺 **Australia**]()| Phased rollout| Peppol BIS 3.0 + PINT A-NZ| Peppol PINT A-NZ · federal default by Dec 2026 · ATO Peppol Authority| Federal B2G default by end-2026; no B2B mandate yet — Peppol-led adoption only.
[🇦🇹 **Austria**]()| Live mandate| Peppol BIS 3.0| Peppol BIS B2G mandate live since 2014 · No B2B mandate yet| Federal B2G live; B2B will follow the EU ViDA timeline.
[🇧🇪 **Belgium**]()| Live mandate| Peppol BIS 3.0| Peppol BIS B2B mandate live since 1 January 2026 (HERMES dropped)| Pure Peppol; no central hub.
[🇧🇬 **Bulgaria**]()| Phased rollout| Peppol BIS 3.0 + national SAF-T| SAF-T phase-in 2026–2028 · No domestic B2B mandate yet| SAF-T being introduced for large taxpayers; full e-invoicing TBD.
[🇨🇳 **China**]()| Live mandate| STA Golden Tax IV| Fully digital e-fapiao · Golden Tax IV nationwide · new VAT Law 2026| Fully digital e-fapiao universal nationwide; VAT Law 2026 cements the regime.
[🇭🇷 **Croatia**]()| Live mandate| National Fiscalisation portal + Peppol BIS 3.0| Fiscalisation 2.0 B2B mandate live since 1 January 2026| B2B mandate ramping; full VAT-taxpayer scope reached during 2026.
[🇨🇾 **Cyprus**]()| Live mandate| Peppol BIS 3.0| Peppol BIS B2G live · No B2B mandate yet| B2G mandate stable; B2B awaiting EU ViDA framework.
[🇨🇿 **Czechia**]()| Live mandate| ISDOC 6.0 (national) + Peppol BIS 3.0| B2G mandate live · ISDOC + Peppol BIS · No B2B mandate yet| Public sector accepts both ISDOC and Peppol BIS; no B2B mandate.
[🇩🇰 **Denmark**]()| Live mandate| Peppol BIS 3.0 + OIOUBL via NemHandel| OIOUBL/Peppol BIS · B2G live since 2005 · Bookkeeping Act phasing 2024–2026| B2G universal since 2005; new Bookkeeping Act introduces digital record-keeping with embedded e-invoicing requirements.
[🇪🇬 **Egypt**]()| Live mandate| ETA portal (national clearance, JSON/XML)| ETA clearance live for B2B/B2G · e-receipt expanding for B2C| Universal B2B/B2G clearance since 2023; B2C e-receipt expanding; threshold lowered for 2026.
[🇪🇪 **Estonia**]()| Phased rollout| Peppol BIS 3.0 + Estonian e-invoicing register| B2B-on-request live since July 2025 · B2G universal · Peppol BIS| B2B-on-request live; full B2B mandate expected ahead of ViDA.
[🇫🇮 **Finland**]()| Live mandate| Peppol BIS 3.0 + Finvoice 3.0 (national)| B2B-on-request since 2020 · B2G universal · Finvoice + Peppol| B2B-on-request universal in practice; B2G universal.
[🇫🇷 **France**]()| Phased rollout| PPF + Peppol BIS 3.0| PPF mandate · receive Sept 2026 · send Sept 2027| PPF receive obligation imminent; full send rollout 2027.
[🇩🇪 **Germany**]()| Phased rollout| Peppol BIS 3.0 + XRechnung CIUS + ZUGFeRD/Factur-X| B2B mandate phasing 2025–2028 · XRechnung B2G · ZUGFeRD/Factur-X B2B| Receive obligation universal since Jan 2025; send phasing through 2028 by company size.
[🇬🇷 **Greece**]()| Live mandate| myDATA (AADE) + Peppol BIS 3.0| myDATA real-time reporting universal · Peppol BIS for cross-border| myDATA universal; B2B e-invoicing extension via approved providers expected to expand.
[🇭🇺 **Hungary**]()| Live mandate| NAV Online Számla + Peppol BIS 3.0| NAV Online Számla 3.0 reporting universal since 2021| Real-time invoice reporting universal; structured-invoice send mandate not yet legislated.
[🇮🇸 **Iceland**]()| Phased rollout| Peppol BIS 3.0| Peppol BIS B2G adoption · No B2B mandate yet| B2G voluntary today; e-invoicing adoption rising via EEA alignment.
[🇮🇳 **India**]()| Live mandate| GST IRP (Invoice Registration Portal) + e-Way Bill| Mandatory IRN issuance via GST IRP · ₹5 crore threshold| B2B IRN clearance universal above ₹5 cr turnover; 30-day reporting cap above ₹10 cr.
[🇮🇪 **Ireland**]()| Live mandate| Peppol BIS 3.0| Peppol BIS B2G live since 2019 · No B2B mandate yet| B2G stable; B2B consultation results expected 2026.
[🇮🇱 **Israel**]()| Live mandate| ITA SHAAM (national clearance, JSON)| ITA allocation-number clearance · accelerated 2026 thresholds| CTC clearance live since May 2024; thresholds tightening rapidly through 2026.
[🇮🇹 **Italy**]()| Live mandate| SDI + Peppol BIS 3.0| SDI mandatory clearance since 2019 — universal B2B + B2G + B2C| Most mature CTC regime in the EU.
[🇯🇵 **Japan**]()| Voluntary| Peppol BIS 3.0 + JP PINT| Peppol JP PINT · voluntary network on top of Qualified Invoice System| Voluntary Peppol layer on top of the mandatory Qualified Invoice System (since Oct 2023).
[🇱🇻 **Latvia**]()| Live mandate| Peppol BIS 3.0| B2B mandate live since 1 January 2026 · G2B universal| B2B mandate now live; reporting model rather than CTC.
[🇱🇮 **Liechtenstein**]()| Voluntary| Peppol BIS 3.0| Peppol BIS available · No mandate · Small market| Voluntary; small market typically routed via Swiss/Austrian APs.
[🇱🇹 **Lithuania**]()| Live mandate| Peppol BIS 3.0 + E.sąskaita + i.MAS| E.sąskaita B2G universal · i.MAS reporting universal · No B2B mandate yet| Reporting universal via i.MAS / i.SAF-T; e-invoicing send obligation B2G only.
[🇱🇺 **Luxembourg**]()| Live mandate| Peppol BIS 3.0| Peppol BIS B2G universal · No B2B mandate yet| Phased B2G complete; B2B awaits EU ViDA framework.
[🇲🇾 **Malaysia**]()| Live mandate| LHDN MyInvois portal + UBL 2.1 (MY CIUS)| MyInvois clearance · phased rollout completing Jan 2026 (RM 1m floor)| Phased clearance live; final wave Jan 2026; SMEs < RM 1m exempt.
[🇲🇹 **Malta**]()| Live mandate| Peppol BIS 3.0| Peppol BIS B2G live since 2019 · No B2B mandate yet| B2G stable; B2B awaiting EU ViDA framework.
[🇳🇱 **Netherlands**]()| Live mandate| Peppol BIS 3.0 + NLCIUS| Peppol-by-default · B2G universal · NLCIUS profile · No B2B mandate yet| B2G universal; very high voluntary B2B adoption via SimplerInvoicing community.
[🇳🇿 **New Zealand**]()| Phased rollout| Peppol BIS 3.0 + PINT A-NZ| Peppol PINT A-NZ · MBIE Peppol Authority · NZ$33m supplier mandate Jan 2027| B2G receive mandate live since 2022; ramping to send obligation in 2026 and supplier obligation in 2027.
[🇳🇴 **Norway**]()| Live mandate| Peppol BIS 3.0 + EHF + SAF-T| EHF/Peppol BIS B2G universal since 2012 · SAF-T universal| EHF/Peppol B2G universal; SAF-T universal; B2B consultation in progress.
[🇵🇱 **Poland**]()| Phased rollout| KSeF (national clearance) + Peppol BIS for cross-border| KSeF mandatory clearance · large taxpayers Feb 2026 · all April 2026| KSeF 2.0 + FA(3) mandatory for large taxpayers; full universal scope April 2026.
[🇵🇹 **Portugal**]()| Live mandate| FE-AP (national B2G) + Peppol BIS 3.0 + SAF-T| ATCUD + SAF-T universal · B2G via FE-AP · No B2B mandate yet| ATCUD + SAF-T universal; B2G universal; B2B mandate proposed for 2027.
[🇷🇴 **Romania**]()| Live mandate| RO e-Factura (ANAF clearance) + Peppol BIS for cross-border| RO e-Factura mandatory clearance universal since July 2024| Universal B2B clearance + SAF-T reporting; one of the most aggressive regimes in the EU.
[🇸🇦 **Saudi Arabia**]()| Live mandate| ZATCA Fatoora + UBL 2.1 (KSA CIUS)| Mandatory clearance via Fatoora portal · live since 2021| Most mature CTC regime in MENA — universal B2B + B2G live; Phase 2 integration ramping by wave through 2026.
[🇸🇬 **Singapore**]()| Phased rollout| Peppol BIS 3.0 + PINT-SG (5-corner)| Peppol InvoiceNow + GST 5-corner reporting · phased through 2031| Voluntary Peppol since 2019; GST InvoiceNow mandatory rollout 2025-2031.
[🇸🇰 **Slovakia**]()| Phased rollout| IS EFA + Peppol BIS 3.0| IS EFA phased B2G · No B2B mandate yet| IS EFA B2G phasing; full B2B not yet legislated.
[🇸🇮 **Slovenia**]()| Live mandate| UJP + Peppol BIS 3.0| UJP B2G universal since 2015 · No B2B mandate yet| B2G universal via UJP; B2B consultation in progress.
[🇰🇷 **South Korea**]()| Live mandate| NTS HomeTax (national clearance, XML)| NTS e-Tax invoice · universal corporate clearance since 2011| World-leading CTC: every corporation, plus sole proprietors above KRW 80m, must issue e-Tax invoices.
[🇪🇸 **Spain**]()| Phased rollout| Veri*Factu (AEAT) + FACe (B2G) + Peppol BIS 3.0| Veri*Factu reporting · Crea y Crece B2B mandate · FACe B2G| Veri*Factu postponed to 2027; Crea y Crece B2B phasing 2026–2028.
[🇸🇪 **Sweden**]()| Live mandate| Peppol BIS 3.0 + SFTI| Peppol BIS B2G universal since 2019 · SFTI · No B2B mandate yet| B2G universal; B2B awaiting EU ViDA framework.
[🇨🇭 **Switzerland**]()| Phased rollout| Peppol BIS 3.0| Federal B2G ramping · No B2B mandate · Peppol BIS| Federal B2G adoption rising; no federal B2B mandate.
[🇹🇭 **Thailand**]()| Voluntary| RD e-Tax Invoice & e-Receipt portal (XML)| Voluntary e-Tax invoice/e-Receipt · ETDA-aligned XML · no mandate yet| Voluntary regime; the Revenue Department is encouraging adoption but no mandate is in force.
[🇹🇷 **Türkiye**]()| Live mandate| GİB / Hazine clearance + UBL-TR| GİB e-Fatura since 2014 · e-Arşiv universal from 2026| Mature CTC regime; e-Arşiv universal from January 2026.
[🇦🇪 **United Arab Emirates**]()| Phased rollout| Peppol BIS 3.0 + PINT AE (5-corner)| Peppol 5-corner model · voluntary pilot 1 July 2026 · first mandate 1 January 2027| Peppol-based CTC — voluntary pilot mid-2026, first mandate January 2027.
[🇬🇧 **United Kingdom**]()| Phased rollout| Peppol BIS 3.0 + MTD reporting| MTD VAT reporting universal · NHS Peppol B2G · No general B2B mandate| MTD VAT universal; e-invoicing consultation results expected 2026.
[🇻🇳 **Vietnam**]()| Live mandate| GDT national e-invoice platform (XML)| Universal e-invoice since 2022 · Decree 70 expansion 2025-2026| Universal mandatory e-invoice; Decree 70 expanded scope to POS retail and foreign suppliers in 2025-2026.
## Mandate timeline
Every country's key e-invoicing dates on a single 2014 → 2030 axis. Each dot is a deadline; **green** = already in force, **amber** = phasing in this year, **blue** = scheduled. Hover or focus a dot for the full description, or click to jump to that country's deadlines section. Below the chart is a sortable "what's coming next" table.
Past — already in force This year — phasing in Future — scheduled Today
Country
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
2026
2027
2028
2029
2030
[🇦🇺 Australia · Peppol via ATO]()
[]( "**2019-10-31** · ATO becomes Australian Peppol Authority — Joins OpenPeppol.")
[]( "**2022-07-01** · All federal NCEs — Mandatory Peppol receipt capability for B2G.")
[]( "**2025-05-15** · All Peppol senders — Migration to PINT A-NZ; legacy A-NZ BIS deprecated.")
[]( "**2026-07-01** · Federal NCEs — 30% of received invoices via Peppol target.")[]( "**2026-12-31** · Federal NCEs — Automated Peppol send + receive default.")
[🇦🇹 Austria · Peppol B2G]()
[]( "2014-01-01 · Federal contracting authorities — B2G e-invoicing mandatory \(BGBl. I Nr. 32/2014\).")
[]( "≥ 2030 · All B2B taxable supplies \(expected\) — Aligned with EU ViDA — not yet legislated; planning baseline only.")
[🇧🇪 Belgium · Peppol BIS]()
[🇧🇬 Bulgaria · NRA SAF-T phase-in]()
[]( "2026-01-01 · Largest taxpayers \(turnover > BGN 300M\) — SAF-T monthly reporting begins.")
[]( "2027-01-01 · Mid-size taxpayers — SAF-T reporting onboarded.")
[]( "2028-01-01 · All VAT-registered businesses — SAF-T reporting universal.")
[🇨🇳 China · Fully digital e-fapiao]()
[]( "**2021-12-01** · Pilot — 5 provinces — Fully digital e-fapiao introduced.")
[]( "**2022-2024** · Geographical rollout — Pilot extends across all provinces.")
[]( "**2024-12-01** · All taxpayers \(general + small-scale\) — Permitted nationwide; paper and earlier electronic formats progressively phased out.")
[]( "**2026-01-01** · All VAT-registered — New VAT Law supporting regulations in force; e-fapiao codified.")
[🇭🇷 Croatia · Fiscalisation 2.0]()
[]( "**2026-01-01** · All VAT-registered B2B — Structured e-invoice + real-time fiscalisation report.")
[]( "2027-01-01 · Non-VAT businesses \(planned\) — Smaller taxpayers absorbed; legislation pending.")
[🇨🇾 Cyprus · Peppol BIS B2G]()
[]( "2019-04-18 · Central government — B2G mandate \(EU directive transposition\).")[]( "2019-04-18 · Sub-central public authorities — Same date — Cyprus did not stagger central vs. sub-central.")
[]( "≥ 2030 · B2B \(expected\) — EU ViDA alignment; not yet legislated.")
[🇨🇿 Czechia · ISDOC + Peppol]()
[]( "2019-04-18 · Central government — Must accept e-invoices \(EU 2014/55/EU\).")
[]( "2020-04-18 · Sub-central public authorities — Mandate extended.")
[]( "≥ 2030 · B2B \(expected\) — EU ViDA timeline; no national legislation yet.")
[🇩🇰 Denmark · OIOUBL & Peppol]()
[](