Flowie
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 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 in production · https://back.flowie.ink in sandbox. All endpoints below are prefixed with /v1.

Quick index

Postman collection

Prefer to explore the API in Postman? Download the ready-made collection — every endpoint, pre-filled with a working example body — and import it in seconds.

⬇ Download Postman collection ⬇ OpenAPI 3.1 spec

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

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

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

Authentication

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

Flowie JWT

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

Switching organizations

JWTs typically grant access to multiple organizations (the user's _permissions claim is a dict of org_id → permissions). By default the API picks the first one in that dict. To act as a specific organization, pass the X-Flowie-Organization-Id header on every request:

curl https://back.p2p-flowie.com/exchange/v1/documents \
  -H "Authorization: Bearer eyJhbGc..." \
  -H "X-Flowie-Organization-Id: 685a5670efafaa26ebf0128e"

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

To list every org a caller can switch to, hit GET /v1/me. The Flowie docs auth widget uses this endpoint to render the org-picker dropdown next to your email.

API keys are bound to a single org at creation time and ignore this header.

Exchange API keys

For programmatic access, issue an Exchange API key from the dashboard or via the API. Keys are prefixed so you can tell them apart at a glance:

  • flw_live_…Personal key

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

  • flw_plat_live_…Platform key

    Scoped to an organization that manages other companies. Combine with X-Flowie-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}.

Scopes

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

send receive documents.read documents.search documents.write companies.read companies.write directory partners payments lifecycle compliance stats platform *
curl https://back.p2p-flowie.com/exchange/v1/companies \
  -H "Authorization: Bearer flw_live_abc123"
from httpx import Client
api = Client(
    base_url="https://back.p2p-flowie.com/exchange/v1",
    headers={"Authorization": "Bearer flw_live_abc123"},
)
const headers = { Authorization: "Bearer flw_live_abc123" };
const res = await fetch(
  "https://back.p2p-flowie.com/exchange/v1/companies",
  { headers }
);
req, _ := http.NewRequest("GET",
    "https://back.p2p-flowie.com/exchange/v1/companies", nil)
req.Header.Set("Authorization", "Bearer flw_live_abc123")
Acting on a managed company (platform keys)
Authorization: Bearer flw_plat_live_xyz789
X-Flowie-Company:  comp_abc123def456

Get caller identity + accessible orgs

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

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

Idempotency

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

  • Keys are strings, up to 255 characters. UUID v4 works great.
  • Cache TTL is 24 hours. After that, a repeated key is treated as new.
  • If you retry before the first response has finished processing, you'll get a 409 idempotency_in_progress. Retry in a moment.
  • Mutating a request under the same key is never allowed. We compare the full body hash — mismatched retries return 422 idempotency_body_mismatch.
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.
curl -X POST …/v1/documents/send \
  -H "Authorization: Bearer $KEY" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d @invoice.json
from uuid import uuid4
key = str(uuid4())   # persist this alongside the row!
resp = api.post("/documents/send",
  headers={"Idempotency-Key": key}, json=payload)
const key = crypto.randomUUID();
await flowie("/documents/send", {
  method: "POST",
  headers: { "Idempotency-Key": key },
  body: JSON.stringify(payload),
});

Pagination

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

  • limitintegeroptional

    Page size. Default 20, 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:

{
  "data":    [ /* records */ ],
  "hasMore": true,
  "cursor":  "eyJpZCI6ImRvY19YLi4uIn0"
}
Iterate all pages
cursor = None
while True:
    params = {"limit": 100}
    if cursor: params["cursor"] = cursor
    page = api.get("/documents", params=params).json()
    for doc in page["data"]:
        process(doc)
    if not page["hasMore"]: break
    cursor = page["cursor"]
let cursor;
do {
  const q = new URLSearchParams({ limit: "100" });
  if (cursor) q.set("cursor", cursor);
  const page = await flowie(`/documents?${q}`);
  page.data.forEach(process);
  cursor = page.hasMore ? page.cursor : null;
} while (cursor);

Rate limits & quotas

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

PlanRequests / minDocuments / month
Free6050
Starter120500
Pro3005,000
Platform60050,000
White-label1,200Unlimited

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
HTTP/1.1 429 Too Many Requests
Retry-After: 37
X-RateLimit-Limit:     300
X-RateLimit-Remaining: 0
X-RateLimit-Reset:     1714046400

{
  "error": {
    "type": "rate_limit_error",
    "code": "RATE_LIMITED",
    "message": "You have exceeded 300 req/min. Retry in 37s.",
    "requestId": "req_01HXYZ…"
  }
}

Errors

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

StatusMeaning
400The request is malformed or fails validation.
401Missing, expired, or invalid credentials.
403Credentials are valid but lack the required scope or company access.
404The resource doesn't exist (or isn't visible to you).
409Conflict — typically an idempotency or state transition issue.
422Semantically invalid (e.g. VAT not in directory, unreachable recipient).
429Rate-limited. Honor Retry-After.
500Internal error. Report requestId to support.
502 / 503Upstream 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.

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

Versioning

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

  • Breaking changes ship under a new path (/v2/…). Old paths stay alive for at least 12 months.
  • Additive changes — new fields, new enum values, new endpoints — land in /v1/ without notice.
  • Deprecations are announced in the changelog and flagged with the 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
Sunset: Wed, 01 Oct 2026 00:00:00 GMT
Deprecation: true
Link: <https://docs.get-flowie.com/changelog#send-v1>; rel="deprecation"

Sandbox mode

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

  • Documents are not delivered to real Peppol access points — they're routed to an internal echo endpoint.
  • Compliance reporting goes to a mock PPF/SDI that always accepts.
  • Webhooks fire the same events with "livemode": 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
    personalplatformwhite_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.

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
    alldocumentseventsidempotency

    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
https://back.flowie.ink/exchange/v1

