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
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.flowie.ink/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.flowie.ink/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.
Body: { "label": "my-laptop", "email": "you@example.com", "keyType": "personal" | "platform" | "white_label" } — all optional. keyType 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.
Reset sandbox state
Wipes events, idempotency cache, and pending scheduled events for the calling organization. Body: {"confirm": "yes", "scope": "all" | "events" | "documents" | "idempotency"}. Test-mode key only.
Advance virtual clock
Move the company-scoped virtual clock forward — used to test 60-day overdue flows, retry escalations, etc. Body: {"companyId": "comp_sbx_…", "by": "60d" | "12h" | "5m"}. Wakes any scheduled events whose virtual fire-time is now in the past.
Reset virtual clock
Snap the virtual clock back to wall-clock time for a company. Body: {"companyId": "comp_sbx_…"}.
Force rate-limit
Make every subsequent request from this organization return 429 for durationSeconds (1 ≤ s ≤ 3600). Use to validate your client's retry/backoff path against a real Retry-After.
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.
Base URL
https://back.flowie.ink/exchange/v1
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.
-
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.
- 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. Any field in the company object that isn't a system-managed attribute (peppolId, status, timestamps, stats) can be updated. 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).
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
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-ordersales-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-ordersales-orderquoteevent -
formatenumoptional
json(default) — we render UBL.ubl-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
comp_…,vat:…, orpeppol:…. -
tostringrequired when type ≠ event
Recipient Peppol participant identifier, e.g.
0208:0123456789. 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.
- paymentPaymentInfooptional
means,iban,bic,reference,discountTerms[]. - deliveryobjectoptional
- linesInvoiceLine[]required
Each line:
description,quantity,unit,unitPrice,vatRate,vatCategory,itemCode,period. - allowances / chargesarrayoptional
Document-level discounts or surcharges.
- attachmentsarrayoptional
- totalsobjectoptional
Pre-computed totals. If omitted, we derive them from lines + allowances.
-
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.
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.
{
"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.
{
"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.
Query parameters
- directionenumoptional
incomingoutgoing - typeenumoptional
- status / deliveryStatusenumoptional
- 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.
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
- tag / userId / note / relatedDocumentId—conditional
Depends on
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.
Retrieve lifecycle history
Full event log, current status, allowed transitions, and per-country compliance state.
Update lifecycle status
Body
-
statusenumrequired
under_reviewapprovedrejectedpartially_paidpaiddisputed - reason / reasonCodestringoptional
Required for
rejectedanddisputed. Use the official Peppol reason codes. - 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.
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"
}'
{
"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"]
}
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
- qstringoptional
- vatNumberstringoptional
- country / city / postalCodestringoptional
- naceCodesstring[]optional
- documentTypesstring[]optional
Only return participants that can receive these types.
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.
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
Filters: role, search, country, tags, hasActivity, peppolStatus, sortBy, order, limit, cursor.
Retrieve partner
Path accepts part_…, vat:…, or peppol:….
Update partner
Delete partner
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"
}
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
Update webhook
Body: url, events, or rotateSecret: true.
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
Filters: type, companyId, limit.
Acknowledge one event
Returns 204 No Content. Acked events are hidden from subsequent list calls.
Batch acknowledge
Body: {"eventIds": ["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
Filters: companyId, country.
Compliance reports
Every report record has documentId, reportedTo, platformResponse, and an error if the authority rejected.
Stats
Usage, quota, and rate-limit status for the current period. Filters: period (day, week, month, year), companyId.
{
"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
- name / address / metadata—optional
- 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.
- scopes / expiresAt / rateLimit—optional
List platform API keys
Revoke a key
Usage breakdown
Query: period, groupBy (company, country, type). Returns total counters and a per-group array.
Update platform settings
Body: branding, defaults, customDomain.
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
- 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
Document payment info
Record a payment
Body: {"amount": …, "date": "YYYY-MM-DD", "reference": "…"}. Automatically advances the lifecycle to partially_paid or paid.
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
Body is SearchFlowParams — updatedAfter, updatedBefore, processingRule[], flowType[], flowDirection[], trackingId, ackStatus. Filters are AND-combined; array values are OR-combined.
Retrieve a flow
Query: docType=Metadata|Original|Converted|ReadableView.
AFNOR webhooks
Same operations as Webhooks but under the AFNOR-shaped schema:
AFNOR directory (SIREN / SIRET / routing codes)
Search body: filters, sorting, fields, include, limit (1–100, default 50), ignore. Response envelope: search, totalNumberOfResults, results.
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. Body schema mirrors routing-code/search; response is currently empty (returns the AFNOR search envelope with totalNumberOfResults: 0) until the PDP-PDP federation handshake is wired up.
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…"
}
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>
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
Rejection reason codes (Peppol BIS 3)
| Code | Meaning |
|---|---|
RE | Receipt rejected by the buyer |
NOA | No adjustment was possible |
QUA | Quantity discrepancy |
PRI | Price disagreement |
REF | Buyer reference incorrect |
TAX | Tax calculation mismatch |
DUP | Duplicate invoice |
OTH | Other — always pair with reason |
