FlowieExchange
Sandbox

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.

Promise
The sandbox is API-identical to production. If a request works in sandbox, the only thing that changes in live mode is the network destination. We test this contract on every release.

Base URLs

EnvironmentBase URLKey prefix
Sandboxhttps://back.flowie.inkflw_test_… · flw_plat_test_… · flw_wl_test_…
Productionhttps://back.p2p-flowie.comflw_live_… · flw_plat_live_… · flw_wl_live_…

Test API keys

⚡ Easiest path — no signup, no JWT
Hit the public bootstrap endpoint from your terminal (rate-limited to 120 keys per IP per hour):
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"}'
keyTypeToken prefixUnlocks
personal (default)flw_test_…All non-platform endpoints
platformflw_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_labelflw_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.

RouteSandbox 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}/xmlSynthesises a minimal valid UBL Invoice XML for any doc_sbx_… / doc_test_… / flw_… id.
GET /v1/documents/{id}/pdfReturns 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/partnersPOST returns a synthetic prt_sbx_…. GET returns an empty page (sandbox tenants start with no partnerships).
POST /v1/categorization/objects/{id}/tags · POST .../autoReturns 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}/replayIdempotent — 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/iso20022Returns 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/flowsRoutes 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.

VATCountryOutcome
BE0000000001BEEnriches as Sandbox Test BVBA, status active, SMP-registered after ~2s.
BE0000000099BEReturns 422 VAT_INACTIVE.
BE0000000404BEReturns 422 VAT_NOT_FOUND.
BE0000000500BEReturns 503 UPSTREAM_UNAVAILABLE (registry down).
FR12345678901FREnriches with a public-sector flag → SDI/PPF reporting enabled.
FR99999999999FR422 VAT_NOT_FOUND.
IT00000000010ITEnriches Italian; auto-enables SDI reporting.
IT00000000099ITSDI returns 00306 (Codice Destinatario unknown).
DE000000001DEEnriches; no auto-compliance (Germany is voluntary).
NL000000001B01NLEnriches; auto-enables NL Peppol routing.
ES00000000CESEnriches; FACe (Spain public-sector) flag set.
Slow enrichment
Append ?simulateLatencyMs=2500 to POST /companies in sandbox to force a slow enrichment. Useful to test loading states.

Test Peppol IDs (recipient side)

Peppol IDBehavior
0208:TEST_OKDelivers in ~1s. Fires document.sent, document.delivered.
0208:TEST_OK_SLOWDelivers in ~30s. Lets you exercise polling UIs.
0208:TEST_AP_FAILRecipient AP rejects with AP_REJECTED. Fires document.failed after ~2s.
0208:TEST_AP_FLAKYFirst two attempts time out, third succeeds. Tests retry logic in your UI.
0208:TEST_TIMEOUTAll transport attempts time out → document.failed with TRANSPORT_FAILURE.
0208:TEST_REJECT_SCHEMARecipient rejects with a UBL schematron failure (BR-CO-15).
0208:TEST_REJECT_BUYER_REFRecipient requires buyerReference — rejects PPF code 00058.
0208:TEST_DUPLICATERecipient marks the document as duplicate (DUP).
0208:TEST_NOT_REGISTEREDSMP returns "not found" → 422 RECIPIENT_NOT_FOUND.
0208:TEST_CANNOT_RECEIVE_INVOICERegistered, 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 IDPersonaLifecycle path it drives on the receiver side
0208:SIM_HAPPYHappy pathdelivered → under_review → approved → paid over ~5 min.
0208:SIM_SLOW_PAYLate payerdelivered → approved immediately, then paid 60 days later (use time-travel to skip ahead).
0208:SIM_DISPUTEDisputes invoicesdelivered → under_review → disputed with reason QUA (quantity discrepancy).
0208:SIM_REJECTRejects on first reviewdelivered → rejected with reason PRI (price disagreement).
0208:SIM_PARTIALPays in installmentsapproved → 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
REGeneric rejection — PPF/SDI accept silently.
QUAQuantity discrepancy.
PRIPrice disagreement.
TAXTax mismatch — SDI flags for review.
DUPDuplicate — 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.

ValuePPF / 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.

EventHow to trigger
document.receivedSend to your own sandbox company from 0208:SIM_HAPPY.
document.sentSend anything to 0208:TEST_OK.
document.deliveredSend to 0208:TEST_OK; arrives ~1s later.
document.failedSend to 0208:TEST_AP_FAIL.
document.updatedPOST /documents/{id}/actions with {"action":"tag","tag":"x"}.
lifecycle.updatedPOST /documents/{id}/lifecycle with any allowed status.
company.smp_registeredCreate a company with VAT BE0000000001; arrives ~2s later.
compliance.reportedMark a French/Italian/Belgian doc as paid with simulateCompliance="accept".
compliance.reported.failedSame 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 valueResulting status / body
INVALID_REQUEST400
EXPIRED_TOKEN401
INSUFFICIENT_SCOPE403
RESOURCE_NOT_FOUND404
IDEMPOTENCY_BODY_MISMATCH409
VAT_NOT_FOUND422
RATE_LIMITED429 with Retry-After: 30
INTERNAL_ERROR500
UPSTREAM_UNAVAILABLE503

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.

Side-effect ordering
Time-travel fires every webhook that would have fired in the skipped interval, in chronological order. Don't skip a year unless you actually want a thousand events on your endpoint.

Reset & data lifetime

ResourceSandbox lifetimeReset
Companies, partners, webhooks, API keysPersistentDelete via API or dashboard.
Documents90 days from creationAuto-purged. Use POST /v1/sandbox/reset to wipe all docs immediately.
Events30 daysAuto-purged.
Idempotency cache24 hours (same as live)POST /v1/sandbox/idempotency/flush
Rate-limit counters60s 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