Agent auth — OAuth & handoff

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

List grantable scopes

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.

{
  "scopes": [
    { "id": "send",           "description": "Send documents" },
    { "id": "documents.read", "description": "Read documents" },
    { "id": "lifecycle",      "description": "Advance lifecycle statuses" }
  ]
}

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.

  • code_challengestringrequired

    BASE64URL(SHA256(code_verifier)), no padding.

  • code_challenge_methodstringoptional

    S256. The plain method is not accepted.

  • redirect_uristringoptional

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

  • statestringoptional

    Echoed back on redirect.

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

Exchange the code for a key

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. The code is single-use and expires 5 minutes after consent.

Request body

  • grant_typestringrequired

    authorization_code.

  • codestringrequired

    The one-time code from the consent screen.

  • code_verifierstringrequired

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

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

Mint a handoff link

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 and gets a key bound to your organization — no consent screen. You can only grant scopes you hold yourself.

Request body

  • scopesstring[]optional

    Defaults to the scopes of the calling key.

  • labelstringoptional

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

  • ttl_secondsintegeroptional

    Token lifetime, 60 minutes maximum.

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

Anonymous sandbox handoff

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. This is what lets the docs home page hand an agent a URL that is already authenticated.

{
  "handoff_token": "hand_AbC…",
  "organization_id": "org_sbx_01HY…",
  "company_id": "comp_sbx_01HY…",
  "expires_in": 3600
}

Redeem a handoff token

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.

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

Companies

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

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.

  • 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 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
curl -X POST https://back.p2p-flowie.com/exchange/v1/companies \
  -H "Authorization: Bearer $KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: upsert-acme-be" \
  -d '{
    "vatNumber": "BE0123456789",
    "capabilities": {
      "send":    ["invoice","credit-note"],
      "receive": ["invoice"]
    },
    "metadata": { "tenantId": "t_acme" }
  }'
company = api.post("/companies",
  headers={"Idempotency-Key": "upsert-acme-be"},
  json={
    "vatNumber": "BE0123456789",
    "capabilities": {
        "send":    ["invoice","credit-note"],
        "receive": ["invoice"],
    },
    "metadata": {"tenantId": "t_acme"},
  },
).json()
const company = await flowie("/companies", {
  method: "POST",
  headers: { "Idempotency-Key": "upsert-acme-be" },
  body: JSON.stringify({
    vatNumber: "BE0123456789",
    capabilities: { send: ["invoice","credit-note"], receive: ["invoice"] },
    metadata: { tenantId: "t_acme" },
  }),
});
payload, _ := json.Marshal(map[string]any{
  "vatNumber": "BE0123456789",
  "capabilities": map[string]any{
    "send":    []string{"invoice","credit-note"},
    "receive": []string{"invoice"},
  },
  "metadata": map[string]string{"tenantId": "t_acme"},
})
req, _ := http.NewRequest("POST",
  "https://back.p2p-flowie.com/exchange/v1/companies",
  bytes.NewReader(payload))
req.Header.Set("Authorization", "Bearer "+key)
req.Header.Set("Idempotency-Key", "upsert-acme-be")
req.Header.Set("Content-Type", "application/json")
http.DefaultClient.Do(req)
Response
{
  "id":        "comp_01HXYZ123ABC",
  "name":      "ACME Business Solutions BVBA",
  "legalName": "ACME Business Solutions BVBA",
  "vatNumber": "BE0123456789",
  "country":   "BE",
  "peppolId":  "0208:0123456789",
  "additionalIdentifiers": [],
  "address": {
    "street":     "Rue de la Loi 16",
    "city":       "Bruxelles",
    "postalCode": "1000",
    "country":    "BE"
  },
  "capabilities": {
    "send":    ["invoice","credit-note"],
    "receive": ["invoice"]
  },
  "status":           "active",
  "smpRegistered":    false,
  "smpRegisteredAt":  null,
  "compliance":       {},
  "settings":         { "defaultCurrency": "EUR" },
  "stats":            { "sent": 0, "received": 0 },
  "metadata":         { "tenantId": "t_acme" },
  "createdAt":        "2026-04-25T10:00:00Z",
  "updatedAt":        "2026-04-25T10:00:00Z"
}
{
  "error": {
    "type":     "conflict",
    "code":     "COMPANY_EXISTS",
    "message":  "A company with this VAT already exists in your organization.",
    "details":  [{ "field": "vatNumber", "value": "BE0123456789",
                  "existingId": "comp_01HXYZ…" }],
    "requestId":"req_01HXYZ…"
  }
}
{
  "error": {
    "type":    "invalid_request_error",
    "code":    "VAT_NOT_FOUND",
    "message": "VAT BE0000000000 is not in the national registry.",
    "requestId":"req_01HXYZ…"
  }
}

List companies

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.

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

Resolve by VAT / SIREN

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.

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

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.

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

Retrieve a company

GET/v1/companies/{company_id}

Returns the company object. The path parameter accepts three forms:

  • comp_01HXYZ… — the canonical id
  • vat:BE0123456789 — VAT-scoped lookup
  • peppol:0208:0123456789 — Peppol-ID lookup
curl https://back.p2p-flowie.com/exchange/v1/companies/vat:BE0123456789 \
  -H "Authorization: Bearer $KEY"

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
curl -X PATCH \
  https://back.p2p-flowie.com/exchange/v1/companies/comp_abc \
  -H "Authorization: Bearer $KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "settings": { "defaultCurrency": "EUR" },
    "metadata": { "tier": "premium", "oldKey": null }
  }'
curl -X DELETE \
  https://back.p2p-flowie.com/exchange/v1/companies/comp_abc \
  -H "Authorization: Bearer $KEY"
# 204 No Content

Import a company (portability)

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.

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

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.

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

Register a company on Peppol

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 of your own id returns canReceive: false.

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

