Test the entire API without sending a real invoice
Every endpoint, every webhook, every regulatory platform has a deterministic sandbox counterpart. Use the rows below to trigger any outcome you need to test — the recipient is unreachable, PPF rejects with code 00058, the rate-limit kicks in, the lifecycle reaches paid after a 30-second delay. No real Peppol traffic is generated.
Base URLs
| Environment | Base URL | Key prefix |
|---|---|---|
| Sandbox | https://back.flowie.ink | flw_test_… · flw_plat_test_… · flw_wl_test_… |
| Production | https://back.p2p-flowie.com | flw_live_… · flw_plat_live_… · flw_wl_live_… |
Test API keys
curl -X POST https://back.flowie.ink/exchange/v1/sandbox/bootstrap \
-H "Content-Type: application/json" \
-d '{"label":"my-laptop"}'
You get back the full apiKey (shown once), a starter sandbox company,
and a 7-day expiry. Or click "Get a test API key" on the
landing page — same endpoint, the result auto-loads into the Playground.
Key types — personal, platform, white-label
By default /v1/sandbox/bootstrap mints a personal key (prefix flw_test_) that behaves like a regular tenant integration. Pass "keyType": "platform" or "white_label" to mint a multi-tenant key that satisfies the platform-key gate on /v1/platform/*:
curl -X POST https://back.flowie.ink/exchange/v1/sandbox/bootstrap \
-H "Content-Type: application/json" \
-d '{"label":"my-platform","keyType":"platform"}'
| keyType | Token prefix | Unlocks |
|---|---|---|
personal (default) | flw_test_… | All non-platform endpoints |
platform | flw_plat_test_… | + POST /v1/platform/companies (multi-tenant onboard), GET /v1/platform/companies, GET /v1/platform/events, GET /v1/platform/usage, PATCH /v1/platform/settings |
white_label | flw_wl_test_… | Same as platform + branding |
If you already have a Flowie dashboard JWT, you can also create longer-lived keys explicitly:
curl -X POST https://back.flowie.ink/exchange/v1/api-keys \
-H "Authorization: Bearer $FLOWIE_DASHBOARD_JWT" \
-d '{"name":"local-dev","scopes":["*"]}'
Or grab one from the dashboard Settings → API keys → New key (test mode). As with live keys, the full string is shown once.
What sandbox synthesises (vs. live infra)
Sandbox keys never reach the live Peppol network, the einvoice-validator, the tag service, the payment service, the request-log store, or org-v2's BOR. Each route either short-circuits to a synthetic response or runs in a memory-only mode so the contract is exercisable without external dependencies. The table below is the canonical list — anything not on it behaves identically to production.
| Route | Sandbox behaviour |
|---|---|
POST /v1/documents/send (any format) | Returns a doc_sbx_… id immediately. format=auto/raw with a file payload returns deliveryStatus="stored" + fileId + storedFormat without uploading. |
GET /v1/documents/{id}/xml | Synthesises a minimal valid UBL Invoice XML for any doc_sbx_… / doc_test_… / flw_… id. |
GET /v1/documents/{id}/pdf | Returns a 1-page PDF stub for any doc_sbx_… / doc_test_… / flw_… id. |
DELETE /v1/companies/{id} | Idempotent — never 404s. |
GET /v1/directory/{peppol_id} | Synthesises a participant record (no live Peppol/PPF lookup). Always returns smpStatus="active". |
POST /v1/partners · GET /v1/partners | POST returns a synthetic prt_sbx_…. GET returns an empty page (sandbox tenants start with no partnerships). |
POST /v1/categorization/objects/{id}/tags · POST .../auto | Returns synthetic assignments / AI suggestions. Tag groups are pre-seeded with grp_sbx_unspsc, grp_sbx_accounting, grp_sbx_custom. |
POST /v1/events/{id}/ack · POST /v1/events/ack · POST /v1/events/{id}/replay | Idempotent — accepts any event id, including ones that were never emitted. Replay returns a synthetic delivery record. |
GET /v1/requests/{request_id} | Synthesises a believable failed-request envelope (502 from a Peppol AP) for any id, so the inspector contract round-trips without first triggering a real failure. |
GET /v1/payments/documents/{id} · POST .../pay · POST /v1/payments/export/iso20022 | Returns synthetic PaymentInfo / PaymentRecord / pain.001 ISO 20022 stubs. Live payment-staging service is bypassed. |
POST /v1/platform/companies (platform key) · GET /v1/platform/companies · PATCH /v1/platform/settings · DELETE /v1/platform/api-keys/{key_id} | Synthesise empty managed-companies pages, echo settings updates, and idempotently revoke arbitrary key ids — no org-v2 children are required. |
GET /afnor/directory-service/v1/siret/code-insee:{siret} · GET /afnor/.../routing-code/siret:{siret}/code:{routing_identifier} | Synthesise believable INSEE establishment / routing-code records for any 14-digit SIRET — no live INSEE lookup required. |
POST /afnor/flow-service/v1/flows | Routes through the broadened POST /v1/documents/send sandbox synth — a flw_… id is returned without requiring a real Peppol registration. |
GET /afnor/flow-service/v1/flows/{flow_id} (any docType) | Resolves any flw_… id (including 32-hex / 36-uuid shapes) via the document_service sandbox synth. |
POST /document/callback (cXML PunchOut) | Not short-circuited. Authentication is still enforced via <SharedSecret> in the cXML envelope (or supplier-identity fallback) — sandbox keys do not bypass this gate. |
Test VAT numbers
Pass any of these to POST /v1/companies or /companies/resolve to deterministically trigger a behavior.
| VAT | Country | Outcome |
|---|---|---|
BE0000000001 | BE | Enriches as Sandbox Test BVBA, status active, SMP-registered after ~2s. |
BE0000000099 | BE | Returns 422 VAT_INACTIVE. |
BE0000000404 | BE | Returns 422 VAT_NOT_FOUND. |
BE0000000500 | BE | Returns 503 UPSTREAM_UNAVAILABLE (registry down). |
FR12345678901 | FR | Enriches with a public-sector flag → SDI/PPF reporting enabled. |
FR99999999999 | FR | 422 VAT_NOT_FOUND. |
IT00000000010 | IT | Enriches Italian; auto-enables SDI reporting. |
IT00000000099 | IT | SDI returns 00306 (Codice Destinatario unknown). |
DE000000001 | DE | Enriches; no auto-compliance (Germany is voluntary). |
NL000000001B01 | NL | Enriches; auto-enables NL Peppol routing. |
ES00000000C | ES | Enriches; FACe (Spain public-sector) flag set. |
?simulateLatencyMs=2500 to POST /companies in sandbox to force a slow enrichment. Useful to test loading states.
Test Peppol IDs (recipient side)
| Peppol ID | Behavior |
|---|---|
0208:TEST_OK | Delivers in ~1s. Fires document.sent, document.delivered. |
0208:TEST_OK_SLOW | Delivers in ~30s. Lets you exercise polling UIs. |
0208:TEST_AP_FAIL | Recipient AP rejects with AP_REJECTED. Fires document.failed after ~2s. |
0208:TEST_AP_FLAKY | First two attempts time out, third succeeds. Tests retry logic in your UI. |
0208:TEST_TIMEOUT | All transport attempts time out → document.failed with TRANSPORT_FAILURE. |
0208:TEST_REJECT_SCHEMA | Recipient rejects with a UBL schematron failure (BR-CO-15). |
0208:TEST_REJECT_BUYER_REF | Recipient requires buyerReference — rejects PPF code 00058. |
0208:TEST_DUPLICATE | Recipient marks the document as duplicate (DUP). |
0208:TEST_NOT_REGISTERED | SMP returns "not found" → 422 RECIPIENT_NOT_FOUND. |
0208:TEST_CANNOT_RECEIVE_INVOICE | Registered, but doesn't accept INVOICE doctype → 422 RECIPIENT_CANNOT_RECEIVE. |
End-to-end recipient simulators
Each test Peppol ID below is a fully simulated recipient. Sending to it triggers a full lifecycle including counterparty acks/rejects.
| Peppol ID | Persona | Lifecycle path it drives on the receiver side |
|---|---|---|
0208:SIM_HAPPY | Happy path | delivered → under_review → approved → paid over ~5 min. |
0208:SIM_SLOW_PAY | Late payer | delivered → approved immediately, then paid 60 days later (use time-travel to skip ahead). |
0208:SIM_DISPUTE | Disputes invoices | delivered → under_review → disputed with reason QUA (quantity discrepancy). |
0208:SIM_REJECT | Rejects on first review | delivered → rejected with reason PRI (price disagreement). |
0208:SIM_PARTIAL | Pays in installments | approved → partially_paid (50%) → partially_paid (75%) → paid over 3 days. |
Lifecycle simulators
For your own sent documents, you can advance the lifecycle on demand:
# Force a sandbox document to "paid" right now
curl -X POST …/v1/documents/{doc_id}/lifecycle \
-H "Authorization: Bearer $TEST_KEY" \
-d '{
"status": "paid",
"paymentDate": "2026-04-25",
"paymentAmount": 2359.50,
"paymentCurrency": "EUR",
"paymentReference":"SBX-PAY-001"
}'
The compliance hooks fire normally — see compliance simulators below.
| Reason code (force a rejection) | What gets reported |
|---|---|
RE | Generic rejection — PPF/SDI accept silently. |
QUA | Quantity discrepancy. |
PRI | Price disagreement. |
TAX | Tax mismatch — SDI flags for review. |
DUP | Duplicate — PPF returns 00043. |
Compliance platform simulators (PPF / SDI)
To exercise the compliance pipeline, set the company's metadata.simulateCompliance field. The next lifecycle update on any of that company's docs uses the simulated response. Belgium has no regulator-side report (HERMES decommissioned 2025-12-31) — BE invoices skip this pipeline entirely.
| Value | PPF / SDI response |
|---|---|
"accept" | 200 OK in < 1s. Fires compliance.reported. |
"reject_00058" | PPF returns 00058 (missing Service Exécutant). compliance.reported.failed. |
"reject_00306" | SDI returns 00306 (Codice Destinatario unknown). |
"timeout_30s" | Authority times out; circuit breaker behavior visible at /health/readiness. |
"flaky_50pct" | 50% probability of acceptance per attempt. |
# Set the simulator on a sandbox company
curl -X PATCH …/v1/companies/{company_id} \
-H "Authorization: Bearer $TEST_KEY" \
-d '{"metadata": {"simulateCompliance": "reject_00058"}}'
Triggering each webhook event
Each row below is a copy-pasteable curl that produces exactly one webhook delivery against your registered sandbox endpoint.
| Event | How to trigger |
|---|---|
document.received | Send to your own sandbox company from 0208:SIM_HAPPY. |
document.sent | Send anything to 0208:TEST_OK. |
document.delivered | Send to 0208:TEST_OK; arrives ~1s later. |
document.failed | Send to 0208:TEST_AP_FAIL. |
document.updated | POST /documents/{id}/actions with {"action":"tag","tag":"x"}. |
lifecycle.updated | POST /documents/{id}/lifecycle with any allowed status. |
company.smp_registered | Create a company with VAT BE0000000001; arrives ~2s later. |
compliance.reported | Mark a French/Italian/Belgian doc as paid with simulateCompliance="accept". |
compliance.reported.failed | Same as above with simulateCompliance="reject_00058". |
To replay any past event byte-identically:
curl -X POST …/v1/events/{event_id}/replay \
-H "Authorization: Bearer $TEST_KEY"
Need fixture payloads to seed your tests without hitting the API? See webhook fixtures.
Forcing specific errors
Pass X-Sandbox-Force-Error on any request to make the API return that error code:
curl …/v1/companies \
-H "Authorization: Bearer $TEST_KEY" \
-H "X-Sandbox-Force-Error: UPSTREAM_UNAVAILABLE"
| Header value | Resulting status / body |
|---|---|
INVALID_REQUEST | 400 |
EXPIRED_TOKEN | 401 |
INSUFFICIENT_SCOPE | 403 |
RESOURCE_NOT_FOUND | 404 |
IDEMPOTENCY_BODY_MISMATCH | 409 |
VAT_NOT_FOUND | 422 |
RATE_LIMITED | 429 with Retry-After: 30 |
INTERNAL_ERROR | 500 |
UPSTREAM_UNAVAILABLE | 503 |
Forcing a rate-limit
Sandbox rate-limits are normally generous. To force a 429 right now:
curl -X POST …/v1/sandbox/rate-limit/exhaust \
-H "Authorization: Bearer $TEST_KEY" \
-d '{"durationSeconds": 60}'
Every subsequent call returns 429 with a real Retry-After header for the next 60 seconds. Useful for testing your backoff implementation under realistic conditions.
Time-travel
For sandbox companies you can advance the clock to verify deferred behaviors (60-day late payments, 12-month deprecation windows, idempotency cache TTL):
curl -X POST …/v1/sandbox/clock/advance \
-H "Authorization: Bearer $TEST_KEY" \
-d '{"companyId": "comp_…", "by": "60d"}'
Accepts by as 1h, 3d, 2w, 1m, 1y. The clock is per-company and never affects another tenant. POST …/clock/reset snaps it back.
Reset & data lifetime
| Resource | Sandbox lifetime | Reset |
|---|---|---|
| Companies, partners, webhooks, API keys | Persistent | Delete via API or dashboard. |
| Documents | 90 days from creation | Auto-purged. Use POST /v1/sandbox/reset to wipe all docs immediately. |
| Events | 30 days | Auto-purged. |
| Idempotency cache | 24 hours (same as live) | POST /v1/sandbox/idempotency/flush |
| Rate-limit counters | 60s window (same as live) | — |
# Nuke EVERYTHING in your sandbox tenant
curl -X POST …/v1/sandbox/reset \
-H "Authorization: Bearer $TEST_KEY" \
-d '{"confirm": "yes"}'
Local webhook tunnels
To receive webhooks while running your handler on localhost, use any tunnel:
ngrok http 3000
# OR
cloudflared tunnel --url http://localhost:3000
Then point a sandbox webhook at https://<random>.ngrok.io/hooks. The dashboard's Resend button sends a byte-identical retry — perfect for iterating on your signature verifier.
Copy-paste bootstrap scripts
Spin up a complete test scenario (one sender, one recipient simulator, one webhook, three sent invoices) with a single shell script:
#!/usr/bin/env bash
set -euo pipefail
BASE="https://back.flowie.ink/exchange/v1"
KEY="$FLOWIE_TEST_KEY"
H=(-H "Authorization: Bearer $KEY" -H "Content-Type: application/json")
# 1. Create a sandbox sender
SEND=$(curl -s -X POST "$BASE/companies" "${H[@]}" \
-d '{"vatNumber":"BE0000000001"}')
COMP=$(echo "$SEND" | jq -r .id)
echo "→ sender: $COMP"
# 2. Register a webhook (replace URL with your tunnel)
curl -s -X POST "$BASE/webhooks" "${H[@]}" \
-d '{"url":"'"$WEBHOOK_URL"'","events":["*"]}' > /dev/null
# 3. Send 3 invoices to the happy-path simulator
for n in 001 002 003; do
curl -s -X POST "$BASE/documents/send" "${H[@]}" \
-H "Idempotency-Key: bootstrap-$n" \
-d '{
"type":"invoice",
"from":"'"$COMP"'",
"to":"0208:SIM_HAPPY",
"document":{
"number":"INV-2026-'"$n"'",
"issueDate":"2026-04-25",
"currency":"EUR",
"lines":[{"description":"Test","quantity":1,"unitPrice":100,"vatRate":21}]
}
}' | jq -r '.id + " → " + .status'
done
The same script in Python · Node · Go on GitHub.
Gotchas
- Sandbox keys never reach production. If you accidentally point a
flw_test_…key athttps://back.flowie.ink, you get401 INVALID_TOKEN. Production rejects test keys and vice versa. - Webhook signatures use the webhook's own secret, not a global sandbox secret. Each webhook you create has its own.
- Time-travel is per-company. Two parallel test runs on different sandbox companies don't interfere.
- Idempotency cache TTL is the same in sandbox (24h). If a test reuses the same key within that window, you'll see the cached response, not a fresh send.
- Test data is not anonymized in logs. Don't paste real customer VATs into sandbox just because "it's only a test."
