---
title: "Sandbox"
description: "Every test scenario for Flowie Exchange sandbox: test VATs, test Peppol IDs, error triggers, lifecycle paths, webhook simulators."
canonical: "https://docs.get-flowie.com/sandbox/"
source: "https://docs.get-flowie.com/sandbox/index.html"
---

# Sandbox

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

Environment| Base URL| Key prefix  
---|---|---  
Sandbox| `https://back.flowie.ink/exchange`| `flw_test_…` · `flw_plat_test_…` · `flw_wl_test_…`  
Production| `https://back.p2p-flowie.com/exchange`| `flw_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): 
[code]
    curl -X POST https://back.flowie.ink/exchange/v1/sandbox/bootstrap \
      -H "Content-Type: application/json" \
      -d '{"label":"my-laptop"}'
[/code]

You get back the full `apiKey` (shown _once_), a starter sandbox company, and a 7-day expiry. Or click [**"Get a test API key"**](<../index.html#get-test-key>) on the landing page — same endpoint, the result auto-loads into the [Playground](<../playground/index.html>). 

### 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/*`:
[code] 
    curl -X POST https://back.flowie.ink/exchange/v1/sandbox/bootstrap \
      -H "Content-Type: application/json" \
      -d '{"label":"my-platform","keyType":"platform"}'
[/code]

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:
[code] 
    curl -X POST https://back.flowie.ink/exchange/v1/api-keys \
      -H "Authorization: Bearer $FLOWIE_DASHBOARD_JWT" \
      -d '{"name":"local-dev","scopes":["*"]}'
[/code]

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 — except a Factur-X or CII invoice sent with `format=auto`, which is read into a structured document as in production.  
`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](<../reference/index.html#create-company>) or [/companies/resolve](<../reference/index.html#resolve-company>) 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.  
  
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 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:
[code] 
    # 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"
      }'
[/code]

The compliance hooks fire normally — see [compliance simulators](<#test-compliance>) 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`](<../reference/index.html#readiness>).  
`"flaky_50pct"`| 50% probability of acceptance per attempt.
[code] 
    # Set the simulator on a sandbox company
    curl -X PATCH …/v1/companies/{company_id} \
      -H "Authorization: Bearer $TEST_KEY" \
      -d '{"metadata": {"simulateCompliance": "reject_00058"}}'
[/code]  
  
## 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:
[code] 
    curl -X POST …/v1/events/{event_id}/replay \
      -H "Authorization: Bearer $TEST_KEY"
[/code]

Need fixture payloads to seed your tests without hitting the API? See [webhook fixtures](<../fixtures/index.html>).

## Forcing specific errors

Pass `X-Sandbox-Force-Error` on any request to make the API return that error code:
[code] 
    curl …/v1/companies \
      -H "Authorization: Bearer $TEST_KEY" \
      -H "X-Sandbox-Force-Error: UPSTREAM_UNAVAILABLE"
[/code]

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:
[code] 
    curl -X POST …/v1/sandbox/rate-limit/exhaust \
      -H "Authorization: Bearer $TEST_KEY" \
      -d '{"durationSeconds": 60}'
[/code]

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):
[code] 
    curl -X POST …/v1/sandbox/clock/advance \
      -H "Authorization: Bearer $TEST_KEY" \
      -d '{"companyId": "comp_…", "by": "60d"}'
[/code]

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

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)| —
[code] 
    # Nuke EVERYTHING in your sandbox tenant
    curl -X POST …/v1/sandbox/reset \
      -H "Authorization: Bearer $TEST_KEY" \
      -d '{"confirm": "yes"}'
[/code]  
  
## Local webhook tunnels

To receive webhooks while running your handler on `localhost`, use any tunnel:
[code] 
    ngrok http 3000
    # OR
    cloudflared tunnel --url http://localhost:3000
[/code]

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:
[code] 
    #!/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
[/code]

The same script in [Python · Node · Go on GitHub](<https://gist.github.com/flowie-fr/exchange-sandbox-bootstrap>).

## Gotchas

  * **Sandbox keys never reach production.** If you accidentally point a `flw_test_…` key at `https://back.flowie.ink`, you get `401 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."