Path parameters

  • company_idstringrequired

    The company to register, e.g. comp_01HY7AB9C2DE3FG.

No request body.

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

Documents

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

The document object
  • idstring

    doc_…

  • typeenum
    invoicecredit-notedebit-notepurchase-orderpurchase-requestsales-orderquoteevent
  • directionenum
    incomingoutgoing
  • numberstring

    Your external document number.

  • issueDate / dueDatedate (YYYY-MM-DD)
  • currencyISO 4217
  • grossAmount / netAmount / vatAmountdecimal
  • sender / receiverParty
  • statusenum
    draftsentdeliveredrejected
  • deliveryStatusenum
    pendingdeliveredfailedrejected
  • lifecycleStatusenum

    Business-level state. See Lifecycle.

  • documentobject

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

  • xmlstring

    Rendered UBL (populated on delivery).

  • metadataobject
  • receivedAt / sentAttimestamp

Send a document

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 for the full matrix of payload shapes (structured JSON · UBL XML · PDF / Factur-X / image / proprietary file).

Body

  • typeenumrequired
    invoicecredit-notedebit-notepurchase-orderpurchase-requestsales-orderquoteevent
  • formatenumoptional
    jsonubl-xmlcii-xmlautoraw

    json (default) — we render UBL. ubl-xml / cii-xml — provide your own XML in xml. auto — supply a file; the server sniffs the bytes and routes to the right pipeline. 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:…, email:…, domain:…, name:…, org:… / id:… (or the bare unprefixed form of any of these). Non-Peppol identifiers are resolved against org-v2 + the PPF Annuaire (FR) + the Peppol Directory, and provisioned if never seen, so they route to a real participant. A French reception point (ligne annuaire) can be addressed with the composed identifier {siren}_{siret}[_{suffix}] (e.g. 75297877500027_001) — see Reception-point addressing. Optional (omit) when type=event — events are pure observability/audit records and have no recipient.

  • documentDocumentBodyrequired when format=json

    See schema below.

    • numberstringrequired
    • issueDatedaterequired
    • dueDatedateoptional
    • currencyISO 4217optional

      Default EUR.

    • buyerReferencestringoptional

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

    • orderReferencestringoptional

      PO number.

    • notestringoptional
    • seller / buyerPartyoptional

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

      • namestring
      • vatNumberstring
      • addressAddress

        Billing address — see the Address object. Carried to the party's billingAddress.

      • shippingAddressAddress

        Same shape as address.

      • contactobject

        { name?, email?, phone?: string }. The email is added to the party's contacts.

      • 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. Each entry:

      • roleenumrequired
        sellerbuyerpayerpayee
      • idstring

        Any resolvable id (same grammar as to).

      • name / vatNumberstring
      • address / shippingAddressAddress

        See the Address object.

      • contact / contactsobject / string[]

        Same as on seller/buyer above.

      • initiatorboolean

        Exactly one entry must be true.

    • paymentPaymentInfooptional

      A PaymentInfo object:

      • meansstring

        Payment-means label/code, e.g. "credit_transfer", "30". Folded into the e-invoice's payment instructions.

      • ibanstring
      • bicstring
      • referencestring

        Remittance / structured communication. Sent as paymentReferenceNumber.

      • discountTermsarray

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

    • deliveryobjectoptional

      Delivery details, e.g. { date?: "YYYY-MM-DD", address?: Address }. Accepted but not yet emitted to the e-invoice.

    • linesInvoiceLine[]required

      VAT is per line — a document with several rates is several lines (see Multiple VAT rates). Each line:

      • descriptionstringrequired
      • quantitynumberrequired
      • unitPricenumberrequired

        Excl. VAT.

      • vatRatenumberrequired

        Percent, e.g. 21, 6, 0.

      • unitstring

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

      • vatCategorystring

        UNCL5305 code; defaults to S. See Tax exemption & zero rate.

      • itemCodestring
      • customFieldsobject

        Keyed by field name or UUID. See Custom fields & templates.

      • periodobject

        { from: "YYYY-MM-DD", to: "YYYY-MM-DD" }. Accepted but not yet emitted to the e-invoice.

    • allowances / chargesarrayoptional

      Document-level discounts (allowances) / surcharges (charges). Each item: { reason?: string, amount?: number, percent?: number, vatRate?: number }. Accepted but not yet emitted to the e-invoice — for document-level allowances/charges today, 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.

    • templateIduuidoptional

      Template to file the document under — it declares which customFields are valid. See Custom fields & templates.

    • customFieldsobjectoptional

      Document-level custom field values, keyed by field name or UUID. Attached to your party. See Custom fields & templates.

  • xmlstringrequired when format=ubl-xml

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

  • fileFileAttachmentrequired when format=auto or raw

    Arbitrary file payload (PDF, image, ZIP, proprietary format). { content: <base64>, contentType?, filename? }. Max 5 MiB. With format=auto we sniff magic bytes — if the file is UBL XML it routes through the regular pipeline (deliveryStatus="pending"); otherwise the bytes are persisted on the document file API and the response carries deliveryStatus="stored", fileId, and storedFormat.

  • 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 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
    invoicecredit-notedebit-notepurchase-ordersales-orderquoteevent

    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; only street, streetLine2, city, postalCode and country are carried to the e-invoice (mapped to street, street2, city, zipCode, country).

  • streetstring
  • streetLine2string

    Second address line (suite, box…). → street2.

  • citystring
  • postalCodestring

    zipCode.

  • countryISO 3166-1 alpha-2

    e.g. "FR", "BE".

  • state / regionstring

    Accepted; not currently emitted.

Multiple VAT rates

VAT is carried per line: every InvoiceLine has its own vatRate and optional vatCategory. A document spanning several rates is simply several lines with different vatRate values — Flowie sums each line's tax, groups the totals by rate, and renders one cac:TaxSubtotal per rate. There is no document-level VAT array (none is accepted). If you also send totals, they must reconcile with the per-line sums or validation fails.

