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

EndpointWhat it does
POST /subscriptions/<id>/advance-clockMakes the next period due now.
POST /subscriptions/<id>/force-renewalRuns the renewal immediately. Charges the test card.
POST /subscriptions/<id>/force-failureSynthesises a failed attempt and runs real dunning. No charge.
POST /subscriptions/<id>/force-auth-challengeSynthesises 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 relevant before) 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.
Node, @invonetwork/web-sdk
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)
Node, raw HTTP helper for the clock tools
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(() => ({})) };
}
Python, invonetwork
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)
Python, raw HTTP helper for the clock tools
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: 0 makes the subscription due now without moving its window. Use it to trigger a retry immediately.
  • intervals: N makes 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
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}'
Node, @invonetwork/web-sdk
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 it
Node, raw HTTP
const adv = await clock(subId, "advance-clock", { intervals: 1 });
Python, invonetwork
adv = invo.sandbox.subscriptions.advance_clock(sub_id, 1)   # 0 = due now, window untouched
Python, raw HTTP
status, 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.

HTTPCode or bodyMeaning
200action: "retired-at-period-end", retired: trueThe subscription was cancelled at period end; nothing billed. This is what production does too.
409SUBSCRIPTION_NOT_LIVETerminal or pending row (with a hint to call /steam/finalize on a pending Steam row).
409ATTEMPT_IN_FLIGHTAn unresolved attempt; wait about 30 minutes for Invo to resolve it.
409AUTH_CHALLENGE_OPENComplete or let the challenge expire first.
409AMOUNT_OVER_CEILINGPrice above 500.00 USD; never charged.
409PERIOD_TOO_FAR_AHEADYou forced a renewal twice without advancing the clock; call advance-clock.
409NO_CHARGEABLE_PERIODNothing due. Advance the clock.
500CLAIM_FAILEDRetry.
503FLOW_PAUSEDBilling paused for maintenance; retry shortly.
curl
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"
Node, @invonetwork/web-sdk
const run = await invo.sandbox.subscriptions.forceRenewal(subId);
// run.engineStats.renewed === 1 on success; run.periodSeq; run.subscription.status
Node, raw HTTP
const run = await clock(subId, "force-renewal");
// run.json.engine_stats, run.json.period_seq; 409 PERIOD_TOO_FAR_AHEAD means: advance the clock first
Python, invonetwork
run = invo.sandbox.subscriptions.force_renewal(sub_id)
# run.engine_stats.renewed == 1 on success
Python, raw HTTP
status, 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
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"
Node, @invonetwork/web-sdk
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"
Node, raw HTTP
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", {});
Python, invonetwork
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"
Python, raw HTTP
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

  1. Make the sandbox game live in the console and register a webhook URL (Webhooks); fire the test event.
  2. Create the player if needed (a purchase, or any endpoint that creates players), then save a card with POST /api/currency-purchases/setup-intent using payment_method_id: "pm_card_visa" (immediate success) or pm_card_threeDSecure2Required (returns requires_action; complete it on the client and call /setup-intent/confirm). Expect card.id.
  3. POST /api/subscriptions/subscribe with that player_card_id. Expect 201, first_charge.status: "paid", first_charge.paid_period_seq: 1, subscription.period_seq: 2, paid_through set, and a subscription.renewed delivery with period_seq: 1.
  4. Replay the same body. Expect 200, idempotent_replay: true, the same first_charge.
  5. advance-clock then force-renewal. Expect engine_stats.renewed: 1, period_seq: 3 on the subscription, subscription.renewed with period_seq: 2. Repeat the pair for as many periods as you like.
  6. force-failure four times (or replace the card with pm_card_chargeDeclined through /payment-method, then advance-clock and force-renewal four times). Expect past_due after the first, payment_failed on each, past_due once, expired with final_period_end on the fourth.
  7. On a fresh subscription, force-auth-challenge. Expect awaiting_authentication; then advance-clock with intervals: 0 and force-renewal to see the challenge lapse into ordinary dunning.
  8. POST .../amount with a new price, advance-clock, force-renewal. Expect the old price on that renewal and the new price on the next.
  9. POST .../cancel with at_period_end: true, then advance-clock and force-renewal. Expect action: "retired-at-period-end" and status canceled.
  10. POST .../refund on a paid period. Expect a receipt and subscription.refunded.
  11. Create a subscription with trial_days: 7. Expect skipped_trial; advance-clock with intervals: 1 then force-renewal. Expect period_seq to move past the trial window and a paid period 2.
Node, @invonetwork/web-sdk, steps 3 to 6 in one script
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 boundary
Python, invonetwork, steps 3 to 6 in one script
created = 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 boundary

6. 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.

  1. 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 steam channel key.
  2. POST /api/subscriptions/steam/init with a real Steam id. Expect 201 pending_steam_authorization, amount_coins for the member’s country, steam_order_id and steam_transid. Call it again with the same terms: expect the same row back with pending_reuse: true.
  3. POST /api/subscriptions/steam/finalize. Expect 200, 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, and subscription.renewed with period_seq: 1, funding.rail: "steam" and funding.steam_charged_usd equal to the price.
  4. advance-clock then force-renewal. Expect engine_stats.steam_charged: 1 and sandbox_synthetic: 1, and subscription.renewed with funding.steam_charged_usd for the shortfall only.
  5. Credit the member’s wallet above the price (a sandbox currency purchase), advance-clock, force-renewal. Expect steam_charged_usd: "0.00".
  6. POST .../amount with a higher price: 409 STEAM_REAUTHORIZATION_REQUIRED. With a lower price: staged. POST .../payment-method: 409 NOT_A_CARD_SUBSCRIPTION.
  7. POST .../refund on the Steam-charged period 1: 409 STEAM_REFUND_NOT_SUPPORTED. On the wallet-covered period from step 5: a receipt with funding_shape: "wallet".
  8. POST .../cancel in both modes. Expect steam_agreement_status: "canceled" on the response.
  9. force-failure does not model a Steam refusal; use it only to exercise your dunning handlers. A pending row you never finalize expires after 24 hours with subscription.expired and failure_code: "steam_authorization_abandoned".
Node, @invonetwork/web-sdk, steps 2 to 4
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 }
Python, invonetwork, steps 2 to 4
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.