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 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.
https://back.p2p-flowie.com in production · https://back.flowie.ink in sandbox. All endpoints below are prefixed with /v1.
Quick index
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 ⬇ OpenAPI 3.1 spec
In Postman: Import → drop the file, or paste the URL https://docs.get-flowie.com/postman_collection.json. Then set two collection variables:
baseUrl—https://back.flowie.ink/exchange(sandbox) orhttps://back.p2p-flowie.com/exchange(production).token— your API key. It is sent asAuthorization: Bearer {{token}}on every request (collection-level bearer auth).
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 spec instead.
Authentication
Every request must carry a bearer token. Flowie Exchange supports two 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:
curl https://back.p2p-flowie.com/exchange/v1/documents \
-H "Authorization: Bearer eyJhbGc..." \
-H "X-Flowie-Organization-Id: 685a5670efafaa26ebf0128e"
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. 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.
Exchange API keys
For programmatic access, issue an Exchange API key from the dashboard or via the API. 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-Companyto 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.
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
*
curl https://back.p2p-flowie.com/exchange/v1/companies \
-H "Authorization: Bearer flw_live_abc123"
from httpx import Client
api = Client(
base_url="https://back.p2p-flowie.com/exchange/v1",
headers={"Authorization": "Bearer flw_live_abc123"},
)
const headers = { Authorization: "Bearer flw_live_abc123" };
const res = await fetch(
"https://back.p2p-flowie.com/exchange/v1/companies",
{ headers }
);
req, _ := http.NewRequest("GET",
"https://back.p2p-flowie.com/exchange/v1/companies", nil)
req.Header.Set("Authorization", "Bearer flw_live_abc123")
Acting on a managed company (platform keys)
Authorization: Bearer flw_plat_live_xyz789
X-Flowie-Company: comp_abc123def456
Get caller identity + accessible orgs
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
{
"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
}
curl https://back.p2p-flowie.com/exchange/v1/me \
-H "Authorization: Bearer eyJhbGc..."
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.
curl -X POST …/v1/documents/send \
-H "Authorization: Bearer $KEY" \
-H "Idempotency-Key: $(uuidgen)" \
-d @invoice.json
from uuid import uuid4
key = str(uuid4()) # persist this alongside the row!
resp = api.post("/documents/send",
headers={"Idempotency-Key": key}, json=payload)
const key = crypto.randomUUID();
await flowie("/documents/send", {
method: "POST",
headers: { "Idempotency-Key": key },
body: JSON.stringify(payload),
});
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, max100. -
cursorstringoptional
Pass the
cursorvalue returned by the previous page. Omit to start at the first page.
Every list response has the same envelope:
{
"data": [ /* records */ ],
"hasMore": true,
"cursor": "eyJpZCI6ImRvY19YLi4uIn0"
}
Iterate all pages
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"]
let cursor;
do {
const q = new URLSearchParams({ limit: "100" });
if (cursor) q.set("cursor", cursor);
const page = await flowie(`/documents?${q}`);
page.data.forEach(process);
cursor = page.hasMore ? page.cursor : null;
} while (cursor);
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.
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
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…"
}
}
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
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.
Error shape
{
"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"
}
}
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 and flagged with the
Sunsetresponse header 6+ months before removal.
Sunset header example
Sunset: Wed, 01 Oct 2026 00:00:00 GMT
Deprecation: true
Link: <https://docs.get-flowie.com/changelog#send-v1>; rel="deprecation"
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": falsein the payload. - There are no quotas; rate limits remain.
Bootstrap a sandbox key
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
personalplatformwhite_labelDefaults to
personal(token prefixflw_test_). Passplatformto mint a multi-tenant key (flw_plat_test_) that satisfies the platform-key gate on/v1/platform/*ops, orwhite_labelfor the branding-enabled variant (flw_wl_test_). See Sandbox · Key types.
Reset sandbox state
Wipes events, idempotency cache, and pending scheduled events for the calling organization. Test-mode key only.
Request body
- confirmenumrequired
Type the literal string
yesto acknowledge the wipe. - scopeenumoptional
alldocumentseventsidempotencyWhat to wipe. Defaults to
all.
Advance virtual clock
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
Snap the virtual clock back to wall-clock time for a company.
Request body
- companyIdstringrequired
Force rate-limit
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
429should last. Default60, range 1–3600 (max 1 hour).
Flush idempotency cache
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
https://back.flowie.ink/exchange/v1
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) 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.
List grantable 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.
{
"scopes": [
{ "id": "send", "description": "Send documents" },
{ "id": "documents.read", "description": "Read documents" },
{ "id": "lifecycle", "description": "Advance lifecycle statuses" }
]
}
Start consent (PKCE)
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.
- 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.
{
"client_name": "My Agent",
"scopes": ["send", "documents.read"],
"code_challenge": "E9Melhoa2Ow…",
"code_challenge_method": "S256"
}
{
"request_id": "areq_01HY…",
"consent_url": "https://back.flowie.ink/exchange/consent?request=areq_01HY…",
"expires_in": 600
}
Exchange the code for a key
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. 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.
{
"grant_type": "authorization_code",
"code": "ac_01HY…",
"code_verifier": "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
}
{
"access_token": "flw_test_…",
"scopes": ["send", "documents.read"],
"expires_in": 604800,
"organization_id": "org_01HY…",
"company_id": "comp_01HY…"
}
Mint a handoff link
Authentication: Authorization: Bearer <key> 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 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.
{
"scopes": ["send"],
"label": "claude-desktop",
"ttl_seconds": 900
}
{
"handoff_url": "https://docs.get-flowie.com/build-with-ai/agent-onboarding.html?handoff=hand_AbC…",
"handoff_token": "hand_AbC…",
"expires_in": 900
}
Anonymous sandbox handoff
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. This is what lets the docs home page hand an agent a URL that is already authenticated.
{
"handoff_token": "hand_AbC…",
"organization_id": "org_sbx_01HY…",
"company_id": "comp_sbx_01HY…",
"expires_in": 3600
}
Redeem a handoff token
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.
{
"handoff_token": "hand_AbC…"
}
{
"access_token": "flw_test_…",
"scopes": ["send"],
"expires_in": 604800,
"organization_id": "org_01HY…",
"company_id": "comp_01HY…"
}
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.
- 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.
- capabilitiesobject
Which document types the company can send/receive.
- statusstring
active,inactive, orsuspended. - 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
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 with status 201. SMP registration happens asynchronously — listen for company.smp_registered via webhook.
vatNumber already owned by your organization returns 409 duplicate with the existing companyId. Use that as your idempotent upsert.
Request
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" }
}'
company = api.post("/companies",
headers={"Idempotency-Key": "upsert-acme-be"},
json={
"vatNumber": "BE0123456789",
"capabilities": {
"send": ["invoice","credit-note"],
"receive": ["invoice"],
},
"metadata": {"tenantId": "t_acme"},
},
).json()
const company = await flowie("/companies", {
method: "POST",
headers: { "Idempotency-Key": "upsert-acme-be" },
body: JSON.stringify({
vatNumber: "BE0123456789",
capabilities: { send: ["invoice","credit-note"], receive: ["invoice"] },
metadata: { tenantId: "t_acme" },
}),
});
payload, _ := json.Marshal(map[string]any{
"vatNumber": "BE0123456789",
"capabilities": map[string]any{
"send": []string{"invoice","credit-note"},
"receive": []string{"invoice"},
},
"metadata": map[string]string{"tenantId": "t_acme"},
})
req, _ := http.NewRequest("POST",
"https://back.p2p-flowie.com/exchange/v1/companies",
bytes.NewReader(payload))
req.Header.Set("Authorization", "Bearer "+key)
req.Header.Set("Idempotency-Key", "upsert-acme-be")
req.Header.Set("Content-Type", "application/json")
http.DefaultClient.Do(req)
Response
{
"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"
}
{
"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…"
}
}
{
"error": {
"type": "invalid_request_error",
"code": "VAT_NOT_FOUND",
"message": "VAT BE0000000000 is not in the national registry.",
"requestId":"req_01HXYZ…"
}
}
List companies
Returns all companies you own or manage, most-recently created first.
Query parameters
- countryISO 3166-1 α-2optional
Filter by country.
- statusstringoptional
active,inactive, orsuspended. - searchstringoptional
Full-text over name, legal name, VAT, and Peppol ID.
- include_addressbooleanoptional
Resolve each row's
legalAddressIdinto a fulladdressobject (adds round-trips). Defaulttrue— setfalsefor a faster, lighter list. - limit / cursorpaginationoptional
See Pagination.
curl "https://back.p2p-flowie.com/exchange/v1/companies?country=BE&status=active&limit=50" \
-H "Authorization: Bearer $KEY"
page = api.get("/companies",
params={"country": "BE", "status": "active", "limit": 50}).json()
{
"data": [
{ "id": "comp_01HXYZ…", "name": "ACME BVBA",
"vatNumber": "BE0123456789", "country": "BE",
"peppolId": "0208:0123456789", "status": "active" }
],
"hasMore": false,
"cursor": null
}
Resolve by VAT / SIREN
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.
curl "https://back.p2p-flowie.com/exchange/v1/companies/resolve?countryCode=FR®istrationNumber=797978996" \
-H "Authorization: Bearer $KEY"
Search companies
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, max50.
[
{ "id": "comp_…", "name": "ACME BVBA", "vatNumber": "BE0123456789",
"country": "BE", "peppolId": "0208:0123456789" }
]
Retrieve a company
Returns the company object. The path parameter accepts three forms:
comp_01HXYZ…— the canonical idvat:BE0123456789— VAT-scoped lookuppeppol:0208:0123456789— Peppol-ID lookup
curl https://back.p2p-flowie.com/exchange/v1/companies/vat:BE0123456789 \
-H "Authorization: Bearer $KEY"
Update a company
Partial update. System-managed attributes (peppolId, status, timestamps, stats) are read-only. Merging rules:
- Top-level keys are replaced wholesale.
metadatais shallow-merged. Set a key tonullto delete it.- Changing
capabilities.sendorcapabilities.receivemay trigger an SMP re-registration (you'll see acompany.smp_registeredevent).
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
nullto delete it.
Deregister a company
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:
and accept or reject with:
Issue a join request as the calling user.
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 }
}'
curl -X DELETE \
https://back.p2p-flowie.com/exchange/v1/companies/comp_abc \
-H "Authorization: Bearer $KEY"
# 204 No Content
Import a company (portability)
Authentication: Authorization: Bearer <key> 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:<siren>), 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
sireninstead 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.
{
"siret": "55210055400013",
"mode": "portability"
}
{
"id": "comp_01HY7AB9C2DE3FG",
"siren": "552100554",
"peppolId": "0009:552100554",
"name": "ACME SAS",
"country": "FR",
"imported": true
}
Import companies in bulk
Authentication: Authorization: Bearer <key> 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.
{
"items": [
{ "siret": "55210055400013" },
{ "siret": "39876543200025" }
]
}
{
"results": [
{ "ok": true, "siret": "55210055400013", "id": "comp_01…" },
{ "ok": false, "siret": "39876543200025", "error": { "code": "SIRET_NOT_FOUND" } }
],
"imported": 1,
"failed": 1
}
Register a company on Peppol
Authentication: Authorization: Bearer <key> 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 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.
POST /v1/companies/comp_01HY7AB9C2DE3FG/register
Authorization: Bearer flw_live_…
{
"id": "comp_01HY7AB9C2DE3FG",
"peppolId": "0208:0123456789",
"registered": true,
"smpStatus": "active",
"activatedAt": "2026-04-25T10:05:00Z"
}
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.
- idstring
doc_… - typeenum
invoicecredit-notedebit-notepurchase-orderpurchase-requestsales-orderquoteevent - directionenum
incomingoutgoing - numberstring
Your external document number.
- issueDate / dueDatedate (YYYY-MM-DD)
- currencyISO 4217
- grossAmount / netAmount / vatAmountdecimal
- sender / receiverParty
- statusenum
draftsentdeliveredrejected - deliveryStatusenum
pendingdeliveredfailedrejected - lifecycleStatusenum
Business-level state. See Lifecycle.
- documentobject
The full structured body (lines, tax, payment, …).
- xmlstring
Rendered UBL (populated on delivery).
- metadataobject
- receivedAt / sentAttimestamp
Send a document
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 for the full matrix of payload shapes (structured JSON · UBL XML · PDF / Factur-X / image / proprietary file).
Body
-
typeenumrequired
invoicecredit-notedebit-notepurchase-orderpurchase-requestsales-orderquoteevent -
formatenumoptional
jsonubl-xmlcii-xmlautorawjson(default) — we render UBL.ubl-xml/cii-xml— provide your own XML inxml.auto— supply afile; the server sniffs the bytes and routes to the right pipeline.raw— supply afile; the server stores it as-is on the document file API and returnsdeliveryStatus="stored"(no Peppol routing). -
fromstringrequired
Your sender company. Accepts a bare Peppol id (
0208:0123456789) or the prefixed formspeppol:…,vat:…,comp_…/org:…. Whatever you pass is normalised to the sender's canonical Peppol id before delivery — the response always echoes the bare0208:…form. -
tostringrequired when type ≠ event
Recipient. A Peppol participant id (
0208:0123456789orpeppol:…) — used as-is — or any other identifier we can resolve to one:vat:…,siren:…/siret:…,duns:…,gln:…,lei:…,eori:…,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. Optional (omit) whentype=event— events are pure observability/audit records and have no recipient. -
documentDocumentBodyrequired when format=json
See schema below.
- numberstringrequired
- issueDatedaterequired
- dueDatedateoptional
- currencyISO 4217optional
Default
EUR. - buyerReferencestringoptional
Required by many public-sector buyers (e.g. Service Executant / Code Service in FR).
- orderReferencestringoptional
PO number.
- notestringoptional
-
seller / buyerPartyoptional
Overrides the auto-derived seller/buyer. A
Partyobject:- namestring
- vatNumberstring
- addressAddress
Billing address — see the Address object. Carried to the party's
billingAddress. - shippingAddressAddress
Same shape as
address. - contactobject
{ name?, email?, phone?: string }. Theemailis added to the party'scontacts. - 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/payeedistinct frombuyer/seller) and for self-billing. Exactly one entry must setinitiator: true(the org the key acts as). When present it overrides the default seller/buyer derivation. See Multiple parties. Each entry:- roleenumrequired
sellerbuyerpayerpayee - idstring
Any resolvable id (same grammar as
to). - name / vatNumberstring
- address / shippingAddressAddress
See the Address object.
- contact / contactsobject / string[]
Same as on
seller/buyerabove. - initiatorboolean
Exactly one entry must be
true.
-
paymentPaymentInfooptional
A
PaymentInfoobject:- meansstring
Payment-means label/code, e.g.
"credit_transfer","30". Folded into the e-invoice's payment instructions. - ibanstring
- bicstring
- referencestring
Remittance / structured communication. Sent as
paymentReferenceNumber. - discountTermsarray
[{ days: int, percent: number, note?: string }]. Accepted but not yet emitted to the e-invoice.
-
deliveryobjectoptional
Delivery details, e.g.
{ date?: "YYYY-MM-DD", address?: Address }. Accepted but not yet emitted to the e-invoice. - linesInvoiceLine[]required
VAT is per line — a document with several rates is several lines (see Multiple VAT rates). Each line:
- descriptionstringrequired
- quantitynumberrequired
- unitPricenumberrequired
Excl. VAT.
- vatRatenumberrequired
Percent, e.g.
21,6,0. - unitstring
UN/ECE Rec 20 code, e.g.
"HUR","C62". - vatCategorystring
UNCL5305 code; defaults to
S. See Tax exemption & zero rate. - itemCodestring
- customFieldsobject
Keyed by field name or UUID. See Custom fields & templates.
- periodobject
{ from: "YYYY-MM-DD", to: "YYYY-MM-DD" }. Accepted but not yet emitted to the e-invoice.
-
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, sendformat=ubl-xml. -
attachmentsarrayoptional
Embedded attachments. Each item:
{ filename: string, contentType: string, content: <base64> }. Accepted but not yet emitted to the e-invoice — to attach a file today useformat=auto/rawwith the top-levelfile. -
totalsobjectoptional
Pre-computed totals — overrides the values computed from lines.
{ netAmount?: number, vatAmount?: number, grossAmount?: number }(aliasesnet/vat/grossalso accepted). If omitted, all three are computed from the lines. - templateIduuidoptional
Template to file the document under — it declares which
customFieldsare valid. See Custom fields & templates. - customFieldsobjectoptional
Document-level custom field values, keyed by field name or UUID. Attached to your party. See Custom fields & templates.
-
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: <base64>, contentType?, filename? }. Max 5 MiB. Withformat=autowe sniff magic bytes — if the file is UBL XML it routes through the regular pipeline (deliveryStatus="pending"); otherwise the bytes are persisted on the document file API and the response carriesdeliveryStatus="stored",fileId, andstoredFormat. -
selfBilledbooleanoptional
Self-billed invoice (autofacturation): the acting org (
from) is the customer issuing on the supplier's behalf, sotobecomes the Seller andfromthe Buyer / initiator. Tags the document with UNCL1001 subtype389. Only valid fortype=invoice. Defaultfalse. For self-billing that also involves a third party, usedocument.partiesinstead.
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
invoicecredit-notedebit-notepurchase-ordersales-orderquoteeventSame 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:<id>) when omitted. - contentTypestringoptional
MIME type of the raw body. Falls back to the
Content-Typeheader, then a magic-byte sniff. - filenamestringoptional
Filename persisted on the file record. Defaults to
<Idempotency-Key>.<ext>or an auto-generatedevent-*.<ext>.
Address object
Used by seller/buyer/parties[].address and shippingAddress. All fields are optional strings; only street, streetLine2, city, postalCode and country are carried to the e-invoice (mapped to street, street2, city, zipCode, country).
- streetstring
- streetLine2string
Second address line (suite, box…). →
street2. - citystring
- postalCodestring
→
zipCode. - countryISO 3166-1 alpha-2
e.g.
"FR","BE". - state / regionstring
Accepted; not currently emitted.
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.
"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" }
]
Exempt / reverse-charge categories (E, AE, K, G, O) additionally need a VAT exemption reason — see Tax exemption & zero rate.
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 asto),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 avatNumber. 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
partiesis 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-xmlfor those.
"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" }
]
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
towith no prefix (e.g.752978775_75297877500027_100003, or just75297877500027_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 aroutage:/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 undermetadata.recipientRouting({ "addressingIdentifier": …, "addressingSuffix": … }) and is echoed back on the responsetoobject alongsidepeppolId. An explicitmetadata.recipientRoutingyou 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.
// request
"to": "752978775_75297877500027_100003"
// response — participant unchanged, suffix carried alongside
"to": {
"peppolId": "0009:75297877500027",
"addressingIdentifier": "752978775_75297877500027_100003",
"addressingSuffix": "100003"
}
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 a400— pass the field's UUID or declare it on thetemplateIdfirst.line.customFields— line-level values on eachInvoiceLine, 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 }).
"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" } }
]
}
Custom fields are carried only on the structured format=json pipeline — for ubl-xml/cii-xml, embed them in the XML yourself.
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 |
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). The structured format=json body has no field for this reason, so a JSON invoice in an exempt category will fail compliance validation. To send an exempt or reverse-charge invoice today, build it as format=ubl-xml and put the reason inside <cac:TaxCategory> yourself. Z (zero-rated) and S need no reason and work fine over JSON.
The exempt <cac:TaxCategory> block to include in your UBL (both at line level under <cac:ClassifiedTaxCategory> and in the document <cac:TaxSubtotal>):
<cac:TaxCategory>
<cbc:ID>AE</cbc:ID>
<cbc:Percent>0</cbc:Percent>
<cbc:TaxExemptionReasonCode>VATEX-EU-AE</cbc:TaxExemptionReasonCode>
<cbc:TaxExemptionReason>Reverse charge</cbc:TaxExemptionReason>
<cac:TaxScheme><cbc:ID>VAT</cbc:ID></cac:TaxScheme>
</cac:TaxCategory>
Returns
201 Created with the document object. 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
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"
}
]
}
}'
doc = api.post("/documents/send",
headers={"Idempotency-Key": "inv-2026-0417"},
json={
"type": "invoice",
"from": company["id"],
"to": "0208:9876543210",
"document": {
"number": "INV-2026-0417",
"issueDate": "2026-04-25",
"dueDate": "2026-05-25",
"currency": "EUR",
"buyerReference": "SERV-FIN-042",
"payment": {"means": "credit_transfer",
"iban": "BE68539007547034",
"bic": "BPOTBEB1",
"reference": "INV-2026-0417"},
"lines": [
{"description": "Consulting — April",
"quantity": 10, "unit": "hours",
"unitPrice": 150.00, "vatRate": 21},
{"description": "Travel",
"quantity": 1, "unitPrice": 450.00, "vatRate": 21},
],
},
}).json()
const doc = await flowie("/documents/send", {
method: "POST",
headers: { "Idempotency-Key": "inv-2026-0417" },
body: JSON.stringify({
type: "invoice",
from: company.id,
to: "0208:9876543210",
document: {
number: "INV-2026-0417",
issueDate: "2026-04-25",
dueDate: "2026-05-25",
currency: "EUR",
payment: { means: "credit_transfer",
iban: "BE68539007547034",
bic: "BPOTBEB1",
reference: "INV-2026-0417" },
lines: [
{ description: "Consulting — April",
quantity: 10, unit: "hours",
unitPrice: 150.00, vatRate: 21 },
{ description: "Travel",
quantity: 1, unitPrice: 450.00, vatRate: 21 },
],
},
}),
});
Response
{
"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"
}
{
"error": {
"type": "delivery_error",
"code": "RECIPIENT_NOT_FOUND",
"message": "0208:9876543210 is not registered on Peppol for document type 'invoice'.",
"requestId":"req_…"
}
}
Batch send
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 (
type,format,from,to,document,xml,file) plus an optional per-itemidempotencyKey.
{
"documents": [
{ "type": "invoice", "from": "comp_…", "to": "0208:…", "document": {…} },
{ "type": "invoice", "from": "comp_…", "to": "0208:…", "document": {…} }
]
}
{
"results": [
{ "ok": true, "id": "doc_01…", "status": "sent" },
{ "ok": false, "error": { "code": "INVALID_REQUEST", "message": "…" } }
],
"sent": 1,
"failed": 1
}
Validate without sending
Run full Peppol BIS schematron + recipient reachability checks without delivering anything. Handy as a CI step before switching a customer live.
Authentication: Authorization: Bearer <key> with a live or test Exchange key (flw_live_… / flw_test_…) or a Flowie JWT.
Request body
Same shape as Send a document, minus the raw file upload mode.
- typeenumrequired
invoicecredit-notedebit-notepurchase-orderpurchase-requestsales-orderquoteevent - formatenumoptional
jsonubl-xmlcii-xml - fromstringrequired
Sender company —
comp_…,vat:…, orpeppol:…. - tostringrequired
Recipient Peppol participant identifier (drives the reachability check).
- documentDocumentBodyrequired when format=json
Same structured body as Send. See schema.
- xmlstringrequired when format=ubl-xml / cii-xml
{
"valid": false,
"errors": [
{ "rule": "BR-16", "message": "An Invoice shall have at least one line.",
"path": "/Invoice/InvoiceLine" }
],
"warnings": [],
"recipientReachable": true
}
List documents
Paginated list across both directions — this is the polling half of receiving documents, for integrations that cannot expose a webhook endpoint.
Authentication: Authorization: Bearer <key> 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
incomingoutgoing - typeenumoptional
invoicecredit-notedebit-notepurchase-ordersales-orderquoteevent - statusstringoptional
Exact
lifecycleStatus— org-specific and may be localized (e.g.draft,sent). The delivery valuesdelivered/failedare routed todeliveryStatus. - deliveryStatusenumoptional
pendingdeliveredfailedrejectedPeppol network delivery state — use this (not
status) to find delivered documents. - from / todateoptional
Filter by
issueDaterange. - amountMin / amountMaxnumberoptional
Gross amount bounds.
- companyIdstringoptional
- searchstringoptional
Full-text over number, party names, references, note.
- limit / cursorpaginationoptional
Advanced search
Same filters as the list endpoint, but accepts a JSON body with compound expressions: AND, OR, NOT trees. Use when a query exceeds URL length limits or you need nested predicates.
Request body
- querystringoptional
Free-text query, same semantics as the list
searchparam. - filtersobjectoptional
Compound predicate tree (
AND/OR/NOT) over the same fields as the list filters (direction, type, status, deliveryStatus, issueDate range, amount bounds, companyId). - sortobjectoptional
Sort spec, e.g.
{"field": "issueDate", "order": "desc"}. - limitintegeroptional
Page size. Default
20. - cursorstringoptional
Opaque pagination cursor from the previous page.
Retrieve a document
Download XML
Returns the signed UBL XML with Content-Type: application/xml.
Download PDF
Returns a human-readable PDF rendering.
Structured view
Flat, scalar-only representation — perfect for pushing to a data warehouse or spreadsheet.
Document actions
Non-lifecycle operations: mark-read, mark-unread, archive, unarchive, tag, untag, assign, unassign, add-note, link.
- actionenumrequired
mark-readmark-unreadarchiveunarchivetaguntagassignunassignadd-notelink - 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.
curl "…/v1/documents?direction=outgoing&status=sent&from=2026-04-01&amountMin=500" \
-H "Authorization: Bearer $KEY"
curl -X POST …/v1/documents/search \
-H "Authorization: Bearer $KEY" \
-d '{
"any": [
{ "all": [{"status":"sent"},{"deliveryStatus":"failed"}]},
{ "all": [{"lifecycleStatus":"disputed"}]}
],
"from": "2026-01-01"
}'
curl -X POST …/v1/documents/doc_abc/actions \
-H "Authorization: Bearer $KEY" \
-d '{"action":"tag","tag":"priority/high"}'
Structured response
{
"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"
}
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.
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 →
Retrieve lifecycle history
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
Body
-
statusenumrequired
under_reviewapprovedrejectedpartially_paidpaiddisputed -
reasonCodeenumconditional
NONREFLEGRECQUADELPRIQTYITMPAYUNRFINPPDOTHRequired for rejected and disputed. One of the 14 official Peppol status reason codes (OPStatusReason) — full table below. 🇫🇷 France: an AFNOR motif code (XP Z12-012 annex) is forwarded verbatim as MDT-113, and
suspendedon a disputed call transmits 208 Suspendue — see FR refusal & rejection. - 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
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 (
status,reason,note,paymentDate, …) plus the targetdocumentId.
issued → paid without a prior approved) returns 409 invalid_transition and a hint listing legal next states. Fetch the history to see what's allowed now.
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"
}'
# reasonCode = one of the 14 official Peppol status
# reason codes (see “Status reason codes” below)
curl -X POST …/v1/documents/doc_abc/lifecycle \
-H "Authorization: Bearer $KEY" \
-d '{
"status": "rejected",
"reasonCode": "PRI",
"reason": "Unit price on line 3 does not match the quote"
}'
{
"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"]
}
Update lifecycle status by invoice number
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 — 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 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 with the specific documentId. |
404, never another org's document.
curl -X POST …/v1/documents/by-number/INV-2026-0042/lifecycle \
-H "Authorization: Bearer $KEY" \
-d '{
"status": "approved",
"note": "Invoice verified against PO"
}'
{
"documentId": "doc_test001",
"previousStatus": "received",
"currentStatus": "approved",
"updatedAt": "2026-04-15T10:32:18.421Z",
"compliance": {},
"allowedTransitions": ["partially_paid", "paid", "disputed"]
}
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
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 ofqorvatNumberis required. - vatNumberstringconditional
Exact VAT number, e.g.
BE0633501357orFR26921376265. One ofqorvatNumberis 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). Settrueto return every Peppol identifier-scheme / establishment row — needed when you want the exact routable participant ID. - detailenumoptional
basicfullDefault
basic(directory fields only).fullenriches each row with access-point / SMP detail — slower, one lookup per result. - limitintegeroptional
Max distinct participants to return. Default
20.
Lookup Peppol ID
Verify recipient
The recommended pre-flight check before every send. Tells you whether the recipient exists, can accept the document type, and returns the access point metadata.
Request body
- peppolIdstringrequired
Recipient Peppol participant identifier, e.g.
0208:9876543210. - documentTypestringrequired
Document type to check reachability for, e.g.
INVOICE.
curl "…/v1/directory/search?q=epsa&country=BE&limit=20" \
-H "Authorization: Bearer $KEY"
{
"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
}
curl -X POST …/v1/directory/verify \
-H "Authorization: Bearer $KEY" \
-d '{
"peppolId": "0208:9876543210",
"documentType":"INVOICE"
}'
{
"peppolId": "0208:9876543210",
"exists": true,
"canReceive": true,
"recipientName": "Globex SRL",
"documentType": "INVOICE",
"accessPoint": "peppol.ehealth.fgov.be"
}
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
At least one of peppolId or vatNumber is required.
- peppolIdstringconditional
Pattern
^\d{4}:.+$. - vatNumberstringconditional
- roleenumoptional
supplierbuyerboth - contactName / contactEmailstringoptional
- defaultsobjectoptional
currency,paymentTermsDays,note,orderReference… - tags / metadataarray / objectoptional
List partners
Query parameters
- roleenumoptional
supplierbuyerboth - 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
Path accepts part_…, vat:…, or peppol:….
Update partner
Request body
All fields optional — same shape as create.
- peppolIdstringoptional
- vatNumberstringoptional
- roleenumoptional
supplierbuyerboth - contactName / contactEmailstringoptional
- defaultsobjectoptional
- tags / metadataarray / objectoptional
Delete partner
Retrieve a partner 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 object.
List a partner’s 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 summaries.
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"]
}'
{
"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"
}
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
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 summaries.
curl …/v1/purchase-orders/PO-2026-0042/invoices \
-H "Authorization: Bearer $KEY"
{
"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
}
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
- urlhttps URLrequired
- eventsstring[]required
document.receiveddocument.updateddocument.sentdocument.delivereddocument.failedlifecycle.updatedcompany.smp_registered* - secretstringoptional
Auto-generated if omitted. Used for HMAC-SHA256 signing.
- companyIdstringoptional
Scope events to a specific managed company.
List webhooks
Query parameters
- companyIdstringoptional
Only return webhooks scoped to this managed company.
Update webhook
Request body
- urlhttps URLoptional
- eventsstring[]optional
- rotateSecretbooleanoptional
Set
trueto mint a new signing secret (returned once in the response).
Delete webhook
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"
}'
{
"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"
}
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
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
Returns 204 No Content. Acked events are hidden from subsequent list calls.
Batch acknowledge
Request body
- eventIdsstring[]required
Event IDs to acknowledge, e.g.
["evt_…", "evt_…"].
Replay an event
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": <n>} with the count of webhook deliveries scheduled.
{
"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
}
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
Query parameters
- companyIdstringoptional
Limit to a single managed company.
- countryISO 3166-1 α-2optional
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
Usage, quota, and rate-limit status for the current period.
Query parameters
- periodenumoptional
dayweekmonthyear - companyIdstringoptional
{
"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 }
}
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
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; created atomically.
- apiKeyobjectoptional
{ "name": "tenant-…", "scopes": ["send","documents.read"] }.
List managed companies
Create API key for tenant
- namestringrequired
- companyIdstringoptional
Scopes the key to that tenant.
- scopesstring[]optional
- expiresAttimestampoptional
- rateLimitobjectoptional
List platform API keys
Revoke a key
Usage breakdown
Returns total counters and a per-group array.
Query parameters
- periodstringoptional
Reporting window, e.g.
month. - groupByenumoptional
companycountrytype
Update 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
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.
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"] }
}'
{
"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" }
}
API keys
Create API key
Authenticate with a Flowie JWT (Auth0) — 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.
- expiresAttimestampoptional
- rateLimitintegeroptional
Response includes key exactly once. Store it in your secret manager immediately.
List API keys
Revoke API key
Immediate. Any request-in-flight bearing the revoked key finishes, but new requests 401.
{
"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"
}
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
List tags in a group
Tags on an object
Assign tag
Body: {"tagId": "tag_…", "objectType": "document"}.
Remove tag
AI tag recommendation
Body: {"objectId":"doc_…", "objectType":"document", "context": {…}} → ranked list of recommended tags with confidence scores.
[
{ "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 }
]
Payments
Authentication: Authorization: Bearer <key> with a live or test Exchange key (flw_live_… / flw_test_…) or a Flowie JWT.
Document payment info
Record a payment
Body: {"amount": …, "date": "YYYY-MM-DD", "reference": "…"}. Automatically advances the lifecycle to partially_paid, or 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.
Export ISO 20022 / SEPA
Generates a pain.001 SEPA credit-transfer file for a set of documents, ready for upload to your bank.
{
"documentId": "doc_abc",
"amountDue": 2359.50,
"amountPaid": 0.00,
"currency": "EUR",
"dueDate": "2026-05-25",
"status": "unpaid",
"iban": "BE68539007547034",
"reference": "INV-2026-0417"
}
AFNOR XP Z12-013
Flowie Exchange is a French PDP (Plateforme de Dématérialisation Partenaire) and ships a fully compliant implementation of the AFNOR XP Z12-013 facade. ERPs, OD (Opérateur de Dématérialisation), other PDPs, and the public PPF infrastructure all talk to us in the standardized format below — so swapping us in or out of an existing AFNOR-compliant integration is a base-URL change.
The standard defines three contractually-required interfaces that every PDP must publish. Flowie's facade implements all three at the URLs below; identifiers and verbs match the AFNOR Annexe A normative grammar verbatim.
-
Part 1 — Service de fluxflow-serviceSubmission, retrieval and search of structured flows (invoices, credit notes, status updates) between PDPs and between PDPs and the PPF concentrator. Mounted at
/afnor/flow-service/v1. -
Part 2 — Service d'annuairedirectory-serviceLookup and reverse-lookup of recipients keyed by SIREN, SIRET, and routing codes — published once per day by the PPF and queried at runtime by every PDP. Mounted at
/afnor/directory-service/v1. -
Part 3 — Webhooksflow-service/webhooksSubscription model so receiving PDPs and ODs are notified the moment a flow targeting them is processed.
Reference documents:
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
Multipart: flowInfo (JSON) + file (binary). Returns 202 Accepted with a flowId.
- flowInfo.namestringrequired
- flowInfo.flowSyntaxenumrequired
CIIUBLFactur-XCDARFRR - flowInfo.trackingIdstring (≤36)optional
- flowInfo.processingRuleenumoptional
B2BB2CB2G - flowInfo.flowProfileenumoptional
BasicCIUSExtended-CTC-FR - flowInfo.sha256hexoptional
Search flows
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
Query parameters
- docTypeenumoptional
MetadataOriginalConvertedReadableView
AFNOR webhooks
Same operations as Webhooks but under the AFNOR-shaped schema:
Create body
- callbackobjectrequired
url(required), plus optionalheaders[],authentication,signature. - metadataobjectrequired
Subscription filters:
flowType,flowDirection(required),processingRule,ackStatus(optional).
Update body — technical params only
- headersobject[]optional
- authenticationobjectoptional
- signatureobjectoptional
AFNOR directory (SIREN / SIRET / routing codes)
Every */search response uses the AFNOR envelope: search, totalNumberOfResults, results.
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.
Query parameters
- fieldsstring[]optional
Comma-separated columns to return.
Request body
- filtersobjectoptional
- sortingobject[]optional
- fieldsstring[]optional
- includestring[]optional
Expand related rows.
- limitintegeroptional
1–100. Default
50. - ignoreintegeroptional
Query parameters
- fieldsstring[]optional
- includestring[]optional
Request body
- filtersobjectoptional
- includestring[]optional
- limitintegeroptional
1–100. Default
50.
Query parameters
- fieldsstring[]optional
- includestring[]optional
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
Public, unauthenticated. Returns { "status": "ok", "version": "1.0", "service": "flow-service|directory-service" }. Required by the AFNOR PDP certification suite.
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'
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…"
}
Get directory line by id
Authentication: Authorization: Bearer <key> 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, 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.
GET /afnor/directory-service/v1/directory-line/code:0009:552100554
Authorization: Bearer flw_live_…
{
"directoryLine": {
"addressingIdentifier": "0009:552100554",
"siren": "552100554",
"name": "ACME SAS",
"status": "active"
}
}
PunchOut cart 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-urlencodedwith acxml-urlencodedorcxml-base64field.Content-Type: application/xmlwith the raw cXML PunchOutOrderMessage.
Response
An HTML <meta refresh> 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.
<cXML payloadID="..." timestamp="...">
<Header>
<Sender><Credential domain="NetworkID">...</Credential>
<SharedSecret>***</SharedSecret></Sender>
</Header>
<Message><PunchOutOrderMessage>
<BuyerCookie>org_01HY…:thread_abc:v2</BuyerCookie>
...
</PunchOutOrderMessage></Message>
</cXML>
OCI cart 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.
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
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.
List captured requests
Authentication: Authorization: Bearer <key> 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.
{
"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
}
Usage rollup
Authentication: Authorization: Bearer <key> 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.
Query parameters
- byenumoptional
keyuser - sincedatetimeoptional
ISO-8601 lower bound.
- untildatetimeoptional
ISO-8601 upper bound.
{
"rows": [
{
"apiKeyId": "key_01HY…",
"label": "erp-prod",
"requests": 1284,
"errors": 3,
"lastSeenAt": "2026-04-25T10:05:00Z"
}
]
}
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 two endpoints build and parse that message.
Build an inter-PA message
Authentication: Authorization: Bearer <key> 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. Returns the normalised subject, the 18-field csv (header + row) and a dispatched flag. Dispatch is off by default, so this is a safe build-and-preview call: nothing is emailed to the counterparty unless dispatch is enabled server-side.
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.
{
"messageType": "request",
"state": "submitted",
"requestRef": "PORT-2026-0042",
"taxpayerSiren": "552100554",
"effectiveDate": "2026-06-01"
}
{
"subject": "[PORTABILITE][REQUEST][SUBMITTED] 552100554 PORT-2026-0042",
"csv": "requestRef;state;siren;…\nPORT-2026-0042;SUBMITTED;552100554;…",
"dispatched": false
}
Parse an inbound message
Authentication: Authorization: Bearer <key> 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.
{
"subject": "[PORTABILITE][REQUEST][SUBMITTED] 552100554 PORT-2026-0042"
}
{
"messageType": "request",
"state": "submitted",
"requestRef": "PORT-2026-0042",
"taxpayerSiren": "552100554",
"valid": true
}
Health
Public, unauthenticated. Great for load balancers and synthetic monitors.
Liveness
Returns {"status":"ok"} as long as the process can serve requests.
Readiness
Includes circuit-breaker state for every upstream.
Contracts
Actively probes upstreams (SMP, national directories). Slower; don't call from a hot path.
{
"status": "ok",
"circuits": {
"peppol-smp": { "state": "closed", "failures": 0 },
"ppf-annuaire": { "state": "closed", "failures": 0 },
"document-service":{ "state": "closed", "failures": 0 }
}
}
Appendices
Address object
- streetstring
- citystring
- postalCodestring (≤20)
- regionstring
- countryISO 3166-1 α-2required
Party object
{ "name":"…", "vatNumber":"…", "address":Address, "contact": {"name":"…", "email":"…", "phone":"…"} }
PaymentInfo object
- meansenum
credit_transferdirect_debitcardcashcheque - ibanIBAN
- bicSWIFT BIC
- referencestring
- discountTermsarray
Status reason codes (Peppol BIS · OPStatusReason)
The coded vocabulary for reasonCode on lifecycle updates 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.
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.