"lines": [
  { "description": "Consulting",      "quantity": 10, "unitPrice": 150.00, "vatRate": 21.0, "vatCategory": "S" },
  { "description": "E-book (reduced)", "quantity": 1,  "unitPrice": 40.00,  "vatRate": 6.0,  "vatCategory": "S" },
  { "description": "Intra-EU goods",   "quantity": 1,  "unitPrice": 500.00, "vatRate": 0.0,  "vatCategory": "K" }
]

Exempt / reverse-charge categories (E, AE, K, G, O) additionally need a VAT exemption reason — see Tax exemption & zero rate.

Multiple parties

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

  • Each entry is a PartyRef: role (seller·buyer·payer·payee), id (any resolvable id, same grammar 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 identityid (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.
"parties": [
  { "role": "seller", "id": "0009:FR86797978996", "name": "ACME FRANCE",      "initiator": true },
  { "role": "buyer",  "id": "0208:0123456789",     "name": "MEGACORP BE" },
  { "role": "payee",  "vatNumber": "FR90123456789", "name": "ACME FACTORING SAS" }
]

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

Reception-point addressing (France)

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

  • Auto-detected. Pass the composed form as 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.
// request
"to": "752978775_75297877500027_100003"

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

Custom fields & templates

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

  • document.templateId — UUID of the template to file the document under. It declares the valid custom fields, their types, and whether each is document- or line-level. Omit to use your org's default template for the type.
  • document.customFields — document-level values, an object keyed by the field's name (e.g. "Cost Center") or its definition UUID. Names are resolved to UUIDs against your org's field definitions; a UUID key is forwarded as-is, while a name that matches no declared field is rejected with 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 }).

"document": {
  "number": "INV-2026-0042",
  "issueDate": "2026-04-15",
  "templateId": "8b1f…-template-uuid",
  "customFields": {
    "Cost Center": "CC-42",
    "9f3a…-budget-uuid": { "currency": "EUR", "amount": 1000.00 }
  },
  "lines": [
    { "description": "Consulting", "quantity": 10, "unitPrice": 150.00, "vatRate": 21.0,
      "customFields": { "GL Account": "606100" } }
  ]
}

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

Tax exemption & zero rate

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

CodeMeaningTypical useExemption reason required?
SStandard rateNormal VAT (e.g. 20%, 21%)No
ZZero ratedTaxable at 0%No
EExemptVAT-exempt supplyYes
AEReverse chargeBuyer accounts for VAT (intra-EU B2B)Yes
KIntra-community supplyIntra-EU supply of goodsYes
GFree export itemExport outside the EUYes
ONot subject to VATOutside the scope of VATYes
Exempt categories need a reason — send them as UBL
EN16931 / Peppol BIS 3.0 schematron rejects an invoice that uses E, AE, K, G, or O unless it also carries a VAT exemption reason — a code from the VATEX list (BT-121) and/or free text (BT-120). The structured format=json body has no field for this reason, so a JSON invoice in an exempt category will fail compliance validation. To send an exempt or reverse-charge invoice today, build it as format=ubl-xml and put the reason inside <cac:TaxCategory> yourself. Z (zero-rated) and S need no reason and work fine over JSON.

The exempt <cac:TaxCategory> block to include in your UBL (both at line level under <cac:ClassifiedTaxCategory> and in the document <cac:TaxSubtotal>):

<cac:TaxCategory>
  <cbc:ID>AE</cbc:ID>
  <cbc:Percent>0</cbc:Percent>
  <cbc:TaxExemptionReasonCode>VATEX-EU-AE</cbc:TaxExemptionReasonCode>
  <cbc:TaxExemptionReason>Reverse charge</cbc:TaxExemptionReason>
  <cac:TaxScheme><cbc:ID>VAT</cbc:ID></cac:TaxScheme>
</cac:TaxCategory>

Returns

201 Created with the document object. For structured payloads deliveryStatus starts as pending; listen for document.delivered or document.failed. For raw uploads deliveryStatus="stored" and the response includes fileId + storedFormat.

Request — full invoice
curl -X POST https://back.p2p-flowie.com/exchange/v1/documents/send \
  -H "Authorization: Bearer $KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: inv-2026-0417" \
  -d '{
    "type": "invoice",
    "from": "comp_01HXYZ…",
    "to":   "0208:9876543210",
    "document": {
      "number":    "INV-2026-0417",
      "issueDate": "2026-04-25",
      "dueDate":   "2026-05-25",
      "currency":  "EUR",
      "buyerReference": "SERV-FIN-042",
      "orderReference": "PO-91234",
      "seller": { "name": "ACME BVBA" },
      "buyer":  { "name": "Globex SRL", "vatNumber": "IT01234567890" },
      "payment": {
        "means":     "credit_transfer",
        "iban":      "BE68539007547034",
        "bic":       "BPOTBEB1",
        "reference": "INV-2026-0417"
      },
      "lines": [
        {
          "description": "Consulting services — April 2026",
          "quantity":    10, "unit": "hours",
          "unitPrice":   150.00,
          "vatRate":     21,
          "vatCategory": "S"
        },
        {
          "description": "Travel expenses",
          "quantity":    1, "unit": "lump",
          "unitPrice":   450.00,
          "vatRate":     21,
          "vatCategory": "S"
        }
      ]
    }
  }'
