Sandbox recipe
Sandbox is a full copy of the platform at https://sandbox.invo.network/sandbox with its own console (https://dev.console.invo.network), its own keys, a test-mode card processor and, for Steam, an auto-approve stand-in. The renewal engine runs there every minute exactly as in production, so a subscription you create in sandbox renews on its own a month later; the clock tools below let you get there in minutes. The clients and raw HTTP helpers the samples use are defined on the overview, with INVO_BASE_URL=https://sandbox.invo.network/sandbox.
The clock tools are NOT under /api
# every ordinary subscription call: POST https://sandbox.invo.network/sandbox/api/subscriptions/subscribe GET https://sandbox.invo.network/sandbox/api/subscriptions/<id> # the four clock tools, and ONLY these, sit directly under the sandbox base: POST https://sandbox.invo.network/sandbox/subscriptions/<id>/advance-clock POST https://sandbox.invo.network/sandbox/subscriptions/<id>/force-renewal POST https://sandbox.invo.network/sandbox/subscriptions/<id>/force-failure POST https://sandbox.invo.network/sandbox/subscriptions/<id>/force-auth-challenge # WRONG (404, looks exactly like a missing feature): POST https://sandbox.invo.network/sandbox/api/subscriptions/<id>/advance-clock POST https://sandbox.invo.network/subscriptions/<id>/advance-clock
A 404 on your first clock call is almost always this. The tools do not exist in production at all (also a plain 404), with any credential, ever. The SDK clients put the path right for you; only raw HTTP callers can get it wrong.
1. The clock tools and their second credential
| Endpoint | What it does |
|---|---|
POST /subscriptions/<id>/advance-clock | Makes the next period due now. |
POST /subscriptions/<id>/force-renewal | Runs the renewal immediately. Charges the test card. |
POST /subscriptions/<id>/force-failure | Synthesises a failed attempt and runs real dunning. No charge. |
POST /subscriptions/<id>/force-auth-challenge | Synthesises a cardholder authentication challenge. No charge. |
Two headers on every call
Every call needs two headers: X-Game-Secret-Key (your sandbox game key) and X-Sandbox-Clock-Key, a separate per-game credential. Get it from the sandbox developer console: open the game, Game Settings, the “Sandbox clock key” card; the key (ivclk_...) is generated the first time you reveal it. The same card rotates it (7-day grace by default, or immediately for a leaked key). It is issued with your console session, never with the game key: a leaked game secret must not also be a charge-on-demand button.
- A missing, wrong or never-issued clock key is always
401 SANDBOX_CLOCK_UNAUTHORIZED; the message points you to the console. A missing or wrong game key gives the ordinary game-key error first. - Keep the clock key out of game clients and source control. It has no effect outside sandbox.
- Rate limit: 10 per minute per (game, subscription); 60 per minute per IP (
429 RATE_LIMITED). - Every clock response includes a
subscription(and where relevantbefore) block with the clock fields:status,period_seq,current_period_start,current_period_end,next_charge_at,trial_end,cancel_at_period_end,wallet_only,has_payment_method,amount_usd.
import { InvoServer } from "@invonetwork/web-sdk/server";
const invo = new InvoServer({
gameSecret: process.env.INVO_GAME_SECRET!, // the SANDBOX game key
baseUrl: "https://sandbox.invo.network/sandbox",
sandboxClockKey: process.env.INVO_SANDBOX_CLOCK_KEY, // ivclk_...; test harness only
});
// invo.sandbox.subscriptions.advanceClock(id, intervals)
// invo.sandbox.subscriptions.forceRenewal(id)
// invo.sandbox.subscriptions.forceFailure(id, outcome)
// invo.sandbox.subscriptions.forceAuthChallenge(id)const SANDBOX = "https://sandbox.invo.network/sandbox";
async function clock(id, tool, body) {
const res = await fetch(SANDBOX + "/subscriptions/" + id + "/" + tool, { // no /api
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Game-Secret-Key": process.env.INVO_GAME_SECRET,
"X-Sandbox-Clock-Key": process.env.INVO_SANDBOX_CLOCK_KEY,
},
body: body === undefined ? undefined : JSON.stringify(body),
});
return { status: res.status, json: await res.json().catch(() => ({})) };
}import os
from invonetwork import InvoServer
invo = InvoServer(
game_secret=os.environ["INVO_GAME_SECRET"], # the SANDBOX game key
base_url="https://sandbox.invo.network/sandbox",
sandbox_clock_key=os.environ["INVO_SANDBOX_CLOCK_KEY"], # ivclk_...; test harness only
)
# invo.sandbox.subscriptions.advance_clock(id, intervals)
# invo.sandbox.subscriptions.force_renewal(id)
# invo.sandbox.subscriptions.force_failure(id, outcome)
# invo.sandbox.subscriptions.force_auth_challenge(id)import os, requests
SANDBOX = "https://sandbox.invo.network/sandbox"
def clock(sub_id, tool, body=None):
r = requests.post(f"{SANDBOX}/subscriptions/{sub_id}/{tool}", # no /api
headers={"Content-Type": "application/json",
"X-Game-Secret-Key": os.environ["INVO_GAME_SECRET"],
"X-Sandbox-Clock-Key": os.environ["INVO_SANDBOX_CLOCK_KEY"]},
json=body, timeout=60)
try:
return r.status_code, r.json()
except ValueError:
return r.status_code, {}2. advance-clock
Body: {"intervals": 1} (0 to 60, default 1; 400 INVALID_INTERVALS).
intervals: 0makes the subscription due now without moving its window. Use it to trigger a retry immediately.intervals: Nmakes it due now and, on a subscription that has never been paid (a trial, or a fresh row), moves the window back by up to N intervals so elapsed windows are forgiven. Once anything has been paid the window stays put (intervals_applied_to_window: 0); the due marker alone is the advance, and the next renewal bills the next window. No window is ever billed twice.
200
{"status": "ok", "action": "advance-clock", "intervals": 1, "intervals_applied_to_window": 0,
"window_moved": false, "before": {...}, "after": {...}, "window_already_elapsed": false,
"note": "The subscription is now due. ..."}409 SUBSCRIPTION_NOT_LIVE on a terminal or pending row; 409 WINDOW_ALREADY_PAID if the window is somehow behind the paid boundary (create a fresh subscription).
curl -sS -X POST "https://sandbox.invo.network/sandbox/subscriptions/$SUB/advance-clock" \
-H "X-Game-Secret-Key: $GAME_SECRET" \
-H "X-Sandbox-Clock-Key: $INVO_SANDBOX_CLOCK_KEY" \
-H "Content-Type: application/json" \
-d '{"intervals": 1}'const adv = await invo.sandbox.subscriptions.advanceClock(subId, 1); // 0 = due now, window untouched
// adv.after.nextChargeAt is now in the past; the next engine run (or forceRenewal) bills itconst adv = await clock(subId, "advance-clock", { intervals: 1 });adv = invo.sandbox.subscriptions.advance_clock(sub_id, 1) # 0 = due now, window untouchedstatus, adv = clock(sub_id, "advance-clock", {"intervals": 1}) 3. force-renewal
No body. Runs the real money path: wallet read, shortfall, card charge (or the Steam stand-in), mint, spend, split, subscription.renewed, and on failure the real dunning.
200
{"status": "ok", "action": "force-renewal", "period_seq": 2, "attempt_no": 1,
"amount_usd": "9.99", "amount_coins": "99.90",
"engine_stats": {"renewed": 1},
"subscription": {...}}engine_stats counters you may see: renewed, declined, insufficient, auth_required, ambiguous, containment, settle_failed, errors, abandoned, refused_over_ceiling; on Steam also steam_charged, steam_pending, steam_refused, steam_failed, steam_canceled_by_steam, sandbox_synthetic.
| HTTP | Code or body | Meaning |
|---|---|---|
| 200 | action: "retired-at-period-end", retired: true | The subscription was cancelled at period end; nothing billed. This is what production does too. |
| 409 | SUBSCRIPTION_NOT_LIVE | Terminal or pending row (with a hint to call /steam/finalize on a pending Steam row). |
| 409 | ATTEMPT_IN_FLIGHT | An unresolved attempt; wait about 30 minutes for Invo to resolve it. |
| 409 | AUTH_CHALLENGE_OPEN | Complete or let the challenge expire first. |
| 409 | AMOUNT_OVER_CEILING | Price above 500.00 USD; never charged. |
| 409 | PERIOD_TOO_FAR_AHEAD | You forced a renewal twice without advancing the clock; call advance-clock. |
| 409 | NO_CHARGEABLE_PERIOD | Nothing due. Advance the clock. |
| 500 | CLAIM_FAILED | Retry. |
| 503 | FLOW_PAUSED | Billing paused for maintenance; retry shortly. |
curl -sS -X POST "https://sandbox.invo.network/sandbox/subscriptions/$SUB/force-renewal" \
-H "X-Game-Secret-Key: $GAME_SECRET" \
-H "X-Sandbox-Clock-Key: $INVO_SANDBOX_CLOCK_KEY"const run = await invo.sandbox.subscriptions.forceRenewal(subId);
// run.engineStats.renewed === 1 on success; run.periodSeq; run.subscription.statusconst run = await clock(subId, "force-renewal");
// run.json.engine_stats, run.json.period_seq; 409 PERIOD_TOO_FAR_AHEAD means: advance the clock firstrun = invo.sandbox.subscriptions.force_renewal(sub_id)
# run.engine_stats.renewed == 1 on successstatus, run = clock(sub_id, "force-renewal")
# run["engine_stats"], run["period_seq"]; 409 PERIOD_TOO_FAR_AHEAD means: advance the clock first 4. force-failure and force-auth-challenge
force-failure body: {"outcome": "card_declined" | "insufficient_funds" | "error", "failure_code": "<up to 100 chars>", "failure_message": "<text>"} (outcome defaults to card_declined; 400 INVALID_OUTCOME). Returns retry_budget_used, retry_schedule_days (for example [2, 3, 2]), retries_remaining and the subscription. Call it four times to walk a subscription from active through past_due to expired and watch the events.
force-auth-challenge body: {"failure_message": "<text>"} (optional). Moves the subscription to awaiting_authentication and returns auth_challenges_used, max_auth_challenges (2) and downgraded_to_decline. It does not mint a confirmation link; use a real challenge (a test card that requires authentication, then force-renewal) to exercise the link and the subscription.authentication_required event. 409 AUTH_CHALLENGE_OPEN if one is already open.
curl -sS -X POST "https://sandbox.invo.network/sandbox/subscriptions/$SUB/force-failure" \
-H "X-Game-Secret-Key: $GAME_SECRET" \
-H "X-Sandbox-Clock-Key: $INVO_SANDBOX_CLOCK_KEY" \
-H "Content-Type: application/json" \
-d '{"outcome": "card_declined"}'
curl -sS -X POST "https://sandbox.invo.network/sandbox/subscriptions/$SUB/force-auth-challenge" \
-H "X-Game-Secret-Key: $GAME_SECRET" \
-H "X-Sandbox-Clock-Key: $INVO_SANDBOX_CLOCK_KEY"for (let i = 0; i < 4; i++) {
const f = await invo.sandbox.subscriptions.forceFailure(subId, "card_declined");
console.log(f.subscription.status, f.retriesRemaining); // past_due 3, past_due 2, past_due 1, expired 0
}
const c = await invo.sandbox.subscriptions.forceAuthChallenge(freshSubId);
// c.subscription.status === "awaiting_authentication"for (let i = 0; i < 4; i++) {
const { json } = await clock(subId, "force-failure", { outcome: "card_declined" });
console.log(json.subscription.status, json.retries_remaining);
}
const { json: c } = await clock(freshSubId, "force-auth-challenge", {});for _ in range(4):
f = invo.sandbox.subscriptions.force_failure(sub_id, "card_declined")
print(f.subscription.status, f.retries_remaining) # past_due 3, past_due 2, past_due 1, expired 0
c = invo.sandbox.subscriptions.force_auth_challenge(fresh_sub_id)
# c.subscription.status == "awaiting_authentication"for _ in range(4):
_, f = clock(sub_id, "force-failure", {"outcome": "card_declined"})
print(f["subscription"]["status"], f["retries_remaining"])
_, c = clock(fresh_sub_id, "force-auth-challenge", {})5. Card road recipe
- Make the sandbox game
livein the console and register a webhook URL (Webhooks); fire the test event. - Create the player if needed (a purchase, or any endpoint that creates players), then save a card with
POST /api/currency-purchases/setup-intentusingpayment_method_id: "pm_card_visa"(immediate success) orpm_card_threeDSecure2Required(returnsrequires_action; complete it on the client and call/setup-intent/confirm). Expectcard.id. POST /api/subscriptions/subscribewith thatplayer_card_id. Expect201,first_charge.status: "paid",first_charge.paid_period_seq: 1,subscription.period_seq: 2,paid_throughset, and asubscription.reneweddelivery withperiod_seq: 1.- Replay the same body. Expect
200,idempotent_replay: true, the samefirst_charge. advance-clockthenforce-renewal. Expectengine_stats.renewed: 1,period_seq: 3on the subscription,subscription.renewedwithperiod_seq: 2. Repeat the pair for as many periods as you like.force-failurefour times (or replace the card withpm_card_chargeDeclinedthrough/payment-method, thenadvance-clockandforce-renewalfour times). Expectpast_dueafter the first,payment_failedon each,past_dueonce,expiredwithfinal_period_endon the fourth.- On a fresh subscription,
force-auth-challenge. Expectawaiting_authentication; thenadvance-clockwithintervals: 0andforce-renewalto see the challenge lapse into ordinary dunning. POST .../amountwith a new price,advance-clock,force-renewal. Expect the old price on that renewal and the new price on the next.POST .../cancelwithat_period_end: true, thenadvance-clockandforce-renewal. Expectaction: "retired-at-period-end"and statuscanceled.POST .../refundon a paid period. Expect a receipt andsubscription.refunded.- Create a subscription with
trial_days: 7. Expectskipped_trial;advance-clockwithintervals: 1thenforce-renewal. Expectperiod_seqto move past the trial window and a paid period 2.
const created = await invo.subscriptions.create({
clientRequestId: "sandbox-run-" + Date.now(),
playerEmail: "member@example.com", playerName: "Member Seven",
itemId: "guild-42-membership", amountUsd: "9.99", playerCardId: cardId,
});
const id = created.subscription.subscriptionId;
console.log(created.firstCharge.status, created.firstCharge.paidPeriodSeq); // paid 1
for (let i = 0; i < 3; i++) {
await invo.sandbox.subscriptions.advanceClock(id, 1);
const run = await invo.sandbox.subscriptions.forceRenewal(id);
console.log(run.periodSeq, run.engineStats); // 2 {renewed:1}, 3 ..., 4 ...
}
for (let i = 0; i < 4; i++) {
const f = await invo.sandbox.subscriptions.forceFailure(id, "card_declined");
console.log(f.subscription.status, f.retriesRemaining);
}
// expect: subscription.expired delivered with final_period_end = the last paid boundarycreated = invo.subscriptions.create(
client_request_id=f"sandbox-run-{int(time.time())}",
player_email="member@example.com", player_name="Member Seven",
item_id="guild-42-membership", amount_usd="9.99", player_card_id=card_id,
)
sub_id = created.subscription.subscription_id
print(created.first_charge.status, created.first_charge.paid_period_seq) # paid 1
for _ in range(3):
invo.sandbox.subscriptions.advance_clock(sub_id, 1)
run = invo.sandbox.subscriptions.force_renewal(sub_id)
print(run.period_seq, run.engine_stats) # 2 {renewed:1}, 3 ..., 4 ...
for _ in range(4):
f = invo.sandbox.subscriptions.force_failure(sub_id, "card_declined")
print(f.subscription.status, f.retries_remaining)
# expect: subscription.expired delivered with final_period_end = the last paid boundary6. Steam road recipe
In sandbox, POST /steam/finalize does not call Steam: it auto-approves, seeds a stand-in agreement and runs the real settlement, and a later force-renewal treats the Steam charge as captured without calling Steam. Sandbox therefore proves Invo’s currency path (coins, split, events, dunning), not Valve’s charge. The response carries sandbox_auto_approved: true so you can tell.
- Have the title enabled for Steam in the sandbox console (Steam app id and publisher key, verified, in sandbox mode; billing set up). Use the primary key or the
steamchannel key. POST /api/subscriptions/steam/initwith a real Steam id. Expect201 pending_steam_authorization,amount_coinsfor the member’s country,steam_order_idandsteam_transid. Call it again with the same terms: expect the same row back withpending_reuse: true.POST /api/subscriptions/steam/finalize. Expect200,first_charge.status: "paid",first_charge.paid_period_seq: 1,subscription.period_seq: 2,funding_rail: "steam",steam_agreement_status: "active",sandbox_auto_approved: true, andsubscription.renewedwithperiod_seq: 1,funding.rail: "steam"andfunding.steam_charged_usdequal to the price.advance-clockthenforce-renewal. Expectengine_stats.steam_charged: 1andsandbox_synthetic: 1, andsubscription.renewedwithfunding.steam_charged_usdfor the shortfall only.- Credit the member’s wallet above the price (a sandbox currency purchase),
advance-clock,force-renewal. Expectsteam_charged_usd: "0.00". POST .../amountwith a higher price:409 STEAM_REAUTHORIZATION_REQUIRED. With a lower price: staged.POST .../payment-method:409 NOT_A_CARD_SUBSCRIPTION.POST .../refundon the Steam-charged period 1:409 STEAM_REFUND_NOT_SUPPORTED. On the wallet-covered period from step 5: a receipt withfunding_shape: "wallet".POST .../cancelin both modes. Expectsteam_agreement_status: "canceled"on the response.force-failuredoes not model a Steam refusal; use it only to exercise your dunning handlers. A pending row you never finalize expires after 24 hours withsubscription.expiredandfailure_code: "steam_authorization_abandoned".
const init = await invo.subscriptions.steamInit({
clientRequestId: "sandbox-steam-" + Date.now(),
playerEmail: "member@example.com", playerName: "Member Seven",
itemId: "guild-42-membership", amountUsd: "9.99",
steamid: "76561198000000000", usersession: "client",
});
console.log(init.amountCoins, init.steamOrderId); // "69" for a US member
const done = await invo.subscriptions.steamFinalize(init.subscriptionId);
console.log(done.firstCharge.paidPeriodSeq, done.subscription.periodSeq, done.sandboxAutoApproved); // 1 2 true
await invo.sandbox.subscriptions.advanceClock(init.subscriptionId, 1);
const run = await invo.sandbox.subscriptions.forceRenewal(init.subscriptionId);
console.log(run.engineStats); // { steam_charged: 1, sandbox_synthetic: 1 }init = invo.subscriptions.steam_init(
client_request_id=f"sandbox-steam-{int(time.time())}",
player_email="member@example.com", player_name="Member Seven",
item_id="guild-42-membership", amount_usd="9.99",
steamid="76561198000000000", usersession="client",
)
print(init.amount_coins, init.steam_order_id) # "69" for a US member
done = invo.subscriptions.steam_finalize(init.subscription_id)
print(done.first_charge.paid_period_seq, done.subscription.period_seq, done.sandbox_auto_approved) # 1 2 True
invo.sandbox.subscriptions.advance_clock(init.subscription_id, 1)
run = invo.sandbox.subscriptions.force_renewal(init.subscription_id)
print(run.engine_stats) # {steam_charged: 1, sandbox_synthetic: 1}Only a real Steam sandbox authorisation (a real Steam account approving in the overlay or at the web checkout URL, against your title’s Steam sandbox interface) exercises Valve’s side. Do that at least once before launch.
What the clock tools will not do
- Move a subscription’s clock behind a period that has already been paid (
WINDOW_ALREADY_PAID). Billing one calendar period twice is not reachable from this surface. If you need a clean clock, create a new subscription. - Un-bill anything. A forced renewal is a real renewal, with a real ledger entry and real webhooks. Refund it the ordinary way (Refunds).
- Exist in production, with any credential, ever. Keep them inside your test harness; never make a production code path conditional on them.