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 secret key) and X-Sandbox-Clock-Key, a separate per-title credential. Get it from the sandbox developer console: open the title, 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 secret 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 secret key gives the ordinary key error first. - Keep the clock key out of client applications 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: on a card subscription the full-price charge to the test card; on Steam the wallet read, the shortfall and the Steam stand-in; then mint, spend, split, subscription.renewed(plus purchase.completed for a card renewal’s mint leg, which you must not grant from), 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 the 25000.00 USD per-charge ceiling; 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
Test cards
Sandbox runs the card processor in test mode. On the Invo-hosted card page the member types a card number; on /setup-intent and /payment-method you send a test token instead. Any future expiry and any three-digit code work with the numbers below.
| On the hosted card page | As a payment_method_id token | Behaviour |
|---|---|---|
4242 4242 4242 4242 | pm_card_visa | Saves without a challenge; every renewal on it succeeds. The card you run the whole recipe on. |
4000 0025 0000 3155 | pm_card_threeDSecure2Required | Asks for issuer authentication when saved (the page runs it). A renewal charged on it raises a real challenge: awaiting_authentication, a confirmation_url, and the subscription.authentication_required event. Use it, not force-auth-challenge, to exercise the link. |
| (use the token) | pm_card_chargeDeclined | Saves, then every charge is declined: failure_code: "card_declined". Attach it through /setup-intent then /payment-method to walk real declines through the dunning ladder; force-failure does the same without a card. |
- Make the sandbox title
livein the console and register a webhook URL (Webhooks). Fire the test event and readGET /api/dev/webhooks/games/<game_id>/deliveries: thewebhook.testrow must readsucceededbefore you go on. Reveal the sandbox clock key (section 1). - Save a card. Hosted card page:
POST /api/checkout/card-setup-sessionswith the sandbox key (addplayer_namefor a member the sandbox title has never seen; it creates them) (expect201and acard_setup_urlonhttps://sandbox.invo.network/card-setup), open the link in a browser, enter4242 4242 4242 4242, save, and confirm the redirect to yoursuccess_url. Own card form instead (the member must already exist; create them with any call that carriesplayer_name):POST /api/currency-purchases/setup-intentwithpayment_method_id: "pm_card_visa"(immediate success) orpm_card_threeDSecure2Required(returnsrequires_action; complete it on the client and call/setup-intent/confirm). Either way,GET /player-cardsnow lists the card; note itsid. 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. To see the real link and thesubscription.authentication_requiredevent, save4000 0025 0000 3155on the hosted card page for a second member, subscribe, and expectfirst_charge.status: "requires_action"with aconfirmation_url; open it, complete the challenge, and watchsubscription.renewedarrive. 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 with areason. A partial first: expectcard_refunded_usdequal to the amount,balance_delta_coins: "0.00",card_fee_charged_to_partner_usdabove zero withpartner_debited_usdequal to the amount, and the subscription still live. Then refund the rest: expectsubscription_canceled: true, and bothsubscription.refundedandsubscription.canceled(in either order), the latter withcancel_cause: "full_refund". The daily self-serve refund limit applies in sandbox too (by default never lower than the largest single charge, so a single period always runs); a refund over it answers202pending_approval. Details on Refunds.- 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. - Paid trial, success. For the member with the
4242card, subscribe to a newitem_idwithamount_usd: "29.00",trial_days: 7,trial_amount_usd: "1.00"and theplayer_card_id. Expect201,subscription.status: "trialing",first_charge.status: "paid"withamount_usd: "1.00"andpaid_period_seq: 1,paid_throughequal totrial_end,trial_amount_coins_estimate: "10.00", and asubscription.reneweddelivery withperiod_seq: 1andis_trial: true. Thenadvance-clockandforce-renewal: expectsubscription.renewedwithperiod_seq: 2,is_trial: false,amount_usd: "29.00", and statusactive. Replaying the create body with a differenttrial_amount_usd(or none) answers409 IDEMPOTENT_REPLAY_MISMATCHnamingtrial_amount_usd. - Paid trial, refused and refused-up-front. Save
pm_card_chargeDeclinedfor a second member through/setup-intentand subscribe them to a paid trial on that card. Expect201withsubscription.status: "expired",first_charge.status: "failed",failure_code: "card_declined"andnext_retry_at: null, thensubscription.payment_failed(retries_remaining: 0,is_trial: true) andsubscription.expiredwithexpire_cause: "trial_payment_failed". The same item can be subscribed again with a newclient_request_id. Finally send a paid trial for an email that has no saved card: expect400 PAYMENT_METHOD_REQUIREDand no subscription.
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 the storefront’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 the storefront’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.