doc = api.post("/documents/send",
  headers={"Idempotency-Key": "inv-2026-0417"},
  json={
    "type": "invoice",
    "from": company["id"],
    "to":   "0208:9876543210",
    "document": {
      "number": "INV-2026-0417",
      "issueDate": "2026-04-25",
      "dueDate":   "2026-05-25",
      "currency":  "EUR",
      "buyerReference": "SERV-FIN-042",
      "payment": {"means": "credit_transfer",
                  "iban":  "BE68539007547034",
                  "bic":   "BPOTBEB1",
                  "reference": "INV-2026-0417"},
      "lines": [
        {"description": "Consulting — April",
         "quantity": 10, "unit": "hours",
         "unitPrice": 150.00, "vatRate": 21},
        {"description": "Travel",
         "quantity": 1, "unitPrice": 450.00, "vatRate": 21},
      ],
    },
  }).json()
const doc = await flowie("/documents/send", {
  method: "POST",
  headers: { "Idempotency-Key": "inv-2026-0417" },
  body: JSON.stringify({
    type: "invoice",
    from: company.id,
    to:   "0208:9876543210",
    document: {
      number: "INV-2026-0417",
      issueDate: "2026-04-25",
      dueDate:   "2026-05-25",
      currency:  "EUR",
      payment: { means: "credit_transfer",
                 iban: "BE68539007547034",
                 bic:  "BPOTBEB1",
                 reference: "INV-2026-0417" },
      lines: [
        { description: "Consulting — April",
          quantity: 10, unit: "hours",
          unitPrice: 150.00, vatRate: 21 },
        { description: "Travel",
          quantity: 1, unitPrice: 450.00, vatRate: 21 },
      ],
    },
  }),
});
Response
{
  "id":        "doc_01HY7AB9C2DE3FG",
  "type":      "invoice",
  "direction": "outgoing",
  "number":    "INV-2026-0417",
  "issueDate": "2026-04-25",
  "dueDate":   "2026-05-25",
  "currency":  "EUR",
  "grossAmount": 2359.50,
  "netAmount":   1950.00,
  "vatAmount":   409.50,
  "sender":   { "peppolId": "0208:0123456789", "name": "ACME BVBA" },
  "receiver": { "peppolId": "0208:9876543210", "name": "Globex SRL" },
  "status":         "sent",
  "deliveryStatus": "pending",
  "lifecycleStatus":"issued",
  "sentAt":    "2026-04-25T10:05:00Z",
  "createdAt": "2026-04-25T10:05:00Z"
}
{
  "error": {
    "type":    "delivery_error",
    "code":    "RECIPIENT_NOT_FOUND",
    "message": "0208:9876543210 is not registered on Peppol for document type 'invoice'.",
    "requestId":"req_…"
  }
}

Batch send

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 (type, format, from, to, document, xml, file) plus an optional per-item idempotencyKey.

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

Validate without sending

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, minus the raw file upload mode.

  • typeenumrequired
    invoicecredit-notedebit-notepurchase-orderpurchase-requestsales-orderquoteevent
  • formatenumoptional
    jsonubl-xmlcii-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.

  • xmlstringrequired when format=ubl-xml / cii-xml
{
  "valid": false,
  "errors": [
    { "rule": "BR-16", "message": "An Invoice shall have at least one line.",
      "path": "/Invoice/InvoiceLine" }
  ],
  "warnings": [],
  "recipientReachable": true
}

List documents

GET/v1/documents

Paginated list across both directions — this is the polling half of receiving documents, for integrations that cannot expose a webhook endpoint.

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

Cursor-paginated like every list endpoint: the response is { "data": […], "hasMore": true, "cursor": "…" }. Keep passing the returned cursor until hasMore is false; never hard-code an offset.

Query parameters

  • directionenumoptional
    incomingoutgoing
  • typeenumoptional
    invoicecredit-notedebit-notepurchase-ordersales-orderquoteevent
  • statusstringoptional

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

  • deliveryStatusenumoptional
    pendingdeliveredfailedrejected

    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

Same filters as the list endpoint, but accepts a JSON body with compound expressions: AND, OR, NOT trees. Use when a query exceeds URL length limits or you need nested predicates.

Request body

  • querystringoptional

    Free-text query, same semantics as the list search param.

  • filtersobjectoptional

    Compound predicate tree (AND / OR / NOT) over the same fields as the list filters (direction, type, status, deliveryStatus, issueDate range, amount bounds, companyId).

  • sortobjectoptional

    Sort spec, e.g. {"field": "issueDate", "order": "desc"}.

  • limitintegeroptional

    Page size. Default 20.

  • cursorstringoptional

    Opaque pagination cursor from the previous page.

Retrieve a document

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-readmark-unreadarchiveunarchivetaguntagassignunassignadd-notelink
  • tagstringconditional

    Required by the tag / untag actions.

  • userIdstringconditional

    Required by the assign / unassign actions.

  • notestringconditional

    Required by the add-note action.

  • relatedDocumentIdstringconditional

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

curl "…/v1/documents?direction=outgoing&status=sent&from=2026-04-01&amountMin=500" \
  -H "Authorization: Bearer $KEY"
curl -X POST …/v1/documents/search \
  -H "Authorization: Bearer $KEY" \
  -d '{
    "any": [
      { "all": [{"status":"sent"},{"deliveryStatus":"failed"}]},
      { "all": [{"lifecycleStatus":"disputed"}]}
    ],
    "from": "2026-01-01"
  }'
curl -X POST …/v1/documents/doc_abc/actions \
  -H "Authorization: Bearer $KEY" \
  -d '{"action":"tag","tag":"priority/high"}'
