Fleeexdocs

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 answer

A 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 errorsIdentical.
Which models you may callIdentical. 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 bodiesIdentical, and drivable on demand (below).
Rate limitsIdentical. A sandbox key counts in your app's own bucket, so a free credential is not a way around the limit.
Concurrent-stream slotsNamespaced separately, so a sandbox stream can never consume a paying identity's slots.
The completion textA fixed canned answer. No provider is called, no model quota is consumed.
The moneyA fake balance per test user, funded with a grant on the first call.
topupUrl in a 402A token-less placeholder, see below.
WebhooksNone. Sandbox touches no real wallet, so there is nothing to notify about.
Your usage metrics, invoices and spend reportsSandbox 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:

drive the refusals
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 appId and same userId on 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 wallet 402s 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() and getUsageSummary() are live-only. They resolve the real (app, user) mapping, so with a test key they answer the 402 onboarding contract and an empty summary rather than the fake balance. Read the sandbox balance from GET /apps/<appId>/sandbox instead.

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 402 stops 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.