Sandbox
A test key runs the whole pipeline over a fake balance and calls no model provider, so you can integrate and reproduce every refusal without paying.
Every app can hold a test key next to its live one. A sandbox key runs the whole Fleeex pipeline, the same wire, the same validation, the same refusals, but it calls no model provider and spends a fake balance instead of your end user's wallet.
Nothing it does is charged, and nothing it does appears in your usage metrics or in anyone's invoice.
Mint a test key
A sandbox key comes from the ordinary rotation route with mode: "sandbox", under the
same ownership rules as a live key, from the account that registered the app:
curl -X POST https://api.fleeex.dev/apps/<appId>/keys \
-H 'authorization: Bearer <your fleeex session token>' \
-H 'content-type: application/json' \
-d '{"mode":"sandbox"}'
# → { "appId": "…", "keyId": "…", "apiKey": "flx_test_…", "mode": "sandbox" }Then point the client at it. Nothing else changes:
const client = new FleeexClient({
apiKey: process.env.FLEEEX_TEST_KEY!, // flx_test_…, a sandbox credential
userId: "test-user-1",
});
const completion = await client.chat.completions.create({
model: "nova-lite",
messages: [{ role: "user", content: "Hello!" }],
});
// → a canned Fleeex response, not a model answerA test key is visibly one (flx_test_ prefix) and cannot be promoted. No gesture re-modes
a key, so a leaked test key buys nothing.
A completion served in sandbox also carries an x-fleeex-mode: sandbox response header,
including on a sandbox 402, so you can tell the two worlds apart from the answer alone.
It's a header rather than a body field because the body stays iso-OpenAI and a generated
client may fail on an unknown property. Only the completions route sets it; the billing
helpers don't, for the reason in the gaps below.
A test user's fake wallet is funded automatically on its first call, so there is no setup step before the snippet above works.
What is the same, and what is not
| Sandbox | |
|---|---|
| The wire, the parameter surface, validation errors | Identical. |
| Which models you may call | Identical. A reserved model is still a 403 MODEL_NOT_ENTITLED. Licence entitlement is not a money question, and the point is to meet in testing what production would do. |
402 codes and bodies | Identical, and drivable on demand (below). |
| Rate limits | Identical. A sandbox key counts in your app's own bucket, so a free credential is not a way around the limit. |
| Concurrent-stream slots | Namespaced separately, so a sandbox stream can never consume a paying identity's slots. |
| The completion text | A fixed canned answer. No provider is called, no model quota is consumed. |
| The money | A fake balance per test user, funded with a grant on the first call. |
topupUrl in a 402 | A token-less placeholder, see below. |
| Webhooks | None. Sandbox touches no real wallet, so there is nothing to notify about. |
| Your usage metrics, invoices and spend reports | Sandbox traffic never appears in them. |
Drive the flows you have to handle
The hardest paths to test are the ones that need you to actually run out of money. In sandbox you set the state instead, from the control plane:
BASE=https://api.fleeex.dev/apps/<appId>/sandbox
AUTH='authorization: Bearer <your fleeex session token>'
# Force "insufficient funds": the next call answers 402 PAYMENT_REQUIRED with its link.
curl -X POST "$BASE/users/test-user-1/reset" -H "$AUTH" \
-H 'content-type: application/json' -d '{"balanceMicros":0}'
# Force the monthly spend cap: the next call answers 402 APP_SPEND_CAP_EXCEEDED.
curl -X POST "$BASE/users/test-user-1/reset" -H "$AUTH" \
-H 'content-type: application/json' -d '{"spendCapMicros":1}'
# Force a suspended wallet: 402 WALLET_SUSPENDED, and no top-up link at all.
curl -X POST "$BASE/users/test-user-1/reset" -H "$AUTH" \
-H 'content-type: application/json' -d '{"suspended":true}'
# Put the grant back and carry on.
curl -X POST "$BASE/users/test-user-1/reset" -H "$AUTH"
# See where every test user stands (fake balance, reservations, requests, tokens).
curl "$BASE" -H "$AUTH"A reset also clears reservations, counters and any suspension, so each scenario starts
from a state you named. WALLET_SUSPENDED is the one refusal a real integration could
otherwise only reach through an actual chargeback. See the
error reference for what each code means.
Note the asymmetry, and it's deliberate: a sandbox API key cannot refill itself. Funding is an owner-gated control-plane gesture on the app you own, because otherwise "insufficient funds" would be a state your integration could never be made to face.
A sandbox 402 cannot take a payment
The 402 body keeps its documented shape, so the integration under test handles it
exactly as it will in production. But its topupUrl carries no token: it is a sandbox
placeholder.
That is on purpose. A test refusal must not be able to open the real onboarding page, one hop away from a real Stripe Checkout. So the redirect is exercisable and the payment is not reachable.
Sandbox traffic cannot leak into production data
This isn't a filter someone has to remember. It's a structural separation:
- A sandbox request writes no usage event, no ledger line and nothing in the wallets store, so no rollup, invoice, reconciliation, fiscal export or revenue metric has a source for it.
- The fake money lives in a different table from real money, in a partition namespace of its own, so the two cannot mix in one balance or one aggregate.
- Same
appIdand sameuserIdon both credentials is therefore safe, which is what lets one app hold both. A test key does not move a funded real wallet for the same(app, user)pair, and a live key on an unfunded wallet402s rather than spending the sandbox balance sitting next to it.
Test wallets are disposable: an abandoned one expires, and the next call re-provisions a fresh grant.
The two gaps to know about
⚠
getBalance()andgetUsageSummary()are live-only. They resolve the real(app, user)mapping, so with a test key they answer the402onboarding contract and an empty summary rather than the fake balance. Read the sandbox balance fromGET /apps/<appId>/sandboxinstead.
The grant is the budget. A sandbox call reaches no provider, so it costs nothing to serve. The grant (€5 by default) bounds how many calls one test user can make before the ordinary
402stops them. Reset to carry on.
Which key is which
A sandbox key is a credential of the app, so keep it in a separate environment variable and choose per environment rather than branching in code:
export const client = new FleeexClient({
apiKey: process.env.FLEEEX_API_KEY!, // flx_test_… in staging, flx_… in production
userId,
});GET /apps/<appId>/keys reports each key's mode, so you never have to parse a key to
know what it does.