Structured response
{
  "id":                "doc_01…",
  "type":              "invoice",
  "direction":         "incoming",
  "number":            "INV-2026-0417",
  "issueDate":         "2026-04-25",
  "dueDate":           "2026-05-25",
  "currency":          "EUR",
  "grossAmount":       2359.50,
  "netAmount":         1950.00,
  "vatAmount":         409.50,
  "status":            "delivered",
  "lifecycleStatus":   "approved",
  "deliveryStatus":    "delivered",
  "senderPeppolId":    "0208:0123456789",
  "senderName":        "ACME BVBA",
  "senderVatNumber":   "BE0123456789",
  "receiverPeppolId":  "0208:9876543210",
  "receiverName":      "Globex SRL",
  "receiverVatNumber": "IT01234567890",
  "buyerReference":    "SERV-FIN-042",
  "orderReference":    "PO-91234",
  "paymentIban":       "BE68539007547034",
  "paymentReference":  "INV-2026-0417",
  "receivedAt":        "2026-04-25T10:05:08Z",
  "sentAt":            "2026-04-25T10:05:00Z",
  "createdAt":         "2026-04-25T10:05:00Z",
  "updatedAt":         "2026-04-25T10:05:08Z"
}

Lifecycle

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

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 →

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_reviewapprovedrejectedpartially_paidpaiddisputed
  • reasonCodeenumconditional
    NONREFLEGRECQUADELPRIQTYITMPAYUNRFINPPDOTH

    Required for rejected and disputed. One of the 14 official Peppol status reason codes (OPStatusReason) — full table below. 🇫🇷 France: an AFNOR motif code (XP Z12-012 annex) is forwarded verbatim as MDT-113, and suspended on a disputed call transmits 208 Suspendue — see FR refusal & rejection.

  • reasonstringoptional

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

  • notestringoptional
  • paymentDatedateconditional

    Required for paid / partially_paid.

  • paymentAmount / paymentCurrency / remainingAmountnumber / ISO 4217conditional
  • paymentReferencestringoptional

Batch lifecycle update

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 (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 to see what's allowed now.
curl -X POST …/v1/documents/doc_abc/lifecycle \
  -H "Authorization: Bearer $KEY" \
  -d '{
    "status": "paid",
    "paymentDate":      "2026-04-25",
    "paymentAmount":    2359.50,
    "paymentCurrency":  "EUR",
    "paymentReference": "PAY-2026-0001"
  }'
# reasonCode = one of the 14 official Peppol status
# reason codes (see “Status reason codes” below)
curl -X POST …/v1/documents/doc_abc/lifecycle \
  -H "Authorization: Bearer $KEY" \
  -d '{
    "status":     "rejected",
    "reasonCode": "PRI",
    "reason":     "Unit price on line 3 does not match the quote"
  }'
{
  "documentId":      "doc_abc",
  "previousStatus":  "approved",
  "currentStatus":   "paid",
  "updatedAt":       "2026-04-25T10:35:00Z",
  "compliance": {
    "reportedTo": ["PPF","SDI"],
    "status":     "reported",
    "nextCheckAt":"2026-04-25T10:40:00Z"
  },
  "allowedTransitions": ["disputed"]
}

Update lifecycle status by invoice number

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 — identical state-machine validation, the same transaction-documents update, the same PPF/SDI compliance reporting for FR/IT documents, and the same lifecycle.updated webhook. The request body and the success response are identical to the id-based route (see Update lifecycle status for the full field list), so payment fields (paymentDate, paymentAmount, paymentCurrency, paymentReference) are required for paid / partially_paid here too.

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

Matches in your orgResult
0404 not_found — no document with that invoice number that your organization is a party on.
exactly 1200 — the transition is applied and the updated document is returned.
more than 1409 conflict — ambiguous; re-issue the call against POST /v1/documents/{documentId}/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.
curl -X POST …/v1/documents/by-number/INV-2026-0042/lifecycle \
  -H "Authorization: Bearer $KEY" \
  -d '{
    "status": "approved",
    "note":   "Invoice verified against PO"
  }'
{
  "documentId":      "doc_test001",
  "previousStatus":  "received",
  "currentStatus":   "approved",
  "updatedAt":       "2026-04-15T10:32:18.421Z",
  "compliance": {},
  "allowedTransitions": ["partially_paid", "paid", "disputed"]
}

Directory

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

Search directory

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.

  • detailenumoptional
    basicfull

    Default basic (directory fields only). full enriches each row with access-point / SMP detail — slower, one lookup per result.

  • limitintegeroptional

    Max distinct participants to return. Default 20.

Lookup Peppol ID

GET/v1/directory/{peppol_id}

Verify recipient

POST/v1/directory/verify

The recommended pre-flight check before every send. Tells you whether the recipient exists, can accept the document type, and returns the access point metadata.

Request body

  • peppolIdstringrequired

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

  • documentTypestringrequired

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

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

Partners

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

Create a partner

POST/v1/partners

At least one of peppolId or vatNumber is required.

  • peppolIdstringconditional

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

  • vatNumberstringconditional
  • roleenumoptional
    supplierbuyerboth
  • contactName / contactEmailstringoptional
  • defaultsobjectoptional

    currency, paymentTermsDays, note, orderReference

  • tags / metadataarray / objectoptional

List partners

GET/v1/partners

Query parameters

  • roleenumoptional
    supplierbuyerboth
  • searchstringoptional

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

  • countryISO 3166-1 α-2optional
  • tagsstringoptional

    Comma-separated tag filter.

  • hasActivitybooleanoptional

    Only partners with at least one sent/received document.

  • peppolStatusstringoptional
  • sortBy / orderstringoptional

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

  • limit / cursorpaginationoptional

Retrieve partner

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.

  • peppolIdstringoptional
  • vatNumberstringoptional
  • roleenumoptional
    supplierbuyerboth
  • 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 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 summaries.

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

Purchase orders

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

List a purchase order’s invoices

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

curl …/v1/purchase-orders/PO-2026-0042/invoices \
  -H "Authorization: Bearer $KEY"
{
  "data": [
    {
      "id":       "doc_01HXY…",
      "type":     "INVOICE",
      "number":   "INV-2026-001",
      "issueDate":"2026-04-14",
      "currency": "EUR",
      "amount":   1210.0,
      "status":   "received",
      "direction":"incoming"
    }
  ],
  "hasMore": false,
  "cursor":  null
}

