The two key kinds
Every account has two API keys side-by-side on the dashboard. They share the same routes but behave differently:
pk_live_… | pk_test_… | |
|---|---|---|
| Randomness | Cryptographic, single-use | Deterministic per (account, clientSeed) |
| Live seed state | Mutates — cursor / nonce advance | Untouched — no rotation, no seed_state row |
| Daily quota | Counted | Not counted |
| Webhooks | Fire on every outcome | Suppressed |
Outcome mode field | "live" | "test" |
| Per-minute rate limit | Anonymous IP limit applies | Anonymous IP limit applies |
Test keys still hit real routes, real validation, real error shapes — they just don't move the world forward. That's the entire point.
What "deterministic per (account, clientSeed)" means
Two test-mode calls with the same clientSeed and same parameters will always return the same numbers — regardless of when, from where, or with which of your account's test keys you call. That makes them safe to use as fixtures:
# Run twice. Same numbers every time.
curl "https://api.provable.io/api/ints?clientSeed=ci-fixture-loot&count=3&min=1&max=100" \
-H "x-api-key: $PROVABLE_TEST_KEY"
curl "https://api.provable.io/api/ints?clientSeed=ci-fixture-loot&count=3&min=1&max=100" \
-H "x-api-key: $PROVABLE_TEST_KEY"
Change any input — the clientSeed, count, min, max, the items array, the dice notation — and you get a different deterministic outcome. The function is pure; it just isn't random.
Pattern: snapshot tests in CI
Because the outputs are stable, you can snapshot-test code that consumes Provable.io without mocking it out. Your test does a real round-trip — same routes, same JSON shapes, same error handling — but against a frozen output set.
// jest example
test("loot drop for fixture user is consistent", async () => {
const res = await fetch(
"https://api.provable.io/api/pick?clientSeed=test-user-1&items=common,rare,legendary&weights=70,25,5",
{ headers: { "x-api-key": process.env.PROVABLE_TEST_KEY } },
);
const body = await res.json();
expect(body.mode).toBe("test");
expect(body.outcome).toMatchSnapshot(); // stable across runs
expect(body.index).toMatchSnapshot();
});
Pattern: local dev that doesn't poison prod state
If you've been generating outcomes for clientSeed=order-123 in production, you don't want a sleepy npm run dev session to advance that seed's cursor twenty more times overnight. Point your dev environment at the test key and your live seed state stays frozen — every test-mode call short-circuits the seed write path entirely.
# .env.development
PROVABLE_API_KEY=pk_test_a1b2c3...
# .env.production
PROVABLE_API_KEY=pk_live_x9y8z7...
Your application code is identical. Only the env var changes.
Detecting test outcomes in your own code
Every test-mode response includes "mode": "test" in the JSON body — and so does every test-mode outcome on the streaming endpoint. Webhooks won't fire for test calls (so your downstream consumers never see test data), but your own client code can guard against accidentally treating a test outcome as live:
const body = await res.json();
if (body.mode === "test" && process.env.NODE_ENV === "production") {
throw new Error("refusing to use a test-mode outcome in production");
}
What test mode does not change
- Per-key allowlists. If you set IP or Referer restrictions on a test key, they still apply. See Locking down an API key.
- Validation. Test-mode calls return the same
400errors as live calls for bad parameters — that's exactly why CI snapshots are useful. - Rate limiting. Test calls still hit the anonymous per-minute IP rate limit. CI runners that do thousands of calls in a tight loop should add a small sleep.