---
title: "API Reference"
description: "Complete API reference for Flowie Exchange. Endpoints, schemas, parameters, errors, and runnable examples."
canonical: "https://docs.get-flowie.com/reference/"
source: "https://docs.get-flowie.com/reference/index.html"
---

# API Reference

API Reference · v3.0.0

# Flowie Exchange API

The Flowie Exchange API is a single REST API for **sending, receiving, and managing electronic invoices over the Peppol network**. It covers [47 countries](<../compliance/index.html>) across Europe, MENA, and Asia-Pacific, handles regulatory compliance reporting automatically, and scales from a freelancer sending one invoice per month to a white-label platform managing thousands of tenant companies.

Base URL

`https://back.p2p-flowie.com/exchange` in production · `https://back.flowie.ink/exchange` in sandbox. Every path below is prefixed with `/v1`. Pick an environment in the session menu (top right) and each endpoint header below shows the full URL for it, ready to copy.

### Quick index

  * [Postman collection](<#postman>)
  * [Authentication1](<#authentication>)
  * [Idempotency](<#idempotency>)
  * [Pagination](<#pagination>)
  * [Rate limits & quotas](<#rate-limits>)
  * [Errors1](<#errors>)
  * [Versioning](<#versioning>)
  * [Sandbox mode6](<#sandbox>)
  * [Agent auth6](<#agent-auth>)
  * [Companies14](<#companies>)
  * [Documents10](<#documents>)
  * [Lifecycle4](<#lifecycle>)
  * [Directory3](<#directory>)
  * [Partners7](<#partners>)
  * [Purchase orders1](<#purchase-orders>)
  * [Webhooks4](<#webhooks>)
  * [Events4](<#events>)
  * [Compliance2](<#compliance>)
  * [Stats1](<#stats>)
  * [Platform8](<#platform>)
  * [API keys3](<#api-keys>)
  * [Categorization6](<#categorization>)
  * [Payments3](<#payments>)
  * [Request log3](<#request-log>)
  * [Portability9](<#portability>)
  * [UBL generator (France)7](<#ubl-generator>)
  * [AFNOR XP Z12-01318](<#afnor>)
  * [PunchOut cart callback2](<#punchout>)
  * [Health3](<#health>)
  * [Appendices](<#appendices>)



## Postman collection

Prefer to explore the API in [Postman](<https://www.postman.com/>)? Download the ready-made collection — every endpoint, pre-filled with a working example body — and import it in seconds.

[⬇ Download Postman collection](<../postman_collection.json>) [⬇ OpenAPI 3.1 spec](<../openapi.json>)

In Postman: **Import** → drop the file, or paste the URL `https://docs.get-flowie.com/postman_collection.json`. Then set the collection variables:

  * `baseUrl` — `https://back.flowie.ink/exchange` (sandbox) or `https://back.p2p-flowie.com/exchange` (production).
  * `token` — your API key or JWT. It is sent as `Authorization: Bearer {{token}}` on every request (collection-level bearer auth).



Authenticating with the [client-credentials grant](<#m2m>) instead? Leave `token` empty, fill in `tokenUrl`, `clientId`, `clientSecret`, `audience` and `organizationId`, and send **auth → Get an access token** : it stores the token in `{{token}}` for every other request, and each request carries `X-Flowie-Organization-Id: {{organizationId}}` — which a machine token cannot do without.

Hit **Send** on any request to call the sandbox straight away. The collection is regenerated on every release, so it always matches this reference. Prefer to generate your own client? Import the [`openapi.json`](<../openapi.json>) spec instead.

## Authentication

Every request must carry a bearer token. Flowie Exchange supports three kinds of credentials; pick whichever matches your caller.

### Flowie JWT

If the caller is a Flowie dashboard user, pass the Auth0-issued JWT you already use elsewhere. The organization is resolved from the `_permissions` claim.

#### Switching organizations

JWTs typically grant access to multiple organizations (the user's `_permissions` claim is a dict of `org_id → permissions`). By default the API picks the first one in that dict. To act as a specific organization, pass the `X-Flowie-Organization-Id` header on every request:
[code] 
    curl https://back.p2p-flowie.com/exchange/v1/documents \
      -H "Authorization: Bearer eyJhbGc..." \
      -H "X-Flowie-Organization-Id: 685a5670efafaa26ebf0128e"
[/code]

The header is validated against the JWT's `_permissions`: passing an org the token doesn't grant returns `403`. `Organization-Id` (the legacy name used by the AFNOR routes) is also accepted as an alias.

To list every org a caller can switch to, hit [`GET /v1/me`](<#get-me>). The Flowie docs auth widget uses this endpoint to render the org-picker dropdown next to your email.

**API keys** are bound to a single org at creation time and ignore this header — a key issued for one org, sent with a header naming another, answers `200` with the _key's_ rows and no warning. Confirm which org a key acts as with [`GET /v1/me`](<#get-me>).

### OAuth 2.0 client credentials (machine-to-machine)

For a backend that runs unattended — an ERP or D365 connector, a nightly sync, a webhook consumer — with no user to sign in. Flowie issues a client id and secret per integration, backed by a technical account made a member of your organization with a role; that membership is what grants access. Staging and production are separate applications with separate credentials.
[code] 
    curl -X POST https://login.flowieapp.io/oauth/token \
      -H "Content-Type: application/json" \
      -d '{
        "grant_type": "client_credentials",
        "client_id": "...",
        "client_secret": "...",
        "audience": "https://auth.flowie.me"
      }'
[/code]

Token endpoint: `https://login.flowieapp.io/oauth/token` in sandbox, `https://login.flowie.me/oauth/token` in production. The response is `{"access_token": "eyJ…", "token_type": "Bearer", "expires_in": 86400}` — put `access_token` in the `Authorization` header. Cache it and renew it on `expires_in` rather than minting one per call.

The `audience` is required and is _not_ the API base URL. A wrong one is refused at the token endpoint with `403 access_denied` — which reads like a credentials problem and is not one: it means the client is not authorized for that audience, either because it is misspelled or because it has not been granted access to the Flowie API.

A machine token carries no organization

An Auth0 `client_credentials` token has an empty `_permissions` claim and names no organization, so [`X-Flowie-Organization-Id`](<#org-switching>) is not an override here — it is the only thing that tells the API which tenant you mean. Send it on **every** call. Omit it and you get `403 No organization found in token`; name an organization your technical account is not a member of and you get `403 Token does not grant access to organization '…'. Available: none.` (`Available` reads `none` for every machine token — that part is normal.) Getting a token proves the audience grant only; the organization membership is a second, separate grant. 
[code] 
    curl https://back.p2p-flowie.com/exchange/v1/documents \
      -H "Authorization: Bearer $ACCESS_TOKEN" \
      -H "X-Flowie-Organization-Id: 019b47ba-..."
[/code]

The whole grant is in the [Postman collection](<#postman>) — send _auth → Get an access token_ and every other request is authenticated. Full walkthrough, including provisioning and rate limits: [the authentication guide](<../auth.md>).

### Exchange API keys

For programmatic access, issue an Exchange API key from the dashboard or [via the API](<#create-api-key>). Keys are prefixed so you can tell them apart at a glance:

  * flw_live_…Personal key

Scoped to a single company. Use for server-to-server calls from your own stack.

  * flw_plat_live_…Platform key

Scoped to an organization that manages other companies. Combine with `X-Flowie-Company` to act on behalf of a tenant.

  * flw_wl_live_…White-label key

Same as a platform key, plus the ability to customize branding, quotas, and settings per tenant.

  * flw_test_…Sandbox key

Any of the above with `_test_` in the prefix hits sandbox. No real Peppol delivery.




🔒 Keys are shown once

The full key string is returned exactly once, at creation. After that, only the key prefix is visible. Rotate a compromised key immediately — revoke it at [DELETE /v1/api-keys/{id}](<#revoke-api-key>). 

### Scopes

Keys carry a list of scopes. Use `*` only for full-access keys you control end-to-end; prefer the narrowest set your workload needs.

`send` `receive` `documents.read` `documents.search` `documents.write` `companies.read` `companies.write` `directory` `partners` `payments` `lifecycle` `compliance` `stats` `platform` `*`
[code] 
    curl https://back.p2p-flowie.com/exchange/v1/companies \
      -H "Authorization: Bearer flw_live_abc123"
[/code]

##### Acting on a managed company (platform keys)
[code] 
    Authorization: Bearer flw_plat_live_xyz789
    X-Flowie-Company:  comp_abc123def456
[/code]

### Get caller identity + accessible orgs

GET/v1/me

Returns who the caller is, which organizations they can act as, and the active org for the current request. Works with both JWT and API-key auth. Used by the docs auth widget to render the organization-switcher dropdown.

#### Returns
[code] 
    {
      "authMethod":      "jwt",
      "userId":          "user_…",
      "email":           "alice@example.com",
      "keyType":         "jwt",
      "organizationId":  "org_685a5670efafaa26ebf0128e",
      "organizationIds": ["org_685a…", "org_72b1…"],
      "organizations": [
        { "id": "org_685a…", "name": "PMU",        "country": "FR", "vatNumber": "FR12345678901" },
        { "id": "org_72b1…", "name": "Subsidiary", "country": "FR", "vatNumber": "FR98765432109" }
      ],
      "scopes":     ["*"],
      "isTestMode": false
    }
[/code]
[code] 
    curl https://back.p2p-flowie.com/exchange/v1/me \
      -H "Authorization: Bearer eyJhbGc..."
[/code]

## Idempotency

Network calls are imperfect. Any `POST` in this API accepts an `Idempotency-Key` header; if a request with that key has already completed in the last 24 hours, we return the original response byte-for-byte instead of acting again.

  * Keys are strings, up to 255 characters. UUID v4 works great.
  * Cache TTL is 24 hours. After that, a repeated key is treated as new.
  * If you retry _before_ the first response has finished processing, you'll get a `409 idempotency_in_progress`. Retry in a moment.
  * Mutating a request under the same key is never allowed. We compare the full body hash — mismatched retries return `422 idempotency_body_mismatch`.



Best practice

Generate the idempotency key _before_ the first attempt — typically from your database row ID, not a random UUID on retry. That way, a crash between generation and HTTP call can still be recovered. 
[code] 
    curl -X POST …/v1/documents/send \
      -H "Authorization: Bearer $KEY" \
      -H "Idempotency-Key: $(uuidgen)" \
      -d @invoice.json
[/code]

## Pagination

All list endpoints are cursor-paginated. Don't hard-code offsets — the cursor is an opaque server-issued token and will change format without notice.

  * limitintegeroptional

Page size. Default `20`, max `100`.

  * cursorstringoptional

Pass the `cursor` value returned by the previous page. Omit to start at the first page.




Every list response has the same envelope:
[code] 
    {
      "data":    [ /* records */ ],
      "hasMore": true,
      "cursor":  "eyJpZCI6ImRvY19YLi4uIn0"
    }
[/code]

##### Iterate all pages
[code] 
    cursor = None
    while True:
        params = {"limit": 100}
        if cursor: params["cursor"] = cursor
        page = api.get("/documents", params=params).json()
        for doc in page["data"]:
            process(doc)
        if not page["hasMore"]: break
        cursor = page["cursor"]
[/code]

## Rate limits & quotas

Rate limits are enforced with a 60-second sliding window per key. Quotas are enforced monthly per organization. Both depend on your plan:

Plan| Requests / min| Documents / month  
---|---|---  
Free| 60| 50  
Starter| 120| 500  
Pro| 300| 5,000  
Platform| 600| 50,000  
White-label| 1,200| Unlimited  
  
Every response includes the current state:

  * X-RateLimit-Limitinteger

Requests allowed in the current 60-second window.

  * X-RateLimit-Remaininginteger

Requests left before you're throttled.

  * X-RateLimit-Resetunix timestamp

When the window rolls over.

  * Retry-Afterseconds

Present only on `429`. How long to wait before retrying.




Exponential backoff

On `429` or `503`, wait `Retry-After` seconds (or `2ⁿ × 250ms` jittered) and try again. Don't retry `4xx` client errors — they'll always fail. 

##### 429 response
[code] 
    HTTP/1.1 429 Too Many Requests
    Retry-After: 37
    X-RateLimit-Limit:     300
    X-RateLimit-Remaining: 0
    X-RateLimit-Reset:     1714046400
    
    {
      "error": {
        "type": "rate_limit_error",
        "code": "RATE_LIMITED",
        "message": "You have exceeded 300 req/min. Retry in 37s.",
        "requestId": "req_01HXYZ…"
      }
    }
[/code]

## Errors

Every error response uses the same envelope (RFC 7807 + Flowie extensions). See the [error catalog](<errors.html>) for a full list of codes and how to fix them.

Status| Meaning  
---|---  
`400`| The request is malformed or fails validation.  
`401`| Missing, expired, or invalid credentials.  
`403`| Credentials are valid but lack the required scope or company access.  
`404`| The resource doesn't exist (or isn't visible to you).  
`409`| Conflict — typically an idempotency or state transition issue.  
`422`| Semantically invalid (e.g. VAT not in directory, unreachable recipient).  
`429`| Rate-limited. Honor `Retry-After`.  
`500`| Internal error. Report `requestId` to support.  
`502 / 503`| Upstream service unavailable. Circuit breaker may be open.  
  
### Request inspector

GET/v1/requests/{request_id}

Every response carries an `X-Request-Id` header (and a `requestId` field on errors). Pass it back to this endpoint to retrieve the full request trace: timing, intermediate upstream calls, validation diff, and final status. Mirrors what you see at [requests.html](<../playground/requests.html>).

##### Error shape
[code] 
    {
      "error": {
        "type":    "validation_error",
        "code":    "INVALID_REQUEST",
        "message": "Request validation failed",
        "details": [
          { "field": "document.lines[0].vatRate",
            "rule":  "range",
            "message":"Must be between 0 and 100" }
        ],
        "requestId": "req_01HXYZ2K3M4N5P6Q7R",
        "docUrl":    "https://docs.get-flowie.com/errors#INVALID_REQUEST"
      }
    }
[/code]

## Versioning

The API version is baked into the URL (`/v1/…`). We follow semantic versioning with these commitments:

  * **Breaking changes** ship under a new path (`/v2/…`). Old paths stay alive for at least 12 months.
  * **Additive changes** — new fields, new enum values, new endpoints — land in `/v1/` without notice.
  * **Deprecations** are announced in the [changelog](<../changelog.html>) and flagged with the `Sunset` response header 6+ months before removal.



Forward-compatible parsers

Ignore unknown fields. Treat enum values as opaque strings. That way your integration survives any additive change automatically. 

##### Sunset header example
[code] 
    Sunset: Wed, 01 Oct 2026 00:00:00 GMT
    Deprecation: true
    Link: <https://docs.get-flowie.com/changelog#send-v1>; rel="deprecation"
[/code]

## Sandbox mode

Use a `flw_test_…` key with the staging base URL. Sandbox behaves identically to live with these differences:

  * Documents are **not** delivered to real Peppol access points — they're routed to an internal echo endpoint.
  * Compliance reporting goes to a mock PPF/SDI that always accepts.
  * Webhooks fire the same events with `"livemode": false` in the payload.
  * There are no quotas; rate limits remain.



### Bootstrap a sandbox key

POST/v1/sandbox/bootstrap

Public, unauthenticated. Mints a fresh `flw_test_*` API key bound to a brand-new throwaway organization plus a Belgian sandbox company (`BE0000000001`, peppolId `0208:0000000001`). Returns the key only once. Rate-limited per IP; meant for the docs Playground and CI smoke tests.

#### Request body

  * labelstringoptional

Free-form tag for the issued key — appears in the dashboard. Default `quickstart`. Max 64 chars.

  * emailstringoptional

Optional contact email (we may follow up with usage tips).

  * keyTypeenumoptional

`personal``platform``white_label`

Defaults to `personal` (token prefix `flw_test_`). Pass `platform` to mint a multi-tenant key (`flw_plat_test_`) that satisfies the platform-key gate on `/v1/platform/*` ops, or `white_label` for the branding-enabled variant (`flw_wl_test_`). See [Sandbox · Key types](<../sandbox/index.html#key-types>).




### Reset sandbox state

POST/v1/sandbox/reset

Wipes events, idempotency cache, and pending scheduled events for the calling organization. Test-mode key only.

#### Request body

  * confirmenumrequired

Type the literal string `yes` to acknowledge the wipe.

  * scopeenumoptional

`all``documents``events``idempotency`

What to wipe. Defaults to `all`.




### Advance virtual clock

POST/v1/sandbox/clock/advance

Move the company-scoped virtual clock forward — used to test 60-day overdue flows, retry escalations, etc. Wakes any scheduled events whose virtual fire-time is now in the past.

#### Request body

  * companyIdstringrequired

Company whose virtual clock should be advanced.

  * bystringrequired

How far to jump. Accepts compact units: `1h`, `3d`, `2w`, `1m`, `1y`.




### Reset virtual clock

POST/v1/sandbox/clock/reset

Snap the virtual clock back to wall-clock time for a company.

#### Request body

  * companyIdstringrequired




### Force rate-limit

POST/v1/sandbox/rate-limit/exhaust

Make every subsequent request from this organization return `429`. Use to validate your client's retry/backoff path against a real `Retry-After`.

#### Request body

  * durationSecondsintegeroptional

How long the forced `429` should last. Default `60`, range 1–3600 (max 1 hour).




### Flush idempotency cache

POST/v1/sandbox/idempotency/flush

Drop the 24h idempotency cache for the calling key — useful when you want to re-issue a request that previously succeeded under the same `Idempotency-Key`. No request body.

##### Base URL
[code] 
    https://back.flowie.ink/exchange/v1
[/code]

## Agent auth — OAuth & handoff

Three ways an AI agent gets a key. **Handoff** is the fastest: a human generates a single-use link and pastes it to the agent, which redeems it in one call. **Sandbox bootstrap** ([below](<#sandbox-bootstrap>)) needs no human at all. **OAuth with PKCE** is the full consent flow when the agent must act on a real user's account and you want an approval screen. The end-to-end walkthrough lives in the [agent onboarding guide](<../build-with-ai/agent-onboarding.html>).

### List grantable scopes

GET/v1/oauth/scopes

**Authentication:** none — this endpoint is public.

Every grantable scope with a human-readable description. Agents call this once at boot to render an honest scope-selection UI before starting the consent flow.
[code] 
    {
      "scopes": [
        { "id": "send",           "description": "Send documents" },
        { "id": "documents.read", "description": "Read documents" },
        { "id": "lifecycle",      "description": "Advance lifecycle statuses" }
      ]
    }
[/code]

### Start consent (PKCE)

POST/v1/oauth/authorize

**Authentication:** none — this endpoint is public.

Step 1 of the consent flow. Returns the URL the agent shows the user. RFC 7636 PKCE: the agent keeps a random `code_verifier` secret and sends only its SHA-256 challenge.

#### Request body

  * client_namestringrequired

Agent display name, shown on the consent screen.

  * scopesstring[]required

Scopes requested, from [the catalogue](<#oauth-scopes>).

  * code_challengestringrequired

`BASE64URL(SHA256(code_verifier))`, no padding.

  * code_challenge_methodstringoptional

`S256`. The plain method is not accepted.

  * redirect_uristringoptional

Omit for out-of-band: the code is shown on screen for the user to paste.

  * statestringoptional

Echoed back on redirect.



[code] 
    {
      "client_name": "My Agent",
      "scopes": ["send", "documents.read"],
      "code_challenge": "E9Melhoa2Ow…",
      "code_challenge_method": "S256"
    }
[/code]
[code] 
    {
      "request_id":  "areq_01HY…",
      "consent_url": "https://back.flowie.ink/exchange/consent?request=areq_01HY…",
      "expires_in":  600
    }
[/code]

### Exchange the code for a key

POST/v1/oauth/token

**Authentication:** none — this endpoint is public.

Final step of the consent flow. The server hashes `code_verifier` and checks it against the challenge recorded at [/authorize](<#oauth-authorize>). The code is single-use and expires 5 minutes after consent.

#### Request body

  * grant_typestringrequired

`authorization_code`.

  * codestringrequired

The one-time code from the consent screen.

  * code_verifierstringrequired

The 43–128 character secret whose SHA-256 was sent as the challenge.



[code] 
    {
      "grant_type":    "authorization_code",
      "code":          "ac_01HY…",
      "code_verifier": "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
    }
[/code]
[code] 
    {
      "access_token":    "flw_test_…",
      "scopes":          ["send", "documents.read"],
      "expires_in":      604800,
      "organization_id": "org_01HY…",
      "company_id":      "comp_01HY…"
    }
[/code]

### Mint a handoff link

POST/v1/oauth/handoff

**Authentication:** `Authorization: Bearer <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](<#oauth-handoff-exchange>) and gets a key bound to _your_ organization — no consent screen. You can only grant scopes you hold yourself.

#### Request body

  * scopesstring[]optional

Defaults to the scopes of the calling key.

  * labelstringoptional

Shown in the API-key list so you can revoke the right one later.

  * ttl_secondsintegeroptional

Token lifetime, 60 minutes maximum.



[code] 
    {
      "scopes": ["send"],
      "label": "claude-desktop",
      "ttl_seconds": 900
    }
[/code]
[code] 
    {
      "handoff_url": "https://docs.get-flowie.com/build-with-ai/agent-onboarding.html?handoff=hand_AbC…",
      "handoff_token": "hand_AbC…",
      "expires_in": 900
    }
[/code]

### Anonymous sandbox handoff

POST/v1/oauth/handoff/sandbox

**Authentication:** none — this endpoint is public.

Bootstraps a fresh sandbox organization _and_ mints a handoff token in one call. Rate-limited to 120 requests per IP per hour, like [sandbox bootstrap](<#sandbox-bootstrap>). This is what lets the docs home page hand an agent a URL that is already authenticated.
[code] 
    {
      "handoff_token": "hand_AbC…",
      "organization_id": "org_sbx_01HY…",
      "company_id": "comp_sbx_01HY…",
      "expires_in": 3600
    }
[/code]

### Redeem a handoff token

POST/v1/oauth/handoff/exchange

**Authentication:** none — this endpoint is public.

Redeem the token for an API key. Single-use: a second attempt returns `400 invalid_grant`. This is the whole of path 1 — one POST, no PKCE, no consent UI.

#### Request body

  * handoff_tokenstringrequired

The `hand_…` value from the URL you were given.



[code] 
    {
      "handoff_token": "hand_AbC…"
    }
[/code]
[code] 
    {
      "access_token":    "flw_test_…",
      "scopes":          ["send"],
      "expires_in":      604800,
      "organization_id": "org_01HY…",
      "company_id":      "comp_01HY…"
    }
[/code]

## Companies

A **company** represents a legal entity that can send or receive documents on Peppol. Create one per VAT number you operate under. Flowie auto-enriches the legal name, address, and Peppol identifier, then registers the company with the Peppol SMP so other access points can route messages to it.

The company object

  * idstring

Unique identifier, `comp_…`.

  * name / legalNamestring

Display name and registered legal name.

  * vatNumberstring

Normalized `^[A-Z]{2}[A-Z0-9]+$`.

  * countryISO 3166-1 α-2

Derived from the VAT prefix.

  * peppolIdstring

Scheme-prefixed Peppol participant identifier, e.g. `0208:0123456789`.

  * additionalIdentifiersobject[]

Extra identifiers (GLN, DUNS, SIRET…).

  * addressAddress

Postal address. See [Address](<#address-object>).

  * capabilitiesobject

Which document types the company can send/receive.

  * statusstring

`active`, `inactive`, or `suspended`.

  * smpRegisteredboolean

True once the SMP record is live.

  * smpRegisteredAttimestamp

When SMP registration completed.

  * complianceobject

Per-country compliance status (PPF for FR, SDI for IT). Belgium has no regulator-side report; the field is empty for BE companies.

  * settingsobject

Sending preferences, default currency, auto-reporting toggles.

  * statsobject

Summary counters (documents sent, received).

  * metadataobject

Your free-form key-value store.

  * createdAt / updatedAttimestamp

ISO 8601 UTC.




### Create a company

POST/v1/companies

Registers a new company. Only `vatNumber` is strictly required — everything else is auto-enriched from the national registry (INSEE, KBO, Camera di Commercio, …) and the Peppol directory.

#### Request body

  * vatNumberstringrequired

Country prefix + number, e.g. `BE0123456789`. Pattern `^[A-Z]{2}[A-Z0-9]+$`.

  * namestringoptional

Display name. Defaults to the enriched legal name.

  * addressAddressoptional

Overrides the auto-enriched address.

  * additionalIdentifiersobject[]optional

Extra routing identifiers. `{ "scheme": "0088", "value": "1234567890128" }` for GLN, etc.

  * capabilitiesobjectoptional

`{"send": ["invoice","credit-note"], "receive": ["invoice"]}`. Default: full set.

  * settingsobjectoptional

Default currency, auto-compliance toggles, preferred contact.

  * complianceobjectoptional

Per-country compliance configuration overrides (e-reporting enrolment, PPF/SDI routing hints).

  * metadataobjectoptional

Free-form key-value (max 40 keys, 500 chars each).




#### Returns

The [company object](<#companies>) with status `201`. SMP registration happens asynchronously — listen for `company.smp_registered` via webhook.

Duplicates

Calling create with a `vatNumber` already owned by your organization returns `409 duplicate` with the existing `companyId`. Use that as your idempotent upsert. 

##### Request
[code] 
    curl -X POST https://back.p2p-flowie.com/exchange/v1/companies \
      -H "Authorization: Bearer $KEY" \
      -H "Content-Type: application/json" \
      -H "Idempotency-Key: upsert-acme-be" \
      -d '{
        "vatNumber": "BE0123456789",
        "capabilities": {
          "send":    ["invoice","credit-note"],
          "receive": ["invoice"]
        },
        "metadata": { "tenantId": "t_acme" }
      }'
[/code]

##### Response

201 Created 409 Duplicate 422 Unknown VAT
[code] 
    {
      "id":        "comp_01HXYZ123ABC",
      "name":      "ACME Business Solutions BVBA",
      "legalName": "ACME Business Solutions BVBA",
      "vatNumber": "BE0123456789",
      "country":   "BE",
      "peppolId":  "0208:0123456789",
      "additionalIdentifiers": [],
      "address": {
        "street":     "Rue de la Loi 16",
        "city":       "Bruxelles",
        "postalCode": "1000",
        "country":    "BE"
      },
      "capabilities": {
        "send":    ["invoice","credit-note"],
        "receive": ["invoice"]
      },
      "status":           "active",
      "smpRegistered":    false,
      "smpRegisteredAt":  null,
      "compliance":       {},
      "settings":         { "defaultCurrency": "EUR" },
      "stats":            { "sent": 0, "received": 0 },
      "metadata":         { "tenantId": "t_acme" },
      "createdAt":        "2026-04-25T10:00:00Z",
      "updatedAt":        "2026-04-25T10:00:00Z"
    }
[/code]
[code] 
    {
      "error": {
        "type":     "conflict",
        "code":     "COMPANY_EXISTS",
        "message":  "A company with this VAT already exists in your organization.",
        "details":  [{ "field": "vatNumber", "value": "BE0123456789",
                      "existingId": "comp_01HXYZ…" }],
        "requestId":"req_01HXYZ…"
      }
    }
[/code]
[code] 
    {
      "error": {
        "type":    "invalid_request_error",
        "code":    "VAT_NOT_FOUND",
        "message": "VAT BE0000000000 is not in the national registry.",
        "requestId":"req_01HXYZ…"
      }
    }
[/code]

### List companies

GET/v1/companies

Returns all companies you own or manage, most-recently created first.

#### Query parameters

  * countryISO 3166-1 α-2optional

Filter by country.

  * statusstringoptional

`active`, `inactive`, or `suspended`.

  * searchstringoptional

Full-text over name, legal name, VAT, and Peppol ID.

  * include_addressbooleanoptional

Resolve each row's `legalAddressId` into a full `address` object (adds round-trips). Default `true` — set `false` for a faster, lighter list.

  * limit / cursorpaginationoptional

See [Pagination](<#pagination>).



[code] 
    curl "https://back.p2p-flowie.com/exchange/v1/companies?country=BE&status=active&limit=50" \
      -H "Authorization: Bearer $KEY"
[/code]
[code] 
    {
      "data": [
        { "id": "comp_01HXYZ…", "name": "ACME BVBA",
          "vatNumber": "BE0123456789", "country": "BE",
          "peppolId": "0208:0123456789", "status": "active" }
      ],
      "hasMore": false,
      "cursor":  null
    }
[/code]

### Resolve by VAT / SIREN

GET/v1/companies/resolve

Looks up any company, anywhere, by legal identifier — returns the same shape as the company object but synthesized from national registries and the Peppol directory. Use it to pre-fill forms, verify recipients, or check Peppol reachability.

#### Query parameters

  * countryCodeISO 3166-1 α-2required

  * vatNumberstringone of

  * registrationNumberstringone of

SIREN, KBO, CF, … depending on `countryCode`.



[code] 
    curl "https://back.p2p-flowie.com/exchange/v1/companies/resolve?countryCode=FR&registrationNumber=797978996" \
      -H "Authorization: Bearer $KEY"
[/code]

### Search companies

GET/v1/companies/search

Autocomplete over your managed companies. Optimized for < 80 ms response time. Use for dropdowns in UIs.

  * qstringrequired

Query fragment (min 2 chars).

  * countryCodeISO 3166-1 α-2optional

  * limitintegeroptional

Default `10`, max `50`.



[code] 
    [
      { "id": "comp_…", "name": "ACME BVBA", "vatNumber": "BE0123456789",
        "country": "BE", "peppolId": "0208:0123456789" }
    ]
[/code]

### Retrieve a company

GET/v1/companies/{company_id}

Returns the [company object](<#companies>). The path parameter accepts three forms:

  * `comp_01HXYZ…` — the canonical id
  * `vat:BE0123456789` — VAT-scoped lookup
  * `peppol:0208:0123456789` — Peppol-ID lookup


[code] 
    curl https://back.p2p-flowie.com/exchange/v1/companies/vat:BE0123456789 \
      -H "Authorization: Bearer $KEY"
[/code]

### Update a company

PATCH/v1/companies/{company_id}

Partial update. System-managed attributes (`peppolId`, `status`, timestamps, stats) are read-only. Merging rules:

  * Top-level keys are replaced wholesale.
  * `metadata` is shallow-merged. Set a key to `null` to delete it.
  * Changing `capabilities.send` or `capabilities.receive` may trigger an SMP re-registration (you'll see a `company.smp_registered` event).



#### Request body

All fields optional — send only what you want to change.

  * namestringoptional

Display name.

  * addressAddressoptional

  * capabilitiesobjectoptional

`{"send": [...], "receive": [...]}`. May trigger SMP re-registration.

  * settingsobjectoptional

  * complianceobjectoptional

  * metadataobjectoptional

Shallow-merged. Set a key to `null` to delete it.




### Deregister a company

DEL/v1/companies/{company_id}

Permanently removes the SMP record and marks the company inactive. Historical documents remain queryable. Returns `204 No Content`.

### Join requests

If a Flowie user wants to connect to an already-registered company, they hit `POST /companies/{id}/join`. The company's organization admins see pending requests via:

GET/v1/companies/join-requests

and accept or reject with:

POST/v1/companies/{company_id}/join

Issue a join request as the calling user.

POST/v1/companies/{company_id}/join-requests/{request_id}/accept

POST/v1/companies/{company_id}/join-requests/{request_id}/reject
[code] 
    curl -X PATCH \
      https://back.p2p-flowie.com/exchange/v1/companies/comp_abc \
      -H "Authorization: Bearer $KEY" \
      -H "Content-Type: application/json" \
      -d '{
        "settings": { "defaultCurrency": "EUR" },
        "metadata": { "tier": "premium", "oldKey": null }
      }'
[/code]

### Import a company (portability)

POST/v1/companies/import

**Authentication:** `Authorization: Bearer <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 `siren` instead only when the establishment is unknown.

  * companyNamestringoptional

Overrides the legal name resolved from the annuaire.

  * countryCodestringoptional

ISO-3166 alpha-2. Defaults to `FR`.

  * modeenumoptional

Migration mode. Governs whether the existing provider connection is reused or re-provisioned.

  * sovosOrganizationIdstringoptional

Existing provider organization id, when migrating a company already live elsewhere.



[code] 
    {
      "siret": "55210055400013",
      "mode": "portability"
    }
[/code]
[code] 
    {
      "id":        "comp_01HY7AB9C2DE3FG",
      "siren":     "552100554",
      "peppolId":  "0009:552100554",
      "name":      "ACME SAS",
      "country":   "FR",
      "imported":  true
    }
[/code]

### Import companies in bulk

POST/v1/companies/import/batch

**Authentication:** `Authorization: Bearer <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](<#import-company>).



[code] 
    {
      "items": [
        { "siret": "55210055400013" },
        { "siret": "39876543200025" }
      ]
    }
[/code]
[code] 
    {
      "results": [
        { "ok": true,  "siret": "55210055400013", "id": "comp_01…" },
        { "ok": false, "siret": "39876543200025", "error": { "code": "SIRET_NOT_FOUND" } }
      ],
      "imported": 1,
      "failed":   1
    }
[/code]

### Register a company on Peppol

POST/v1/companies/{company_id}/register

**Authentication:** `Authorization: Bearer <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](<#verify-recipient>) of your own id returns `canReceive: false`.

Idempotent. For an organization already provisioned this re-syncs and re-activates the local registration; for a new one it provisions the provider customer config and managed connection first.

#### Path parameters

  * company_idstringrequired

The company to register, e.g. `comp_01HY7AB9C2DE3FG`.




No request body.
[code] 
    POST /v1/companies/comp_01HY7AB9C2DE3FG/register
    Authorization: Bearer flw_live_…
[/code]
[code] 
    {
      "id":         "comp_01HY7AB9C2DE3FG",
      "peppolId":   "0208:0123456789",
      "registered": true,
      "smpStatus":  "active",
      "activatedAt": "2026-04-25T10:05:00Z"
    }
[/code]

## Documents

The **document** resource represents an invoice, credit note, debit note, or purchase order. Flowie accepts a structured JSON body (we'll render valid UBL 2.1) or a raw UBL/CII XML payload. Either way, we validate, sign, deliver over Peppol, and track lifecycle status through to payment.

The document object

  * idstring

`doc_…`

  * typeenum

`invoice``credit-note``debit-note``purchase-order``purchase-request``sales-order``quote``goods-receipt``event`

  * directionenum

`incoming``outgoing`

  * numberstring

Your external document number.

  * issueDate / dueDatedate (YYYY-MM-DD)

  * currencyISO 4217

  * grossAmount / netAmount / vatAmountdecimal

  * sender / receiverParty

  * statusenum

`draft``sent``delivered``rejected`

  * deliveryStatusenum

`pending``delivered``failed``rejected`

  * lifecycleStatusenum

Business-level state. See [Lifecycle](<#update-lifecycle>).

  * documentobject

The full structured body (lines, tax, payment, …).

  * xmlstring

Rendered UBL (populated on delivery).

  * metadataobject

  * receivedAt / sentAttimestamp




### Send a document

POST/v1/documents/send

Delivers a document over Peppol to the `to` participant. Always set `Idempotency-Key` — duplicate sends to SDI or PPF can create regulatory headaches.

**Doubles as Flowie's inbound integration point.** Wire any ERP / accounting system / iPaaS webhook directly here — see [Inbound: ERP webhooks](<../guides/index.html#ingest>) for the full matrix of payload shapes (structured JSON · UBL XML · PDF / Factur-X / image / proprietary file).

#### Body

  * typeenumrequired

`invoice``credit-note``debit-note``purchase-order``purchase-request``sales-order``quote``goods-receipt``event`

  * formatenumoptional

`json``ubl-xml``cii-xml``auto``raw`

`json` (default) — we render UBL. `ubl-xml` / `cii-xml` — provide your own XML in `xml`; a `cii-xml` payload we cannot read is refused with `422` rather than recorded as an empty document. `auto` — supply a `file`; the server sniffs the bytes and routes to the right pipeline. A Factur-X PDF or a CII (UN/CEFACT `CrossIndustryInvoice`) is read into a structured document — number, dates, parties, lines, totals and the type from BT-3 — with the CII kept as the original. `raw` — supply a `file`; the server stores it as-is on the document file API and returns `deliveryStatus="stored"` (no Peppol routing).

  * fromstringrequired

Your sender company. Accepts a bare Peppol id (`0208:0123456789`) or the prefixed forms `peppol:…`, `vat:…`, `comp_…` / `org:…`. Whatever you pass is normalised to the sender's canonical Peppol id before delivery — the response always echoes the bare `0208:…` form.

  * tostringrequired when type ≠ event

Recipient. A Peppol participant id (`0208:0123456789` or `peppol:…`) — used as-is — or any other identifier we can resolve to one: `vat:…`, `siren:…` / `siret:…`, `duns:…`, `gln:…`, `lei:…`, `eori:…`, `registration:…`, `email:…`, `domain:…`, `name:…`, `org:…` / `id:…` (or the bare unprefixed form of any of these). Non-Peppol identifiers are resolved against org-v2 + the PPF Annuaire (FR) + the Peppol Directory, and provisioned if never seen, so they route to a real participant. A French reception point (_ligne annuaire_) can be addressed with the composed identifier `{siren}_{siret}[_{suffix}]` (e.g. `75297877500027_001`) — see [Reception-point addressing](<#reception-point-addressing>). Optional (omit) when `type=event` — events are pure observability/audit records and have no recipient.

**`name:` needs a country, and refuses to guess.** With one — taken from the counterparty in your payload, so you rarely state it separately — a name is searched against the legal registries, and an unknown company is found and provisioned. Without one it resolves nothing: a name is not a legal identity. “SAFRAN NACELLES MOROCCO” is both a French SIREN and a Moroccan registration, two different legal persons, and binding an invoice to the wrong one is a compliance defect. When several companies match, the API answers `409` listing them rather than picking one.

When your name and the registry’s name differ, use the number

Registries hold the _legal_ name, which is often not the one in your customer master: a Chinese customer filed as `BEIJING GE HUALUN MEDICAL EQUIPMENT CO, LTD` is registered as `GE Hualun Medical Systems Co., Ltd`. No name search bridges that, and none should try. **`registration:<number>`** takes a national registration number in any jurisdiction — a Chinese Unified Social Credit Code, a UK company number, a Moroccan RC — and resolves it exactly. Company registries are searched one jurisdiction at a time, so the country has to be known: it is taken from the recipient's address in the document, or stated inline with a trailing `@<ISO country code>`: 
[code] "to": "registration:91110302621705062U@CN"
[/code]

It is accepted only when the registry returns exactly one company carrying that number, so a loose search never becomes a wrong match. Without a country, from either source, the call fails with a 400 that says so rather than reporting the company as unknown. `siren:` and `siret:` remain the French forms. 

**Better still, route on your own reference.** If your ERP already holds a stable code for each customer, store it on the partnership as a custom field and address the recipient with `cf:FIELD_NAME=VALUE` — for example `cf:CODE_CLIENT=C-4471`. That is an exact match on a value you control, so it does not depend on how either side spells the company name, and it keeps working when the legal name changes.

#### Addressing by your own reference

Every ERP already has a stable code for each customer. Stored on the partnership as a custom field, that code becomes a routing key: `cf:FIELD_NAME=VALUE`, e.g. `cf:CODE_CLIENT=C-4471` (aliases: `ref:`, `customfield:`).

It is matched exactly against custom fields scoped to your organization, so unlike `name:` it does not depend on spelling, accents or locale, and it survives a change of legal name.
[code] {
          "type": "invoice",
          "to": "cf:CODE_CLIENT=C-4471",
          "document": { "number": "INV-2026-0042", "...": "..." }
        }
[/code]

Two deliberate limits, both there so a routing key can never silently send a document to the wrong company:

    * **The field name is required.** `cf:C-4471` is rejected with `400`: Flowie will not guess which custom field holds your reference.
    * **No match is an error, never a fallback.** An unknown reference returns `404` and nothing is created — it does not degrade into a name search. If the reference matches more than one partner you get `409` rather than an arbitrary pick; de-duplicate it, or address that recipient by `org:<id>`.

Set the field on the partnership through the partners API (or the UI) before you route on it.

  * documentDocumentBodyrequired when format=json

See schema below.

    * numberstringrequired

Invoice number — **BT-1**.

    * issueDatedaterequired

**BT-2**.

    * dueDatedateoptional

**BT-9**.

    * currencyISO 4217optional

**BT-5**. Default `EUR`.

    * buyerReferencestringoptional

**BT-10**. Required by many public-sector buyers (e.g. Service Executant / Code Service in FR).

    * orderReferencestringoptional

PO number — **BT-13**.

    * despatchAdviceReferencestringoptional

Delivery-note number — **BT-16**.

    * incotermsstringoptional

Delivery terms code — **EXT-FR-FE-185**. `1`, `2`, or an Incoterms 2020 code (`EXW`, `FCA`, `CPT`, `CIP`, `DAP`, `DPU`, `DDP`, `FAS`, `FOB`, `CFR`, `CIF`).

    * incotermsLocationstringoptional

Named place the delivery terms refer to — **EXT-FR-FE-186**. Requires `incoterms`.

    * notestringoptional

**BT-22**.

    * seller / buyerPartyoptional

Overrides the auto-derived seller/buyer. A `Party` object:

      * namestring

**BT-27** (seller) / **BT-44** (buyer).

      * vatNumberstring

**BT-31** (seller) / **BT-48** (buyer).

      * addressAddress

Billing address — see the [Address](<#address-object>) object. Carried to the party's `billingAddress` and rendered as the party's postal-address group: **BG-5** for the seller (`BT-35` street, `BT-36` street 2, `BT-37` city, `BT-38` post code, `BT-39` subdivision, `BT-40` country) and **BG-8** for the buyer (`BT-50`…`BT-55`).

      * shippingAddressAddress

Same shape as `address`.

      * contactobject

`{ name?, email?, phone?: string }`. The `email` is added to the party's `contacts`. Rendered as the contact group — **BT-41/42/43** (seller), **BT-56/57/58** (buyer).

      * contactsstring[]

Contact email addresses, e.g. `["ap@acme.example"]`.

    * partiesPartyRef[]optional

Explicit, role-tagged party list for documents with **more than two parties** (a `payer`/`payee` distinct from `buyer`/`seller`) and for self-billing. **Exactly one** entry must set `initiator: true` (the org the key acts as). When present it **overrides** the default seller/buyer derivation. See [Multiple parties](<#multiple-parties>). Each entry:

      * roleenumrequired

`seller``buyer``payer``payee`

      * idstring

Any resolvable id (same grammar as `to`).

      * name / vatNumberstring

      * address / shippingAddressAddress

See the [Address](<#address-object>) object.

      * contact / contactsobject / string[]

Same as on `seller/buyer` above.

      * initiatorboolean

Exactly one entry must be `true`.

    * paymentPaymentInfooptional

A `PaymentInfo` object:

      * meansstring

How the payment is made. A UNTDID 4461 code is sent as **BT-81** : `"30"` credit transfer, `"42"` payment to bank account, `"48"` bank card, `"49"` direct debit, `"58"` SEPA credit transfer, `"97"` clearing between partners (the netting a customer and supplier settle against each other), or `"ZZZ"` for a means the two of you defined between yourselves — the list runs 1 to 97 plus `ZZZ`. Anything else, e.g. `"Virement SEPA"` or `"credit_transfer"`, is sent as **BT-82** , the means description; a number outside the code list goes there too rather than being passed off as a code, since free text in BT-81 fails `BR-CL-16`.

      * ibanstring

Account credited — **BT-84**. The holder name (**BT-85**) is taken from the seller.

      * bicstring

Account provider — **BT-86**.

      * referencestring

Remittance / structured communication — **BT-83**. Also sent as `paymentReferenceNumber`.

      * discountTermsarray

`[{ days: int, percent: number, note?: string }]`. _Accepted but not yet emitted to the e-invoice._

    * deliveryobjectoptional

Delivery details (**BG-13**), e.g. `{ actualDeliveryDate?: "YYYY-MM-DD", deliveryLocation?: Address }`. Two parts are emitted: the delivery date (**BT-72** , also read from `deliveryDate` / `date`) and the deliver-to country (**BT-80** , from `deliveryLocation.country` — required by `BR-IC-12` on intra-community supplies, and defaulted to the buyer's country there when omitted). The rest of the group is accepted but not yet emitted.

    * linesInvoiceLine[]required

**VAT is per line** — a document with several rates is several lines (see [Multiple VAT rates](<#multiple-vat>)). Each line:

      * descriptionstringrequired

      * quantitynumberrequired

      * unitPricenumberrequired

Excl. VAT.

      * vatRatenumberrequired

Percent, e.g. `21`, `6`, `0` — **BT-152**.

      * unitstring

UN/ECE Rec 20 code, e.g. `"HUR"`, `"C62"`.

      * vatCategorystring

UNCL5305 code; defaults to `S`. See [Tax exemption & zero rate](<#tax-exemption>).

      * vatExemptionReasonstring

Free-text reason why the line carries no VAT (**BT-120**), e.g. `"TVA non applicable, art. 293 B du CGI"`. Required by EN 16931 whenever `vatCategory` is a zero-VAT category. Ignored on standard-rated lines.

      * vatExemptionCodestring

VATEX code backing the reason (**BT-121**), e.g. `VATEX-EU-IC`, `VATEX-EU-AE`, `VATEX-FR-FRANCHISE`. Optional under EN 16931, **mandatory for the French franchise en base**.

      * itemCodestring

Your identifier for the item — **BT-155**.

      * customFieldsobject

Keyed by field name or UUID. See [Custom fields & templates](<#custom-fields>).

      * periodobject

Billing period the line covers (**BG-26**): `{ startDate: "YYYY-MM-DD", endDate: "YYYY-MM-DD" }` → **BT-134** / **BT-135**. The pairs `start`/`end` and `from`/`to` are also accepted.

    * allowances / chargesarrayoptional

Document-level discounts (`allowances`) / surcharges (`charges`). Each item: `{ reason?: string, amount?: number, percent?: number, vatRate?: number }`. _Accepted but not yet emitted to the e-invoice — for document-level allowances/charges today, send`format=ubl-xml`._

    * attachmentsarrayoptional

Embedded attachments. Each item: `{ filename: string, contentType: string, content: <base64> }`. _Accepted but not yet emitted to the e-invoice_ — to attach a file today use `format=auto`/`raw` with the top-level `file`.

    * totalsobjectoptional

Pre-computed totals — **overrides** the values computed from lines. `{ netAmount?: number, vatAmount?: number, grossAmount?: number }` (aliases `net`/`vat`/`gross` also accepted). If omitted, all three are computed from the lines. **Recommended when your ERP has already posted the invoice** — see [Totals](<#totals>).

    * templateIduuidoptional

Template to file the document under — it declares which `customFields` are valid. See [Custom fields & templates](<#custom-fields>).

    * customFieldsobjectoptional

Document-level custom field values, keyed by field **name or UUID**. Attached to your party. See [Custom fields & templates](<#custom-fields>).

  * xmlstringrequired when format=ubl-xml

Raw UBL 2.1 or CII XML. We validate against the Peppol BIS 3.0 schematron before delivery.

  * fileFileAttachmentrequired when format=auto or raw

Arbitrary file payload (PDF, image, ZIP, proprietary format). `{ content: <base64>, contentType?, filename? }`. Max 5 MiB. With `format=auto` we sniff magic bytes — if the file is UBL XML it routes through the regular pipeline (`deliveryStatus="pending"`); if it is a Factur-X PDF or a CII and `type` is an invoice, credit note or debit note, the CII is read into a structured document (`deliveryStatus="pending"`, the CII stored as the original, the PDF as the readable copy); otherwise the bytes are persisted on the document file API and the response carries `deliveryStatus="stored"`, `fileId`, and `storedFormat`. Use `format=raw` to archive a Factur-X without it being read.

  * selfBilledbooleanoptional

Self-billed invoice (_autofacturation_): the acting org (`from`) is the **customer** issuing on the supplier's behalf, so `to` becomes the Seller and `from` the Buyer / initiator. Tags the document with UNCL1001 subtype `389`. Only valid for `type=invoice`. Default `false`. For self-billing that also involves a third party, use [`document.parties`](<#multiple-parties>) instead.




#### Query parameters — raw-body mode

Instead of a JSON body, you can POST the **native ERP payload verbatim** (UBL/CII XML, PDF, image, proprietary file) as the raw request body and carry the wrapper constants in the URL. Triggered whenever `type` is present as a query param. Handy for wiring an ERP / iPaaS webhook straight at this endpoint.

  * typeenumrequired

`invoice``credit-note``debit-note``purchase-order``sales-order``quote``goods-receipt``event`

Same enum as the body `type`. Its presence is what switches the endpoint into raw-body mode.

  * fromstringoptional

Sender company. Defaults to the key's organization (`org:<id>`) when omitted.

  * contentTypestringoptional

MIME type of the raw body. Falls back to the `Content-Type` header, then a magic-byte sniff.

  * filenamestringoptional

Filename persisted on the file record. Defaults to `<Idempotency-Key>.<ext>` or an auto-generated `event-*.<ext>`.




#### Address object

Used by `seller`/`buyer`/`parties[].address` and `shippingAddress`. All fields are optional strings, and every one of them reaches the e-invoice as the party's postal-address group — **BG-5** for the seller, **BG-8** for the buyer. All but `state` are also carried to the party's `billingAddress` record, as `street`, `street2`, `city`, `zipCode` and `country`.

  * streetstring

Street and number — the first address line. → `BT-35` (seller) / `BT-50` (buyer).

  * streetLine2string

Second address line (suite, box…). → `street2`, `BT-36` / `BT-51`.

  * citystring

Town or city. → `BT-37` / `BT-52`.

  * postalCodestring

→ `zipCode`, `BT-38` / `BT-53`.

  * countryISO 3166-1 alpha-2

e.g. `"FR"`, `"BE"`. → `BT-40` / `BT-55`. It decides whose VAT rules the invoice is judged by; absent it, Flowie falls back to the country prefix of the party's VAT number.

  * statestring

Country subdivision — province, région, state. → `BT-39` (seller) / `BT-54` (buyer). Also accepted as `region`. Required by some jurisdictions to fix which local tax applies; optional in the EU. This is the one address field that does not also land on the `billingAddress` record.




#### Totals: let your ERP be the source of truth

Omit `totals` and Flowie derives every amount from the lines, rounding the VAT once per rate as EN 16931 **BR-CO-17** requires. Send `totals` and **your** figures win outright.
[code] 
    "totals": {
      "netAmount":   1267.16,
      "vatAmount":    253.43,
      "grossAmount": 1520.59
    }
[/code]

The short keys `net` / `vat` / `gross` are accepted too.

Send them if your ERP has already posted the invoice

Your accounting system is the source of truth for what the customer owes, not us. Two systems computing the same total independently will eventually disagree by a cent — rounding a hundred lines is not associative — and then you have to explain which one is right. Sending `totals` removes the question: we carry your amounts through to the UBL unchanged. If you do send them, they must reconcile with the per-line sums, or validation fails. 

#### Multiple VAT rates

VAT is carried **per line** : every `InvoiceLine` has its own `vatRate` and optional `vatCategory`. A document spanning several rates is simply several lines with different `vatRate` values — Flowie sums each line's tax, groups the totals by rate, and renders one `cac:TaxSubtotal` per rate. There is no document-level VAT array (none is accepted). If you also send `totals`, they must reconcile with the per-line sums or validation fails.
[code] 
    "lines": [
      { "description": "Consulting",      "quantity": 10, "unitPrice": 150.00, "vatRate": 21.0, "vatCategory": "S" },
      { "description": "E-book (reduced)", "quantity": 1,  "unitPrice": 40.00,  "vatRate": 6.0,  "vatCategory": "S" },
      { "description": "Intra-EU goods",   "quantity": 1,  "unitPrice": 500.00, "vatRate": 0.0,  "vatCategory": "K" }
    ]
[/code]

Exempt / reverse-charge categories (`E`, `AE`, `K`, `G`, `O`) additionally need a VAT exemption reason — see [Tax exemption & zero rate](<#tax-exemption>).

#### Multiple parties

The common seller→buyer case needs no `parties` block — `from`/`to` (or `document.seller`/`document.buyer`) are enough, and Flowie injects a Payer party mirroring the buyer automatically. Supply `document.parties` only when the document has **more than two roles** (a `payer`/`payee` distinct from buyer/seller) or when the issuer is not the seller.

  * Each entry is a `PartyRef`: `role` (`seller`·`buyer`·`payer`·`payee`), `id` (any resolvable id, same grammar as `to`), `name`, `vatNumber`, `initiator`.
  * **Exactly one** entry must set `initiator: true` — the org the calling key is acting as (tx-docs requires the acting org to be a party).
  * **Give every party a resolvable identity** — `id` (peppol / vat / siren / siret / duns / gln) or a `vatNumber`. tx-docs requires an organization on every party, so each is resolved to one (auto-created if new); if an id can't be resolved it falls back to the acting org so the document is still accepted.
  * When `parties` is present it **overrides** the default seller/buyer derivation; Flowie injects nothing and your list is authoritative.
  * Roles beyond these four aren't modelled by the structured pipeline — use `format=ubl-xml` for those.


[code] 
    "parties": [
      { "role": "seller", "id": "0009:FR86797978996", "name": "ACME FRANCE",      "initiator": true },
      { "role": "buyer",  "id": "0208:0123456789",     "name": "MEGACORP BE" },
      { "role": "payee",  "vatNumber": "FR90123456789", "name": "ACME FACTORING SAS" }
    ]
[/code]

For **self-billing** (the customer issues on the supplier's behalf), prefer the top-level `selfBilled: true` flag — Flowie flips the roles and tags the document UNCL1001 `389`. Use an explicit `parties` list only when self-billing also involves a third party.

#### Reception-point addressing (France)

In the French PPF/AFNOR model a recipient is not just a legal unit (SIREN) or an establishment (SIRET) — it is a specific **reception point** (_ligne annuaire_). A reception point is addressed with a composed identifier `{siren}_{siret}[_{suffix}]`, where the trailing `suffixeAdressage` selects which reception point inside the SIRET receives the document. The routing platform itself (`identifiantRoutage` — a declared PDP or the default public PPF) is a separate directory concept, resolved for you; you do not encode it here.

  * **Auto-detected.** Pass the composed form as `to` with no prefix (e.g. `752978775_75297877500027_100003`, or just `75297877500027_001`) and Flowie recognises it by shape — an underscore-joined string carrying a 14-digit SIRET and/or a 9-digit SIREN. You can also be explicit with a `routage:` / `addressing:` prefix (aliases: `adressage:`, `routing:`, `adr:`).
  * **The participant resolves as usual.** The SIRET (preferred, most specific) or SIREN drives recipient resolution through the ordinary layers — the suffix does not change _who_ the participant is.
  * **The suffix is business routing, not part of the Peppol id.** It is never folded into `receiverPeppolId`. Instead it travels as document metadata under `metadata.recipientRouting` (`{ "addressingIdentifier": …, "addressingSuffix": … }`) and is echoed back on the response `to` object alongside `peppolId`. An explicit `metadata.recipientRouting` you send yourself is preserved and takes precedence.
  * **Org ids are safe.** `org_…` / `comp_…` ids also contain an underscore; they are excluded from this detection and never mistaken for a SIREN/SIRET.


[code] 
    // request
    "to": "752978775_75297877500027_100003"
    
    // response — participant unchanged, suffix carried alongside
    "to": {
      "peppolId": "0009:75297877500027",
      "addressingIdentifier": "752978775_75297877500027_100003",
      "addressingSuffix": "100003"
    }
[/code]

#### Custom fields & templates

Custom fields carry organization-specific data (cost centre, GL account, internal references…) on a document. They are defined by a **template** in your organization and are always scoped to **your own party** : document-level fields attach to your party (the acting org / initiator), line-level fields to a per-line party on your org.

  * `document.templateId` — UUID of the template to file the document under. It declares the valid custom fields, their types, and whether each is document- or line-level. Omit to use your org's default template for the type.
  * `document.customFields` — document-level values, an object keyed by the field's **name** (e.g. `"Cost Center"`) or its **definition UUID**. Names are resolved to UUIDs against your org's field definitions; a UUID key is forwarded as-is, while a **name that matches no declared field is rejected with a`400`** — pass the field's UUID or declare it on the `templateId` first.
  * `line.customFields` — line-level values on each `InvoiceLine`, same key rules.



Value shapes follow each field's declared type: a bare string for text/date/number fields, `{ "currency": "EUR", "amount": 1000.00 }` for monetary fields, or an address object (`{ street, street2, city, zipCode, country }`).
[code] 
    "document": {
      "number": "INV-2026-0042",
      "issueDate": "2026-04-15",
      "templateId": "8b1f…-template-uuid",
      "customFields": {
        "Cost Center": "CC-42",
        "9f3a…-budget-uuid": { "currency": "EUR", "amount": 1000.00 }
      },
      "lines": [
        { "description": "Consulting", "quantity": 10, "unitPrice": 150.00, "vatRate": 21.0,
          "customFields": { "GL Account": "606100" } }
      ]
    }
[/code]

Custom fields are carried only on the structured `format=json` pipeline — for `ubl-xml`/`cii-xml`, embed them in the XML yourself.

#### Which BT fields are sent

**What a “BT” is.** An e-invoice is not a picture of an invoice — it is a list of named values that the recipient's software reads. The European standard EN 16931 gives each of those values a number: `BT-1` is the invoice number, `BT-9` the due date, `BT-120` the wording that explains why a line carries no VAT. Your accountant knows these as the mandatory mentions of an invoice; your customer's system knows them as the fields it matches and pays on; your developers see them as the codes a validator quotes when it rejects a document. They are the same thing under three names, and the table below lines all three up. The table below is the subset this endpoint maps; [the full list of all 164 business terms](<../compliance/fr/business-terms.html>) says what France requires of each one.

How to read the table

**Keeping the books?** Column 2 is the invoice mention you already know, and column 4 tells you when it is legally required.  
**Running the business?** Column 4 is what happens when the value is missing — a rejected invoice, or one nobody can match and pay.  
**Building the integration?** Column 1 is the JSON field you send, column 3 the EN 16931 code your customer's validator will name. 

What you send| What it is on the invoice| BT| Why it matters  
---|---|---|---  
`number`| Invoice number| BT-1| Mandatory. The reference both sides quote for the life of the invoice.  
`issueDate`| Invoice date| BT-2| Mandatory. Decides the VAT period the invoice falls in.  
`dueDate`| Payment due date| BT-9| Drives the customer's payment run, and your late-payment rights.  
`currency`| Invoice currency| BT-5| Mandatory. A non-EUR invoice must also report its VAT in EUR (`BR-FR-CO-12`).  
`note`| Free-text mention| BT-22| Where legal wording goes that no coded field carries.  
`buyerReference`| Customer's own reference (“Service exécutant” for public buyers)| BT-10| Public-sector buyers route on it; without it Chorus Pro refuses the invoice.  
`orderReference`| Purchase order number| BT-13| Large customers match invoice to order before paying. No order number, no payment.  
`despatchAdviceReference`| Delivery-note number| BT-16| Same match, against what was actually delivered.  
`billingReference`, `billingReferenceDate`| The original invoice a credit note corrects, and its date| BT-25, BT-26| Mandatory on credit and debit notes under the French reform (`BR-FR-CO-04`/`05`).  
`seller.name`, `buyer.name`| Legal name of each party| BT-27, BT-44| Mandatory identification of who sold and who bought.  
`seller.vatNumber`, `buyer.vatNumber`| VAT identification number| BT-31, BT-48| Mandatory. Your customer reclaims its VAT against this number.  
`seller.address`, `buyer.address`| Billing address (street, city, post code, country)| BG-5 (BT-35…BT-40), BG-8 (BT-50…BT-55)| Mandatory, and the country decides whose VAT rules apply.  
`contact` on either party| Contact name, phone, e-mail| BT-41/42/43, BT-56/57/58| Where the customer's platform sends questions about the invoice.  
`payment.means`| Method of payment| BT-81 or BT-82| Tells the customer how you expect to be paid.  
`payment.reference`| Payment reference to quote on the transfer| BT-83| What lets you tie an incoming transfer back to this invoice.  
`payment.iban`| Bank account to credit, and its holder| BT-84, BT-85| Where the money actually lands.  
`payment.bic`| Bank identifier| BT-86| Required by some banks for cross-border transfers.  
`delivery.actualDeliveryDate`| Delivery date| BT-72| On goods, this can be the date the VAT becomes chargeable.  
`delivery.deliveryLocation.country`| Country the goods were delivered to| BT-80| Mandatory on intra-community supplies (`BR-IC-12`).  
`incoterms`, `incotermsLocation`| Delivery terms code, and the named place it refers to| EXT-FR-FE-185, EXT-FR-FE-186| Who bears carriage and risk — what your customer reconciles freight charges against. AFNOR's French extension, not part of the EN 16931 core, so a recipient on the core profile may ignore it. One of `1`, `2` (UNTDID 4053) or an Incoterms 2020 code: `EXW`, `FCA`, `CPT`, `CIP`, `DAP`, `DPU`, `DDP`, `FAS`, `FOB`, `CFR`, `CIF`. The place is optional; the code is not.  
line — `description`, `quantity`, `unit`, `unitPrice`| Description, quantity, unit, unit price excl. VAT| BT-153, BT-129, BT-130, BT-146| Mandatory. The line detail every invoice has to show.  
line — `vatRate`, `vatCategory`| VAT rate and VAT category of the line| BT-152, BT-151| Mandatory. Sets the VAT charged, line by line.  
line — `vatExemptionReason`, `vatExemptionCode`| Wording that justifies charging no VAT, and its official code| BT-120, BT-121| An exempt or reverse-charge invoice without it is rejected outright. See [Tax exemption & zero rate](<#tax-exemption>).  
line — `period`| Period the line covers| BT-134, BT-135| How a subscription or a service billed per period states what it covers.  
line — `itemCode`, `buyerItemCode`, `itemDescription`| Your item reference, your customer's item reference, long description| BT-155, BT-156, BT-154| Your customer's system matches on _its own_ part number, not yours.  
line — `orderLineReference`, `objectIdentifier` \+ `objectIdentifierScheme`| Order line the line answers, and a document it refers to with the kind of document that is| BT-132, BT-128 + BT-128-1| Line-by-line matching when one invoice covers several orders or deliveries. EN 16931 requires the scheme whenever the identifier is present (`BR-CO-24`).  
line — `priceBaseQuantity` \+ `priceBaseUnit`| Number of units the unit price applies to, and their unit| BT-149 + BT-150| For a price quoted per batch — « per 1000 pieces » — rather than per single unit. Without it the price reads as a per-unit price and the line total looks wrong by orders of magnitude.  
line — `netAmount`| The line's own net amount, VAT excluded| BT-131| Stops Flowie recomputing the line from `quantity × unitPrice`. Reach for it when the line carries a discount or a surcharge, or when its price is quoted per batch: 519.1 ML at 12.60 less a 3 % line discount is 6344.44, not the 6540.66 the multiplication gives.  
line — `despatchAdviceReference`, `despatchAdviceLineReference`| Delivery note the line arrived on, and the line within it| EXT-FR-FE-140, EXT-FR-FE-141| AFNOR's French extension to EN 16931, for an invoice spanning several deliveries. Defaults to the document's `despatchAdviceReference`.  
`totals`| Pre-computed net, VAT and gross for the whole invoice| BT-109, BT-112, BT-115, BT-117| Overrides the sums Flowie takes from your lines. Send it when your accounting system is the authority on the figures; omit it and the lines are summed for you.  
`exchangeRate`| Rate your books posted the invoice at| feeds BT-111| A non-EUR invoice must also report its VAT in euros (`BR-FR-CO-12`). Stating your own rate keeps that figure tied to your ledger instead of a market feed.  
`parties`| The same parties, stated as an explicit role-tagged list| BG-4 / BG-7, exactly as `seller` / `buyer` above| How you state a `payer` or `payee` distinct from the buyer, or self-bill. Overrides `seller`/`buyer` when present.  
`contacts` on either party| Contact e-mail addresses| BT-43 / BT-58| The first entry becomes the party's contact e-mail when `contact.email` is absent.  
  
##### Fields that do not become a BT

Everything else the `document` body accepts is listed here, so that no field you can send is left unaccounted for. Two of these are worth an accountant's attention: `allowances`/`charges` and `payment.discountTerms` are accepted today and **not yet placed on the e-invoice** , so a discount stated only there will not be visible to your customer's software.

What you send| What it is for| What happens to it  
---|---|---  
`templateId`| Which of your organization's e-invoicing templates to file the document under| Decides which BT slots exist at all, so it governs the table above. Not itself a value on the invoice.  
`customFields`, and `customFields` on a line| Your own data on the document — cost centre, GL account, an internal reference| Carried on the document, attached to your own party. Organization-specific by definition, so outside EN 16931 and invisible to your customer's validator.  
`allowances`, `charges`| Discounts and surcharges for the whole invoice (BG-20 / BG-21) — a loyalty discount, shipping| **Accepted, not yet rendered.** Send `format=ubl-xml` to carry them today, or fold the amount into a line.  
`attachments`| Supporting documents embedded in the invoice (BG-24) — a timesheet, a signed delivery note| **Accepted, not yet rendered.** Post the file with `format=auto`/`raw` and the top-level `file`, or embed it in `format=ubl-xml`.  
`payment.discountTerms`| Early-payment discount — « 2 % if paid within 10 days »| **Accepted, not yet rendered.** EN 16931 carries it in the payment terms (BT-20); put the wording in `note` (BT-22) if your customer has to read it.  
`shippingAddress` on either party| That party's delivery address| Accepted and stored, but the deliver-to country the invoice states (BT-80) is read from `delivery.deliveryLocation`, not from here.  
`type`, `format`, `from`, `to`, `file`| The envelope — what kind of document, in what shape, from whom, to whom| Routing and recognition, not invoice content. `from` and `to` resolve to the Peppol participants that deliver the document, and the parties they resolve to fill the party BTs above.  
  
##### What decides whether a field is actually sent

Your e-invoicing template declares a slot for each BT it supports and marks each one _required_ or _optional_. Two rules decide what fills them:

  * **Anything you state is sent** , required slot or optional one. If you put a value in the request — the customer's order number, your IBAN, the exemption wording on a line — it reaches its BT. This is worth stating because it used to be false: an optional slot was skipped, so a value you sent could be accepted with `200` and then carried by nothing. Real French e-invoicing templates mark almost everything past the EN 16931 core optional, so that silence covered a lot of ground.
  * **Anything Flowie works out for you is sent only where the template requires it.** Totals summed from your lines, the document type code (`BT-3`), the line numbering (`BT-126`), the VAT category of the breakdown (`BT-118`): these are our inference, not your statement, so they fill a required slot and stay out of an optional one. Put plainly — we will complete an invoice for you, but we will not put words in your mouth where you did not have to speak.



Where both apply, what you stated wins: `BT-40`, the seller's country, comes from `seller.address.country` when you send an address, and falls back to the country prefix of the VAT number when you don't.

One limit worth knowing: **a BT only renders if your template declares a slot for it.** The mapping fills the slots your template has; it does not create new ones. If a value you send is not appearing on the e-invoice, ask your Flowie contact which BT-* fields your template declares.

#### Tax exemption & zero rate

Each line's `vatCategory` is a UNCL5305 code. Use `S` for normal taxable supplies. The categories below carry `vatRate: 0` and cover zero-rate, exemption, reverse charge, and out-of-scope supplies:

Code| Meaning| Typical use| Exemption reason required?  
---|---|---|---  
`S`| Standard rate| Normal VAT (e.g. 20%, 21%)| No  
`Z`| Zero rated| Taxable at 0%| No  
`E`| Exempt| VAT-exempt supply| **Yes**  
`AE`| Reverse charge| Buyer accounts for VAT (intra-EU B2B)| **Yes**  
`K`| Intra-community supply| Intra-EU supply of goods| **Yes**  
`G`| Free export item| Export outside the EU| **Yes**  
`O`| Not subject to VAT| Outside the scope of VAT| **Yes**  
  
Exempt categories need a reason

EN16931 / Peppol BIS 3.0 schematron **rejects** an invoice that uses `E`, `AE`, `K`, `G`, or `O` unless it also carries a VAT exemption reason — a code from the [VATEX](<https://docs.peppol.eu/poacc/billing/3.0/codelist/vatex/>) list (BT-121) and/or free text (BT-120). Send it **per line** with `vatExemptionReason` and `vatExemptionCode`: Flowie lifts the reason to the document VAT breakdown (BG-23) _and_ repeats it on each line’s `cac:ClassifiedTaxCategory`, which the French `BR-FREXT-<cat>-08rev` reconciliation rule requires — a reason present only at document level makes that rule count zero lines and warn on BT-92 / BT-99 / BT-116 / BT-131. `Z` (zero-rated) and `S` need no reason. 

**French franchise en base needs the CODE, not just the text.** `BR-FR-CO-16` requires `BT-118 = "E"` _and_ `BT-121 = "VATEX-FR-FRANCHISE"`. Free text in BT-120 alone does not satisfy it. If the seller has no VAT number it must also repeat its SIREN in BT-32.

**And neither survives the reform flows.** `BR-FR-MAP-08` / `-09` instruct the platform to transcode `BT-118 = "E"` \+ `VATEX-FR-FRANCHISE` to `"Z"` and to **drop BT-121 and BT-120** in flux 1 and flux 10.1. So BT-120 must not be the only carrier of anything the recipient has to read: put that wording in the document `note` (BT-22) as well, which is passed through.

**Two aggregation limits, both of which drop BT-120 silently.**

1\. **One reason per document.** The breakdown has a single BT-120 slot. If two exempt lines carry _different_ `vatExemptionReason` texts, neither is emitted. Use the same wording on every exempt line.

2\. **Do not mix standard-rated and exempt lines.** When a document contains any `vatCategory: "S"` line, the breakdown collapses to `S` and BT-120 / BT-121 are not emitted at all. Split the exempt lines onto their own document, or send `format=ubl-xml` with repeated `cac:TaxSubtotal` groups.

The exempt `<cac:TaxCategory>` block to include in your UBL (both at line level under `<cac:ClassifiedTaxCategory>` and in the document `<cac:TaxSubtotal>`):
[code] 
    <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>
[/code]

#### Returns

`201 Created` with the [document object](<#documents>). For structured payloads `deliveryStatus` starts as `pending`; listen for `document.delivered` or `document.failed`. For raw uploads `deliveryStatus="stored"` and the response includes `fileId` \+ `storedFormat`.

##### Request — full invoice
[code] 
    curl -X POST https://back.p2p-flowie.com/exchange/v1/documents/send \
      -H "Authorization: Bearer $KEY" \
      -H "Content-Type: application/json" \
      -H "Idempotency-Key: inv-2026-0417" \
      -d '{
        "type": "invoice",
        "from": "comp_01HXYZ…",
        "to":   "0208:9876543210",
        "document": {
          "number":    "INV-2026-0417",
          "issueDate": "2026-04-25",
          "dueDate":   "2026-05-25",
          "currency":  "EUR",
          "buyerReference": "SERV-FIN-042",
          "orderReference": "PO-91234",
          "seller": { "name": "ACME BVBA" },
          "buyer":  { "name": "Globex SRL", "vatNumber": "IT01234567890" },
          "payment": {
            "means":     "credit_transfer",
            "iban":      "BE68539007547034",
            "bic":       "BPOTBEB1",
            "reference": "INV-2026-0417"
          },
          "lines": [
            {
              "description": "Consulting services — April 2026",
              "quantity":    10, "unit": "hours",
              "unitPrice":   150.00,
              "vatRate":     21,
              "vatCategory": "S"
            },
            {
              "description": "Travel expenses",
              "quantity":    1, "unit": "lump",
              "unitPrice":   450.00,
              "vatRate":     21,
              "vatCategory": "S"
            }
          ]
        }
      }'
[/code]

##### Response

201 Created 422 Unreachable
[code] 
    {
      "id":        "doc_01HY7AB9C2DE3FG",
      "type":      "invoice",
      "direction": "outgoing",
      "number":    "INV-2026-0417",
      "issueDate": "2026-04-25",
      "dueDate":   "2026-05-25",
      "currency":  "EUR",
      "grossAmount": 2359.50,
      "netAmount":   1950.00,
      "vatAmount":   409.50,
      "sender":   { "peppolId": "0208:0123456789", "name": "ACME BVBA" },
      "receiver": { "peppolId": "0208:9876543210", "name": "Globex SRL" },
      "status":         "sent",
      "deliveryStatus": "pending",
      "lifecycleStatus":"issued",
      "sentAt":    "2026-04-25T10:05:00Z",
      "createdAt": "2026-04-25T10:05:00Z"
    }
[/code]
[code] 
    {
      "error": {
        "type":    "delivery_error",
        "code":    "RECIPIENT_NOT_FOUND",
        "message": "0208:9876543210 is not registered on Peppol for document type 'invoice'.",
        "requestId":"req_…"
      }
    }
[/code]

### Batch send

POST/v1/documents/send/batch

Submit up to 100 documents in one request. Results come back in the same order as the input; failures don't poison successful sends.

#### Request body

  * documentsSendItem[]required

Array of send items. Each item takes the same fields as [Send a document](<#send-document>) (`type`, `format`, `from`, `to`, `document`, `xml`, `file`) plus an optional per-item `idempotencyKey`.



[code] 
    {
      "documents": [
        { "type": "invoice", "from": "comp_…", "to": "0208:…", "document": {…} },
        { "type": "invoice", "from": "comp_…", "to": "0208:…", "document": {…} }
      ]
    }
[/code]
[code] 
    {
      "results": [
        { "ok": true,  "id": "doc_01…", "status": "sent" },
        { "ok": false, "error": { "code": "INVALID_REQUEST", "message": "…" } }
      ],
      "sent": 1,
      "failed": 1
    }
[/code]

### Validate without sending

POST/v1/documents/validate

Run full Peppol BIS schematron + recipient reachability checks without delivering anything. Handy as a CI step before switching a customer live.

**Authentication:** `Authorization: Bearer <key>` with a live or test Exchange key (`flw_live_…` / `flw_test_…`) or a Flowie JWT.

#### Request body

Same shape as [Send a document](<#send-document>), minus the raw `file` upload mode.

  * typeenumrequired

`invoice``credit-note``debit-note``purchase-order``purchase-request``sales-order``quote``goods-receipt``event`

  * formatenumoptional

`json``ubl-xml``cii-xml`

  * fromstringrequired

Sender company — `comp_…`, `vat:…`, or `peppol:…`.

  * tostringrequired

Recipient Peppol participant identifier (drives the reachability check).

  * documentDocumentBodyrequired when format=json

Same structured body as Send. See [schema](<#send-document>).

  * xmlstringrequired when format=ubl-xml / cii-xml



[code] 
    {
      "valid": false,
      "errors": [
        { "rule": "BR-16", "message": "An Invoice shall have at least one line.",
          "path": "/Invoice/InvoiceLine" }
      ],
      "warnings": [],
      "recipientReachable": true
    }
[/code]

### List documents

GET/v1/documents

Paginated list across both directions — this is the polling half of [receiving documents](<../guides/receive-invoices.html>), for integrations that cannot expose a webhook endpoint.

**Authentication:** `Authorization: Bearer <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

`incoming``outgoing`

  * typeenumoptional

`invoice``credit-note``debit-note``purchase-order``sales-order``quote``goods-receipt``event`

  * statusstringoptional

Exact `lifecycleStatus` — org-specific and may be localized (e.g. `draft`, `sent`). The delivery values `delivered` / `failed` are routed to `deliveryStatus`.

  * deliveryStatusenumoptional

`pending``delivered``failed``rejected`

Peppol network delivery state — use this (not `status`) to find delivered documents.

  * from / todateoptional

Filter by `issueDate` range.

  * amountMin / amountMaxnumberoptional

Gross amount bounds.

  * companyIdstringoptional

  * searchstringoptional

Full-text over number, party names, references, note.

  * limit / cursorpaginationoptional




### Advanced search

POST/v1/documents/search

The POST counterpart of [List documents](<#list-documents>). It runs against the same index and returns the same paginated `{ "data": […], "hasMore": …, "cursor": … }` shape and the same tenant scoping — the only difference is that the query travels in a JSON body instead of the query string. Reach for it when a filter set is too large for a URL, or when you need a nested boolean predicate that the flat query params can't express.

The `filters` object is a predicate tree. Each leaf is `{ "<field>": <match> }`, where the match is either a bare scalar (exact match) or an operator object like `{ "$gte": 1000 }`. Wrap leaves in the boolean keys `$and` and `$or` to nest them to any depth. Field names are the document's **stored field names** (e.g. `documentType`, `issuedAt`) — _not_ the friendly query-string params of the list endpoint — so consult the table below rather than reusing the `GET` parameter names.

#### Request body

  * querystringoptional

Free-text query — identical semantics to the list `search` param (matches document number and party names).

  * filtersobjectoptional

Predicate tree over the fields in the table below, combined with the `$and` / `$or` keys. A bare value is an exact match, a bare array an any-of match; an operator object narrows it. See the operator table and example.

  * sortobjectoptional

Map of `field → direction`, e.g. `{"issuedAt": "desc"}`. Multiple keys are applied left to right. A bare string (`"issuedAt:desc"`) is also accepted, as is a list mixing either form. Sort on any field in the table below, including `createdAt` / `updatedAt`.

  * limitintegeroptional

Page size, clamped to `1`–`100`. Default `20`.

  * cursorstringoptional

Opaque pagination cursor from the previous page. Keep passing it until `hasMore` is `false`.




#### Filterable fields

Field| Type| Notes  
---|---|---  
`documentType`| string| Uppercase — `INVOICE`, `CREDIT_NOTE`, `DEBIT_NOTE`, `PURCHASE_ORDER`, …  
`lifecycleStatus`| string| Exact business status. Org-specific and may be localized (e.g. `sent`, `Reçue par la plateforme`).  
`issuedAt`| date| Issue date. Use `$gte` / `$lte` for a range — this is the field behind the list endpoint's `from` / `to`.  
`dueDate`| date| Payment due date.  
`totalAmountDue`| number| Gross amount. Use `$gte` / `$lte` for bounds (the list endpoint's `amountMin` / `amountMax`).  
`number`| string| Document number.  
`currency`| string| ISO 4217 code.  
`sellerId` / `payerId`| string| Party org ids. Every result is already scoped to your org; add one of these to pin the direction — your org as `sellerId` is outgoing, as `payerId` is incoming.  
`createdAt` / `updatedAt`| datetime| Ingest / last-change timestamps. Handy as `sort` keys.  
  
#### Operators

Values follow Strapi v4 filter semantics. A bare scalar is shorthand for `$eq`, a bare array for `$in`. An operator outside this table is refused with a `400` naming it.

Operator| Meaning  
---|---  
`$eq` / `$ne`| Equals / not equal (bare scalar ⇒ `$eq`).  
`$gt` / `$gte`| Greater than / greater-or-equal.  
`$lt` / `$lte`| Less than / less-or-equal.  
`$in` / `$notIn`| Matches / does not match any value in a JSON array (bare array ⇒ `$in`).  
`$contains` / `$notContains`| Case-sensitive substring match / its negation.  
`$null` / `$notNull`| Field is unset / set. Takes `true`.  
`$and` / `$or`| Boolean combinators over an array of nested predicate blocks. There is no `$not`.  
  
`companyId` is a tenant selector, not a filter

A `companyId` key inside `filters` is pulled out and used to **scope the query to that company** (validated against your token — it can narrow to a company you manage, never widen to another tenant). It is not matched as a document column. Pass it as a bare id or `{"$eq": "<companyId>"}`. 

Two list filters have no structured equivalent

The list endpoint's `direction` and `deliveryStatus` are computed conveniences, not stored columns — putting them in `filters` will not match. For direction, filter on `sellerId` / `payerId` as above; for Peppol delivery state, use `GET /v1/documents?deliveryStatus=…`. 

#### Filter recipes

Worked `filters` for the queries people actually build. Sibling keys inside a block are **AND** ed for you — you only need an explicit `$and` when you want to group an `$or` alongside other conditions.

**Invoices over €1,000, newest first.** Two sibling keys ⇒ implicit AND.
[code] 
    {
      "filters": {
        "documentType": { "$eq": "INVOICE" },
        "totalAmountDue": { "$gte": 1000 }
      },
      "sort": { "issuedAt": "desc" }
    }
[/code]

**Anything issued in Q1 2026.** A single field with two bounds is a closed range.
[code] 
    {
      "filters": { "issuedAt": { "$gte": "2026-01-01", "$lte": "2026-03-31" } }
    }
[/code]

**Invoices _or_ credit notes.** `$in` matches any value in the list — cleaner than an `$or` of equalities.
[code] 
    {
      "filters": { "documentType": { "$in": ["INVOICE", "CREDIT_NOTE"] } }
    }
[/code]

**Everything that needs attention: disputed, or high-value.** An `$or` block holds an array of alternatives.
[code] 
    {
      "filters": {
        "$or": [
          { "lifecycleStatus": { "$eq": "disputed" } },
          { "totalAmountDue": { "$gte": 10000 } }
        ]
      }
    }
[/code]

**Unpaid invoices in a window.** Group an `$or` with other conditions using an explicit `$and`, and exclude a status with `$ne`.
[code] 
    {
      "filters": {
        "$and": [
          { "documentType": { "$eq": "INVOICE" } },
          { "lifecycleStatus": { "$ne": "paid" } },
          { "issuedAt": { "$gte": "2026-01-01" } }
        ]
      },
      "sort": { "dueDate": "asc" }
    }
[/code]

**Free text, then narrow structurally.** `query` runs the full-text search; `filters` refines it.
[code] 
    {
      "query": "ACME",
      "filters": { "issuedAt": { "$gte": "2026-01-01" } }
    }
[/code]

**One company in a multi-tenant account.** `companyId` scopes the query to that company (validated against your token); the rest still filters normally.
[code] 
    {
      "filters": {
        "companyId": "comp_abc123",
        "documentType": { "$eq": "INVOICE" }
      }
    }
[/code]

**Outgoing only.** There's no `direction` field — pin the direction by matching your own org as the seller (use `payerId` for incoming). Results are already scoped to your org, so this just narrows the role.
[code] 
    {
      "filters": { "sellerId": { "$eq": "org_685a5670efafaa26ebf0128e" } }
    }
[/code]

### Retrieve a document

GET/v1/documents/{document_id}

### Download XML

GET/v1/documents/{document_id}/xml

Returns the signed UBL XML with `Content-Type: application/xml`.

### Download PDF

GET/v1/documents/{document_id}/pdf

Returns a human-readable PDF rendering.

### Structured view

GET/v1/documents/{document_id}/structured

Flat, scalar-only representation — perfect for pushing to a data warehouse or spreadsheet.

### Document actions

POST/v1/documents/{document_id}/actions

Non-lifecycle operations: `mark-read`, `mark-unread`, `archive`, `unarchive`, `tag`, `untag`, `assign`, `unassign`, `add-note`, `link`.

  * actionenumrequired

`mark-read``mark-unread``archive``unarchive``tag``untag``assign``unassign``add-note``link`

  * tagstringconditional

Required by the tag / untag actions.

  * userIdstringconditional

Required by the assign / unassign actions.

  * notestringconditional

Required by the add-note action.

  * relatedDocumentIdstringconditional

The document to link to — required by the link action.



[code] 
    curl "…/v1/documents?direction=outgoing&status=sent&from=2026-04-01&amountMin=500" \
      -H "Authorization: Bearer $KEY"
[/code]

##### Structured response
[code] 
    {
      "id":                "doc_01…",
      "type":              "invoice",
      "direction":         "incoming",
      "number":            "INV-2026-0417",
      "issueDate":         "2026-04-25",
      "dueDate":           "2026-05-25",
      "currency":          "EUR",
      "grossAmount":       2359.50,
      "netAmount":         1950.00,
      "vatAmount":         409.50,
      "status":            "delivered",
      "lifecycleStatus":   "approved",
      "deliveryStatus":    "delivered",
      "senderPeppolId":    "0208:0123456789",
      "senderName":        "ACME BVBA",
      "senderVatNumber":   "BE0123456789",
      "receiverPeppolId":  "0208:9876543210",
      "receiverName":      "Globex SRL",
      "receiverVatNumber": "IT01234567890",
      "buyerReference":    "SERV-FIN-042",
      "orderReference":    "PO-91234",
      "paymentIban":       "BE68539007547034",
      "paymentReference":  "INV-2026-0417",
      "receivedAt":        "2026-04-25T10:05:08Z",
      "sentAt":            "2026-04-25T10:05:00Z",
      "createdAt":         "2026-04-25T10:05:00Z",
      "updatedAt":         "2026-04-25T10:05:08Z"
    }
[/code]

## Lifecycle

Once a document is delivered, it moves through a business-level state machine: `issued → under_review → approved → partially_paid → paid`, with side branches for `rejected` and `disputed`. Flowie persists the history, enforces allowed transitions, and **reports each relevant change to the national compliance platform automatically**.

Put it on hold before you refuse

Refusing (`rejected`) is **terminal** — in France it transmits _210 Refusée_ , which cancels the invoice for VAT and forces the supplier to issue a corrective. If the disagreement might still be resolved, **prioritize the reversible paths first** : `disputed` to contest the content, or `disputed` with `reasonCode:"suspended"` to put the invoice **on hold** pending documents — both keep it alive and can resolve back to approval. Reach for `rejected` only when you are certain the invoice must be cancelled and re-issued. [Choosing the right status & reason →](<#reason-codes>)

### Retrieve lifecycle history

GET/v1/documents/{document_id}/lifecycle

Full event log, current status, allowed transitions, and per-country compliance state. When the current status stems from a failed validation, `currentStatusReason` carries the failing EN 16931 / CTC-FR schematron rule ids.

### Update lifecycle status

POST/v1/documents/{document_id}/lifecycle

#### Body

  * statusenumrequired

`under_review``approved``rejected``partially_paid``paid``disputed`

  * reasonCodeenumconditional

`NON``REF``LEG``REC``QUA``DEL``PRI``QTY``ITM``PAY``UNR``FIN``PPD``OTH`

Required for **rejected** and **disputed**. One of the 14 [official Peppol status reason codes](<#reason-codes>) (OPStatusReason) — full table below. 🇫🇷 France: an AFNOR motif code (XP Z12-012 annex) is forwarded verbatim as MDT-113, and `suspended` on a _disputed_ call transmits _208 Suspendue_ — see [FR refusal & rejection](<../compliance/fr/refusal-rejection.html#motifs>).

  * reasonstringoptional

Free-text explanation shown to the counterparty (forwarded verbatim as MDT-114 in France). Always pair it with reasonCode **OTH**.

  * notestringoptional

  * paymentDatedateconditional

Required for `paid` / `partially_paid`.

  * paymentAmount / paymentCurrency / remainingAmountnumber / ISO 4217conditional

  * paymentReferencestringoptional




### Batch lifecycle update

POST/v1/documents/lifecycle/batch

Up to 500 updates in one call. Atomic per document; failures are reported per item.

#### Request body

  * updatesobject[]required

Array of updates. Each entry is a [lifecycle update body](<#update-lifecycle>) (`status`, `reason`, `note`, `paymentDate`, …) plus the target `documentId`.




Allowed transitions

Trying to skip states (e.g. `issued → paid` without a prior `approved`) returns `409 invalid_transition` and a hint listing legal next states. Fetch [the history](<#get-lifecycle>) to see what's allowed now. 
[code] 
    curl -X POST …/v1/documents/doc_abc/lifecycle \
      -H "Authorization: Bearer $KEY" \
      -d '{
        "status": "paid",
        "paymentDate":      "2026-04-25",
        "paymentAmount":    2359.50,
        "paymentCurrency":  "EUR",
        "paymentReference": "PAY-2026-0001"
      }'
[/code]
[code] 
    {
      "documentId":      "doc_abc",
      "previousStatus":  "approved",
      "currentStatus":   "paid",
      "updatedAt":       "2026-04-25T10:35:00Z",
      "compliance": {
        "reportedTo": ["PPF","SDI"],
        "status":     "reported",
        "nextCheckAt":"2026-04-25T10:40:00Z"
      },
      "allowedTransitions": ["disputed"]
    }
[/code]

### Update lifecycle status by invoice number

POST/v1/documents/by-number/{number}/lifecycle

Move a document to a new status, targeting it by its **invoice number** (the value printed on the invoice) instead of Flowie's internal `documentId`. Integration partners often only hold the human-readable number, not our id.

This route resolves the number to exactly one document **scoped to your organization** , then applies the _same_ transition as [`POST /v1/documents/{document_id}/lifecycle`](<#update-lifecycle>) — identical state-machine validation, the same transaction-documents update, the same PPF/SDI compliance reporting for FR/IT documents, and the same `lifecycle.updated` webhook. **The request body and the success response are identical to the id-based route** (see [Update lifecycle status](<#update-lifecycle>) for the full field list), so payment fields (`paymentDate`, `paymentAmount`, `paymentCurrency`, `paymentReference`) are required for `paid` / `partially_paid` here too.

Because invoice numbers are **not unique** (the same number can exist as a sale and a purchase, or across periods), resolution is strict:

Matches in your org| Result  
---|---  
0| `404 not_found` — no document with that invoice number that your organization is a party on.  
exactly 1| `200` — the transition is applied and the updated document is returned.  
more than 1| `409 conflict` — ambiguous; re-issue the call against [`POST /v1/documents/{documentId}/lifecycle`](<#update-lifecycle>) with the specific `documentId`.  
  
Tenant-scoped resolution

Matching is always confined to documents your organization is a party on — an invoice number belonging to another tenant is invisible and resolves to `404`, never another org's document. 
[code] 
    curl -X POST …/v1/documents/by-number/INV-2026-0042/lifecycle \
      -H "Authorization: Bearer $KEY" \
      -d '{
        "status": "approved",
        "note":   "Invoice verified against PO"
      }'
[/code]
[code] 
    {
      "documentId":      "doc_test001",
      "previousStatus":  "received",
      "currentStatus":   "approved",
      "updatedAt":       "2026-04-15T10:32:18.421Z",
      "compliance": {},
      "allowedTransitions": ["partially_paid", "paid", "disputed"]
    }
[/code]

## Directory

Peppol's public directory lets you find any registered participant across every access point in Europe. Use these endpoints to verify reachability _before_ sending.

### Search directory

GET/v1/directory/search

Find any participant registered on the Peppol network. You must supply at least one search criterion — `q` or `vatNumber` — and **a free-text`q` must be scoped by `country`** (a bare SIREN/SIRET or a `vatNumber` already carries its country, so it's exempt). Matching on `q` is fuzzy (substring). By default results are collapsed to one row per legal entity — the directory lists each company once per identifier scheme.

  * qstringconditional

Free-text company name, e.g. `epsa`. A bare 9- or 14-digit value is treated as a French SIREN/SIRET and routed to an exact lookup. **One of`q` or `vatNumber` is required.**

  * vatNumberstringconditional

Exact VAT number, e.g. `BE0633501357` or `FR26921376265`. **One of`q` or `vatNumber` is required.**

  * countryISO 3166-1 α-2conditional

**Required when searching by a free-text`q`**, e.g. `BE`. Optional (a filter) otherwise.

  * city / postalCodestringoptional

Further geographic filters.

  * naceCodesstring[]optional

Filter by NACE business-activity code(s).

  * documentTypesstring[]optional

Only return participants that can receive these types.

  * includeSubEntitiesbooleanoptional

Default `false` (one row per legal entity). Set `true` to return every Peppol identifier-scheme / establishment row — needed when you want the exact routable participant ID. For a French SIREN or SIRET this includes the company's Peppol addressing lines (`0225:{siren}_{suffix}`).

  * detailenumoptional

`basic``full`

Default `basic` (directory fields only). `full` enriches each row with access-point / SMP detail — slower, one lookup per result. A French row is resolved from its company's Peppol lines, read once for all the rows of the same company.

  * limitintegeroptional

Max distinct participants to return. Default `20`.




### Lookup Peppol ID

GET/v1/directory/{peppol_id}

Resolve a participant ID — `0009:921376265` for a French SIREN, `0208:0123456789` for a Belgian CBE — against local registrations, the PPF annuaire (French IDs) and the Peppol Directory. Returns `404` when no source knows it.

A French company is usually listed on Peppol several times: `0225:{siren}` and suffixed addressing lines such as `0225:{siren}_hrs`. `participants` lists every line of the company. For a company ID such as `0009:{siren}`, `smpStatus` and `documentTypes` describe the company across its lines; for one line looked up directly, they describe that line alone.

### Verify recipient

POST/v1/directory/verify

The **recommended pre-flight check** before every send. Tells you whether the recipient exists _on the Peppol network_ , whether a participant actually advertises the document type, and where to send it. Being listed in a national company register is not being reachable: a French company the PPF annuaire knows but the network does not answers `exists: false`. For a French company ID (`0009:{siren}`) the answer covers its addressing lines, and `acceptedBy` names the ones that take this document — the company ID itself is not an address.

#### Request body

  * peppolIdstringrequired

Recipient Peppol participant identifier, e.g. `0208:9876543210`.

  * documentTypestringrequired

Document type to check reachability for, e.g. `INVOICE`.



[code] 
    curl "…/v1/directory/search?q=epsa&country=BE&limit=20" \
      -H "Authorization: Bearer $KEY"
[/code]
[code] 
    {
      "data": [
        {
          "peppolId":      "0208:0655917760",
          "name":          "EPSA MARKETPLACE Belgium SRL",
          "country":       "BE",
          "city":          null,
          "postalCode":    null,
          "vatNumber":     null,
          "documentTypes": ["invoice", "credit-note"],
          "accessPoint":   null
        }
      ],
      "hasMore": true,
      "cursor":  null
    }
[/code]
[code] 
    curl -X POST …/v1/directory/verify \
      -H "Authorization: Bearer $KEY" \
      -d '{
        "peppolId":    "0208:9876543210",
        "documentType":"INVOICE"
      }'
[/code]
[code] 
    {
      "peppolId":      "0208:9876543210",
      "exists":        true,
      "canReceive":    true,
      "recipientName": "Globex SRL",
      "documentType":  "INVOICE",
      "accessPoint":   "peppol.ehealth.fgov.be",
      "acceptedBy":    []
    }
[/code]

## Partners

A **partner** is a counterparty you regularly transact with — a customer, a supplier, or both. Partners store defaults (preferred currency, payment terms, contacts, routing ID) so you don't have to supply them on every send.

### Create a partner

POST/v1/partners

At least one of `peppolId` or `vatNumber` is required.

  * peppolIdstringconditional

Pattern `^\d{4}:.+$`.

  * vatNumberstringconditional

  * roleenumoptional

`supplier``buyer``both`

  * contactName / contactEmailstringoptional

  * defaultsobjectoptional

`currency`, `paymentTermsDays`, `note`, `orderReference`…

  * tags / metadataarray / objectoptional




### List partners

GET/v1/partners

#### Query parameters

  * roleenumoptional

`supplier``buyer``both`

  * searchstringoptional

Full-text over name, VAT, and Peppol ID.

  * countryISO 3166-1 α-2optional

  * tagsstringoptional

Comma-separated tag filter.

  * hasActivitybooleanoptional

Only partners with at least one sent/received document.

  * peppolStatusstringoptional

  * sortBy / orderstringoptional

Field to sort by and direction (`asc` / `desc`).

  * limit / cursorpaginationoptional




### Retrieve partner

GET/v1/partners/{partner_id}

Path accepts `part_…`, `vat:…`, or `peppol:…`.

### Update partner

PATCH/v1/partners/{partner_id}

#### Request body

All fields optional — same shape as [create](<#create-partner>).

  * peppolIdstringoptional

  * vatNumberstringoptional

  * roleenumoptional

`supplier``buyer``both`

  * contactName / contactEmailstringoptional

  * defaultsobjectoptional

  * tags / metadataarray / objectoptional




### Delete partner

DEL/v1/partners/{partner_id}

### Retrieve a partner by account number

GET/v1/partners/by-account-number

Reverse lookup: resolve the partner behind one of your own internal customer or supplier account numbers. The value is matched against a custom field on your partner records — scoped to your organization — and the matched record is resolved to the partner’s full profile (name, VAT number, country).

The custom field must be populated on the partner records you want to reach. Returns `404` when no partner carries that value.

#### Query parameters

  * valuestringrequired

The exact account number to look up.

  * fieldstringoptional

Name of the custom field holding the account number. Defaults to `Numéro de compte interne`.

  * entityTypestringoptional

Entity the custom field is attached to. Defaults to `PARTNERSHIP`.




Requires the `partners.read` scope. Returns a [partner](<#get-partner>) object.

### List a partner’s invoices

GET/v1/partners/{partner_id}/invoices

Every invoice exchanged between your organization and this partner — the partner is matched as either seller or payer. Results are always scoped to your organization: you only ever see documents your organization is a party to.

#### Query parameters

  * limitintegeroptional

1–100. Defaults to 20.

  * cursorstringoptional

Opaque cursor returned by the previous page.




Returns a paginated list of [document](<#list-documents>) summaries.
[code] 
    curl -X POST …/v1/partners \
      -H "Authorization: Bearer $KEY" \
      -d '{
        "peppolId":  "0208:9876543210",
        "role":      "buyer",
        "contactName":"Laura Rossi",
        "contactEmail":"laura@globex.it",
        "defaults":  { "currency": "EUR", "paymentTermsDays": 30 },
        "tags":      ["strategic","italy"]
      }'
[/code]
[code] 
    {
      "id":          "part_01HXY…",
      "peppolId":    "0208:9876543210",
      "name":        "Globex SRL",
      "vatNumber":   "IT01234567890",
      "country":     "IT",
      "role":        "buyer",
      "contactName": "Laura Rossi",
      "contactEmail":"laura@globex.it",
      "peppolStatus":"active",
      "defaults":    { "currency": "EUR", "paymentTermsDays": 30 },
      "tags":        ["strategic","italy"],
      "enrichment":  { "naceCode": "70.22" },
      "stats":       { "documentsSent": 12, "documentsReceived": 0 },
      "metadata":    {},
      "createdAt":   "2026-04-25T10:00:00Z",
      "updatedAt":   "2026-04-25T10:00:00Z"
    }
[/code]

## Purchase orders

A read-only view over the purchase orders already flowing through Flowie. Use it to walk from an order to the invoices billed against it — handy for reconciliation and for answering “what has been invoiced on this order so far?”.

### List a purchase order’s invoices

GET/v1/purchase-orders/{purchase_order_id}/invoices

Every invoice linked to the given purchase order. Results are always scoped to your organization: you only ever see documents your organization is a party to. An order with nothing billed against it returns an empty list, not a `404`.

#### Query parameters

  * limitintegeroptional

1–100. Defaults to 20.

  * cursorstringoptional

Opaque cursor returned by the previous page.




Returns a paginated list of [document](<#list-documents>) summaries.
[code] 
    curl …/v1/purchase-orders/PO-2026-0042/invoices \
      -H "Authorization: Bearer $KEY"
[/code]
[code] 
    {
      "data": [
        {
          "id":       "doc_01HXY…",
          "type":     "INVOICE",
          "number":   "INV-2026-001",
          "issueDate":"2026-04-14",
          "currency": "EUR",
          "amount":   1210.0,
          "status":   "received",
          "direction":"incoming"
        }
      ],
      "hasMore": false,
      "cursor":  null
    }
[/code]

## Webhooks

Webhooks deliver events to your HTTPS endpoint. Every delivery is signed (`X-Flowie-Signature`), retried with exponential backoff, and recorded for replay. See the [Webhook cookbook](<webhooks.html>) for signing, retries, and idempotency patterns.

### Create a webhook

POST/v1/webhooks

  * urlhttps URLrequired

  * eventsstring[]required

`document.received``document.updated` `document.sent``document.delivered` `document.failed``lifecycle.updated` `company.smp_registered``*`

  * secretstringoptional

Auto-generated if omitted. Used for HMAC-SHA256 signing.

  * companyIdstringoptional

Scope events to a specific managed company.




### List webhooks

GET/v1/webhooks

#### Query parameters

  * companyIdstringoptional

Only return webhooks scoped to this managed company.




### Update webhook

PATCH/v1/webhooks/{webhook_id}

#### Request body

  * urlhttps URLoptional

  * eventsstring[]optional

  * rotateSecretbooleanoptional

Set `true` to mint a new signing secret (returned once in the response).




### Delete webhook

DEL/v1/webhooks/{webhook_id}
[code] 
    curl -X POST …/v1/webhooks \
      -H "Authorization: Bearer $KEY" \
      -d '{
        "url":    "https://example.com/hooks/peppol",
        "events": ["document.received","document.delivered","document.failed"],
        "secret": "whsec_rotate_me"
      }'
[/code]
[code] 
    {
      "id":               "wh_01…",
      "url":              "https://example.com/hooks/peppol",
      "events":           ["document.received","document.delivered","document.failed"],
      "status":           "active",
      "companyId":        null,
      "failureCount":     0,
      "lastDeliveredAt":  null,
      "createdAt":        "2026-04-25T10:00:00Z"
    }
[/code]

## Events

Every webhook delivery has a durable twin in the Events API. If your endpoint was down, or you want a replay, poll `/v1/events` and acknowledge what you've processed.

### List events

GET/v1/events

#### Query parameters

  * typestringoptional

Filter by event type, e.g. `document.received`.

  * companyIdstringoptional

Scope to a managed company.

  * limitintegeroptional

Page size. Default `20`.




### Acknowledge one event

POST/v1/events/{event_id}/ack

Returns `204 No Content`. Acked events are hidden from subsequent list calls.

### Batch acknowledge

POST/v1/events/ack

#### Request body

  * eventIdsstring[]required

Event IDs to acknowledge, e.g. `["evt_…", "evt_…"]`.




### Replay an event

POST/v1/events/{event_id}/replay

Re-emits a delivered event onto every matching webhook subscription as if it had just happened. Useful for recovering from a downstream outage on your side without rewinding our delivery state. Returns `{"replayed": <n>}` with the count of webhook deliveries scheduled.
[code] 
    {
      "data": [
        {
          "id":        "evt_01HY…",
          "type":      "document.received",
          "createdAt": "2026-04-25T10:05:08Z",
          "data": {
            "documentId": "doc_01…",
            "direction":  "incoming",
            "type":       "invoice",
            "number":     "INV-2026-0417"
          }
        }
      ],
      "hasMore": false
    }
[/code]

## Compliance

France **PPF** and Italy **SDI** require that lifecycle state changes (accepted / rejected / paid) be reported to a national platform. Flowie does this for you. These endpoints surface the current state and the underlying report records. Belgium runs pure Peppol since 2026-01-01 (HERMES decommissioned 2025-12-31) — no regulator-side report fires for BE.

### Compliance status

GET/v1/compliance/status

#### Query parameters

  * companyIdstringoptional

Limit to a single managed company.

  * countryISO 3166-1 α-2optional




### Compliance reports

GET/v1/compliance/reports

Every report record has `documentId`, `reportedTo`, `platformResponse`, and an `error` if the authority rejected.

#### Query parameters

  * companyIdstringoptional

  * countryISO 3166-1 α-2optional

  * statusstringoptional

Filter by reporting status.

  * from / todateoptional

Report-date range.

  * limit / cursorpaginationoptional




## Stats

GET/v1/stats

Usage, quota, and rate-limit status for the current period.

#### Query parameters

  * periodenumoptional

`day``week``month``year`

  * companyIdstringoptional



[code] 
    {
      "period":    { "start":"2026-04-01", "end":"2026-04-30" },
      "quota":     { "limit": 5000, "used": 412, "remaining": 4588 },
      "rateLimit": { "perMinute": 300 },
      "documents": {
        "sent":      180,
        "received":  232,
        "delivered": 178,
        "failed":    2
      },
      "byType":    { "invoice": 390, "credit-note": 22 },
      "byCountry": { "FR": 150, "BE": 120, "IT": 142 },
      "partners":  { "total": 47, "active": 31 }
    }
[/code]

## Platform

These endpoints are for organizations running Flowie under their own brand — accounting SaaS, ERPs, public-sector aggregators. Most require a `flw_plat_live_…` or `flw_wl_live_…` key.

### Onboard a managed company

POST/v1/platform/companies

Registers a tenant, optionally creates a scoped API key and webhook, and registers on SMP — all in one call.

  * vatNumberstringrequired

  * namestringoptional

  * addressAddressoptional

  * metadataobjectoptional

  * receiveDocumentsbooleanoptional

Default `true`.

  * autoVerifybooleanoptional

  * webhookobjectoptional

Same shape as [webhook create](<#create-webhook>); created atomically.

  * apiKeyobjectoptional

`{ "name": "tenant-…", "scopes": ["send","documents.read"] }`.




### List managed companies

GET/v1/platform/companies

### Create API key for tenant

POST/v1/platform/api-keys

  * namestringrequired

  * companyIdstringoptional

Scopes the key to that tenant.

  * scopesstring[]optional

  * expiresAttimestampoptional

  * rateLimitobjectoptional




### List platform API keys

GET/v1/platform/api-keys

### Revoke a key

DEL/v1/platform/api-keys/{key_id}

### Usage breakdown

GET/v1/platform/usage

Returns total counters and a per-group array.

#### Query parameters

  * periodstringoptional

Reporting window, e.g. `month`.

  * groupByenumoptional

`company``country``type`




### Update platform settings

PATCH/v1/platform/settings

#### Request body

  * brandingobjectoptional

Logo, colors, sender display name for white-label delivery.

  * defaultsobjectoptional

Default tenant settings applied at onboard time.

  * customDomainstringoptional

Custom domain for webhook/callback URLs.




### Cross-tenant event stream

GET/v1/platform/events

Returns the unified event stream across every tenant managed by this platform key. Same shape as `/v1/events` with an extra `companyId` on each row so you can fan out per-tenant. Filters: `type`, `companyId`, `limit`, `cursor`. Platform / white-label keys only.
[code] 
    curl -X POST …/v1/platform/companies \
      -H "Authorization: Bearer flw_plat_live_xyz" \
      -d '{
        "vatNumber":"FR86797978996",
        "receiveDocuments":true,
        "webhook": {
          "url": "https://erp.acme.fr/hooks/flowie",
          "events": ["*"]
        },
        "apiKey": { "name":"erp-tenant-t001",
                    "scopes":["send","documents.read","lifecycle"] }
      }'
[/code]
[code] 
    {
      "company":  { "id":"comp_01HY…", "peppolId":"0009:FR86797978996", … },
      "apiKey":   { "id":"key_01…", "key":"flw_live_t001_abc…", "keyPrefix":"flw_live_t001" },
      "webhook":  { "id":"wh_01…", "status":"active" }
    }
[/code]

## API keys

### Create API key

POST/v1/api-keys

Authenticate with a [Flowie JWT (Auth0)](<#authentication>) — the same token your dashboard uses. The new key is bound to the caller's Flowie organization (resolved from the JWT's `_permissions` claim) and inherits its tier. Multi-org users should pass `X-Flowie-Organization-Id` to target a specific org. An existing `flw_live_*` key may also call this endpoint to mint additional keys for the same org.

  * namestringrequired

  * companyIdstringoptional

  * scopesstring[]optional

See [scopes list](<#authentication>).

  * expiresAttimestampoptional

  * rateLimitintegeroptional




Response includes `key` **exactly once**. Store it in your secret manager immediately.

### List API keys

GET/v1/api-keys

### Revoke API key

DEL/v1/api-keys/{key_id}

Immediate. Any request-in-flight bearing the revoked key finishes, but new requests 401.
[code] 
    {
      "id":        "key_01HY…",
      "key":       "flw_live_abc123def456ghi…",   // shown once
      "keyPrefix": "flw_live_abc123",
      "name":      "Mobile App",
      "scopes":    ["send","documents.read"],
      "companyId": null,
      "createdAt": "2026-04-25T10:00:00Z",
      "expiresAt": "2027-04-25T00:00:00Z"
    }
[/code]

## Categorization

Tag documents, partners, or other objects. Tags live in _groups_ (e.g. `business-unit`, `project`, `cost-center`). We also expose an AI suggest endpoint — feed it a document, get a ranked list of tags.

### List tag groups

GET/v1/categorization/groups

### List tags in a group

GET/v1/categorization/groups/{group_id}/tags

### Tags on an object

GET/v1/categorization/objects/{object_id}/tags

### Assign tag

POST/v1/categorization/objects/{object_id}/tags

Body: `{"tagId": "tag_…", "objectType": "document"}`.

### Remove tag

DEL/v1/categorization/objects/{object_id}/tags/{tag_id}

### AI tag recommendation

POST/v1/categorization/objects/tags/auto

Body: `{"objectId":"doc_…", "objectType":"document", "context": {…}}` → ranked list of recommended tags with confidence scores.
[code] 
    [
      { "tagId": "tag_cc_rd",   "name": "R&D",      "groupId": "cost-center", "confidence": 0.92 },
      { "tagId": "tag_proj_x1", "name": "Project X1", "groupId": "project",     "confidence": 0.71 }
    ]
[/code]

## Payments

**Authentication:** `Authorization: Bearer <key>` with a live or test Exchange key (`flw_live_…` / `flw_test_…`) or a Flowie JWT.

### Document payment info

GET/v1/payments/documents/{documentId}

Every payment recorded against the document, plus what is still owed. `balanceDue` is the document total minus everything paid — `null` when the document carries no total to subtract from. `payments` is empty, not absent, when nothing has been paid yet.
[code] 
    {
      "objectId":       "doc_01HZXABCDEF0123456789",
      "objectType":     "document",
      "organizationId": "comp_abc123",
      "payments": [
        {
          "id":        "pay_01HZXPAID0000001",
          "amount":    1200.00,
          "currency":  "EUR",
          "paidAt":    "2026-06-10T13:42:11Z",
          "method":    "SEPA",
          "reference": "INV-2026-0042",
          "status":    "recorded"
        }
      ],
      "balanceDue": 0.00,
      "totalPaid":  1200.00,
      "currency":   "EUR"
    }
[/code]

### Record a payment

POST/v1/payments/documents/{documentId}/pay

Records a payment against an invoice or a purchase order, and returns the created `Payment`.

Recording also advances the lifecycle to `partially_paid`, or to `paid` once the recorded payments cover the document total. The advance is best-effort: the payment is always recorded, and the response’s `lifecycleStatus` is `null` if the document could not legally move to a paid state. Only `approved`, `partially_paid` and `disputed` can — a document still in `draft`/`received`/`under_review` must be approved first. Check `GET /v1/documents/{documentId}/lifecycle` → `allowedTransitions`.

#### Request body

  * amountnumberrequired

Amount paid — a positive number.

  * datedateoptional

`YYYY-MM-DD` or an ISO 8601 timestamp. `paidAt` is accepted as an alias.

  * currencyISO 4217optional

ISO 4217 code. Defaults to `EUR`.

  * methodenumoptional

One of `SEPA`, `card`, `cheque`, `cash`, `wire`, `other`.

  * referencestringoptional

Free-form external reference — a bank transaction id, for instance.

  * notestringoptional

Free text stored on the payment.



[code] 
    {
      "amount":    1200.00,
      "date":      "2026-06-17",
      "currency":  "EUR",
      "method":    "SEPA",
      "reference": "INV-2026-0042"
    }
[/code]
[code] 
    {
      "id":              "pay_01HZXNEW000000001",
      "objectId":        "doc_01HZXABCDEF0123456789",
      "objectType":      "document",
      "organizationId":  "comp_abc123",
      "amount":          1200.00,
      "currency":        "EUR",
      "paidAt":          "2026-06-17T09:30:00Z",
      "method":          "SEPA",
      "reference":       "INV-2026-0042",
      "status":          "recorded",
      "lifecycleStatus": "paid",
      "createdAt":       "2026-06-17T09:30:00Z"
    }
[/code]

### Export ISO 20022 / SEPA

POST/v1/payments/export/iso20022

Generates a pain.001 SEPA credit-transfer file for a set of documents, ready for upload to your bank. The returned `content` is base64-encoded XML — decode it before saving. Sandbox keys receive a placeholder file with `transactionCount: 0`.

#### Request body

  * documentIdsstring[]required

Documents to bundle. Each needs a registered creditor bank account and a non-zero outstanding balance.

  * currencyISO 4217optional

ISO 4217 code. Defaults to `EUR`.

  * executionDatedateoptional

Date on which the bank should execute the payments. Defaults to the next business day.



[code] 
    {
      "documentIds": [
        "doc_01HZXABCDEF0123456789",
        "doc_01HZXABCDEF0123456790"
      ],
      "currency":      "EUR",
      "executionDate": "2026-06-18"
    }
[/code]
[code] 
    {
      "messageId":        "MSG-2026-06-17-0001",
      "organizationId":   "comp_abc123",
      "format":           "pain.001.001.09",
      "filename":         "MSG-2026-06-17-0001.xml",
      "content":          "PD94bWwgdmVyc2lvbj0iMS4wIiA…(base64)…",
      "contentType":      "application/xml",
      "transactionCount": 2,
      "totalAmount":      2400.00,
      "currency":         "EUR",
      "generatedAt":      "2026-06-17T09:35:14Z"
    }
[/code]

## Request log

Every mutation (POST/PUT/PATCH/DELETE) and every error is captured for your organization, so you can answer "what did that integration actually send?" without adding logging of your own. Successful GETs are captured only when the server-side `REQUEST_LOG_ALL` flag is on. Individual entries are also browsable in the [request inspector](<../playground/requests.html>).

### List captured requests

GET/v1/requests

**Authentication:** `Authorization: Bearer <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.



[code] 
    {
      "data": [
        {
          "id":       "req_01HY…",
          "method":   "POST",
          "path":     "/v1/documents/send",
          "status":   201,
          "apiKeyId": "key_01HY…",
          "createdAt": "2026-04-25T10:05:00Z"
        }
      ],
      "hasMore": false,
      "cursor":  null
    }
[/code]

### Usage rollup

GET/v1/requests/summary

**Authentication:** `Authorization: Bearer <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.

Grouped by key, each row also carries rate-limit pressure: `throttledRequests` (requests refused with 429 — exact, since every error is captured), `peakRequestsPerMinute` (the busiest minute in the window) and `rateLimitPerMinute` (the budget that key is allowed). Treat the peak as a _lower bound_ : successful reads spend the budget without being logged.

#### Query parameters

  * byenumoptional

`key``user`

  * sincedatetimeoptional

ISO-8601 lower bound.

  * untildatetimeoptional

ISO-8601 upper bound.



[code] 
    {
      "by": "apiKey",
      "data": [
        {
          "apiKeyId":              "key_01HY…",
          "totalRequests":         1284,
          "errorRequests":         3,
          "throttledRequests":     0,
          "peakRequestsPerMinute": 96,
          "rateLimitPerMinute":    600,
          "lastRequestAt":         "2026-04-25T10:05:00Z"
        }
      ]
    }
[/code]

### Volume over time

GET/v1/requests/timeseries

**Authentication:** `Authorization: Bearer <key>` with a live or test Exchange key (`flw_live_…` / `flw_test_…`) or a Flowie JWT.

The same traffic as the rollup, but bucketed by minute or hour, so a spike has a shape instead of a number. `errors` counts every 4xx/5xx; `throttled` isolates the requests the rate limiter itself refused with a 429.

`total` is a _lower bound_ unless the server captures successful reads: those spend the rate-limit budget without being logged. `errors` and `throttled` are exact.

#### Query parameters

  * bucketenumoptional

`minute``hour`

  * apiKeyIdstringoptional

Only this API key / client id.

  * organizationIdstringoptional

Only this organization.

  * sincedatetimeoptional

ISO-8601 lower bound.

  * untildatetimeoptional

ISO-8601 upper bound.



[code] 
    {
      "bucket": "minute",
      "data": [
        { "ts": "2026-08-28 21:14", "total": 96,  "errors": 2,  "throttled": 0,  "maxDurationMs": 210 },
        { "ts": "2026-08-28 21:15", "total": 412, "errors": 31, "throttled": 29, "maxDurationMs": 940 }
      ]
    }
[/code]

## Portability

Inter-PA messaging for the French portability process: when a taxpayer moves from one Plateforme Agréée to another, the gaining and losing platforms exchange a normalised message (a strict subject line plus an 18-field CSV). These endpoints send that message to the counterparty platform, keep every one that goes out, parse the ones that come in, and list the registered platforms so you know where to write. Full walkthrough: [Portability (change of PA)](<../guides/portability.html>).

### Send an inter-PA message

POST/v1/portability/messages

**Authentication:** `Authorization: Bearer <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, email it to the counterparty platform and log it. Name the counterparty (`losingPaName` / `gainingPaName`) and the address is resolved from the registry of registered Plateformes Agréées, or pass `to` yourself; `recipientSource` in the response says which happened. Sending is gated by a server-side kill-switch and the SMTP configuration, and a sandbox key never reaches a real platform — when the mail does not go out, `dispatched` is `false` and `reason` says why. The message is recorded either way: `GET /v1/portability/messages` lists them and `GET /v1/portability/messages/{id}` returns the CSV as sent with its SHA-256. `GET /v1/portability/platforms` lists the platforms and the address a port request goes to, and `POST /v1/portability/routing` switches the taxpayer's routing on the agreed _date d'effet_ (`validFrom` when gaining, emission stop plus a 12-month reception window when losing). `GET /v1/portability/annuaire/{siren}` reads the PPF _annuaire_ back: which platform matricule routes the taxpayer today, since when, and until when. Outside France there is no registry to read, so `GET /v1/portability/access-point/{participantId}` resolves the answer live instead — SML, then the SMP, then the endpoint certificate, which names the platform that routes that Peppol participant. And because nobody knows their own SIRET by heart, `GET /v1/portability/companies?q=` turns a company name into the company — ranked suggestions, or a direct lookup when the query already is an identifier.

#### Request body

  * messageTypeenumrequired

Which step of the portability exchange this message is.

  * stateenumrequired

State of the request the message reports.

  * requestRefstringrequired

Your reference for the portability request; echoed in the subject.

  * taxpayerSirenstringrequired

9-digit SIREN of the taxpayer being ported.

  * gainingPaId / losingPaIdstringoptional

Platform identifiers on each side of the move.

  * effectiveDatedateoptional

When the transfer takes effect.



[code] 
    {
      "messageType":   "REQUEST",
      "state":         "received",
      "requestRef":    "POR-2026-000123",
      "directionRole": "GAINING_PA",
      "taxpayerSiren": "552100554",
      "losingPaName":  "ESKER",
      "effectiveDate": "2026-10-01"
    }
[/code]
[code] 
    {
      "id":              "pmsg_9f2c7a1d4b8e4c0f9a6d3e2b1c7f5a80",
      "subject":         "[PORTABILITE][REQUEST][REQ][SIREN:552100554][REF:POR-2026-000123]",
      "csvRow":          "POR-2026-000123;REQUEST;REQ;GAINING_PA;552100554;…",
      "csvSha256":       "6b1f…",
      "to":              "contact-pdp@esker.com",
      "recipientSource": "registry:ESKER",
      "dispatched":      true,
      "reason":          "sent"
    }
[/code]

### Parse an inbound message

POST/v1/portability/messages/parse

**Authentication:** `Authorization: Bearer <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.



[code] 
    {
      "subject": "[PORTABILITE][REQUEST][REQ][SIREN:552100554][REF:POR-2026-000123]"
    }
[/code]
[code] 
    {
      "messageType": "REQUEST",
      "statusCode":  "REQ",
      "state":       "received",
      "siren":       "552100554",
      "requestRef":  "POR-2026-000123"
    }
[/code]

### Resolve a taxpayer

POST/v1/portability/resolve

**Authentication:** `Authorization: Bearer <key>` with a live or test Exchange key (`flw_live_…` / `flw_test_…`) or a Flowie JWT.

One identifier in, everything a migration request needs out. Accepts a SIRET, a SIREN, a VAT number, a Peppol id, a national registration number, a domain, an e-mail or `name:<company>`, and answers with the legal name, country, identifiers and the French annuaire addressing line — plus the regime that governs the switch, what changes, and what must be re-granted. Whatever cannot be resolved is listed in `stillNeeded` rather than guessed. `404` when no layer resolves the identifier.

#### Request body

  * taxpayerstringrequired

The only value a company has to supply.

  * countrystring (ISO-2)optional

Used only when the identifier does not carry its own country.



[code] 
    {
      "taxpayer": "92137626500017"
    }
[/code]
[code] 
    {
      "taxpayer": {
        "name":          "ACME SAS",
        "country":       "FR",
        "registrationNumber": "92137626500017",
        "peppolId":      "0009:921376265",
        "addressingIdentifier": "921376265_92137626500017_001",
        "resolvedFrom":  "legal_base"
      },
      "regime": "PPF · plateforme agréée",
      "requirements": {
        "addressChanges": false,
        "reGrant":        "A dated, signed designation agreement (accord formel).",
        "archiveHolder":  "You / your platform — the outgoing one owes 1 year of lifecycle statuses."
      },
      "stillNeeded": []
    }
[/code]

### Open a migration request

POST/v1/portability/requests

**Authentication:** `Authorization: Bearer <key>` with a live or test Exchange key (`flw_live_…` / `flw_test_…`) or a Flowie JWT.

Opens a platform change from the same one identifier, resolving everything else. Produces the designation agreement **art. 242 nonies E bis** requires — taxpayer, incoming platform, previous platform, effective date, scope of electronic addresses, signatory — numbers it, and starts a hash-linked evidence chain. Deadlines are computed in _jours ouvrés_ including _jours fériés_. A missing signatory does not fail the call: it comes back in `mandate.gaps`, because that gap is exactly what an outgoing platform may object to. Returns `201`.

#### Request body

  * taxpayerstringrequired

Identifier, as above.

  * signatorystringoptional

Who signs for the company. Required by the decree; reported as a gap when absent.

  * outgoingPlatformstringoptional

The platform being left. Resolved from the directory when omitted.

  * effectiveDatestring (date)optional

Defaults to the first business day after the objection window could close.

  * addressScopestring[]optional

Addresses covered. Defaults to the resolved annuaire line and Peppol id.

  * overridesobjectoptional

Explicit values that beat anything resolved.



[code] 
    {
      "taxpayer":  "92137626500017",
      "signatory": "Camille Roy, Directrice Générale"
    }
[/code]
[code] 
    {
      "requestRef": "POR-2026-4F2A91C08B7D",
      "state":      "received",
      "statusCode": "REQ",
      "mandate": {
        "reference":     "POR-2026-4F2A91C08B7D",
        "effectiveDate": "2026-09-09",
        "addressScope":  ["921376265_92137626500017_001", "0009:921376265"]
      },
      "clocks": {
        "notifyBy":            "2026-09-03",
        "objectionWindowEnds": "2026-09-08",
        "annuaireUpdateBy":    "2026-09-29",
        "continuityUntil":     "2027-09-09"
      },
      "tacitApproval": false,
      "evidence": { "verification": { "intact": true, "length": 1 } }
    }
[/code]

### Read a migration request

GET/v1/portability/requests/{request_ref}

**Authentication:** `Authorization: Bearer <key>` with a live or test Exchange key (`flw_live_…` / `flw_test_…`) or a Flowie JWT.

State is never stored — it is folded from the evidence chain on every read, so this is the single truth about where a port stands, and an agent that was not running when the request was opened reaches the same answer as one that was. `tacitApproval` flips to `true` by itself once the objection window lapses with no admissible objection (_le silence vaut accord_), with no scheduler involved. `404` when the reference does not belong to the calling organization.
[code] 
    curl …/v1/portability/requests/POR-2026-4F2A91C08B7D \
      -H "Authorization: Bearer $FLOWIE_KEY"
[/code]
[code] 
    {
      "requestRef":    "POR-2026-4F2A91C08B7D",
      "state":         "auto_accepted",
      "statusCode":    "TAC",
      "tacitApproval": true,
      "objections": [
        { "ground": "unpaid_invoices", "admissible": false }
      ],
      "evidence": {
        "verification":    { "intact": true, "length": 3, "brokenAt": null },
        "manifestSha256":  "b41c…"
      }
    }
[/code]

### Have the agreement signed

POST/v1/portability/requests/{request_ref}/mandate/signature

**Authentication:** `Authorization: Bearer <key>` with a live or test Exchange key (`flw_live_…` / `flw_test_…`) or a Flowie JWT.

Renders the designation agreement from the mandate this request already holds — the five items article 242 _nonies_ E bis requires — and opens an **approval check** on it for the users you name. They sign by deciding in Flowie; what is stored is their decision, its instant and its author, none of which this API supplies. `201` returns the text exactly as it is put in front of them, and its two digests.

**The context key is what binds a decision to this agreement:** `portability:<requestRef>:<mandateSha256>`. An approval vote is scoped to the object it hangs on, so without it a decision taken on the same document for another reason would read here as a signature. Change the mandate — a new _date d'effet_ , a different address scope — and the digest moves with it, so the earlier signature stops answering for the new agreement. That is the point: it is not the agreement that was signed.

The check hangs on a **document** , never on the request — approval's `ObjectType` is a closed enum with no portability member. Leave `objectId` out and the rendered agreement is stored as one for you. `502` when the approval service does not answer: a silent failure would leave you believing somebody had been asked. `409` when the mandate is still missing a decree item (`taxpayerId`, `incomingPlatform`, `effectiveDate`, `addressScope`): a request can be opened on an identifier alone, and an agreement rendered with blanks where the decree wants values is not one anybody should be asked to sign.

#### Request body

  * userIdsstring[]required

Who is asked to sign, as Flowie user ids. The agreement is a designation _by the taxpayer_ , so this is its legal representative — not your own team. One to ten.

  * objectIdstringoptional

The object the check hangs on. Leave it out and the rendered agreement is stored as a document and used.

  * objectTypestringoptional

Defaults to `DocumentVersion`. Only set it if `objectId` points at another kind approval accepts.

  * expiresAtstringoptional

ISO-8601. A mandate nobody signs should lapse rather than sit open against a _date d'effet_ that has passed.



[code] 
    curl -X POST …/v1/portability/requests/POR-2026-4F2A91C08B7D/mandate/signature \
      -H "Authorization: Bearer $FLOWIE_KEY" \
      -H "Content-Type: application/json" \
      -d '{"userIds": ["usr_01J9ZC3K7V8QDX4M2T6NRPB5HE"]}'
[/code]
[code] 
    {
      "requestRef":      "POR-2026-4F2A91C08B7D",
      "checkId":         "chk_01J9ZC6Q4T2VHM8B5D7KXNPR3W",
      "contextKey":      "portability:POR-2026-4F2A91C08B7D:9f2c…",
      "objectId":        "docv_01J9ZC4B2N7RSF5K8W3PQXTM6D",
      "objectType":      "DocumentVersion",
      "mandateSha256":   "9f2c…",
      "agreementSha256": "41ab…",
      "signed":          false,
      "signature":       null
    }
[/code]

### Read the signature

GET/v1/portability/requests/{request_ref}/mandate/signature

**Authentication:** `Authorization: Bearer <key>` with a live or test Exchange key (`flw_live_…` / `flw_test_…`) or a Flowie JWT.

`signed` stays `false` while the check is open, refused or withdrawn — only a passed check carrying _this_ agreement's context key is a signature, and one taken on an earlier version of the mandate is not. The first read that finds one writes it into the evidence chain, so the request carries its `signedAt` from then on whether or not this endpoint is called again.

`404` when nobody has been asked to sign this request. A mandate signed outside Flowie is recorded instead with `signedAt` and `signatureMethod` when the request is opened.
[code] 
    curl …/v1/portability/requests/POR-2026-4F2A91C08B7D/mandate/signature \
      -H "Authorization: Bearer $FLOWIE_KEY"
[/code]
[code] 
    {
      "signed": true,
      "signature": {
        "signatory":       "Yann Ravel-Sibillot",
        "signedAt":        "2026-09-18T08:59:00+00:00",
        "method":          "approval",
        "checkId":         "chk_01J9ZC6Q4T2VHM8B5D7KXNPR3W",
        "mandateSha256":   "9f2c…",
        "agreementSha256": "41ab…"
      }
    }
[/code]

### Sign with your own document

POST/v1/portability/requests/{request_ref}/mandate/signature/document

**Authentication:** `Authorization: Bearer <key>` with a live or test Exchange key (`flw_live_…` / `flw_test_…`) or a Flowie JWT.

The third way to sign, and the one that fits a taxpayer whose representative has no Flowie account. Signing _in_ Flowie gives an authenticated actor and an observed instant; asserting a paper signature at opening gives neither and keeps nothing. This sits between them honestly: **you** state who signed and when, exactly as for paper, but the artefact is stored under your organization and hashed — so what was signed stops being a claim. The signature then replays onto the mandate like any other and `mandateGaps` closes.

**`agreementSha256` is the digest of the file you uploaded**, not of the text this API would have rendered. Those are different documents — you may have signed your own wording, or your advocate’s, or ours with a scan on top — and recording ours as the one signed would be a statement we cannot support. `mandateSha256` still binds to the decree content, so amending the mandate afterwards invalidates this signature exactly as it invalidates an in-app one.

`400` when `signedAt` is not an ISO-8601 instant, when it is _in the future_ (an act cannot be dated after the moment it is recorded), or when the file is empty. `409` when the agreement is _already signed_ — the chain folds signatures in order, so a second one would replace the first without trace — or when the mandate is still missing a decree item. `413` above 10 MB, `502` when the document could not be stored — the artefact is the whole point of this path, so a signature is never recorded without it.

#### Form fields

  * filefilerequired

The signed agreement, as signed. Kept under your organization; PDF, image or text.

  * signatorystringrequired

Who signed it, for the taxpayer — name and role, as it appears on the document.

  * signedAtstringrequired

When they signed it, ISO-8601. Refused if it is in the future.



[code] 
    curl -X POST …/v1/portability/requests/POR-2026-4F2A91C08B7D/mandate/signature/document \
      -H "Authorization: Bearer $FLOWIE_KEY" \
      -F "file=@mandat-signe.pdf" \
      -F "signatory=Camille Roy, Directrice Générale" \
      -F "signedAt=2026-09-18T09:00:00+00:00"
[/code]
[code] 
    {
      "signed": true,
      "objectId": "docv_01J9ZC4B2N7RSF5K8W3PQXTM6D",
      "mandateSha256":   "9f2c…",
      "agreementSha256": "7d10…",
      "signature": {
        "signatory": "Camille Roy, Directrice Générale",
        "signedAt":  "2026-09-18T09:00:00+00:00",
        "method":    "upload",
        "documentId": "docv_01J9ZC4B2N7RSF5K8W3PQXTM6D"
      }
    }
[/code]

### Record a step

POST/v1/portability/requests/{request_ref}/events

**Authentication:** `Authorization: Bearer <key>` with a live or test Exchange key (`flw_live_…` / `flw_test_…`) or a Flowie JWT.

Appends one step to the evidence chain and returns the re-derived state. **An objection is recorded, then judged:** the decree limits the outgoing platform to grounds questioning the taxpayer's intent — `more_recent_agreement`, `identity_mismatch`, `mandate_invalid`. Any other ground is stored verbatim and flagged `admissible: false`; the request keeps running and carries the mandate digest as the answer to it.

#### Request body

  * kindenumrequired

`notified`, `objection`, `acceptance` or `annuaire_updated`.

  * groundstringoptional

For `objection`: the ground stated by the outgoing platform.

  * statementstringoptional

For `objection`: their wording, kept verbatim as evidence.

  * channelRefstringoptional

For `notified`: your reference for the message sent.

  * entryRefstringoptional

For `annuaire_updated`: the directory entry reference.



[code] 
    {
      "kind": "objection",
      "ground": "unpaid_invoices",
      "statement": "Contract runs to December."
    }
[/code]
[code] 
    {
      "state": "received",
      "objections": [
        {
          "ground":     "unpaid_invoices",
          "admissible": false
        }
      ]
    }
[/code]

## UBL generator (France)

A compliant French e-invoice for every business situation the reform recognises — all 45 _cas d'usage_ of AFNOR XP Z12-014 plus the foundations. Each scenario carries its business narrative, the BT fields it turns on, the 200–213 lifecycle it drives, the EN 16931 UBL 2.1 XML and the exact `POST /v1/documents/send` body. The catalogue and the generator are pure functions of the request and touch no tenant data, so they need **no API key**. Full guide: [UBL generator](<../compliance/fr/ubl-generator.html>).

### List every business case

GET/v1/tools/fr/ubl/scenarios

**Authentication:** none — this is reference material, not your data.

Returns every scenario with its business description. Filter by `theme`, `family`, `channel` (`e-invoicing` / `e-reporting`) or `case`, or search the narratives with `q`.

#### Query parameters

  * themestringoptional

e.g. `Acompte & paiement échelonné`.

  * familyenumoptional

`data`, `third-party`, `lifecycle` or `foundation`.

  * channelenumoptional

`e-invoicing` or `e-reporting`.

  * casestringoptional

XP Z12-014 case number, e.g. `20` or `19b`.

  * qstringoptional

Free-text search across the title, the story and the French case title.




### Read one business case

GET/v1/tools/fr/ubl/scenarios/{scenario_id}

**Authentication:** none — this is reference material, not your data.

The story, the rule that makes it its own case, the trap, and the BT fields that carry it — without generating anything. An unknown id returns `404` with the closest matches.
[code] 
    curl https://back.flowie.ink/exchange/v1/tools/fr/ubl/scenarios?case=20
[/code]
[code] 
    {
      "scenarios": [
        {
          "id":        "uc-20-deposit-invoice",
          "case":      "20",
          "caseTitleFr": "Facture d'acompte",
          "title":     "A builder asks for 30% up front before starting the job",
          "theme":     "Acompte & paiement échelonné",
          "channel":   "e-invoicing",
          "typeCode":  "386",
          "cadre":     "S1",
          "story":     "Grand Client commissions a €20,000 fit-out…",
          "why":       "A deposit invoice is a real invoice for VAT…",
          "watchOut":  "Type code 386 is what makes it a deposit…",
          "keyData":   [ { "bt": "BT-3", "label": "Type code 386", "why": "…" } ],
          "lifecycle": ["200 Déposée", "205 Approuvée", "212 Encaissée"],
          "sendFormat": "ubl-xml",
          "validatesAsEInvoice": true
        }
      ],
      "summary":     { "total": 59, "numberedCases": 50 },
      "referential": { "useCases": "AFNOR XP Z12-014 v1.4 (2026-06-30) — 45 cas d'usage" }
    }
[/code]

### List every business term

GET/v1/tools/fr/ubl/business-terms

**Authentication:** none — this is a referential, not your data.

The whole EN 16931 semantic model: 30 business groups, 164 business terms (`BT-1`…`BT-165`; `BT-4` is unassigned), plus the `-1`/`-2` scheme attributes the French rules lean on. Each entry says what the term is in both languages, where it lives in UBL 2.1, what France requires of it and under which `BR-FR-*` rule, and which field of [`POST /v1/documents/send`](<#send-document>) carries it. Full guide: [Business terms](<../compliance/fr/business-terms.html>).

`fr` is one of `mandatory`, `conditional`, `restricted` (France narrows the allowed values), `optional` or `unused`; `apiState` is `sent` (you state it), `derived` (Flowie computes it), `accepted` (stored, not rendered yet) or `xml-only` (no JSON field — carry it with `format=ubl-xml`). The response's `legend` spells both out.

#### Query parameters

  * groupstringoptional

One business group and its members, e.g. `BG-23`.

  * scopeenumoptional

`document` or `line`.

  * frenumoptional

`mandatory`, `conditional`, `restricted`, `optional` or `unused`.

  * mappedbooleanoptional

`true` for the terms `POST /v1/documents/send` has a field for; `false` for the gaps.

  * qstringoptional

Free-text across the id, both names, the UBL path, the French note and the API field.




### Read one business term

GET/v1/tools/fr/ubl/business-terms/{term_id}

**Authentication:** none — this is a referential, not your data.

One `BT`, `BG` or scheme attribute by id — `BT-121`, `BG-23`, `BT-29-1`. Case-insensitive. An id the standard does not assign returns `404 BUSINESS_TERM_NOT_FOUND`.
[code] 
    curl https://back.flowie.ink/exchange/v1/tools/fr/ubl/business-terms?group=BG-23
[/code]
[code] 
    {
      "terms": [
        {
          "id":          "BT-121",
          "kind":        "term",
          "name":        "VAT exemption reason code",
          "nameFr":      "Code du motif d'exonération",
          "group":       "BG-23",
          "scope":       "document",
          "cardinality": "0..1",
          "ubl":         ".../cac:TaxCategory/cbc:TaxExemptionReasonCode",
          "fr":          "conditional",
          "frNote":      "A VATEX code. Optional under EN 16931 but mandatory for the franchise en base…",
          "api":         "lines[].vatExemptionCode",
          "apiState":    "sent"
        }
      ],
      "summary":     { "groups": 32, "terms": 164, "mandatoryInFrance": 37, "mappedToApi": 84 },
      "legend":      { "fr": { "mandatory": "Required on every French e-invoice." } },
      "referential": { "semanticModel": "EN 16931-1:2017/A1:2019 — 30 business groups, 164 business terms" }
    }
[/code]

### Generate a case

POST/v1/tools/fr/ubl/generate

**Authentication:** none — this is reference material, not your data.

Renders the scenario as EN 16931 UBL 2.1 and builds the send call for it. Override the invoice number, the dates, the currency and either party to make the sample look like your own data; identifier _schemes_ stay fixed, so an override cannot produce a party whose SIRET and SIREN disagree (`BR-FR-09`).

#### Request body

  * scenarioIdstringrequired

Scenario id from the catalogue, e.g. `uc-20-deposit-invoice`.

  * numberstringoptional

Invoice number (BT-1). `BR-FR-01` caps it at 35 characters.

  * issueDate / dueDatedateoptional

`YYYY-MM-DD`. `BR-FR-03` wants a year between 2000 and 2099.

  * currencystringoptional

Anything other than EUR also needs the VAT accounting currency (`BR-FR-CO-12`) — the generator does not add it for you.

  * seller / buyerobjectoptional

`{name, siret, siren, vatNumber, legalForm, address, contact}` — your own party in place of the catalogue's.

  * formatenumoptional

`json` or `ubl-xml`. Defaults to the scenario's own recommendation.




### Simple mode — your own data, no scenario id

POST/v1/tools/fr/ubl/simple

**Authentication:** none — the response is a pure function of the request.

The catalogue answers "what does _this_ business case look like?". This answers the question people ask first — "here is my data, make it legal" — which nine out of ten French invoices need and no numbered _cas d'usage_ covers, because they are just one company billing another.

From the line kinds and the VAT situation it derives the type code (`BT-3`), the _cadre de facturation_ (`BT-23`), the tax point (`BT-8`), the VAT category with the exemption reason EN 16931 demands, the three legal mentions of `BR-FR-05` and the `BAR` regime note. Every one comes back in `inferred` with the rule that forced it, so you can check what was added on your behalf instead of trusting it. `warnings` carries what is legal but probably not what you meant — a VAT rate that is not in force in France, an intra-community supply with no buyer VAT number.

Two fields cannot be guessed and are asked for: each party's **SIRET** , because the routing address and the legal identifier both derive from it, and the seller's **legal form and share capital** (`BT-33`), which France makes mandatory. A request that cannot produce a compliant invoice comes back `400 CANNOT_BUILD_COMPLIANT_INVOICE` naming the field and the fix.

#### Request body

  * seller / buyerobjectrequired

`{name, siret, siren?, vatNumber?, legalForm?, address?, contact?}`. `legalForm` is required on the seller (`BT-33`) and ignored on the buyer, where `UBL-CR-244` forbids it.

  * linesarrayrequired

`{description, quantity, unitPrice, vatRate?, kind?, unit?}`. `kind` is `goods` or `services` and decides the cadre and the tax point.

  * numberstringrequired

Invoice number (BT-1). `BR-FR-01` caps it at 35 characters.

  * documentTypeenumoptional

`invoice` (default) or `credit-note` — which becomes type 381, not a negative invoice.

  * vatRegimeenumoptional

`standard`, `reverse-charge-subcontracting`, `franchise-en-base`, `intra-community`, `export`. Anything but `standard` zero-rates every line and attaches the reason.

  * alreadyPaid / vatOnDebitsbooleanoptional

Move the cadre to B2 / S2 / M2, and make services taxable on the invoice date.

  * deliveryCountrystringoptional

Where the goods went (`BT-80`) — required in substance on an intra-community supply (`BR-IC-12`), inferred from the buyer when omitted.

  * paymentobjectoptional

`{iban?, bic?, means?, reference?, terms?}`. With an IBAN the invoice declares a credit transfer (code 30); without one it declares code 1, because claiming a transfer with no account fails `BR-61`.

  * issueDate / dueDate / currency / buyerReference / orderReference / corrects / note / formatmixedoptional

The usual header fields. `corrects` is `{number, issueDate}` — what a credit note refers back to (`BG-3`).



[code] 
    curl -X POST https://back.flowie.ink/exchange/v1/tools/fr/ubl/simple \
      -H 'Content-Type: application/json' \
      -d '{"seller":{"name":"Ma Boîte SAS","siret":"12345678900017",
                     "legalForm":"SAS au capital de 10 000 EUR — RCS Paris 123 456 789"},
           "buyer":{"name":"Client SARL","siret":"39158000400021"},
           "lines":[{"description":"Prestation de conseil","quantity":2,"unitPrice":500,
                     "vatRate":20,"kind":"services"}],
           "number":"FA-2026-0001"}'
[/code]

### Generate and validate

POST/v1/tools/fr/ubl/generate-and-validate

**Authentication:** `Authorization: Bearer <key>` with a live or test Exchange key (`flw_live_…` / `flw_test_…`) or a Flowie JWT.

Generates the case and runs it through the official FNFE **XP Z12-012 v1.3.0** schematrons — XSD, the EN 16931 profile rules, the French `BR-FR` rules — plus the complementary CIUS-FR field checks. `errors` flattens every failing rule across all steps. Needs a key because it calls the schematron service on your behalf.
[code] 
    {
      "scenarioId": "uc-21-final-after-deposit",
      "number":     "FA-2027-0042",
      "issueDate":  "2027-03-01",
      "seller":     { "name": "Ma Société SAS", "siret": "55210055400013" },
      "buyer":      { "name": "Mon Client SA",  "siret": "39158000400021" }
    }
[/code]
[code] 
    {
      "scenario":  { "id": "uc-21-final-after-deposit", "case": "21", "cadre": "S4" },
      "ubl":       "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<Invoice …>",
      "totals":    {
        "lineExtensionAmount": 20000.0,
        "taxExclusiveAmount":  20000.0,
        "taxAmount":           4000.0,
        "taxInclusiveAmount":  24000.0,
        "prepaidAmount":       7200.0,
        "payableAmount":       16800.0
      },
      "sendRequest": {
        "type":   "invoice",
        "from":   "0009:55210055400013",
        "to":     "0009:39158000400021",
        "format": "ubl-xml",
        "xml":    "<?xml version=\"1.0\" …"
      }
    }
[/code]

## 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.

XP Z12-013 specification (AFNOR — French e-invoicing reform)

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-service

Submission, 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-service

Lookup 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/webhooks

Subscription model so receiving PDPs and ODs are notified the moment a flow targeting them is processed.




**Reference documents** :

  * [XP Z12-013 — AFNOR boutique (norm reference)](<https://www.boutique.afnor.org/fr-fr/norme/xp-z12013/echanges-electroniques-de-donnees-facturation-electronique-architecture-/fa207983/2452167>)
  * [Spécifications externes B2B — DGFiP / portail PPF](<https://www.impots.gouv.fr/specifications-externes-b2b>)
  * [Chorus Pro / PPF technical specs (EN)](<https://communaute.chorus-pro.gouv.fr/documentation/specifications-externes-en-anglais/>)
  * [AFNOR X12U commission — XP Z12-013 announcement](<https://www.afnor.org/en/news/the-afnor-x12u-commission-publishes-xp-z12-013/>)



### Architecture & vocabulary

The PPF (Portail Public de Facturation) sits as a passive concentrator and annuaire. Every B2B invoice in France must flow through at least one PDP. PDPs route to each other directly when both sides are on different platforms; flows transit the PPF only for fallback, reporting (e-Reporting), and lifecycle aggregation.

  * **PA** (Plateforme Acheteur) — the buyer's PDP receives the flow.
  * **PV** (Plateforme Vendeur) — the seller's PDP submits the flow.
  * **OD** (Opérateur de Dématérialisation) — non-certified upstream of a PDP; can submit but not receive.
  * **OPDF** — Operation Process Description Format; how flow lifecycle is described on the wire.
  * **MR-DG** — Mandat de Représentation côté Destinataire / côté Generic; routing-code level mandate.



Every operation below is authenticated with a Flowie token (Bearer) _or_ the AFNOR-compliant `?token=` query parameter — both forms are accepted.

### Submit a flow

POST/afnor/flow-service/v1/flows

Multipart: `flowInfo` (JSON) + `file` (binary). Returns `202 Accepted` with a `flowId`.

  * flowInfo.namestringrequired

  * flowInfo.flowSyntaxenumrequired

`CII``UBL``Factur-X``CDAR``FRR`

  * flowInfo.trackingIdstring (≤36)optional

  * flowInfo.processingRuleenumoptional

`B2B``B2C``B2G`

  * flowInfo.flowProfileenumoptional

`Basic``CIUS``Extended-CTC-FR`

  * flowInfo.sha256hexoptional




### Search flows

POST/afnor/flow-service/v1/flows/search

#### Request body

`SearchFlowParams`. Filters are AND-combined; array values are OR-combined.

  * limitintegeroptional

Page size, 1–100. Default `25`.

  * whereSearchFlowFiltersoptional

Filter object. Fields: `updatedAfter`, `updatedBefore`, `processingRule[]`, `flowType[]`, `flowDirection[]`, `trackingId`, `ackStatus`.




### Retrieve a flow

GET/afnor/flow-service/v1/flows/{flow_id}

#### Query parameters

  * docTypeenumoptional

`Metadata``Original``Converted``ReadableView`




### AFNOR webhooks

Same operations as [Webhooks](<#create-webhook>) but under the AFNOR-shaped schema:

GET/afnor/flow-service/v1/webhooks

POST/afnor/flow-service/v1/webhooks

#### Create body

  * callbackobjectrequired

`url` (required), plus optional `headers[]`, `authentication`, `signature`.

  * metadataobjectrequired

Subscription filters: `flowType`, `flowDirection` (required), `processingRule`, `ackStatus` (optional).




GET/afnor/flow-service/v1/webhooks/{webhook_uid}

PATCH/afnor/flow-service/v1/webhooks/{webhook_uid}

#### Update body — technical params only

  * headersobject[]optional

  * authenticationobjectoptional

  * signatureobjectoptional




DEL/afnor/flow-service/v1/webhooks/{webhook_uid}

### AFNOR directory (SIREN / SIRET / routing codes)

Every `*/search` response uses the AFNOR envelope: `search`, `totalNumberOfResults`, `results`.

POST/afnor/directory-service/v1/siren/search

#### Request body

  * filtersobjectoptional

Field → value map of search predicates.

  * sortingobject[]optional

  * fieldsstring[]optional

Restrict the returned columns.

  * limitintegeroptional

1–100. Default `50`.

  * ignoreintegeroptional

Offset — rows to skip.




GET/afnor/directory-service/v1/siren/code-insee:{siren}

#### Query parameters

  * fieldsstring[]optional

Comma-separated columns to return.




POST/afnor/directory-service/v1/siret/search

#### Request body

  * filtersobjectoptional

  * sortingobject[]optional

  * fieldsstring[]optional

  * includestring[]optional

Expand related rows.

  * limitintegeroptional

1–100. Default `50`.

  * ignoreintegeroptional




GET/afnor/directory-service/v1/siret/code-insee:{siret}

#### Query parameters

  * fieldsstring[]optional

  * includestring[]optional




POST/afnor/directory-service/v1/routing-code/search

#### Request body

  * filtersobjectoptional

  * includestring[]optional

  * limitintegeroptional

1–100. Default `50`.




GET/afnor/directory-service/v1/routing-code/siret:{siret}/code:{routing_identifier}

#### Query parameters

  * fieldsstring[]optional

  * includestring[]optional




### Directory-line search

POST/afnor/directory-service/v1/directory-line/search

Stub endpoint for _directory-line_ queries — the AFNOR aggregate row that joins SIREN + SIRET + routing-code data into a single result row, used for OD ↔ PDP onboarding flows. Response is currently empty (returns the AFNOR `search` envelope with `totalNumberOfResults: 0`) until the PDP-PDP federation handshake is wired up.

#### Request body

  * filtersobjectoptional

  * sortingobject[]optional

  * fieldsstring[]optional

  * limitintegeroptional

1–100. Default `50`.




### Healthchecks

GET/afnor/flow-service/v1/healthcheck

GET/afnor/directory-service/v1/healthcheck

Public, unauthenticated. Returns `{ "status": "ok", "version": "1.0", "service": "flow-service|directory-service" }`. Required by the AFNOR PDP certification suite.
[code] 
    curl -X POST …/afnor/flow-service/v1/flows \
      -H "Authorization: Bearer $KEY" \
      -F 'flowInfo={"name":"INV-2026-0417","flowSyntax":"UBL","processingRule":"B2B","flowProfile":"Extended-CTC-FR","trackingId":"t-42"};type=application/json' \
      -F 'file=@invoice.xml'
[/code]
[code] 
    HTTP/1.1 202 Accepted
    {
      "flowId":         "flw_01HY…",
      "submittedAt":    "2026-04-25T10:00:00Z",
      "name":           "INV-2026-0417",
      "flowSyntax":     "UBL",
      "trackingId":     "t-42",
      "processingRule": "B2B",
      "flowProfile":    "Extended-CTC-FR",
      "sha256":         "e3b0c442…"
    }
[/code]

### Get directory line by id

GET/afnor/directory-service/v1/directory-line/code:{addressing_identifier}

**Authentication:** `Authorization: Bearer <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](<#afnor-directory-line>), filtered on the identifier and reduced to one line. Note the AFNOR path grammar: the value is prefixed `code:` in the path segment.

#### Path parameters

  * addressing_identifierstringrequired

The routing code of the line, e.g. `code:0009:552100554`.




#### Query parameters

  * includestringoptional

Related resources to embed.

  * fieldsstringoptional

Sparse fieldset.



[code] 
    GET /afnor/directory-service/v1/directory-line/code:0009:552100554
    Authorization: Bearer flw_live_…
[/code]
[code] 
    {
      "directoryLine": {
        "addressingIdentifier": "0009:552100554",
        "siren":  "552100554",
        "name":   "ACME SAS",
        "status": "active"
      }
    }
[/code]

## PunchOut cart callback

POST/document/callback

The cXML PunchOut return endpoint. SAP Ariba, Coupa, Ivalua and friends POST the user's cart back here when they check out. We OCR the cXML into a Flowie request, then respond with an HTML page that redirects the user to the originating chat thread.

**Authentication:** no bearer — we validate the `SharedSecret` in the cXML header against a per-partner allow-list, plus `BuyerCookie` for org scoping.

#### Accepted bodies

  * `Content-Type: application/x-www-form-urlencoded` with a `cxml-urlencoded` or `cxml-base64` field.
  * `Content-Type: application/xml` with the raw cXML PunchOutOrderMessage.



#### Response

An HTML `<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.
[code] 
    <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>
[/code]

### OCI cart callback

POST/document/oci-callback

The OCI return endpoint, for Mercateo, Conrad and SAP-style suppliers. Accepted as both `POST` (form post) and `GET` (supplier auto-submit), because OCI suppliers differ on which they use. Cart lines arrive as the flat `NEW_ITEM-*` field family.

**Authentication:** no bearer — the supplier-facing HOOK_URL carries a `flowie_cookie` query parameter (or form field) holding the BuyerCookie `flowie:{org_id}:{thread_id}:{nonce}`. We use it to route the cart to the right organization and to redirect the user back to the originating thread.
[code] 
    POST /document/oci-callback?flowie_cookie=flowie:org_01HY…:thr_01HY…:9f3c
    Content-Type: application/x-www-form-urlencoded
    
    NEW_ITEM-DESCRIPTION[1]=Laptop stand&NEW_ITEM-QUANTITY[1]=2&NEW_ITEM-PRICE[1]=49.00
[/code]

## Health

Public, unauthenticated. Great for load balancers and synthetic monitors.

### Liveness

GET/health/liveness

Returns `{"status":"ok"}` as long as the process can serve requests.

### Readiness

GET/health/readiness

Includes circuit-breaker state for every upstream.

### Contracts

GET/health/contracts

Actively probes upstreams (SMP, national directories). Slower; don't call from a hot path.
[code] 
    {
      "status": "ok",
      "circuits": {
        "peppol-smp":      { "state": "closed", "failures": 0 },
        "ppf-annuaire":    { "state": "closed", "failures": 0 },
        "document-service":{ "state": "closed", "failures": 0 }
      }
    }
[/code]

## Appendices

### Address object

Every field is optional. Defined in full — with the BT number each one maps to — under [Send a document → Address object](<#address-object>).

### Party object

`{ "name":"…", "vatNumber":"…", "address":[Address](<#address-object>), "contact": {"name":"…", "email":"…", "phone":"…"} }`

### PaymentInfo object

  * meansenum

`credit_transfer``direct_debit``card``cash``cheque`

  * ibanIBAN

  * bicSWIFT BIC

  * referencestring

  * discountTermsarray




### Status reason codes (Peppol BIS · OPStatusReason)

The coded vocabulary for `reasonCode` on [lifecycle updates](<#update-lifecycle>) is the official OpenPeppol _Status Clarification Reason_ list ([OPStatusReason](<https://docs.peppol.eu/poacc/upgrade-3/codelist/OPStatusReason/>), Peppol BIS Invoice Response 3). All **14** codes — nothing else is part of the official list:

Code| Label| Use it when…  
---|---|---  
`NON`| No issue| Pure status update — nothing is wrong (e.g. with `under_review`).  
`REF`| References incorrect| A required reference (PO number, buyer reference, contract) is missing or wrong.  
`LEG`| Legal information incorrect| The document doesn't meet legal requirements (mandatory mentions, VAT identifiers…).  
`REC`| Receiver unknown| The invoice is not addressed to this party.  
`QUA`| Item quality insufficient| Unacceptable or incorrect quality of the delivered goods / services.  
`DEL`| Delivery issues| Goods / services not delivered, or the delivery is not acceptable.  
`PRI`| Prices incorrect| Price differs from the order, quote or contract.  
`QTY`| Quantity incorrect| Quantity differs from what was ordered or delivered.  
`ITM`| Items incorrect| The invoiced items don't match what was ordered / delivered.  
`PAY`| Payment terms incorrect| Payment terms differ from the agreement.  
`UNR`| Not recognized| The commercial transaction is not recognized (unknown order / relation).  
`FIN`| Finance incorrect| Financing terms differ from expectations.  
`PPD`| Partially paid| The invoice is only partially paid.  
`OTH`| Other| No code fits — **always** pair with a free-text `reason`.  
  
#### Rejecting vs putting on hold — pick the reversible path first

You want to…| Send| Terminal?| What the reason must say  
---|---|---|---  
**Pause / on hold** — something is missing (delivery note, PO reference, supporting document)| `{"status":"disputed","reasonCode":"suspended","reason":"…"}`| No — supplier answers with the material and processing resumes| Exactly _what is missing_ , so the supplier can supply it and lift the hold.  
**Contest** — you disagree with part of the content but it may be resolved| `{"status":"disputed","reasonCode":"…"}`| No — resolves to approval or refusal| The code that names the disagreement (`PRI`, `QTY`, `ITM`…), plus free text with the specifics (line, expected value).  
**Refuse / reject** — the invoice must be cancelled and re-issued| `{"status":"rejected","reasonCode":"…","reason":"…"}`| **Yes** — the supplier must issue a corrective| The code that justifies a definitive refusal, plus free text precise enough for the supplier to re-invoice correctly first time.  
  
**Prioritize on hold / dispute over refusing directly.** A rejection cannot be undone: the supplier has to start over. A hold or dispute keeps the invoice alive, tells the supplier exactly what to fix, and costs nothing if the answer is satisfactory. Whatever the status, make the reason _actionable_ : code for the machine, free text for the human — a rejection or hold whose reason the supplier can't act on just moves the problem to email.

France — AFNOR motifs, not Peppol codes

On the French DGFiP leg the `reasonCode` is forwarded _verbatim_ as the CDAR's `MDT-113`: for _210 Refusée_ / _213 Rejetée_ use a code from the official AFNOR XP Z12-012 motif annex (« Tableau des motifs de STATUTS »), and the special value `suspended` on a `disputed` call is the discriminator that transmits _208 Suspendue_. See [FR refusal, rejection & on-hold](<../compliance/fr/refusal-rejection.html#motifs>).