Webhooks

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

Create a webhook

POST/v1/webhooks
  • urlhttps URLrequired
  • eventsstring[]required
    document.receiveddocument.updated document.sentdocument.delivered document.failedlifecycle.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}
curl -X POST …/v1/webhooks \
  -H "Authorization: Bearer $KEY" \
  -d '{
    "url":    "https://example.com/hooks/peppol",
    "events": ["document.received","document.delivered","document.failed"],
    "secret": "whsec_rotate_me"
  }'
{
  "id":               "wh_01…",
  "url":              "https://example.com/hooks/peppol",
  "events":           ["document.received","document.delivered","document.failed"],
  "status":           "active",
  "companyId":        null,
  "failureCount":     0,
  "lastDeliveredAt":  null,
  "createdAt":        "2026-04-25T10:00:00Z"
}

Events

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

List events

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.

{
  "data": [
    {
      "id":        "evt_01HY…",
      "type":      "document.received",
      "createdAt": "2026-04-25T10:05:08Z",
      "data": {
        "documentId": "doc_01…",
        "direction":  "incoming",
        "type":       "invoice",
        "number":     "INV-2026-0417"
      }
    }
  ],
  "hasMore": false
}

Compliance

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

Compliance status

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
    dayweekmonthyear
  • companyIdstringoptional
{
  "period":    { "start":"2026-04-01", "end":"2026-04-30" },
  "quota":     { "limit": 5000, "used": 412, "remaining": 4588 },
  "rateLimit": { "perMinute": 300 },
  "documents": {
    "sent":      180,
    "received":  232,
    "delivered": 178,
    "failed":    2
  },
  "byType":    { "invoice": 390, "credit-note": 22 },
  "byCountry": { "FR": 150, "BE": 120, "IT": 142 },
  "partners":  { "total": 47, "active": 31 }
}

Platform

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

Onboard a managed company

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; 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
    companycountrytype

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.

curl -X POST …/v1/platform/companies \
  -H "Authorization: Bearer flw_plat_live_xyz" \
  -d '{
    "vatNumber":"FR86797978996",
    "receiveDocuments":true,
    "webhook": {
      "url": "https://erp.acme.fr/hooks/flowie",
      "events": ["*"]
    },
    "apiKey": { "name":"erp-tenant-t001",
                "scopes":["send","documents.read","lifecycle"] }
  }'
{
  "company":  { "id":"comp_01HY…", "peppolId":"0009:FR86797978996", … },
  "apiKey":   { "id":"key_01…", "key":"flw_live_t001_abc…", "keyPrefix":"flw_live_t001" },
  "webhook":  { "id":"wh_01…", "status":"active" }
}

API keys

Create API key

POST/v1/api-keys

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

  • namestringrequired
  • companyIdstringoptional
  • scopesstring[]optional

    See scopes list.

  • expiresAttimestampoptional
  • rateLimitintegeroptional

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

List API keys

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.

{
  "id":        "key_01HY…",
  "key":       "flw_live_abc123def456ghi…",   // shown once
  "keyPrefix": "flw_live_abc123",
  "name":      "Mobile App",
  "scopes":    ["send","documents.read"],
  "companyId": null,
  "createdAt": "2026-04-25T10:00:00Z",
  "expiresAt": "2027-04-25T00:00:00Z"
}

Categorization

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

List tag groups

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.

[
  { "tagId": "tag_cc_rd",   "name": "R&D",      "groupId": "cost-center", "confidence": 0.92 },
  { "tagId": "tag_proj_x1", "name": "Project X1", "groupId": "project",     "confidence": 0.71 }
]

Payments

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

Document payment info

GET/v1/payments/documents/{documentId}

Record a payment

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

Body: {"amount": …, "date": "YYYY-MM-DD", "reference": "…"}. Automatically advances the lifecycle to partially_paid, or paid once the recorded payments cover the document total.

The advance is best-effort: the payment is always recorded, and the response's lifecycleStatus is null if the document could not legally move to a paid state. Only approved, partially_paid and disputed can — a document still in draft/received/under_review must be approved first. Check GET /v1/documents/{documentId}/lifecycleallowedTransitions.

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.

{
  "documentId":   "doc_abc",
  "amountDue":    2359.50,
  "amountPaid":   0.00,
  "currency":     "EUR",
  "dueDate":      "2026-05-25",
  "status":       "unpaid",
  "iban":         "BE68539007547034",
  "reference":    "INV-2026-0417"
}

AFNOR XP Z12-013

Flowie Exchange is a French PDP (Plateforme de Dématérialisation Partenaire) and ships a fully compliant implementation of the AFNOR XP Z12-013 facade. ERPs, OD (Opérateur de Dématérialisation), other PDPs, and the public PPF infrastructure all talk to us in the standardized format below — so swapping us in or out of an existing AFNOR-compliant integration is a base-URL change.

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:

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
    CIIUBLFactur-XCDARFRR
  • flowInfo.trackingIdstring (≤36)optional
  • flowInfo.processingRuleenumoptional
    B2BB2CB2G
  • flowInfo.flowProfileenumoptional
    BasicCIUSExtended-CTC-FR
  • flowInfo.sha256hexoptional
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
    MetadataOriginalConvertedReadableView

AFNOR webhooks

Same operations as Webhooks 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.

curl -X POST …/afnor/flow-service/v1/flows \
  -H "Authorization: Bearer $KEY" \
  -F 'flowInfo={"name":"INV-2026-0417","flowSyntax":"UBL","processingRule":"B2B","flowProfile":"Extended-CTC-FR","trackingId":"t-42"};type=application/json' \
  -F 'file=@invoice.xml'
