Sandbox Testing

The Invo Sandbox environment provides a safe testing space to integrate and validate your title's currency operations without affecting real player data or processing actual payments. Perfect for development, testing, and QA workflows.

API paths take the sandbox prefix. Page links do not.

Every API call in sandbox goes to https://sandbox.invo.network/sandbox/..., which is why the SDK base URL carries the /sandbox and why every sample on this site shows it. Leave it off and the call returns 404, which is indistinguishable from an endpoint that does not exist. That is the single most common sandbox mistake.

Links Invo hands you for a PERSON to open are different. Hosted checkout and hosted card setup are served at the host root, so they come back as https://sandbox.invo.network/checkout?session=... and https://sandbox.invo.network/card-setup?session=..., with no /sandbox in them. Always use the URL exactly as returned rather than assembling one from your base URL. Invo knows which environment minted it; a URL you build yourself does not.

Safe Testing Environment

Test all API operations without risk to production data or real financial transactions.

Separate Everything

A separate database, separate keys, separate game IDs. Nothing you do in sandbox can reach your production data.

Realistic Simulation

Full API functionality with simulated payments, SMS verification, and transfer operations.

One Click From Your Console

Open sandbox from the INVO console and your account is there, with a sandbox copy of each of your titles. No second sign-up.

Nearly Full Parity

Everything works the same way, with one exception worth planning around: cross-game transfers and subscriptions need a live title. See below.

Test Scenarios

Built-in error scenarios and edge cases to validate your error handling logic.

Sandbox Environment

The INVO Network sandbox environment uses a separate base URL and requires the /sandbox prefix for all API routes. Test with fake payments, SMS, and crypto without real money.

Production Environment

