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

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 the 25000.00 USD per-charge ceiling; 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

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 pageAs a payment_method_id tokenBehaviour
4242 4242 4242 4242pm_card_visaSaves without a challenge; every renewal on it succeeds. The card you run the whole recipe on.
4000 0025 0000 3155pm_card_threeDSecure2RequiredAsks 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_chargeDeclinedSaves, 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.
  1. Make the sandbox title live in the console and register a webhook URL (Webhooks). Fire the test event and read GET /api/dev/webhooks/games/<game_id>/deliveries: the webhook.test row must read succeeded before you go on. Reveal the sandbox clock key (section 1).
  2. Save a card. Hosted card page: POST /api/checkout/card-setup-sessions with the sandbox key (add player_name for a member the sandbox title has never seen; it creates them) (expect 201 and a card_setup_url on https://sandbox.invo.network/card-setup), open the link in a browser, enter 4242 4242 4242 4242, save, and confirm the redirect to your success_url. Own card form instead (the member must already exist; create them with any call that carries player_name): POST /api/currency-purchases/setup-intent with 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). Either way, GET /player-cards now lists the card; note its 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. To see the real link and the subscription.authentication_required event, save 4000 0025 0000 3155 on the hosted card page for a second member, subscribe, and expect first_charge.status: "requires_action" with a confirmation_url; open it, complete the challenge, and watch subscription.renewed arrive.
  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 with a reason. A partial first: expect card_refunded_usd equal to the amount, balance_delta_coins: "0.00", card_fee_charged_to_partner_usd above zero with partner_debited_usd equal to the amount, and the subscription still live. Then refund the rest: expect subscription_canceled: true, and both subscription.refunded and subscription.canceled (in either order), the latter with cancel_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 answers 202 pending_approval. Details on Refunds.
  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.
  12. Paid trial, success. For the member with the 4242 card, subscribe to a new item_id with amount_usd: "29.00", trial_days: 7, trial_amount_usd: "1.00" and the player_card_id. Expect 201, subscription.status: "trialing", first_charge.status: "paid" with amount_usd: "1.00" and paid_period_seq: 1, paid_through equal to trial_end, trial_amount_coins_estimate: "10.00", and a subscription.renewed delivery with period_seq: 1 and is_trial: true. Then advance-clock and force-renewal: expect subscription.renewed with period_seq: 2, is_trial: false, amount_usd: "29.00", and status active. Replaying the create body with a different trial_amount_usd (or none) answers 409 IDEMPOTENT_REPLAY_MISMATCH naming trial_amount_usd.
  13. Paid trial, refused and refused-up-front. Save pm_card_chargeDeclined for a second member through /setup-intent and subscribe them to a paid trial on that card. Expect 201 with subscription.status: "expired", first_charge.status: "failed", failure_code: "card_declined" and next_retry_at: null, then subscription.payment_failed (retries_remaining: 0, is_trial: true) and subscription.expired with expire_cause: "trial_payment_failed". The same item can be subscribed again with a new client_request_id. Finally send a paid trial for an email that has no saved card: expect 400 PAYMENT_METHOD_REQUIRED and no subscription.
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 the storefront’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 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.