HTTP/1.1 202 Accepted
{
  "flowId":         "flw_01HY…",
  "submittedAt":    "2026-04-25T10:00:00Z",
  "name":           "INV-2026-0417",
  "flowSyntax":     "UBL",
  "trackingId":     "t-42",
  "processingRule": "B2B",
  "flowProfile":    "Extended-CTC-FR",
  "sha256":         "e3b0c442…"
}

Get directory line by id

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, filtered on the identifier and reduced to one line. Note the AFNOR path grammar: the value is prefixed code: in the path segment.

Path parameters

  • addressing_identifierstringrequired

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

Query parameters

  • includestringoptional

    Related resources to embed.

  • fieldsstringoptional

    Sparse fieldset.

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

PunchOut cart callback

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.

<cXML payloadID="..." timestamp="...">
  <Header>
    <Sender><Credential domain="NetworkID">...</Credential>
      <SharedSecret>***</SharedSecret></Sender>
  </Header>
  <Message><PunchOutOrderMessage>
    <BuyerCookie>org_01HY…:thread_abc:v2</BuyerCookie>
    ...
  </PunchOutOrderMessage></Message>
</cXML>

OCI cart callback

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.

POST /document/oci-callback?flowie_cookie=flowie:org_01HY…:thr_01HY…:9f3c
Content-Type: application/x-www-form-urlencoded

NEW_ITEM-DESCRIPTION[1]=Laptop stand&NEW_ITEM-QUANTITY[1]=2&NEW_ITEM-PRICE[1]=49.00

Request log

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

List captured requests

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.

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

Usage rollup

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.

Query parameters

  • byenumoptional
    keyuser
  • sincedatetimeoptional

    ISO-8601 lower bound.

  • untildatetimeoptional

    ISO-8601 upper bound.

{
  "rows": [
    {
      "apiKeyId":  "key_01HY…",
      "label":     "erp-prod",
      "requests":  1284,
      "errors":    3,
      "lastSeenAt": "2026-04-25T10:05:00Z"
    }
  ]
}

Portability

Inter-PA messaging for the French portability process: when a taxpayer moves from one Plateforme Agréée to another, the gaining and losing platforms exchange a normalised message (a strict subject line plus an 18-field CSV). These two endpoints build and parse that message.

Build an inter-PA message

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. Returns the normalised subject, the 18-field csv (header + row) and a dispatched flag. Dispatch is off by default, so this is a safe build-and-preview call: nothing is emailed to the counterparty unless dispatch is enabled server-side.

Request body

  • messageTypeenumrequired

    Which step of the portability exchange this message is.

  • stateenumrequired

    State of the request the message reports.

  • requestRefstringrequired

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

  • taxpayerSirenstringrequired

    9-digit SIREN of the taxpayer being ported.

  • gainingPaId / losingPaIdstringoptional

    Platform identifiers on each side of the move.

  • effectiveDatedateoptional

    When the transfer takes effect.

{
  "messageType":   "request",
  "state":         "submitted",
  "requestRef":    "PORT-2026-0042",
  "taxpayerSiren": "552100554",
  "effectiveDate": "2026-06-01"
}
{
  "subject":    "[PORTABILITE][REQUEST][SUBMITTED] 552100554 PORT-2026-0042",
  "csv":        "requestRef;state;siren;…\nPORT-2026-0042;SUBMITTED;552100554;…",
  "dispatched": false
}

Parse an inbound message

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.

{
  "subject": "[PORTABILITE][REQUEST][SUBMITTED] 552100554 PORT-2026-0042"
}
{
  "messageType":   "request",
  "state":         "submitted",
  "requestRef":    "PORT-2026-0042",
  "taxpayerSiren": "552100554",
  "valid":         true
}

Health

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

Liveness

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.

{
  "status": "ok",
  "circuits": {
    "peppol-smp":      { "state": "closed", "failures": 0 },
    "ppf-annuaire":    { "state": "closed", "failures": 0 },
    "document-service":{ "state": "closed", "failures": 0 }
  }
}

Appendices

Address object

  • streetstring
  • citystring
  • postalCodestring (≤20)
  • regionstring
  • countryISO 3166-1 α-2required

Party object

{ "name":"…", "vatNumber":"…", "address":Address, "contact": {"name":"…", "email":"…", "phone":"…"} }

PaymentInfo object

  • meansenum
    credit_transferdirect_debitcardcashcheque
  • ibanIBAN
  • bicSWIFT BIC
  • referencestring
  • discountTermsarray

Status reason codes (Peppol BIS · OPStatusReason)

The coded vocabulary for reasonCode on lifecycle updates is the official OpenPeppol Status Clarification Reason list (OPStatusReason, Peppol BIS Invoice Response 3). All 14 codes — nothing else is part of the official list:

CodeLabelUse it when…
NONNo issuePure status update — nothing is wrong (e.g. with under_review).
REFReferences incorrectA required reference (PO number, buyer reference, contract) is missing or wrong.
LEGLegal information incorrectThe document doesn't meet legal requirements (mandatory mentions, VAT identifiers…).
RECReceiver unknownThe invoice is not addressed to this party.
QUAItem quality insufficientUnacceptable or incorrect quality of the delivered goods / services.
DELDelivery issuesGoods / services not delivered, or the delivery is not acceptable.
PRIPrices incorrectPrice differs from the order, quote or contract.
QTYQuantity incorrectQuantity differs from what was ordered or delivered.
ITMItems incorrectThe invoiced items don't match what was ordered / delivered.
PAYPayment terms incorrectPayment terms differ from the agreement.
UNRNot recognizedThe commercial transaction is not recognized (unknown order / relation).
FINFinance incorrectFinancing terms differ from expectations.
PPDPartially paidThe invoice is only partially paid.
OTHOtherNo code fits — always pair with a free-text reason.

Rejecting vs putting on hold — pick the reversible path first

You want to…SendTerminal?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 resumesExactly 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 refusalThe 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 correctiveThe 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.