Base URL:
https://invo.network
API Routes: /api/* and /auth/*
Payments: Real-money transactions.

Sandbox Environment (Testing)

Base URL:
https://sandbox.invo.network
API Routes: /sandbox/api/*
Payments: Test cards only. No real money moves.
Sandbox API call
// Sandbox API call, same single-header auth as production
const SDK_KEY = process.env.INVO_SDK_KEY; // ivsdk_<sandbox_key>

const r = await fetch(
  'https://sandbox.invo.network/sandbox/api/player-balances/player/by-email/test@example.com',
  { headers: { 'X-Game-Secret-Key': SDK_KEY } }
);

console.log(await r.json());

Sandbox vs Production: what changes

  • Path prefix. Sandbox is /sandbox/api/...; production is /api/....
  • SDK key. Different key per environment. Sandbox keys do not work against production and vice versa.
  • Database. Fully isolated; sandbox players, balances, and transactions live in their own database.
  • Card processing. Sandbox accepts test cards only. No real money moves regardless of amount.
  • SMS verification. Sandbox sends through a test channel; verification codes are surfaced in the response and the developer console for QA convenience.
  • Game IDs and keys are different. A title in sandbox has its own game_id and its own keys, deliberately. Nothing is shared, so a value copied from one environment cannot work in the other, and an ID in a log line or a support ticket can only ever mean one environment.
  • Going live is self-serve in sandbox and reviewed in production. In sandbox you flip a title from Testing to Live yourself, and you need to, because a Testing title cannot receive a cross-game transfer or sell a subscription. In production, INVO reviews go-live requests.

Your account across the two environments

You have one INVO account. Sandbox is reached from it, and the two environments share nothing except who you are.

Opening sandbox

Click Open sandbox in the INVO console. Your sandbox account is created the first time, and you are signed in. There is no second password: the sandbox account is entered from your INVO console, so there is one credential to look after rather than two.

If you already made a sandbox account

Sign in to it as usual and link it to your INVO account from there. We deliberately do not join two accounts just because they share an email address: anyone can sign up for sandbox, so an email match on its own is not proof that the same person owns both.

Your titles

Each of your production titles gets a sandbox copy with the same name, artwork, fees and settings, and its own game ID and its own keys. Settings are copied once, when the copy is made, and the two then go their own way: changing a fee in production does not change it in sandbox, and neither does the reverse. Nothing you do in sandbox can affect a production title.

Plan for this one: a title starts in Testing, and a Testing title cannot be a cross-game transfer destination, cannot receive a currency send and cannot sell subscriptions. In sandbox, switch it to Live yourself before you test any of those. In production, send a go-live request from the title's settings and we will come back to you.

Test Data & Scenarios

The sandbox comes pre-loaded with test data to help you get started quickly:

Test Players

Create your own test players
Register test accounts in the sandbox console
Use sandbox console to manage test balances
Example Test Data
Email: your-test@example.com
Create players via API or console

Test Card Numbers

Success
4242 4242 4242 4242
Always succeeds.
Generic decline
4000 0000 0000 0002
Always declines.
Insufficient funds
4000 0000 0000 9995
Card-issuer-side decline for insufficient funds.
3D Secure required
4000 0025 0000 3155
Triggers strong customer authentication challenge.
Bank account linking (sandbox)
Test bank-link credentials are surfaced in the developer console under Sandbox > Test Data.

SMS Testing

Test SMS verification flows without sending real SMS messages:

SMS Testing Configuration
// SMS verification in sandbox uses test mode
// Use any phone number format for testing - no real SMS is sent

// Example: Initiate transfer with test phone number
{
  "from_player_email": "player@example.com",
  "to_player_email": "player@example.com",
  "from_game_secret": "your_game_secret",
  "to_game_secret": "target_game_secret",
  "amount": "50.00",
  "phone_number": "+1234567890"  // Any valid format works in sandbox
}

// Verification codes in sandbox are displayed in the API response
// or available in the sandbox console for testing

Subscription time travel

A monthly billing cycle cannot be validated by waiting a month. Sandbox therefore exposes four endpoints that push one subscription through the real renewal machinery on demand: the same funding, dunning and step-up code that runs on a live billing day, described in full on Renewals, Funding & Dunning. Nothing here is a mock: a forced renewal really runs the money path, against sandbox test cards, and produces the same webhooks your production integration will receive.

These endpoints do not exist in production

They are not merely disabled, permission-gated or hidden behind something we could switch on for you. The routes are never registered on the production service at all. Calling any of them against https://invo.network returns a plain 404, indistinguishable from any other unknown path, and there is no production credential that changes that.

So keep time travel inside your test harness. Never ship code that calls it, and never make a production code path conditional on it. In production the billing clock is ours and webhooks are how you learn it moved.

Two credentials, not one

Every call carries your usual sandbox X-Game-Secret-Key and a second header, X-Sandbox-Clock-Key. The second one exists because force-renewal genuinely charges a card: a leaked game secret should not also be a charge-on-demand button. Both are required on all four endpoints, and presenting one without the other is refused.

  • The clock key is per title, and you issue it yourself from the sandbox developer API: GET /sandbox/api/dev/games/{game_id}/sandbox-clock-key, documented immediately below. A console screen for it is on the way; until it ships, that endpoint is how you get one.
  • It is deliberately not retrievable with the game secret. The issuing endpoint authenticates with your developer dashboard session, never with X-Game-Secret-Key. If the game secret could fetch the clock key, the game secret would once again be sufficient on its own and the second factor would be decorative. That constraint is the entire reason this credential exists.
  • It is a sandbox credential only. There is no production counterpart, because there is no production endpoint to present it to.
  • Rotation has two modes and the difference matters. A routine rotation leaves the previous key working for a 7-day grace so a test run already in flight does not break; a leaked key needs the immediate mode, which revokes the old value on the spot. See the rotate endpoint below, and Partner Credentials & Key Handling.
  • Server-side only. Never in a browser bundle, never in a repository, never in chat.

Getting and rotating your clock key

Two endpoints on the sandbox developer API, both authenticated with the dashboard session token your console login issues, Authorization: Bearer <token>, the same credential the dashboard's own pages use. The game secret is not accepted here and never will be; you must be signed in as the developer account that owns the title. Both live only on the sandbox service: calling them on production returns 409 SANDBOX_ONLY and issues nothing, because a production clock key could never authenticate anything.

MethodPathPurpose
GET/sandbox/api/dev/games/{game_id}/sandbox-clock-keyReveal this title's clock key. The first call mints it. Keys are created only for titles that ask, so newly_issued is true exactly once. Repeat calls return the same value.
POST/sandbox/api/dev/games/{game_id}/sandbox-clock-key/rotateMint a new key. Body {"immediate": false} (the default) parks the old one for 7 days; {"immediate": true} revokes it now.
Issue a clock key, then use it
DEV=https://sandbox.invo.network/sandbox/api/dev
GAME_ID=1234

# Reveal (mints on the first call). Dashboard session token, NOT the game secret.
curl -s "$DEV/games/$GAME_ID/sandbox-clock-key" \
  -H "Authorization: Bearer $INVO_DASHBOARD_TOKEN"

# → { "success": true, "game_id": 1234,
#     "header": "X-Sandbox-Clock-Key",
#     "sandbox_clock_key": "ivclk_…",
#     "issued": true, "newly_issued": true,
#     "rotated_at": "2026-08-14T11:02:57+00:00",
#     "previous_key_active": false, "previous_key_valid_until": null,
#     "note": "Send this in the X-Sandbox-Clock-Key header IN ADDITION to your …" }

export INVO_SANDBOX_CLOCK_KEY=ivclk_…

A leaked key needs immediate: the default does not revoke it

The default rotation is a hygiene rotation: it mints a new key and keeps the old one valid for a 7-day grace window, so a test rig mid-run does not hard-break. That grace is exactly wrong for a key that has escaped. Rotating the ordinary way and assuming it is dead leaves a charge-on-demand credential live for a week.

# Routine: new key now, old key valid for 7 more days.
curl -X POST "$DEV/games/$GAME_ID/sandbox-clock-key/rotate" \
  -H "Authorization: Bearer $INVO_DASHBOARD_TOKEN"

# LEAKED KEY: revoke the old value immediately, no grace.
curl -X POST "$DEV/games/$GAME_ID/sandbox-clock-key/rotate" \
  -H "Authorization: Bearer $INVO_DASHBOARD_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"immediate": true}'

The rotate response returns the new key once and tells you which mode ran: immediate, previous_key_active and previous_key_valid_until. The previous key's value is never returned. If you have lost it, rotate. immediate must be a real boolean; a string is a 400 INVALID_IMMEDIATE, deliberately, so a quoted "false" cannot silently trigger the emergency path.

HTTPCodeMeaning
400INVALID_IMMEDIATERotate only. immediate was not a JSON boolean.
401noneNo valid dashboard session. Note this is a session failure: sending X-Game-Secret-Key here does not authenticate anything.
403noneYou are signed in, but this title is not yours, or it is not active. An inactive title is never minted for.
409SANDBOX_ONLYYou called production. Clock keys are issued only by the sandbox API, because the endpoints that consume them exist only there. Nothing was issued or changed.
500ISSUE_FAILED / ROTATE_FAILEDThe key could not be minted or rotated. Nothing changed; retry.

Watch the path. These four sit at /sandbox/subscriptions/…. There is no /api segment, unlike every other subscription call, which is /sandbox/api/subscriptions/…. A 404 on your first attempt is almost always this.

Advance a subscription one month, then bill it
BASE=https://sandbox.invo.network/sandbox/subscriptions
SUB=SUB_1786012800_A1B2C3D4

# 1. Pretend a month went by: the NEXT period becomes due.
curl -X POST "$BASE/$SUB/advance-clock" \
  -H "X-Game-Secret-Key: $INVO_SDK_KEY" \
  -H "X-Sandbox-Clock-Key: $INVO_SANDBOX_CLOCK_KEY" \
  -H "Content-Type: application/json" \
  -d '{"intervals": 1}'

# 2. Bill it now instead of waiting for the scheduled renewal.
curl -X POST "$BASE/$SUB/force-renewal" \
  -H "X-Game-Secret-Key: $INVO_SDK_KEY" \
  -H "X-Sandbox-Clock-Key: $INVO_SANDBOX_CLOCK_KEY"

# Repeat 1 and 2 to run the subscription across as many consecutive
# periods as you want to test.

The loop, and why it is a loop

advance-clock then force-renewal, repeated, is the intended pattern and it runs for as many consecutive periods as you like. Each pass bills a new, later period, and no period is ever billed twice. That is how you get a year of renewal history (trial conversion, a decline, a recovery, an expiry) into a few minutes of testing.

Calling force-renewal repeatedly on its own does not work, deliberately. Each success moves the subscription onto the next, not-yet-due period, and billing ahead is capped at one period; a further call without moving the clock is refused with PERIOD_TOO_FAR_AHEAD. Say a period went by, then charge it.

POST

/sandbox/subscriptions/{subscription_id}/advance-clock

Makes the next period due, right now. No money moves here. The charge happens on the next scheduled renewal, or immediately if you follow with force-renewal.

Request body (optional)

FieldTypeNotes
intervalsinteger0 to 60, default 1. 1 simulates one billing interval, one month on a monthly subscription. 0 means "become due now" and nothing else, which is how you trigger a first charge or an early dunning retry without touching the period window.

Response (200)

{
  "status": "ok",
  "action": "advance-clock",
  "intervals": 1,
  "intervals_applied_to_window": 0,
  "window_moved": false,
  "window_already_elapsed": false,
  "before": { ...clock fields before... },
  "after": {
    "subscription_id": "SUB_1786012800_A1B2C3D4",
    "status": "active",
    "period_seq": 3,
    "current_period_start": "2026-09-13T09:14:22+00:00",
    "current_period_end": "2026-10-13T09:14:22+00:00",
    "next_charge_at": "2026-08-14T11:02:57+00:00",
    "trial_end": null,
    "cancel_at_period_end": false,
    "wallet_only": false,
    "has_payment_method": true,
    "amount_usd": "9.99"
  },
  "note": "The subscription is now due..."
}

before and after carry the same eleven clock fields, so you can diff exactly what the call changed.

It arms the next period. It does not rewind onto a paid one

"A month went by" is expressed by making the subscription due, not by dragging its period window backwards. The window stays exactly where the last successful renewal left it (one interval after the period just paid) and the next renewal bills that one. This is what makes the advance-and-bill loop repeatable: nothing moves backwards, so nothing can be re-billed.

The window does still move across periods nobody has paid for, which is how you elapse a trial so it converts, or reproduce a long gap in billing. intervals_applied_to_window tells you how many of the intervals you asked for the window actually gave up. 0 is the ordinary answer once anything has been collected, and the advance is carried entirely by the due marker. window_already_elapsed: true means the current window is fully in the past, so the next renewal will record it as forgiven and bill the following one.

HTTPCodeMeaning
400INVALID_INTERVALSNot an integer, or outside 0 to 60.
409SUBSCRIPTION_NOT_LIVEAlready canceled or expired; there is no clock left to advance.
409WINDOW_ALREADY_PAIDThe subscription's window already sits behind what the member is paid through, so arming it would bill a period twice. Nothing is changed. Start from a fresh subscription. The response echoes paid_through and requested_period_start so you can see the overlap. Only checked when intervals is 1 or more: with intervals: 0 the window is not touched at all, so the same overlap is caught one step later, by the renewal, as NO_CHARGEABLE_PERIOD. Either way nothing is billed twice.
500INTERNAL_ERRORThe clock could not be advanced. Nothing was changed; retry.
POST

/sandbox/subscriptions/{subscription_id}/force-renewal

Runs the renewal now: balance first, card for the shortfall, mint, settle, split the fee, emit subscription.renewed. This one really charges a card, a sandbox test card, which is the point. No request body.

Response (200)

{
  "status": "ok",
  "action": "force-renewal",
  "period_seq": 3,
  "attempt_no": 1,
  "amount_usd": "9.99",
  "amount_coins": "99.90",
  "engine_stats": { "renewed": 1 },
  "subscription": { ...clock fields after the run... }
}

engine_stats is the renewal engine's own counters for this one run, so a failed renewal is as legible as a successful one. Exactly one outcome counter is set, and it is one of renewed, declined, insufficient, auth_required, containment, ambiguous, settle_failed, errors, abandoned (the subscription was cancelled or ended under the claim, so nothing was charged and no retry was consumed) or refused_over_ceiling (the price failed the per-renewal ceiling; held, not dunned). periods_forgiven may appear alongside the outcome when the run had elapsed windows to catch up on. Treat any counter you did not expect as a real outcome rather than switching on a fixed set of two or three.

Which outcome you get is decided by the card on the subscription. Pick from the test cards above to steer it, or use force-failure when you want a specific outcome regardless of card.

Refusals are the two shared sets below: opening a renewal and all four endpoints.

POST

/sandbox/subscriptions/{subscription_id}/force-failure

Synthesizes a failed renewal and runs the real dunning ladder: the retry budget arithmetic, the retry spacing, the move to past_due, the eventual expired, and the member-facing webhooks that go with each. No card is touched and no money moves, which makes this the endpoint to build your failure handling against.

Request body (optional)

FieldTypeNotes
outcomestringOne of card_declined (default), insufficient_funds, error. Anything else is a 400 INVALID_OUTCOME. For the step-up branch use force-auth-challenge.
failure_codestringFree text, truncated at 100 characters. Surfaces on subscription.payment_failed. Defaults to outcome.
failure_messagestringFree text, truncated at 2000 characters. The human-readable reason on the event.

Response (200)

{
  "status": "ok",
  "action": "force-failure",
  "period_seq": 3,
  "attempt_no": 2,
  "outcome": "card_declined",
  "failure_code": "card_declined",
  "retry_budget_used": 1,
  "retry_schedule_days": [2, 3, 2],
  "retries_remaining": 2,
  "containment_failure_code": "…",
  "subscription": { ...clock fields after the run... }
}

attempt_no and retry_budget_used are different numbers on purpose: not every failure consumes retry budget. Call this repeatedly to walk a subscription all the way to expired and verify your revocation path. containment_failure_code is echoed back so you can pass it as failure_code to reproduce the origin-containment branch: held rather than retried, exempt from the retry budget, and it never expires the member.

Refusals are the two shared sets below (this endpoint opens a real claim, so it can return every refusal in opening a renewal too), plus 400 INVALID_OUTCOME.

POST

/sandbox/subscriptions/{subscription_id}/force-auth-challenge

Forces the step-up authentication path, the behaviour that surprises every integrator. The subscription moves to awaiting_authentication, the dunning clock pauses, and no retry budget is consumed. No card is touched. Optional body: failure_message (string, truncated at 2000 characters).

Response (200)

{
  "status": "ok",
  "action": "force-auth-challenge",
  "period_seq": 3,
  "attempt_no": 2,
  "auth_challenges_used": 1,
  "max_auth_challenges": 2,
  "downgraded_to_decline": false,
  "retry_budget_used": 0,
  "subscription": { ...clock fields after the run... }
}

This call does not emit the step-up event, and returns no link

It drives the state machine only. The 200 carries no confirmation URL, and subscription.authentication_required does not fire from this request. The challenge object and its one-time link are minted by a separate pass that runs on the billing schedule, and only for a challenge that has a real payment behind it. A synthesized challenge has no payment attached, so no link is issued for it at all: an honest link is not something we can fabricate.

So use this endpoint to test the state half: the pause, the retained access, the untouched retry budget, your awaiting_authentication handling. To exercise the link and the event end to end, run a real force-renewal against the 3D Secure test card above. Do not go debugging your webhook configuration because nothing arrived after this call.

What the ceiling looks like when you hit it

Call this repeatedly and the engine stops honouring the challenge past max_auth_challenges. A card that challenges forever would otherwise loop free of the retry budget. The signal is in the numbers, not in a flag:

  • auth_challenges_used stops rising and stays pinned at max_auth_challenges.
  • retry_budget_used starts incrementing. It sits at 0 for every honoured challenge, because a challenge is not a payment judgement.
  • subscription.status moves from awaiting_authentication to past_due, then to expired once the budget is spent, with the ordinary subscription.payment_failed events along the way.

Do not assert on downgraded_to_decline. It is reported from a count taken after the downgrade has already rewritten the attempt, so in practice it stays false even on the calls that were downgraded. Assert on the status and on retry_budget_used instead. That ceiling is worth exercising. It is what stops a member sitting in awaiting_authentication forever.

Refusals are the two shared sets below (this endpoint opens a real claim, so it can return every refusal in opening a renewal too). AUTH_CHALLENGE_OPEN is the one to expect here: once a challenge object exists for the period, the next call is refused until it is completed or expires.

Refusals shared by the three that open a renewal

force-renewal, force-failure and force-auth-challenge all open a real claim (the same period record and attempt record the scheduled renewal opens) before they do anything else. So all three return every refusal below, not just the one that spends money. Write your handling once.

HTTPCodeMeaning
409PERIOD_TOO_FAR_AHEADYou are trying to open a period more than one interval ahead of now without moving the clock. Call advance-clock first. Carries period_start, max_period_start and max_future_intervals.
409ATTEMPT_IN_FLIGHTA previous attempt on this period has not resolved yet, so whether money moved is not yet known and a second attempt is not allowed. has_processor_payment_reference tells you whether a payment was already opened. Wait for it to settle.
409AUTH_CHALLENGE_OPENA step-up challenge is live for this subscription and the member still holds the link; opening another attempt would start a second payment. Complete it or wait for challenge_expires_at. This is the refusal you meet when you call force-auth-challenge again while a challenge is still open.
409AMOUNT_OVER_CEILINGThe price is above the absolute per-renewal ceiling. Refused, not clamped; no retry budget is spent.
409NO_CHARGEABLE_PERIODNo billable period can be derived: usually the current window overlaps one that was already invoiced, or the current cycle is already terminal.
409SUBSCRIPTION_NOT_LIVEAlready canceled or expired.
500CLAIM_FAILEDThe claim could not be opened. Nothing was charged.

A cancelled subscription is retired, not charged, and that is a 200

If cancel_at_period_end is set, all three return 200 with "action": "retired-at-period-end" and "retired": true. Nothing was billed, no attempt was recorded, and no failure was synthesized. That is the production behaviour at the cancellation boundary, reproduced. Branch on action, not on the status code.

Refusals shared by all four

HTTPCodeMeaning
401SANDBOX_CLOCK_UNAUTHORIZEDX-Sandbox-Clock-Key is missing or not valid for this title. Three cases collapse into this one response: header absent, key wrong, and no key has ever been issued for this title. That is deliberate, so the refusal cannot be used to enumerate which tenants have provisioned the second factor. There is consequently no "not configured" state to detect: if you have not issued a key yet, that is indistinguishable from a typo. Issue or re-reveal one (see above) before assuming anything else is wrong.
401noneX-Game-Secret-Key is missing or invalid. Reported separately from the clock key so a two-credential handshake is debuggable.
403noneThe title is not active.
404noneUnknown subscription_id in this title. Every lookup is tenant-scoped, so another partner's subscription reads as absent. A 404 on every call means you are on production, or missing the path note above.
429RATE_LIMITEDThese are hand-driven test tools, not a traffic surface. Roughly 10 calls per minute per subscription and 60 per minute per source address.
503FLOW_PAUSEDSubscription billing is paused platform-wide for maintenance. All four honour it, including advance-clock. Arming a charge during a pause would only fire it the moment the pause lifted. Retry shortly.

What time travel will not do for you

  • It cannot move a subscription's clock behind a period that has already been paid. That is refused with WINDOW_ALREADY_PAID, and the period window is never rewound across a paid boundary even when you ask for more intervals than are available. Billing one calendar period twice is not reachable from this surface. If you need a clean clock, create a new subscription.
  • It cannot un-bill anything. A forced renewal is a real renewal, with a real ledger entry and real webhooks. Refund it the ordinary way if you need it reversed (see Manage a Subscription).
  • It cannot be reached in production, with any credential, ever. See the top of this section.

Testing Checklist

Use this checklist to ensure comprehensive testing of your integration:

Basic Operations

Error Scenarios

Ready for Production?

Once you've thoroughly tested your integration in sandbox, switch to production:

  • Update base URL: https://invo.network
  • Remove /sandbox prefix: Use /api/* instead of /sandbox/api/*
  • Use production credentials: Get your production game_secret from console.invo.network
  • Environment detection: Implement logic to switch between environments
  • Test with real cards. Real money will be charged in production.
Environment Switcher
// Environment configuration
const INVO = {
  production: {
    baseURL: 'https://invo.network',
    apiRoute: '/api'
  },
  sandbox: {
    baseURL: 'https://sandbox.invo.network',
    apiRoute: '/sandbox/api'
  }
};

const env = process.env.NODE_ENV === 'production' ? 'production' : 'sandbox';
const cfg = INVO[env];
const SDK_KEY = process.env.INVO_SDK_KEY; // matching key for the env

// Read a player's balance
const url = `${cfg.baseURL}${cfg.apiRoute}/player-balances/player/by-email/${encodeURIComponent('test@example.com')}`;
const r = await fetch(url, { headers: { 'X-Game-Secret-Key': SDK_KEY } });