Skip to main content

invonetwork

First-party Python server SDK for integrating INVO into partner backends. It is the server-side counterpart to the INVO JS/Web SDK: same endpoints, same field mappings, and the same webhook HMAC scheme, so both hit the same live backend interchangeably.

Status: 3.6.0 — stable, published on PyPI (pip install invonetwork). The backend it wraps is live on sandbox + production, so you can build and test against sandbox today. Recent highlights: 3.6.0 makes the device approval grant a first-class SDK surfacebegin_device_approval, poll_device_approval, confirm_device_enrollment and, the step that actually moves the money, approve_with_device_code (plus the optional complete_device_approval loop); a grant that is polled to approved and left there settles nothing, which is the failure this release exists to make impossible, and TRANSACTION_NOT_PENDING now comes back as a typed status="not_pending" result with already_settled rather than a raised error you would have to string-match; 3.1.0 makes the authentication guidance explicit — passkeys are the gold standard and the SMS-PIN completion (verify_sms_transfer/verify_sms_send) is deprecated and being phased out (documentation only — no behavior change, and no runtime DeprecationWarning, so warnings-as-errors test suites are unaffected); 3.0.0 moves the Platform Commerce card leg to INVO's hosted checkoutpurchase(funding_source="card") now returns a checkout_url to send the buyer to (the 2.5.x BillingAddress/client_secret surface is removed; see the CHANGELOG migration note); 2.5.0 adds Platform Commerce (ecommerce) — a platform tenant selling items funded by balance or card, with server.platform_commerce.purchase/ get_status/refund and the platform_commerce.* webhooks (the browser card-confirm step lives in the JS SDK); 2.4.0 adds Steam transfer-policy handling (is_steam_value_non_transferable/is_non_steam_value_into_steam_blocked + DestinationGame.accepts_steam_origin_value); 2.3.0 surfaces the claim-time phone-share 409 on claim_transfer/claim_currency (+ err.phone_share_last4). Full history in the CHANGELOG. Canonical partner reference: https://docs.invo.network.

Highlights

  • Device approval (RFC 8628) — the console / TV / native-client passkey path, driven end to end from your server: begin, poll, answer the on-screen match code, and approve_with_device_code — the call that settles the transaction.
  • Server money flows — mint player tokens, initiate cross-game sends/transfers, run the currency-purchase flow (hosted checkout + rail selector), spend game currency on items, and Platform Commerce (ecommerce: a platform tenant selling items funded by balance or card).
  • Server-only reads — player balances, inbound-pending "you have X to collect", and linked wallet identities (PII, server-only).
  • Webhook verification — constant-time HMAC-SHA256, replay window, multi-secret rotation.
  • Resilient — automatic retries with backoff/jitter on network errors, 429 (honoring retry_after), and 5xx — for idempotent calls only.
  • Zero runtime dependencies — stdlib only (urllib, hmac, json, dataclasses). Python 3.9+.
  • Fully typed — ships py.typed; passes mypy --strict.

The game secret stays on your server — it authenticates every call here via the X-Game-Secret-Key header and must never reach a browser.

Passkeys are the gold standard — don't build on SMS

If you take one thing from this README: money movement should be authorized by a passkey. Do not design your verification UX around the SMS PIN.

A passkey is a WebAuthn assertion — phishing-resistant, bound to your origin, backed by the device's secure hardware. An SMS PIN is a shared secret delivered over a channel exposed to SIM swap, SS7 interception, and social engineering. They are not two equivalent ways to approve a transfer; one is materially weaker, and INVO treats it that way (hence the 24-hour money-out cooldown after a passkey recovery — that gate exists because phone-based possession can be stolen).

The WebAuthn ceremony itself runs in the browser via the JS SDK, so from this server SDK the rule shows up in how you read initiate_*:

Do this Not this
verification_method == "in_app" sender has a passkey → have the browser call approveSend/approveTransfer
verification_method == "sms" read it as "this user has no passkey" → have the browser offer enrollPasskey(), then approve route straight to PIN entry
Console / TV / native client the QR device-approval grant, then server.approve_with_device_code polling to approved and stopping — that settles nothing
Recipient collects the browser confirmReceipt* (passkey), or the QR flow with flow="send_receipt" / "transfer_receipt" claim code as the primary path
Fallback verify_sms_transfer / verify_sms_send only when the user can't enroll or declines the PIN as your default flow

Prerequisite: which passkey path serves your tenant. There are two places a passkey ceremony can run, decided by whether you hold a partner passkey domain:

  • No partner domain (every new title): INVO runs the ceremony on its own domain — the hosted approval page — for every platform. There is no domain to verify and nothing to configure; submitting one is refused (409 PARTNER_RP_FROZEN). Your server starts a device approval grant with server.begin_device_approval(...) (the player's session token, never the game secret; below) and the browser / console / phone opens the page — then polls, and calls server.approve_with_device_code(...) when the poll says approved, which is the step that actually moves the money. The in-app browser ceremonies return 403 WEBAUTHN_NOT_ENABLED_FOR_TENANT for you — classify it with err.is_webauthn_not_enabled_for_tenant and treat it as the expected state, not a failure (the body's hosted_flow points at device_code).
  • A domain verified before the freeze (existing tenants): your passkeys are bound to that RP ID for life, so the in-app ceremonies keep working. You may still change or re-verify the domain; removing it is irreversible — it can never be re-added, and the hosted flow serves the game from then on.

So the honest sequence is: start the transfer → approve on the hosted page (or in-app if you hold a domain) → you're on the gold-standard path. The SMS PIN carries only a player who cannot or will not enrol — which is exactly why the deprecation below has no removal date attached.

⚠️ verify_sms_transfer / verify_sms_send are deprecated as of 3.1.0 and a future major version will remove them. They still work exactly as before — this release changes documentation only and deliberately emits no DeprecationWarning, so pytest -W error suites keep passing. Keep the PIN path as a genuine last resort for: users who cannot enroll (unsupported device, no platform authenticator) and users who decline.

What is not discouraged. These use a one-time code, but they are the on-ramp to a passkey — not a substitute for one. Use them freely:

  • recovery_begin / recovery_complete — the passkey recovery relay; restores a passkey the user lost or deleted.
  • phone_share_initiate / phone_share_approve — phone-ownership consent, not transaction authorization.
  • The browser-side enrollmentBegin / enrollmentVerify grant — how a user gets a first passkey.

Beyond security, this is also an economics story: SMS costs real money per message at every scale, and INVO's architecture targets passkey/in-app verification as the primary path so the platform never depends on carrier delivery. A passkey-first integration is faster for your users, cheaper to run, and won't need migrating later.

Which method fits which platform

The right approval method is a property of the client the player is sitting in front of, not of your title. Because the WebAuthn ceremony runs browser-side, this server SDK's job is to read initiate_* and let the right client half take over — pick that half per platform:

Where the player is Approve with Why
Mobile app & mobile web (iOS 16+ / Android 9+) a passkey, directly the platform authenticator (Face ID / Touch ID / fingerprint) is available in-client — strongest option, zero messaging cost
Desktop web (browser) a passkey, directly Touch ID / Windows Hello are available to the browser
Consoles (PlayStation / Xbox / Switch) the QR device-approval flow (RFC 8628, below) no browser and no WebAuthn in-client — show the QR, the player scans it and completes the passkey on their phone, your server polls, and your server then calls approve_with_device_code — the poll proves who, that call moves the money
Native Steam / desktop game clients (Mac & Windows) the QR device-approval flow (RFC 8628, below) the OS supports passkeys, but the embedded game client can't invoke the platform authenticator from inside the engine — it is the client, not the OS, that forces QR here

When there is no passkey path at all — a remote approval that can't run a ceremony (a guardian, a phone's existing owner) — INVO goes email first, SMS on request: a signed link to a hosted page to the oldest verified address, and a text only when there is no verified email, the email could not be delivered, or the player asks for one. SMS is the last resort, not the primary channel.

Contents

Install

Requires Python 3.9+. The command differs slightly by OS:

# macOS / Linux
python3 -m pip install invonetwork
# Windows (PowerShell)
py -m pip install invonetwork

Recommended — inside a virtual environment:

# macOS / Linux
python3 -m venv .venv && source .venv/bin/activate && pip install invonetwork
# Windows (PowerShell)
py -m venv .venv; .venv\Scripts\Activate.ps1; pip install invonetwork

Then import:

from invonetwork import InvoServer, InvoError, verify_webhook

No third-party runtime dependencies.

Get your account & game secret (INVO console)

Sign up, create your game, and copy its game secret in the INVO console. Use the console that matches the environment you're building against:

Environment Console API base_url
Testing / sandbox https://dev.console.invo.network https://sandbox.invo.network/sandbox
Production https://console.invo.network https://invo.network

Build and test against the dev console + sandbox first, then switch to production for launch. Each environment has its own game secret — never mix them, and keep the secret server-side only.

Architecture (this SDK is the server half)

INVO integrations split across two trust boundaries. This package is the server half; the browser half is @invonetwork/web-sdk.

┌──────────────────────────────┐         ┌──────────────────────────────┐
│  YOUR SERVER (trusted)        │         │  THE BROWSER (untrusted)      │
│  invonetwork (this package)   │  mint   │  @invonetwork/web-sdk         │
│  • holds X-Game-Secret-Key    │ ──────► │  • holds short-lived token    │
│  • mint_player_token()        │  token  │    (~15 min, game-scoped)     │
│  • initiate_send/transfer()   │         │  • enroll/approve passkeys    │
│  • create_checkout()          │         │  • confirm_receipt / claim    │
│  • purchase_currency/item()   │         │  • balances / destinations    │
│  • verify_webhook()           │         │                               │
└───────────────┬───────────────┘         └───────────────┬──────────────┘
                └──────────────► INVO BACKEND ◄────────────┘
Package Runs on Holds Responsibilities
invonetwork (this) your backend (Python 3.9+) the game secret mint tokens; initiate sends/transfers; currency + item purchase; server reads; verify webhooks
@invonetwork/web-sdk the browser a short-lived player token passkey enroll/approve, self-claim, balances/destinations for the logged-in player

The game secret authenticates every call here and must never reach a browser. Mint a short-lived player token server-side with mint_player_token and hand that to the browser SDK.

Player token (session mint for an existing player)

mint_player_token mints a short-lived, game-scoped session token for a player who already exists on your game — it is not a registration call. The backend looks the player up by player_email and returns a token for their existing identity (or 404 if unknown). It only needs player_email:

token = server.mint_player_token(player_email="player@example.com")

player_phone is optional here (validated as E.164 only if you pass it, and ignored by this endpoint) — an existing email-only player still mints a token.

Where phone actually matters. A player's INVO identity encodes their phone, and cross-game money routing keys off it — but that's enforced on the money calls, not the token mint:

  • initiate_send / initiate_transfer require the sender's phone (E.164), and take the recipient's phone. You can send by the recipient's phone alone — they supply their email when they claim.
  • An account with no phone can't receive cross-game money or take part in the account-linking consent SMS, so make sure players have a phone at creation / enrollment (in your own player system), before they transact.

Not in this SDK (by design): the browser WebAuthn ceremonies

This is the game-secret / server-side SDK. The player-token WebAuthn ceremonies — passkey enroll, approve / step-up, confirm-receipt / claim, the enrollment OTP grant, and device link — are not here, because they run in the browser (navigator.credentials) and authenticate with the player token, not the game secret. Handle them one of two ways:

  • Browser: use the JS @invonetwork/web-sdk InvoClient (enrollPasskey, approveSend/approveTransfer, confirmReceipt*, enrollmentBegin/enrollmentVerify, linkDevice), or
  • Proxy: relay those player-token HTTP calls through your backend (the browser still performs the actual ceremony).

Everything else — mint, initiate, verify-SMS, claim, status, guardian, phone-share, passkey-recovery relay, checkout, purchase, item purchase, balances, inbound-pending, destinations, linked-identities, and webhook verification — is in this SDK.

Passkey recovery relay (recovery_begin / recovery_complete)

The recovery calls themselves are plain OTP posts (no WebAuthn), so a Python backend can relay them. When the browser's enrollment is blocked with a 409 ENROLLMENT_REQUIRES_PROOF (the player deleted/lost their passkey — the server can't know a device-side key is gone), offer "Lost or replaced your passkey?":

# authed with the SDK player token (mint one, or relay the browser's) — NOT the game secret
server.recovery_begin(player_token=token)                 # code -> email on file first (text only if no email)
server.recovery_complete(player_token=token, code=otp)    # deactivates the stale passkey
# then the BROWSER re-runs the normal enrollPasskey() ceremony — it now succeeds
  • recovery_begin sends the code email first; a text goes out only when there is no email on file. The SDK method takes no channel argument — for a "text me instead" tap, POST /api/sdk/device/recover/begin yourself with the player token and body {"channel": "sms"}. Errors: no_channel_on_file (422), rate_limited (429 — max 5 codes / 10 min).
  • recovery_complete errors: ENROLLMENT_CODE_INVALID (wrong/expired, attempt-capped), RECOVERY_FAILED (500, transient).
  • A player who lost the phone their INVO passkey was on can also recover from the hosted approval or claim page with no in-app call at all — see the console section below.
  • Step 3 — the WebAuthn create() ceremony — can only run in the browser (JS SDK enrollPasskey()); these two calls just clear the way for it.

⚠️ 24-hour money cooldown after recovery. A recovery-enrolled passkey logs in and collects funds immediately, but money-OUT approves return 403 PASSKEY_RECOVERY_COOLDOWN for 24 hours (SIM-swap protection). Branch on err.is_passkey_recovery_cooldown, show — "For your security, transfers are paused for 24 hours after a passkey reset. You can still receive funds. Try again after err.retry_after_at." — do not retry-loop it.

Before you go live

INVO enables each flow for your tenant in the console. What to do:

  • Store the game secret server-side (env var / secret manager) and expose a small endpoint that calls mint_player_token so your front-end can fetch/refresh a player token.
  • Make sure players have a phone (E.164) at creation/enrollment — it's required on the money calls (initiate_send/initiate_transfer) and for cross-game receive, though not on the token mint itself (see Player token).
  • Set your webhook signing secret and verify every delivery with verify_webhook — grant currency/items off webhooks, not synchronous responses.
  • For currency purchase: hosted checkout works out of the box; ask INVO to enable the game/steam rails if you need them. The steam rail additionally requires the studio to register their own Steam app id + publisher Web API key and put a payment method on file — see the Steam section below.
  • For sends/transfers with passkeys: nothing to configure — new titles approve on INVO's hosted page (your server starts the device approval grant; the browser half uses @invonetwork/web-sdk's approveHosted(), consoles show the QR, the mobile plugins open the system browser). Only a tenant that verified a partner domain before the freeze uses the in-app ceremony on its own origins. Wire the approve step before launch: until a sender is enrolled they fall back to the deprecated SMS-PIN path, which is the flow you don't want your users on — see Passkeys are the gold standard.
  • For item purchase: nothing extra — it's a currency-balance debit.

If a flow isn't enabled yet, calls return a clear InvoError (e.g. TENANT_NOT_MIGRATED, WEBAUTHN_NOT_ENABLED_FOR_TENANT, flow_paused) — coordinate with your INVO contact to turn it on.

Configuration

import os
from invonetwork import InvoServer, Hooks

server = InvoServer(
    game_secret=os.environ["INVO_GAME_SECRET"],       # server-side only
    base_url="https://sandbox.invo.network/sandbox",  # prod: "https://invo.network"
    timeout=30,               # optional, seconds (default 30)
    max_retries=2,            # optional, default 2 (0 disables)
    retry_base_delay=0.25,    # optional backoff base, seconds
    user_agent="my-game/1.0", # optional; a sensible non-blocked UA is set by default
    hooks=Hooks(),            # optional observability (see below)
)

base_url must be https:// — the game secret travels in a request header, so plaintext is rejected. http://localhost (and loopback) is allowed for local development only.

Construct one InvoServer and reuse it. All request methods are keyword-only for clarity.


Currency purchase (real money in)

Buy game currency with real money. Authenticated by the payment rail, not a passkey.

Hosted checkout (recommended — you never touch card data)

result = server.create_checkout(
    player_email="p@example.com",
    usd_amount="20.00",                 # USD, 0 < x <= 999.99
    rail="platform",                    # optional: "platform" (default) | "game" | "steam"
    success_url="https://you/buy/ok",
    cancel_url="https://you/buy/cancel",
    metadata={"your_order_id": "ord_42"},  # echoed on the purchase.completed webhook (all rails); order_id also reconciles
)
# -> send the browser to result.checkout_url. Token TTL is result.expires_in_seconds (~900s).

The INVO-hosted page handles card entry, saved cards, and 3-D Secure. Reloading the URL after a completed payment is idempotent — it shows an already-complete success screen, not an error. Grant currency off the purchase.completed webhook, not this response.

Payment rails (neutral names)

rail selects who processes the payment. Use the neutral names; INVO enables the ones your tenant is approved for.

rail What it is Notes
"platform" INVO's own checkout (default) Cards + Apple Pay / Google Pay / Link + international billing, on the hosted page; no app-store commission
"game" Your own processor You may get a payment_url to redirect to (status == "pending_payment")
"steam" Steam's in-client purchase flow Hosted checkout / initiated on Steam's side — rejected by purchase_currency (WRONG_RAIL_ENDPOINT)

Steam titles: before any of this works, the studio has setup to do

Steam pays whoever owns the Steam application. Every INVO title therefore sells currency through its own Steam app, and the money lands in the studio's own Steamworks account. INVO issues the player's currency the moment Steam captures the charge, then settles with the studio separately against a payment method they keep on file.

Once per title, in the INVO dashboard:

  1. Enable in-game purchases (microtransactions) for the app in Steamworks.
  2. Create a publisher Web API key — a publisher group key, not a personal user key, with the app in the group. A personal key fails verification.
  3. Enter the app id and that key under the title's Steam settings. INVO calls Steam to prove the pair before saving it.
  4. Add a payment method on the Billing screen and accept the authorisation.

Until all four are done, purchases are refused before the player is charged: STEAM_NOT_CONFIGURED (503) or PARTNER_BILLING_NOT_SET_UP (409). That is deliberate — the alternative is taking a player's money for currency that cannot be issued.

This changed in September 2026. Versions before 3.4.0 said Steam purchases ran through an INVO-owned Steam app and there was nothing to register. That was true while one INVO application billed every title; it is not true now, and following the old text leaves the rail closed.

Steam titles: currency must be bought through Steam, and INVO sets the packs.

A title distributed on Steam may only sell currency on the steam rail — any other rail is refused with 409 STEAM_PURCHASE_LAYER_REQUIRED. This is a platform requirement, not an INVO preference, and it applies to every storefront the title ships on because a player's balance is shared across them.

You do not set Steam prices and you do not send an amount. INVO defines the pack catalogue for every game on the network. Fetch it with GET /steam/packs?steamid=..., render it, and pass the pack_id back to the purchase call.

The price is the same everywhere; the currency inside is not. Steam prices are VAT-inclusive wherever VAT is collected, so more of a fixed price goes to tax in a high-VAT country and less is left to buy currency with — the same $9.99 pack yields 69 units in the US and 58 in France. Always pass steamid when fetching the catalogue, or the amounts are quoted with no VAT deducted and your store promises more than the purchase delivers. Do not cache one catalogue for all players and do not hard-code amounts: pack prices and VAT rates are both configuration.

Omit rail to use "platform". Amounts are USD, 0 < x <= 999.99.

Steam purchases (packs, not prices)

Steam has first-class SDK methods as of 3.5.0: steam_packs(), steam_init_purchase(), steam_finalize_purchase() — all on InvoServer, all server-side. purchase_currency still rejects rail="steam" with WRONG_RAIL_ENDPOINT and points you here.

INVO owns the pack catalogue. You never send a price. Fetch the packs, show them, pass a pack_id back.

# 1. The catalogue, priced for THIS player. Always pass steamid.
result = server.steam_packs(steamid=steam_id)
# result.packs: [SteamPack(pack_id, label, price_usd, currency_amount), ...]

# 2. Start the purchase with the pack the player chose.
init = server.steam_init_purchase(
    player_email="player@example.com",
    steamid=steam_id,
    pack_id="steam_medium",                # NOT a price
    purchase_reference=my_idempotency_key, # unique per INTENDED purchase; reuse on retry
    metadata={"player_id": my_player_id},  # echoed on the purchase.completed webhook
)
# -> init.order_id, init.steam_transid, init.charged_usd, init.currency_amount

# 3a. CLIENT SESSION (default): Steam shows its in-game overlay; your game
#     client receives MicroTxnAuthorizationResponse_t and tells your backend.
# 3b. WEB SESSION: for platforms where the overlay does not render (observed:
#     Electron on macOS). Pass usersession="web" + player_ip (the PLAYER's IP,
#     never your server's) and open init.steam_checkout_url as a TOP-LEVEL
#     browser tab — it cannot be iframed.

# 4. Finalize: INVO re-checks the authorization with Steam, captures, credits.
done = server.steam_finalize_purchase(order_id=init.order_id)
# -> done.status == "success", done.new_balance, done.already_processed

The price is identical everywhere; the currency inside is not. Steam prices are VAT-inclusive wherever VAT is collected, and the storefront's revenue share comes out before currency is derived — the same $9.99 pack yields 69 units in the US and 58 in France, and a $4.99 pack yields 34, not 50. Render currency_amount verbatim; never compute currency from the sticker price.

Finalize is idempotent, and not every 409 means "try later". A replay returns already_processed=True and never credits twice. For a poller, branch on the error:

Signal Meaning Do
err.is_steam_authorization_pending player has not approved yet (Init) keep polling / wait for the callback
err.is_steam_authorization_dead any settled status: Cancelled / Failed / Refunded / PartialRefund / Chargedback / anything else non-Init stop — no later call can succeed
err.is_steam_app_changed the title's registration changed under the order stop; terminal for this order
HTTP 502 / 503 transient retry
"Order is not finalizable" (4xx, no not_authorized) the reconciler already settled the order's fate stop

A poller that treats every 409 as "not yet" polls a dead order forever. A missed finalize is not a lost credit — INVO's reconciler independently detects the authorized transaction and credits, on a delay.

Refusals before any charge, so a player is never charged for currency that cannot be issued:

  • err.is_partner_credit_unavailable — the studio's settlement float cannot cover it. Plain failure toast; do not hot-poll init (each attempt briefly re-reserves headroom), and reuse the same purchase_reference on a manual retry.

  • err.is_partner_rail_suspended — terminal from the game's seat until the studio resolves it in the INVO console.

  • err.is_unknown_steam_pack — re-fetch steam_packs(); the body's valid_pack_ids lists what exists.

  • err.is_steam_not_configured (503) / err.is_steam_rail_not_entitled (403) — the studio's one-time Steam setup is incomplete.

  • A locked Steam account is refused up front with 409 STEAM_ACCOUNT_LOCKED.

  • Always pass steamid to steam_packs(). Without it the amounts are quoted with no VAT deducted — the most any pack yields — so your store promises more than the purchase delivers.

  • Do not cache one catalogue for all players, and do not hard-code amounts. Pack prices and VAT rates are configuration and change without an SDK release.

  • On a Steam-distributed title, a channel key may only sell on its own channel's rail. A mismatched caller is refused with 409 STEAM_PURCHASE_LAYER_REQUIRED. (The old absolute form -- "only the Steam rail, across every storefront" -- was the rule before 2026-08-31 and no longer holds for channel-keyed callers.)

  • Correlation is metadata, not purchase_reference. The webhook payload deliberately omits your idempotency key; put your own player/order ids in metadata and read them back at data["metadata"] on purchase.completed -- note the Steam rail ADDS its own context keys (steam_wallet_currency, steam_country, steam_account_status, vat_rate_pct) into the echoed object, so avoid those names in yours (data["channel"] is the channel of the KEY you presented -- "steam" when you call with your Steam channel key; None on a legacy game key).

Direct rail (advanced — you tokenize the card yourself)

import uuid

purchase = server.purchase_currency(
    player_email="p@example.com",
    usd_amount="20.00",
    purchase_reference=str(uuid.uuid4()),  # idempotency key, required
    rail="platform",
    payment_method_id="pm_...",            # a tokenized payment method
    metadata={"your_order_id": "ord_42"},
)

if purchase.status == "success":
    pass  # captured; purchase.new_balance updated
elif purchase.status == "requires_action":
    # 3-D Secure: run the client action with purchase.client_secret, then:
    server.confirm_payment(payment_intent_id=purchase.payment_intent_id)
elif purchase.status == "pending_payment":
    pass  # redirect the browser to purchase.payment_url (game rail)

rail="steam" is rejected here (WRONG_RAIL_ENDPOINT) — Steam uses its own in-client flow. Reconcile with server.get_order_details(order_id=...). Most integrations should prefer hosted checkout.


Item purchase (spend game currency)

Spend the currency a player already owns to buy an in-game item. A balance debit — no real money, no payment rail, no passkey — server-side only. Amounts are in game-currency units.

import uuid

item = server.purchase_item(
    client_request_id=str(uuid.uuid4()),  # idempotency key, unique per game
    player_email="p@example.com",
    player_name="P",
    item_id="sword_001",
    item_name="Legendary Sword",
    item_quantity=1,                       # integer 1..1000
    unit_price="100.00",                   # > 0 and <= 999999.99
    total_price="100.00",                  # must equal unit_price * item_quantity (+/-0.01)
    # optional: player_phone, item_description, item_category
)
# item.status == "success"; item.new_balance / item.previous_balance / item.currency_name
# item.transaction_id / item.order_id; item.financial_breakdown
  • Grant the item off the item.purchased webhook, not just this response. INVO debits currency and records the purchase; your game owns the item catalog and grants the item.
  • Idempotent on client_request_id — a duplicate raises 409 (err.is_duplicate_request).
  • Insufficient balance raises 400 (err.is_insufficient_balance; required_amount + current_balance on err.body).
  • Client-side validation (missing fields, quantity outside 1..1000, bad price, total mismatch) raises INVALID_INPUT before any network call.

Companion reads: get_item_purchase_history(player_email=..., limit=?, offset=?) and get_item_order_details(order_id | transaction_id | client_request_id) (pass exactly one id — use client_request_id for recovery: "did this purchase complete?"). To walk the full history, iterate — it pages automatically:

for row in server.iterate_item_purchase_history(player_email="p@example.com"):
    ...

Platform Commerce (ecommerce)

This is not item purchase. Item purchase is a game tenant spending a player's existing game currency on an in-game item — always a balance debit, never a card, no refunds. Platform Commerce is a platform tenant (a non-game app: vertical video, creator merch, marketplace) running a storefront: the buyer pays with INVO balance or a real card (new money), INVO is merchant of record, and refunds exist. Only platform tenants may call it — a game tenant gets 403 (err.is_not_platform_tenant).

The funding source is resolved server-side under lock — the client can request balance or card, but the backend verifies the real balance before value moves. This SDK is the server half: it creates purchases and refunds. On the card leg, INVO hosts the entire checkout (card fields, Apple Pay / Google Pay, billing-address collection, 3-D Secure) — the server call returns a checkout_url and your app just sends the buyer there. There is no billing address in the request and no client payment code to write.

import uuid

# Balance leg — settles synchronously
r = server.platform_commerce.purchase(
    client_request_id=str(uuid.uuid4()),   # idempotency key, unique per tenant
    funding_source="balance",
    player_email="user@example.com",
    player_name="Ada",
    item_id="sticker_pack_01",
    item_name="Sticker Pack",
    item_quantity=1,                        # integer 1..1000
    unit_price="5.00",                      # BALANCE leg: the tenant's network-currency amount
    total_price="5.00",                     # must equal unit_price * item_quantity (+/-0.01)
)
# r.status == "success"; r.new_balance / r.currency_name / r.order_id
# r.financial_breakdown  # INVO fee: 3.5% flat

# Card leg — returns a hosted-checkout session; NOT yet paid. total_price is USD ($0.50–$999.99).
r = server.platform_commerce.purchase(
    client_request_id=str(uuid.uuid4()),
    funding_source="card",
    player_email="user@example.com",
    player_name="Ada",
    item_id="sticker_pack_01",
    item_name="Sticker Pack",
    item_quantity=1,
    unit_price="5.00",                      # CARD leg: USD
    total_price="5.00",
    success_url="https://app.example/thanks",  # optional: where the buyer lands after paying
    cancel_url="https://app.example/cart",     # optional: where the buyer lands on cancel
    metadata={"cart_id": "c_9"},               # optional: echoed back on the webhook
)
# r.status == "requires_payment"
# r.checkout_url  → send the buyer here (redirect, or the JS SDK's mountCheckout embed)
# r.session_id / r.expires_at (unix seconds)
# Status + refunds
s = server.platform_commerce.get_status(r.order_id)
# s.status: "completed" (balance now; card after the webhook) | "pending_payment" | "refunded"

ref = server.platform_commerce.refund(order_id=r.order_id, reason="customer request")
# or refund(client_request_id=...). Pass EXACTLY ONE id.
# INVO retains its fee (ref.fee_retained is True); the customer is made whole minus that fee.
# A second refund of the same order raises 409 (err.is_already_refunded) — treat as already done.
  • Fulfill card orders on the platform_commerce.purchased webhook, never on the client return — the sale is real only once the payment settles on INVO's hosted page.
  • Idempotency on client_request_id: a duplicate BALANCE purchase raises 409 (err.is_duplicate_request); a duplicate CARD purchase replays the same checkout session (r.idempotent_replay is True, same URL — never a second charge), so a lost card-leg response is safely recovered by retrying with the same client_request_id.
  • INVO fee: 3.5% flat (balance) · 3.5% + $0.30 (card). Too-small amounts raise err.is_amount_below_minimum / err.is_below_card_minimum (card < $0.50); a card total over $999.99 raises err.is_above_card_maximum.
  • Client-side validation (missing fields, bad funding_source, quantity outside 1..1000, total mismatch, card USD bounds, non-E.164 player_phone) raises INVALID_INPUT before any network call.

Subscriptions (recurring billing)

A subscription is a standing agreement your server creates: INVO bills one of your players a fixed USD price on a fixed interval and grants them your game's currency each period. You keep the entitlement (what the member gets in your game). INVO keeps the clock, the charges, the retries, the notifications and the revenue split. Each period INVO spends the member's existing wallet balance first and tops up only the shortfall from the rail behind the subscription, the saved card, or the member's Steam agreement, then splits the coins between INVO's platform fee (10% by default) and your revenue, settled through your normal partner payout.

Two rails, fixed at creation:

funding_rail How the shortfall is funded Created by
card the member's saved card on file with INVO server.subscriptions.create(...)
steam the member's Steam wallet, through a recurring agreement they authorise once server.subscriptions.steam_init(...) then steam_finalize(...)

One item_id per recurring product, shared across rails. The one-live-subscription rule is per (game, player, item_id) and is rail-agnostic: a member with a live card subscription to guild-42-membership cannot start a Steam one to the same item_id, and vice versa. So the subscription's item_id is the recurring product's handle, the same on both roads, and it must differ from any one-off pack id you sell (a Steam pack, a currency bundle, an in-game item).

Before you create your first subscription: the game must be live in the environment you are calling (testing is refused with 403 GAME_NOT_LIVE, err.is_game_not_live; make it live in the developer console, in sandbox too), and you must have a webhook target registered, renewals, failures, authentication challenges, cancellations, expiries and refunds are all reported by webhook, and some carry data you cannot fetch any other way.

The two clocks

Two different dates live on every subscription and must not be confused:

Field Meaning Exists when
current_period_start / current_period_end the billing window INVO is currently billing or about to bill, a projection from the instant of creation, before any money has moved
paid_through the entitlement boundary: the furthest date the member has actually paid for only after a period has been charged successfully; None before that

Grant access from paid_through, never from current_period_end. A subscription created a minute ago has a current_period_end a month out and a paid_through of None until its first charge lands.

Statuses: trialing, active, past_due (a charge failed, INVO is retrying, access retained) and awaiting_authentication (a card charge needs the cardholder, nothing charged, not a failure) are live. pending_steam_authorization (Steam, waiting for the member) is not. canceled and expired are terminal. One live subscription per (player, item_id).

The card road

1. Capture a card (server.cards)

A card subscription needs a card saved for off-session use. begin_setup charges nothing, and the subscription endpoints never receive card data, your PCI scope does not change. The player must already exist in your game (this endpoint does not create players).

import uuid

setup = server.cards.begin_setup(
    player_email="member@example.com",
    setup_reference=str(uuid.uuid4()),   # idempotency anchor, reuse it on retries
    payment_method_id=payment_method_id, # a card tokenised on the client by the card form (optional)
)
if setup.status == "succeeded":
    card_id = setup.card.id              # the ONLY handle for the card; pass it as player_card_id
else:
    # "requires_action" / "requires_confirmation" / "requires_payment_method":
    # hand setup.client_secret (+ setup.publishable_key) to the card form on the client,
    # let it confirm / authenticate, then record the card:
    confirmed = server.cards.confirm_setup(setup_intent_id=setup.setup_intent_id)
    card_id = confirmed.card.id          # idempotent: confirmed.already_saved on a repeat

cards = server.cards.list("member@example.com")   # unexpired cards, newest first
# cards.cards[0].id / .brand / .last_four / .exp_month / .exp_year

Failures: CARD_DECLINED, SETUP_FAILED, INVALID_PAYMENT_METHOD, RAW_CARD_NOT_SUPPORTED (send a tokenised card, never raw card numbers), PLAYER_NOT_FOUND (404), SETUP_REFERENCE_REUSED (409, same reference with different parameters), CARD_PERSIST_FAILED (500, the card was authorised but not recorded; call confirm_setup again with the same setup_intent_id). While the client has not finished, confirm_setup answers 400 {"status": "still_requires_action"}.

The other way to save a card is a currency purchase with a new payment_method_id and save_card=True (the purchase response carries card_saved: true; fetch the id from cards.list). Hosted checkout does not save a card for subscriptions, and cards saved through saved_card_id purchases or before this behaviour shipped do not carry the off-session consent a subscription needs, the member must save the card again.

2. Create the subscription and handle first_charge

create charges the first period before responding (unless it is a trial), expect it to take as long as a card charge. It is idempotent on client_request_id.

import uuid

key = str(uuid.uuid4())   # generate ONCE per subscription, persist it, reuse it on every retry

r = server.subscriptions.create(
    client_request_id=key,
    player_email="member@example.com",
    player_name="Ada",                        # used only if the player does not exist yet
    item_id="guild-42-membership",            # your entitlement handle; one live sub per (player, item)
    item_name="Guild 42 membership",          # optional, echoed on events
    amount_usd="9.99",                        # decimal string; keep at or below 500.00 to be chargeable
    interval="month",                         # "month" (default) | "year"
    interval_count=1,                         # 1..36; 3 + "month" bills quarterly
    player_card_id=card_id,                   # optional: omitted, INVO picks the newest saved card
    metadata={"guild_id": "42"},              # optional, echoed on every read and event (not `_invo`)
    consent={                                 # optional but SEND IT, a disputed charge is argued from this
        "consent_at": "2026-09-06T14:01:50+00:00",
        "consent_ip": "203.0.113.7",          # the MEMBER's IP as your server saw it
        "consent_user_agent": "Mozilla/5.0 ...",
        "disclosed_amount_usd": "9.99",
        "disclosed_interval": "month",
        "terms_version": "2026-09",
    },
)

sub, first = r.subscription, r.first_charge
if first.status == "paid":
    grant_access(sub.subscription_id, until=sub.paid_through)   # money moved; expect subscription.renewed
elif first.status == "requires_action":
    # NOTHING has been charged. Send the member to the link before it expires; do not grant paid
    # access yet (paid_through is None). On completion you receive subscription.renewed.
    send_link(first.confirmation_url, expires_at=first.expires_at)   # a bearer link, do not log it
elif first.status == "skipped_trial":
    grant_trial_access(sub.subscription_id, until=sub.trial_end)     # first charge runs at trial end
elif first.status == "failed":
    tell_member_to_fix_card(first.failure_code, next_retry_at=first.next_retry_at)  # past_due, in dunning
else:  # "pending"
    pass  # unresolved; wait for subscription.renewed / .payment_failed (INVO resolves it within ~30 min)

# r.idempotent_replay is True when this response REPLAYED an earlier create for the same key.
# r.card is the card backing the subscription (or None). r.warning set => read it with get().

After a successful first charge sub.period_seq already reads 2: period 1 was just paid and current_period_* is the next window; paid_through is the end of the window just paid. Key entitlement on first.paid_period_seq (1 here; None on every status but paid) or on the subscription.renewed webhook's period_seq (which reports the period just paid) , never on subscription.period_seq, which on both roads points at the next period after a create or finalize.

Replay before retry. On any timeout or 5xx, call create again with the same client_request_id, the backend returns the same body (idempotent_replay=True) and charges nothing again. A new key inside a retry loop lands on err.is_active_subscription_exists (err.existing_subscription_id carries the live one) at best and a second subscription at worst. The same key with different terms raises err.is_idempotent_replay_mismatch (err.mismatched_fields).

Trials: trial_days=7 (1..365) or trial_end="2026-10-01T00:00:00+00:00" (takes precedence) creates the subscription trialing with first_charge.status == "skipped_trial". Not available on Steam. wallet_only=True opts out of the card backstop entirely (renewals spend wallet balance only and go into dunning when it is short); it cannot be combined with player_card_id. revenue_share={"recipient_player_email": "founder@example.com", "percent": "70"} records an attribution to another player in your game, INVO pays nothing to the recipient (settled_by_invo is always False); the figure to pay them from is on every renewal event.

The Steam road

A Steam subscription is funded from the member's Steam wallet through a recurring agreement the member authorises once, in Steam. INVO still owns the clock, same schedule, retries and events as the card rail. The title must be enabled for Steam in INVO (Steam app id and publisher Web API key verified, billing on file) and you must use the primary game key or the steam channel key (err.is_steam_channel_required otherwise). Not available on Steam: trials, interval_count other than 1, wallet_only, card fields, price increases, refunds of Steam-charged periods.

import uuid

init = server.subscriptions.steam_init(
    client_request_id=str(uuid.uuid4()),
    player_email="member@example.com",
    player_name="Ada",
    item_id="guild-42-membership",
    amount_usd="9.99",
    steam_id="76561198000000000",     # the member's 64-bit Steam id
    user_session="client",            # "client" (Steam overlay in-game) | "web" (hosted checkout URL)
    # player_ip="203.0.113.7",        # REQUIRED for "web": the PLAYER's IP, never your server's
    metadata={"guild_id": "42"},
)
# init.subscription.status == "pending_steam_authorization"; nothing charged yet.
# init.amount_coins   -> what one period is worth for THIS member (price-derived for their country,
#                        VAT included: 9.99 USD is 69 coins for a US member, 58 for a German one).
#                        Show it. Never price x 10.
# init.steam_order_id -> the uint64 the Steam CLIENT callback echoes as order_id. MATCH THE
#                        CALLBACK ON THIS to learn which subscription the member authorised
#                        (or abandoned), i.e. which subscription_id to finalize.
# init.steam_transid  -> Steam's transaction reference for period 1, for your support tooling only.
# init.pending_reuse  -> True when INVO handed back an EXISTING pending authorisation for this
#                        player + item with the same terms (new key, no new agreement). Finalize it.
pending[init.steam_order_id] = init.subscription_id

# The client step: "client" -> the Steam overlay presents the authorisation inside the game;
# "web" -> open init.steam_checkout_url as a TOP-LEVEL tab/window (it cannot be framed).

# Then finalize. Steam does not tell INVO the member authorised, YOUR game calls this after the
# overlay or checkout closes, and again if it returned not_authorized. The member has 24 hours.
try:
    fin = server.subscriptions.steam_finalize(init.subscription_id)
except InvoError as e:
    if e.is_not_authorized:        # 409: the normal wait state, call again after they authorise
        ...
    elif e.is_subscription_terminal:   # canceled / expired (the 24 h lapsed), start a new one
        ...
    else:
        raise
else:
    if fin.first_charge.status == "paid":
        grant_access(fin.subscription.subscription_id, until=fin.subscription.paid_through)
        # fin.first_charge.paid_period_seq == 1; fin.subscription.period_seq is already 2 (the NEXT
        # period) and the subscription.renewed webhook for this charge reports period_seq 1.
    # fin.already_processed -> already finalised (or a concurrent finalize won): treat as success
    # fin.warning           -> Steam charged but period 1 could not settle in one step: the money
    #                          IS taken, do not retry the charge, watch for subscription.renewed
    # fin.steam_agreement_status is normally "active"; fin.sandbox_auto_approved in sandbox only

Funding on Steam: period 1 always charges the full price (that is what creates the agreement). Later periods are wallet-first, wallet covers the period: no Steam charge; wallet empty: the full advertised price; wallet partly funded: the smallest charge that buys the missing coins (never above the price, never below the 1.00 USD minimum top-up). A replayed steam_init (same key) returns the row's current status (init.idempotent_replay), which may already be active, canceled or expired. A new key while an authorisation for the same player + item is still pending: with the same terms INVO hands the pending row back (init.pending_reuse is True, no new agreement, finalize it); with different terms it raises 409 STEAM_AUTHORIZATION_PENDING (err.is_steam_authorization_pending, err.existing_subscription_id names the pending row, finalize or cancel it first). An unfinalised row expires on its own after 24 hours.

Renewals: grant on subscription.renewed, revoke on expired / canceled

Billing is in advance: each later period is charged at next_charge_at. The renewal engine runs every minute; INVO adds up to an hour of random offset to the first period so a cohort does not renew on the same instant. Do not poll for renewals, handle the events. There is no subscription.created and no subscription.amount_changed: the create/finalize response is your creation signal, and the first period's subscription.renewed is the first event.

Event Fires Your move
subscription.renewed once per successfully charged period, including period 1 extend access to current_period_start (= period_end of the paid period = the new paid_through). funding says how it was paid; split.partner_revenue_usd is what you earned
subscription.payment_failed every failed attempt, including the first tell the member; do not revoke (retry_at, retries_remaining, grace_period_end)
subscription.past_due once, when the subscription enters past_due do not revoke
subscription.authentication_required a card issuer wants the cardholder to authenticate (nothing charged, no retry consumed) relay confirmation_url to the member before expires_at; not a failure
subscription.canceled once, at the moment cancellation is requested, both modes revoke at access_until; canceled_by is partner or steam
subscription.expired the retry budget ran out (or a Steam row was never authorised: failure_code steam_authorization_abandoned; or the wallet holds value that cannot be spent in this game: failure_code steam_containment, which arrives without a payment_failed before it) revoke at final_period_end (the last paid instant; None if nothing was ever paid)
subscription.refunded a refund reverse what refund describes; revenue_share_attribution.net_attributed_amount_usd is the corrected figure for your recipient

Default dunning: retry after 2 days, then 3, then 2, then expire, three retries over seven days, access retained through the whole window. Only subscription.expired and subscription.canceled revoke access; nothing else does.

from typing import cast
from invonetwork import (
    verify_webhook, InvoError, SUBSCRIPTION_EVENT_TYPES,
    SubscriptionRenewedData, SubscriptionExpiredData, SubscriptionCanceledData,
)

@app.post("/invo/webhooks")
def invo_webhooks():
    try:
        event = verify_webhook(request.get_data(), request.headers.get("X-Invo-Signature"),
                               os.environ["INVO_WEBHOOK_SECRET"])
    except InvoError as e:
        return Response(e.code or "invalid_signature", status=400)
    if already_seen(event.idempotency_key):       # dedupe on X-Invo-Idempotency-Key / idempotency_key
        return Response(status=200)

    if event.event_type == "subscription.renewed":
        d = cast(SubscriptionRenewedData, event.data)
        extend_access(d["subscription_id"], until=d["current_period_start"])   # the new paid_through
        record_revenue(d["split"]["partner_revenue_usd"], period_seq=d["period_seq"])
        # d["funding"]["card_charged_usd"] / ["steam_charged_usd"] / ["balance_applied_coins"]
        # d["amount_usd"] is the price this period was billed at (a staged change shows here)
    elif event.event_type == "subscription.expired":
        d = cast(SubscriptionExpiredData, event.data)
        revoke_access(d["subscription_id"], at=d["final_period_end"])    # NOT at ended_at
    elif event.event_type == "subscription.canceled":
        d = cast(SubscriptionCanceledData, event.data)
        revoke_access(d["subscription_id"], at=d["access_until"])
    elif event.event_type == "subscription.authentication_required":
        send_link(event.data["confirmation_url"], expires_at=event.data["expires_at"])
    elif event.event_type in SUBSCRIPTION_EVENT_TYPES:
        pass  # payment_failed / past_due / refunded: inform, reconcile, never revoke here
    return Response(status=200)

Every money and coin figure on these payloads is a decimal string, never a number. On Steam, split.total_usd is what the coins are worth after Valve's share and VAT (basis: "steam_net"), not the price; the top-level amount_usd is always the price.

Reading, cancelling, repricing, changing the card

sub = server.subscriptions.get("SUB_1757155200_A1B2C3D4")
# sub.status, sub.paid_through (entitlement), sub.next_charge_at, sub.period_seq,
# sub.pending_amount_usd (a staged price change), sub.has_payment_method, sub.funding_rail

page = server.subscriptions.list_for_player("member@example.com", status=["live", "canceled"], limit=50)
# page.subscriptions (newest first), page.pagination["total_count"] / ["has_more"]
# an unknown player is an empty list, not a 404

c = server.subscriptions.cancel(sub.subscription_id, at_period_end=True, reason="member request")
# at_period_end=True  -> stays live with cancel_at_period_end, retired at paid_through, no more charges
#                        BUT if nothing has been paid it is DOWNGRADED to immediate:
#                        c.terminated_immediately is True, c.downgrade_reason == "NO_PAID_PERIOD"
# at_period_end=False -> ended now; keep access until c.access_until
# already canceled / expired -> 200 with c.already_canceled and c.final_status (no special case)
# Steam: c.steam_agreement_status / c.steam_agreement_canceled

a = server.subscriptions.change_amount(sub.subscription_id, "14.99")
# STAGED: applied on the next successful renewal, the current window bills at the old price.
# a.change_queued, a.pending_amount_usd, a.applies_from (None until something has been paid),
# a.applies_from_period_seq. Sending the current price clears a staged change.
# Steam: an INCREASE raises err.is_steam_reauthorization_required (the price is a ceiling the
# member consented to, start a new Steam subscription and cancel this one); a decrease stages.
# More than double the last agreed price, or above 500.00, is staged but never applied.

pm = server.subscriptions.set_payment_method(sub.subscription_id, player_card_id=new_card_id)
# card rail only (Steam raises err.is_not_a_card_subscription). Used from the next charge,
# including a pending retry in dunning. On a wallet_only subscription pass wallet_only=False
# together with the card to convert it. pm.replaced / pm.card / pm.wallet_only

Refunds

ref = server.subscriptions.refund(
    sub.subscription_id,
    client_request_id=str(uuid.uuid4()),   # one per refund; reuse it on retries
    period_seq=2,                          # optional: defaults to the most recent billed period
    amount_usd="9.99",                     # optional: defaults to the full remaining refundable amount
    reason="member request",
)
# ref.is_full_refund, ref.funding_shape ("wallet" | "card" | "mixed"),
# ref.card_refunded_usd (back to the card), ref.balance_delta_coins (signed; can be negative),
# ref.partner_revenue_reversed_usd (your revenue is reversed; INVO's fee is retained),
# ref.remaining_refundable_usd, ref.new_balance,
# ref.processor_refund_reference (the neutral card-refund reference for your reconciliation)
# A repeat with the same client_request_id -> ref.idempotent_replay (or a reduced body with ref.note)

A full refund returns the whole card charge for that period and reclaims the coins it minted (the member holds the cash instead); a partial credits the wallet in coins at the 10:1 peg. 409 NO_PAID_PERIOD is the normal state of a brand-new subscription; 409 ALREADY_REFUNDED, 409 PERIOD_NOT_REFUNDABLE, 400 AMOUNT_EXCEEDS_REMAINING (remaining_refundable_usd on .body) are the other refusals you will branch on. A fully refunded period no longer counts toward paid_through; the subscription itself is not cancelled. Refunds of Steam-charged periods are not supported yet: a period charged through Steam is refused with 409 STEAM_REFUND_NOT_SUPPORTED (err.is_steam_refund_not_supported). Periods of a Steam subscription that the wallet covered entirely are refundable in coins.

Sandbox recipe (the clock tools)

Sandbox runs the renewal engine every minute exactly as production does, so a subscription you create there renews on its own a month later. Four sandbox-only tools move a subscription's billing clock and drive the real renewal engine so you can get there in minutes:

Method What it does
sandbox.subscriptions.advance_clock(id, intervals=1) makes the next period due now (0 = due now without moving the window, use it to trigger a retry)
sandbox.subscriptions.force_renewal(id) runs the renewal immediately through the real money path, charges the test card
sandbox.subscriptions.force_failure(id, outcome) synthesises a failed attempt and runs real dunning (no charge)
sandbox.subscriptions.force_auth_challenge(id) synthesises a cardholder authentication challenge (no charge)

Note the path. These live directly under the sandbox base, not under /api: the full URL is https://sandbox.invo.network/sandbox/subscriptions/<id>/advance-clock. The SDK resolves them relative to your base_url (which already ends in /sandbox). They do not exist in production at all (404).

Every call needs a second credential, X-Sandbox-Clock-Key (ivclk_...), a separate per-game key: in the sandbox developer console open the game, Game Settings, the "Sandbox clock key" card (generated the first time you reveal it; the same card rotates it). Pass it as InvoServer(..., sandbox_clock_key=...). A missing key is refused before the network (INVALID_INPUT); a wrong or never-issued one is 401 SANDBOX_CLOCK_UNAUTHORIZED (err.is_sandbox_clock_unauthorized). Keep it out of game clients and source control.

server = InvoServer(
    game_secret=os.environ["INVO_SANDBOX_GAME_SECRET"],
    base_url="https://sandbox.invo.network/sandbox",
    sandbox_clock_key=os.environ["INVO_SANDBOX_CLOCK_KEY"],   # ivclk_... from the sandbox console
)
clock = server.sandbox.subscriptions

# 1. Save a card: begin_setup(payment_method_id="pm_card_visa") succeeds immediately;
#    "pm_card_threeDSecure2Required" returns requires_action (confirm on the client, then confirm_setup).
# 2. create(...) with that player_card_id -> first_charge.status == "paid", period_seq == 2,
#    and a subscription.renewed delivery with period_seq 1. Replay the same body -> idempotent_replay.
# 3. Renew as many periods as you like:
clock.advance_clock(sub_id)                       # due now
r = clock.force_renewal(sub_id)                   # r.engine_stats == {"renewed": 1}; period_seq advances
# 4. Walk the dunning ladder to expired (payment_failed x4, past_due once, expired with final_period_end):
for _ in range(4):
    f = clock.force_failure(sub_id, "card_declined")   # f.retries_remaining, f.retry_schedule_days
# 5. On a fresh subscription: force_auth_challenge -> awaiting_authentication; then
#    advance_clock(sub_id, intervals=0) + force_renewal to watch the challenge lapse into dunning.
# 6. change_amount, advance_clock, force_renewal -> old price on that renewal, new price on the next.
# 7. cancel(at_period_end=True), advance_clock, force_renewal -> r.action == "retired-at-period-end".
# 8. refund(...) on a paid period -> a receipt and subscription.refunded.
# 9. create(..., trial_days=7) -> skipped_trial; advance_clock(intervals=1) + force_renewal -> paid period 2.

Steam in sandbox: steam_finalize does not call Steam, it auto-approves, seeds a stand-in agreement and runs the real settlement (fin.sandbox_auto_approved is True), and a later force_renewal treats the Steam charge as captured (engine_stats shows steam_charged and sandbox_synthetic). Sandbox proves INVO's currency path (coins, split, events, dunning), not Valve's charge, do one real Steam sandbox authorisation before launch.

Errors you will branch on

Helper Meaning
.is_game_not_live create / steam_init refused: the game is testing (403), make it live in the console
.is_active_subscription_exists the player already has a live subscription to this item_id (409); .existing_subscription_id carries it. Usually a regenerated key inside a retry loop
.is_idempotent_replay_mismatch same key, different material terms (409); .mismatched_fields names them
.is_steam_reauthorization_required change_amount tried to raise a Steam price (409), new subscription
.is_not_a_card_subscription set_payment_method on a Steam subscription (409)
.is_steam_channel_required steam_init with a non-Steam storefront key (409)
.is_subscription_terminal canceled / expired (409), from change_amount, set_payment_method, steam_finalize
.is_not_authorized steam_finalize before the member authorised (409), the normal wait state
.is_steam_authorization_pending steam_init with a new key while a pending authorisation for the same player + item has different terms (409 STEAM_AUTHORIZATION_PENDING); .existing_subscription_id names it
.is_steam_refund_not_supported refund on a period charged through Steam (409), not supported yet
.is_sandbox_clock_unauthorized missing / wrong X-Sandbox-Clock-Key (401)
.is_flow_paused maintenance pause (503, both wire shapes), retry later with the same key
.is_phone_share_approval_required the player_phone you supplied belongs to another identity (409), run the phone-share approval, then retry

Other codes come through on err.code unchanged: the create validation codes (CLIENT_REQUEST_ID_RESERVED, AMOUNT_TOO_LARGE, TRIAL_NOT_SUPPORTED_ON_STEAM, ...), PAYMENT_METHOD_NOT_FOUND (404), WALLET_ONLY_SUBSCRIPTION (409), STEAM_RAIL_NOT_ENTITLED (403, err.is_steam_rail_not_entitled), STEAM_NOT_CONFIGURED (503, err.is_steam_not_configured), PARTNER_BILLING_NOT_SET_UP / PARTNER_RAIL_SUSPENDED / PARTNER_CREDIT_UNAVAILABLE (409, the partner-settlement helpers), and the refund and sandbox refusals listed above. Client-side guards (a reserved sub_ key, a bad interval, interval_count outside 1..36 or not 1 on Steam, wallet_only with a card, a non-E.164 player_phone, a web session without player_ip, a missing clock key) raise INVALID_INPUT before any network call.

Traps

  1. Replay before retry. On any timeout or 5xx from create, send the same body again. Never mint a new client_request_id inside a retry loop.
  2. requires_action is not paid. Nothing has been charged. Do not grant paid access until subscription.renewed.
  3. pending is not failed. Wait for the event; INVO resolves it.
  4. current_period_end is not entitlement. Use paid_through.
  5. payment_failed, past_due and authentication_required do not revoke access. Only expired (at final_period_end) and canceled (at access_until) do.
  6. Do not poll for renewals. Handle subscription.renewed.
  7. One live subscription per item. Cancel the old one before creating a new one for the same item_id, or change the price with change_amount.
  8. At-period-end cancel on an unpaid subscription is immediate. Read terminated_immediately.
  9. Price changes apply next period, and big jumps do not apply at all (more than double, or above 500.00, stays pending forever). Raise prices in steps.
  10. A card saved without off-session consent cannot back a subscription, save it again with cards.begin_setup or a save_card=True purchase.
  11. wallet_only is absolute. A card attached later is ignored until you pass wallet_only=False on set_payment_method.
  12. sub_ is reserved as a prefix for every idempotency key on the platform; metadata is yours except the key _invo.
  13. Send consent. INVO is the merchant of record on the card rail; a disputed charge is argued from that record.
  14. Do not store or log confirmation_url beside anything you publish. It is a bearer link.
  15. Steam: finalize is your call, within 24 hours; match the client callback on steam_order_id; web sessions need player_ip and a top-level window; the price is a ceiling (increases are refused); coins per period depend on the member's country and are fixed at init (show amount_coins); a member can cancel from Steam (canceled_by: "steam"); refunds of Steam-charged periods are refused (STEAM_REFUND_NOT_SUPPORTED).
  16. The clock tools are not under /api. POST /subscriptions/<id>/advance-clock and friends are relative to the sandbox base (https://sandbox.invo.network/sandbox/subscriptions/...), need X-Sandbox-Clock-Key, and do not exist in production.

Player balance

result = server.get_player_balance(player_email="p@example.com")
# Lookup is by EMAIL only — there is no by-id balance route (player_id is a per-game internal
# id). For a client-side read, use the browser InvoClient.getBalance() (identity from the token).
for b in result.balances:
    print(b.currency_name, b.available_balance, b.total_balance)

Sends & transfers

Move already-owned game currency from one player to another, authorized by the sender's passkey — the gold standard.

There are exactly two approval paths, and both are passkeys:

  • In-app passkey — wherever WebAuthn can run in the client: the browser, via the JS SDK (approveSend / approveTransfer), or INVO's hosted approval page (approveHosted).
  • Device approval (QR) — wherever it cannot: consoles, TVs, native Steam and desktop clients. This SDK drives that one end to end — the player approves on their phone and your server then makes the call that moves the money. See Consoles and TVs and Sends and transfers, stage by stage.

A legacy SMS-PIN fallback still exists for a sender who can use neither — it is being retired and new integrations should not build on it.

The server initiates:

import uuid

t = server.initiate_transfer(
    client_request_id=str(uuid.uuid4()),
    source_player_name="P",
    source_player_email="p@example.com",
    source_player_phone="+15555550100",
    target_player_email="q@example.com",
    target_player_phone="+15555550111",
    target_game_id=123456,
    amount="50",
)
# initiate_send uses sender_*/receiver_* + receiving_game_id instead.

# Check guardian_approval FIRST — the guardian path takes precedence.
if t.guardian_approval:
    ...  # minor/guardian path (HTTP 202): pending approval, do NOT show a PIN UI
elif t.verification_method == "in_app":
    ...  # sender HAS a passkey -> approve in the browser (JS SDK). The good path.
else:
    ...  # sender has NO passkey yet -> enrol one (browser enrollPasskey() / approveHosted()),
    ...  # or run the QR device-approval grant on a console. See "stage by stage" below.

On the guardian path verification_method is None (even though the raw 202 body also carries "sms") so guardian_approval wins — but branch on it first to be safe.

Both games must be Live. initiate_send/initiate_transfer raise 403 if either side is still in testing, and the two cases are deliberately separate because the fix differs: err.is_source_game_not_live is your game (self-serve — switch it to Live in the console under Game Settings > Status), err.is_target_game_not_live is the destination game, usually owned by another developer, so you can't flip it — show err.message and steer the player elsewhere via get_destinations. err.game_status carries the current state. Don't merge these into one "not live" branch; it produces a "go fix it" action that leads nowhere half the time.

  • The one message that is still a text: the claim link to a phone-only recipient. A send can be addressed to someone INVO has never met — the sender knows their phone number and nothing else, so there is no email to write to and no session to notify. That is the only remaining place INVO sends an SMS by default; everything else that needs a human (guardian consent, enrolment backup, phone-share) goes by email first. The claim text a send's receiver gets now ends with a link to a hosted INVO page ("Or collect here: …"). They see who sent what in which game, enter one email (the same thing in-game claim already requires), confirm it with an emailed 6-digit code, and their phone's fingerprint / face / screen lock creates an INVO passkey — a recipient who already has one just confirms with it. The currency lands in their balance for the receiving game; the page shows the new balance and tells them to sign in to the game with that email + phone. The in-game claim is unchanged — point a player at the link when they don't have the game open, at in-game claim when they do; both are the same claim with one lifetime. transfer.received fires the same either way, and you change nothing.

  • Resend the claim SMS — a plain REST call, not an SDK method. Authed with the sender's player token (not the game secret), no body. It re-sends the identical message (same code, same link); nothing new is minted.

    r = requests.post(
        f"{INVO_BASE}/api/sdk/send/{transaction_id}/resend-claim",
        headers={"Authorization": f"Bearer {sender_player_token}"},
    )
    # 200 {"status": "resent", "transaction_id": ..., "retry_after": 30}
    # 429 RESEND_COOLDOWN (+ retry_after) — 30-second cooldown; max 10 per transaction per hour
    # 400 CLAIM_EXPIRED / NOT_CLAIMABLE — the send can no longer be claimed; start a new send
    # 403 — the token is not the send's sender
    
  • Uncollected after 24 hours → refunded, and both parties are told. The refund to the sender is as before; what's new is that the sender gets an email and the recipient a single text saying the currency was returned, instead of silence. transfer.claim_expired + transfer.refunded remain the source of truth — reconcile off those, not the messages.

  • Guardian approval goes by email first. When a minor's initiate returns the 202, the guardian gets a signed, single-use link to a hosted INVO page with Approve / Decline — not a text. A reply-YES text goes out only when the guardian has no verified email or the email could not be delivered. The raw 202 guardian_approval block (t.raw) now carries consent_channel ("email" | "sms") — use it for your waiting copy — and resend_endpoint. Poll get_guardian_approval_status exactly as before; the approval object's consent_channel says how it went out and decision_source is email_link for a page decision.

  • "Text my parent instead" — a plain REST call, not an SDK method. Authed with the minor's player token (the session that started the transaction), once per approval:

    r = requests.post(
        f"{INVO_BASE}/api/sdk/approvals/guardian/{approval_id}/resend",
        headers={"Authorization": f"Bearer {initiator_player_token}"},
        json={"channel": "sms"},   # the only accepted value
    )
    # 200 {"status": "sent", "channel": "sms"}
    # 400 INVALID_INPUT   — channel was not "sms"
    # 403 NOT_INITIATOR   — the token is not the minor who started it
    # 404 APPROVAL_NOT_FOUND (opaque)
    # 409 CHANNEL_WAS_SMS — it already went by text; hide the button when consent_channel == "sms"
    # 409 ALREADY_RESENT  — one text per approval
    # 410 APPROVAL_GONE   — already decided or expired
    # 503 DELIVERY_FAILED — retryable; does not use up the one allowance
    

    The emailed link keeps working alongside the text; whichever answers first wins.

  • Phone-share and recipient-identity consents use the same hosted page. On err.is_phone_share_approval_required the phone's existing owner gets an emailed link (Allow / Decline) when INVO has itself proven an address for them — at most three holders, one email per request — and the 409 body carries consent_channel: "email"; the OTP text goes out instead only when no proven holder exists (consent_channel: "sms"). The RECIPIENT_IDENTITY_PENDING hold emails the phone owner's oldest verified address the same way. Nothing changes for you: same 409 / 202 codes, same poll-and-retry, and phone_share_approve (typed code) and the in-app approve still work.

Legacy fallback: the SMS PIN (being retired)

This is the legacy path. It is being retired, and new integrations should not build on it. It is kept working only for a sender who genuinely cannot approve with a passkey: an unsupported device with no platform authenticator, or a player who declines both the in-app ceremony and the QR flow.

When a sender has no enrolled approver, initiate_send / initiate_transfer return verification_method == "sms" and a one-time PIN is texted. The completion calls are server.verify_sms_transfer(transaction_id, pin) / verify_sms_send(...)deprecated since 3.1.0, still fully functional, and with no runtime DeprecationWarning so warnings-as-errors suites are unaffected. They will be removed at some future major; no date is being promised.

Why it is going away: a PIN in a text is a shared secret on a channel exposed to SIM swap, SS7 interception and social engineering, it costs real money per message at every scale, and it depends on carrier delivery INVO does not control. A passkey — in the client where WebAuthn can run, on the player's phone by QR where it cannot — is stronger, faster and free. Read verification_method == "sms" as "this sender has no passkey yet" and offer them one; show a PIN pad only when there is no other way to serve that specific player.

Sends and transfers, stage by stage

One reference for the whole money path — both flows (send and transfer) and both sides (the sender who authorises, the recipient who collects). It exists because the stages are easy to read as three when there are four, and the fourth one is the money.

Companion page, same title, on the docs site: Sends and transfers, stage by stage — the same walkthrough with the raw REST calls, for stacks that do not use this package.

The four stages.

  1. Initiate (your server). Nothing has moved; a transaction now exists, pending the sender's approval.
  2. Prove who is approving. A passkey ceremony in the browser (the JS SDK), on INVO's hosted page, or — where no browser exists — a device approval grant on the player's phone, driven from here.
  3. Settle (your server, for the grant path). A proof is not a payment. The approval says who; a separate call moves the value and runs every gate (guardian, risk, recovery cooldown). In the browser the approve call does both at once, which is why the split only becomes visible on the grant path.
  4. Collect. The recipient confirms receipt (their own passkey, or the grant), or redeems the claim code. Reconcile off webhooks, never off stage 3's response alone.

What your server must call, in order

# Stage Send Transfer Auth
1 Initiate server.initiate_send(...) server.initiate_transfer(...) game secret
2 Mint the player's session server.mint_player_token(player_email=...) same game secret
3a Sender approves — browser JS SDK approveSend(txnId) JS SDK approveTransfer(txnId) player token
3b Sender approves — web, hosted page JS SDK approveHosted({ flow: "send" }) ... flow: "transfer" player token
3c Sender approves — console / TV / native server.begin_device_approval(flow="send") -> poll_device_approval ... flow="transfer" player token
4 Settle the grant (3c only) server.approve_with_device_code(flow="send", device_code=...) ... flow="transfer" player token
5a Recipient collects — browser JS SDK confirmReceiptSend(txnId) JS SDK confirmReceiptTransfer(txnId) player token
5b Recipient collects — console begin/poll flow="send_receipt" -> approve_with_device_code ... flow="transfer_receipt" -> approve_with_device_code player token
5c Recipient collects — claim code server.claim_currency(claim_code=..., ...) server.claim_transfer(claim_code=..., ...) game secret
6 Reconcile transfer.claim_pending -> transfer.received webhooks same webhook secret

Steps 3c, 4 and 5b take the player token explicitly — on a console the game server holds the token it minted, and the game secret is never sent on any of them.

The four device-approval flows

flow picks which endpoint stage 4 settles against. Begin with one flow and settle with the same one: a grant approved for transfer settles nothing else.

Side flow What approve_with_device_code calls Success looks like
Sender, send send POST /api/sdk/send/{id}/approve status="approved", next="pending_claim"
Sender, transfer transfer POST /api/sdk/transfers/{id}/approve status="approved", next="pending_claim", claim_code
Recipient, send send_receipt POST /api/sdk/send/{id}/confirm-receipt status="completed", amount_received
Recipient, transfer transfer_receipt POST /api/sdk/transfers/{id}/confirm-receipt status="completed", amount_received

Errors on the money path

Code HTTP Means Do
TRANSACTION_NOT_PENDING 400 The transaction is not at this step. Three different situations: your earlier attempt already landed; the transaction is dead (expired / refunded); or — on a receipt flow — it has not got here yet, because the sender's own approval is still pending or held at a guardian. Not raised by approve_with_device_code — it comes back as status="not_pending" with already_settled and current_status. already_settled separates the first case from the other two. When it is False, go read get_transfer_status / get_send_status rather than assuming; never retry-loop.
DEVICE_APPROVAL_NOT_APPROVED 400 The device_code was unknown, another player's, bound to a different transaction, bound to a different flow, or the player has not approved yet. One answer for all five, deliberately — distinguishing them would leak which transactions have a live approval. err.is_device_approval_not_approved. Check the flow matches the begin, and that the poll actually said approved.
DEVICE_APPROVAL_ALREADY_PENDING 409 An approved grant for this transaction and flow is still inside its window. The player already approved. err.is_device_approval_already_pending, err.device_approval_expires_at. You are missing stage 4, not a new code. (A merely pending earlier grant does not 409; begin supersedes it.)
PASSKEY_RECOVERY_COOLDOWN 403 Money out is paused for 24 h after a passkey recovery (SIM-swap protection). Receiving and collecting are unaffected. err.is_passkey_recovery_cooldown, err.retry_after_at (ISO; the body also carries retry_after_seconds). Expect a fresh initiate later — do not retry-loop.
SEND_APPROVE_FAILED, TRANSFER_APPROVE_FAILED, CONFIRM_RECEIPT_FAILED 400 A fault on INVO's side, not a rule. err.is_settle_fault; the body carries error_ref (err.error_ref). Never retryable. See the ambiguity note below — do not tell the player it failed until you have read the transaction. Quote error_ref to INVO support; it names the exact server log line.
guardian hold 202 A minor's transaction is waiting on a guardian. The money is held, not refused. Returned as a result with hold_reason; poll get_guardian_approval_status. Terminal outcomes (GUARDIAN_APPROVAL_REJECTED / _EXPIRED) raise as 410.

Also reachable from a receipt settle (send_receipt / transfer_receipt) — a console recipient built only from the table above ships with no fallback for these:

Code HTTP Means Do
receiver_not_enrolled_use_claim_code 409 The recipient has no passkey in this game, so there is no in-app confirm to run. err.is_receiver_not_enrolled. Fall back to the claim code — show it, or point them at the hosted claim link from their text.
not_intended_receiver 403 The token's identity is not the addressed recipient. Not retryable. You minted the token for the wrong player, or the send was addressed to a different phone.
PHONE_SHARE_APPROVAL_REQUIRED 409 The receiver phone is contested, so INVO holds the money and asks its owner to approve. err.is_phone_share_approval_required. Not a failure: show err.message verbatim (err.phone_share_last4 names the phone), then retry the same call once approved — it then succeeds idempotently. On denial/expiry the sender is refunded.
RECIPIENT_IDENTITY_PENDING 202 Same shape, on the identity check: money held, the phone's owner emailed. Comes back as a result with hold_reason, not a raise. Keep the collect pending and retry later.
RECIPIENT_IDENTITY_DECLINED 403 The phone's owner said no. Terminal. The sender is refunded; do not retry.

⚠️ A settle FAILURE is ambiguous about the money. INVO commits the transfer and then builds its 200 by reading the transaction back — so a fault raised after that commit answers 400 *_FAILED for money that already moved. An err.status == 0 (timeout, dropped connection) is ambiguous for exactly the same reason, and this call is deliberately never auto-retried. On either: read get_transfer_status / get_send_status, or wait for the transfer.claim_pending webhook, before you tell the player it failed — and never auto-start a replacement transaction.

"Already settled" is not a refusal

The integrator question this section was written for: did the guard stop me, or is her gold stuck? You must be able to answer that without matching on message text.

approve_with_device_code does not raise for TRANSACTION_NOT_PENDING. It returns:

r = server.approve_with_device_code(
    player_token=player_token,
    transaction_id=transaction_id,
    flow="transfer",
    device_code=device_code,
)
if r.status == "not_pending":
    if r.already_settled:
        # This step already happened — typically your first attempt landed and the
        # response was lost. r.current_status says where it is now
        # (pending_claim, completed, ...).
        ...
    else:
        # NOT settled. Either dead (expired / failed / refunded), or — on a receipt flow —
        # not there YET: the sender has not approved, or their approval is held at a
        # guardian. It is also what an unrecognised status returns, deliberately.
        # Go and read it; do not guess and do not start a replacement transaction:
        t = server.get_transfer_status(transaction_id)
        ...
else:
    # status "approved" (+ next="pending_claim", r.claim_code on a transfer)
    # or "completed" (+ r.amount_received) on the receipt flows
    # or a 202 hold — r.hold_reason names it.
    ...

Every other refusal is a normal raised InvoError with a typed property, so a try/except around this call still means "something is wrong". Nothing in your code has to read an English sentence to tell the two apart.

already_settled fails closed. It is True only for a status INVO knows to be past this flow's step — and the two receipt flows have a different set from the two sender flows, because the four endpoints gate on different statuses. Anything else, including a status this SDK version has never seen, answers False with current_status set. Cautious and wrong costs you a status lookup; confident and wrong credits goods for money that never arrived.

(Older INVO deployments answered this case with only a message — "Transaction not pending PIN verification…", "…cannot be confirmed…" — and no machine code. The SDK matches those shapes too, so already_settled is correct against an older backend without any string-matching in your code. That fallback requires HTTP 400, one of those two phrases, and a readable (current status: …) / (status: …) clause: "cannot be confirmed" is ordinary English, so a phrase match alone is never allowed to assert that money moved.)

Common mistakes

1. Polling to approved and stopping. The one that costs real money. A device approval grant is a factor, not a payment: approved means the player proved who they are, and the transaction is still sitting at pending_pin_verification. You must call approve_with_device_code. Nothing else in this SDK settles it, and no webhook does it for you — device_approval.approved is the cue to call it. Left alone, the transaction expires on its own window and is refunded.

2. Treating "already settled" as a failure. A repeat call after a crash or a lost response answers TRANSACTION_NOT_PENDING. That is your earlier attempt succeeding, not a refusal. Branch on r.already_settled — never on the message.

3. Sending the game secret on the grant calls. Every device-approval call authenticates with the player token. A game secret in a console build is a game secret in a disassembler.

4. Beginning a second grant instead of settling the first. If begin answers 409 DEVICE_APPROVAL_ALREADY_PENDING, the player has already approved; you need stage 4, not another QR.

5. Polling faster than interval. You get slow_down; add 5 seconds and resume.

6. Using the wrong flow at stage 4. The sender's grant (transfer) cannot settle the recipient's step (transfer_receipt) — it answers DEVICE_APPROVAL_NOT_APPROVED, which reads like a broken code and is really a wrong flow.

7. Granting value off stage 3's HTTP response. Reconcile off webhooks.

Inbound pending & linked identities

"You have X to collect" (server, game-secret): the player's incoming, unclaimed sends/transfers — including value sent from other games to a player on your platform.

pending = server.get_inbound_pending(player_email="p@example.com")  # or player_phone=...
for row in pending.inbound_pending:
    # Match row.to_phone to the logged-in player. row.to_identity_id is None when the phone
    # maps to more than one of your players — don't require it.
    print(row.transaction_id, row.net_amount, row.to_phone, row.source_game)
  • Lists only pending/unclaimed inbound; once claimed it drops off. row.source_game is where it came from (another game/platform). Pairs with the transfer.claim_pending webhook (the webhook is the wake-up; this is the list).
  • This is the server/platform view (game-secret). The browser player-token equivalent lives in the JS SDK as client.getPendingCollect() (there, incoming rows are kind="receiving_confirm").

Linked wallet identities (server-only — returns PII):

ident = server.get_linked_identities(player_email="p@example.com")  # phone wins if both given
if ident.not_found:
    ...  # no in-game match (backend 404) — treat as "no linked identities", not an error
else:
    print(ident.primary_email, ident.is_minor, [e.email for e in ident.emails])

⚠️ Returns first-party PII (emails/phones) — never expose this to the browser.


Consoles and TVs — approving without a browser (RFC 8628)

A console has no browser, so it cannot run a passkey ceremony at all. Device Approval moves the ceremony to a device that can: your game shows a short code, the player approves on their phone, your server polls until it hears back — and then your server makes the call that actually moves the money. It is the OAuth 2.0 Device Authorization Grant, RFC 8628 — the same flow every console and TV app uses — so a standard client library understands the responses.

The grant is a factor, not a payment. Reaching approved proves who approved and settles nothing. The transaction is still waiting. approve_with_device_code is the money step, and without it the transaction expires and is refunded. This is the single most common integration failure on this flow — see Sends and transfers, stage by stage.

Five SDK methods, since 3.6.0 (begin_device_approval, poll_device_approval, confirm_device_enrollment, approve_with_device_code, and the optional complete_device_approval loop). Earlier versions documented this as raw REST only, which is how a grant could be polled to approved and then dropped. The REST endpoints are unchanged and still usable directly.

Every one of these calls authenticates with the player token, never the game secret — on a console it is your server that holds the token it minted for the player.

The endpoints underneath, for a stack that is not using this package. All four take Authorization: Bearer <player token>; none takes the game secret:

SDK method Endpoint
begin_device_approval POST /api/sdk/approvals/device/begin
poll_device_approval POST /api/sdk/approvals/device/poll
confirm_device_enrollment POST /api/sdk/approvals/device/confirm-enrollment
approve_with_device_code, flow="send" POST /api/sdk/send/{transaction_id}/approve
approve_with_device_code, flow="transfer" POST /api/sdk/transfers/{transaction_id}/approve
approve_with_device_code, flow="send_receipt" POST /api/sdk/send/{transaction_id}/confirm-receipt
approve_with_device_code, flow="transfer_receipt" POST /api/sdk/transfers/{transaction_id}/confirm-receipt

Each settle call takes the body {"device_code": "..."} and the player's bearer token — the same token that began the grant.

Available to every title, with nothing to configure. The constraint is a property of the device, not your game: a title with a website and a console build uses the normal ceremony in the browser and this one on the console.

Native Steam and desktop game clients (Mac & Windows) belong here too. Even though the operating system supports passkeys, an embedded game client can't invoke the platform authenticator from inside the engine — so a native desktop title uses this QR flow exactly as a console does. It is the client that forces QR here, not the OS.

import time
from invonetwork import InvoError

player_token = session["invo_player_token"]   # minted by mint_player_token()

# 1. BEGIN — start an approval for ONE transaction.
#    flow:    "transfer" | "send" | "send_receipt" | "transfer_receipt"
#    channel: "qr" (default — console / TV / native desktop) or "app_browser"
#             (the Unity / Unreal plugin opens the system browser; INVO derives the
#             return scheme invo-sdk-<game_id> itself, never caller-supplied).
#             "popup" is BROWSER-only — the page must call begin itself so its https
#             Origin becomes the popup's opener; that is the JS SDK's approveHosted().
#             This SDK refuses it before the network.
grant = server.begin_device_approval(
    player_token=player_token,
    transaction_id=transaction_id,
    flow="transfer",
)
# grant.device_code / user_code / verification_uri / verification_uri_complete
#      / expires_in / interval / channel

# 2. SHOW — render grant.verification_uri_complete as a QR and print grant.user_code
#    under it. The QR already carries the code, so most players never type.
#    Keep grant.device_code on your server: it is a bearer credential.

# 3. POLL — never faster than grant.interval seconds.
interval = grant.interval
while True:
    time.sleep(interval)
    poll = server.poll_device_approval(
        player_token=player_token, device_code=grant.device_code
    )

    if poll.status == "authorization_pending":
        # A first-time phone may be waiting on YOUR screen — see `enrollment` below.
        if poll.enrollment:
            show_match_prompt(poll.enrollment)
        if poll.interval:
            interval = poll.interval
        continue
    if poll.status == "slow_down":        # RFC 8628 §3.5
        interval += 5
        continue
    if poll.status == "access_denied":
        return player_declined()
    if poll.status == "expired_token":
        return start_over()               # begin again

    # 4. APPROVED -> **CALL THE MONEY STEP.** Nothing has moved until this returns.
    settle = server.approve_with_device_code(
        player_token=player_token,
        transaction_id=transaction_id,
        flow="transfer",                  # the SAME flow you began with
        device_code=grant.device_code,
    )

    if settle.status == "not_pending":
        # Your earlier attempt already landed (settle.already_settled) or the
        # transaction is dead (expired / refunded). Read settle.current_status.
        # NOT a refusal.
        ...
    else:
        # "approved" + next="pending_claim" (+ settle.claim_code on a transfer), or
        # "completed" + settle.amount_received on the receipt flows, or a 202 hold
        # (settle.hold_reason).
        ...
    break

Or let the SDK run stages 3 and 4complete_device_approval polls on interval, adds 5 s on slow_down, surfaces the enrolment prompt, and on approved calls approve_with_device_code for you:

def on_enrollment(enrollment, respond):
    if enrollment is None:
        return hide_match_prompt()
    show_match_prompt(enrollment)        # "Set up INVO on <device_label>? Code <match_code>"
    respond("approve" if player_said_yes() else "deny")

done = server.complete_device_approval(
    player_token=player_token,
    transaction_id=transaction_id,
    flow="transfer",
    device_code=grant.device_code,
    interval=grant.interval,
    on_enrollment=on_enrollment,
    should_stop=lambda: shutting_down,   # optional
)
# done.status: "approved" (done.settlement carries the result) | "denied" | "expired" | "stopped"
#   ("stopped" is your should_stop callback; the JS SDK calls its equivalent "aborted",
#    because there it is an AbortSignal. Same situation, different mechanism.)

Convenience only. It blocks for as long as the player takes, so it suits a worker or a job, not a request handler with a short timeout. And because it moves money from inside a loop, record the attempt in your own store before you call it — if the process dies between the approve call and its response, the only way to learn whether the money moved is a record you wrote first, plus get_transfer_status / get_send_status. Servers that cannot block should use the three calls directly and keep device_code in their own store.

  • One approval authorises one transaction. Unlike the plain RFC 8628 grant, which authorises a client, an INVO device code is bound to the transaction and the flow you named. It cannot approve anything else.

  • When the code is spent: at the money step, not at approval. The device_code stops being usable when approve_with_device_code settles the transaction — because the transaction is then no longer pending, and that is what the settle call checks under a row lock. Polling to approved consumes nothing; a second approve_with_device_code with the same code answers TRANSACTION_NOT_PENDING (returned as status="not_pending", not raised) — which, on a sender flow, is how you confirm that a settle whose response you lost did in fact land.

  • user_code is meant to be seen; device_code is not. Poll from your backend, never from the game client, and never log or render the device code.

  • One live code per transaction and flow. Calling begin again while an earlier grant is merely pending supersedes it — you get a fresh code and a page still open on the old one is told to scan again (the right answer when a player retries after a crash or an abandoned phone ceremony). If the earlier grant was already approved, the begin is refused with 409 DEVICE_APPROVAL_ALREADY_PENDING + expires_at (err.is_device_approval_already_pending, err.device_approval_expires_at): the player has approved and you are missing the money step, not a new code. Once that approved grant's own window passes, begin works again for the same transaction. The sender's grant and the recipient's are scoped separately, so transfer and transfer_receipt never block each other.

  • Starting an approval extends the transaction's window. For transfer and send, begin pushes the transaction's own approval window past the code's expiry, so an approval at minute nine can't land on a transfer that has already expired and been refunded.

  • Subscribe to device_approval.approved to learn the moment an approval lands instead of waiting for the next poll — then call approve_with_device_code. The webhook is a latency improvement, not the money step and not a replacement for it; the transfer.* lifecycle events that follow are what you reconcile against.

  • First-time approvers enrol on the spot — your only job is one Yes/No prompt. The approval page uses a passkey registered with INVO, on INVO's own domain; a passkey the player set up on your domain is a different credential and cannot be used there — that is how passkeys work. So the first scan enrols one, and the proof it's really them is your game screen: the phone says "Confirm on your game screen", your game shows "Set up INVO on this phone: iPhone?", the player presses Yes on the console, and their phone's fingerprint / face / screen lock creates the passkey and approves. No code to type, no SMS, ever. Every later scan is scan, biometric, done. Same begin/poll, same device_approval.approved webhook, same method value device_grant_webauthn, same approve call with the device_code afterwards.

  • Poll: the enrollment object. While a phone is waiting on your screen, the authorization_pending body gains it (absent when no phone asked — an already-enrolled phone never triggers it):

    {"error": "authorization_pending",
     "enrollment": {"state": "awaiting_screen", "device_label": "iPhone",
                    "match_code": "48-27", "recovery": false,
                    "requested_at": "2026-09-03T18:04:12+00:00"}}
    

    Show the prompt while state is awaiting_screen — "Set up INVO on iPhone? Code 48-27. Say Yes only if the phone you just scanned shows this code." — and remove it the moment it changes (confirmed / denied) or the object disappears. The player confirms a match, so draw match_code large. device_label is from a fixed list (iPhone, iPad, Android phone, Android device, Mac, Windows PC, Chromebook, Linux device, phone), derived from the scanning phone's browser — a hint, not an identity. Lead the copy with "scanned just now". recovery: true means the phone declared it is replacing a lost INVO passkey — word the prompt "Replace your INVO passkey with this iPhone? Code 48-27" and answer through the same confirm-enrollment (see Recovering a lost passkey below).

  • Answer it: confirm_device_enrollment — the same player token as begin/poll (POST /api/sdk/approvals/device/confirm-enrollment underneath).

    r = server.confirm_device_enrollment(
        player_token=player_token,
        device_code=grant.device_code,
        decision="approve",              # or "deny"
    )
    # r.status: "confirmed" | "denied"
    # raises 409 DEVICE_APPROVAL_ENROLLMENT_ALREADY_DECIDED {"decided", "via"}
    #     -> the backup email answered first; take the prompt down quietly
    # raises 409 DEVICE_APPROVAL_NO_ENROLLMENT_PENDING -> no phone has asked
    # raises 400 invalid_grant / expired_token -> the grant is gone; the next poll says so
    

    Inside complete_device_approval the respond callable does this for you and swallows exactly those four "the prompt is moot" refusals, because a prompt nobody answered in time must not break the loop.

    Deny ends the grant: poll returns access_denied, and the phone says it was declined on the game screen.

  • Backup email. At the scan, an email goes to the address on file — "confirm on your game screen; can't see the prompt? tap here; wasn't you? tap here; ignore this if you already confirmed". Opened after the screen answered, it says "you already confirmed this". A backup for a missed prompt, not the proof channel; nothing for you to do.

  • Mobile (channel: "app_browser"): enrolment is auto-confirmed, no prompt. The Unity / Unreal plugins open the same page in the system browser and return on invo-sdk-<game_id>://done, which carries nothing. Because the phone running the page is the device that opened it, the game's prompt is unreachable and a match code proves nothing — so INVO confirms the enrolment itself: the poll's enrollment object appears already confirmed and goes straight to approved; the backup email still goes out as the "if this wasn't you" alert. Start polling on interval the moment begin returns — the return is only a hint to poll sooner.

  • Recovering a lost passkey — from the page, nothing new on your side. A player whose only INVO passkey was on a phone they no longer have taps "Recover my passkey by email" on the page. Recovery needs both halves of the identity: your game screen confirms the phone (the recovery: true prompt above — auto-confirmed on mobile), and only then a single-use recovery link is emailed to the address on file (never an address the page supplies; one recovery per identity per 24 h). Opened on the same phone, that tab confirms and the page carries on: INVO deactivates the old INVO passkeys, notifies the owner on every channel, enqueues an identity.passkey_reset webhook to your game, and the phone enrols a fresh passkey and settles the approval — your poll reports approved and device_approval.approved fires as usual. The hosted pages are browser-only by design; there is no server-side call to make and nothing for this SDK to wrap.

    ⚠️ Money out is paused for 24 hours after a recovery. The approval settles, but the approve call for a transfer / send is refused with 403 PASSKEY_RECOVERY_COOLDOWN (err.is_passkey_recovery_cooldown, err.retry_after_at). The page tells the player "recovered; start this transfer again after the hold" — expect a fresh initiate later, not a retry. Receiving and collecting are unaffected.

  • An identity with a method elsewhere must vouch for the new one. If the identity already has an active approver — a passkey on a partner domain, the INVO app's device key — the page refuses to enrol beside it with 409 ENROLLMENT_REQUIRES_PROOF and tells the player to add this phone from where that method lives (device link), then scan again. An identity with no method enrols freely.

  • The code box on the page is the RFC 8628 user_code, there for a TV with no camera. When the QR pre-fills it, the box isn't shown. It is not an SMS field.

  • Currency purchases on a console go through that console's store, as the platform holder requires. This flow is for approving transfers and sends.


Webhooks

Synchronous responses are for UX; reconcile and grant value off webhooks. They're HMAC-signed; dedupe on idempotency_key (stable across retries/replays).

verify_webhook does constant-time HMAC-SHA256 over f"{t}.{raw_body}", enforces a 5-minute replay window, and accepts a list of secrets during rotation. Pass the raw request bytes (never a re-parsed object).

Flask

from flask import Flask, request, Response
from invonetwork import verify_webhook, InvoError

app = Flask(__name__)
seen = set()  # replace with a durable store

@app.post("/invo/webhooks")
def invo_webhooks():
    try:
        event = verify_webhook(
            request.get_data(),                        # raw bytes — do NOT use request.json
            request.headers.get("X-Invo-Signature"),
            os.environ["INVO_WEBHOOK_SECRET"],         # or [old_secret, new_secret] during rotation
        )
    except InvoError as e:
        return Response(e.code or "invalid_signature", status=400)

    if event.idempotency_key in seen:
        return Response(status=200)                     # already processed
    seen.add(event.idempotency_key)

    if event.event_type == "purchase.completed":
        grant_currency(event.data)                      # event.data is a dict
    elif event.event_type == "item.purchased":
        grant_item(event.data)
    # transfer.*, payout.status_changed, ...

    return Response(status=200)                          # 2xx fast; offload slow work

FastAPI

from fastapi import FastAPI, Request, Response
from invonetwork import verify_webhook, InvoError

app = FastAPI()

@app.post("/invo/webhooks")
async def invo_webhooks(request: Request):
    raw = await request.body()  # raw bytes
    try:
        event = verify_webhook(
            raw,
            request.headers.get("x-invo-signature"),
            os.environ["INVO_WEBHOOK_SECRET"],
        )
    except InvoError as e:
        return Response(e.code or "invalid_signature", status_code=400)

    # de-dupe on event.idempotency_key, then grant value.
    handle(event)
    return Response(status_code=200)  # raise / return 5xx to make INVO retry

verify_webhook raises InvoError (all status == 0) with one of these codes on failure: WEBHOOK_SIGNATURE_MISSING, WEBHOOK_SECRET_MISSING, WEBHOOK_TIMESTAMP_EXPIRED, WEBHOOK_SIGNATURE_INVALID, WEBHOOK_MALFORMED. Return a 4xx on those; return a 5xx from your own handler if you want INVO to retry.

Key event types

Event Fires for Use it to
purchase.completed every currency-purchase rail grant currency (data includes usd_amount, currency_amount, new_balance, rail, metadata) — metadata echoes what you passed to create_checkout/purchase_currency (all rails); order_id is also on every webhook as a secondary reconciliation key (get_order_details).
item.purchased every item purchase grant the in-game item (data includes item_id, item_quantity, total_price, new_balance, fee_breakdown)
platform_commerce.purchased every Platform Commerce purchase (balance immediately; card after payment settles) fulfill the ecommerce order — never on the browser confirm (data: transaction_id, order_id, funding_source, player_email, identity_id, item_id, item_name, item_quantity, total_price; + unit_price, currency_name, new_balance on balance; total_price_usd on card; fee_breakdown)
platform_commerce.refunded a Platform Commerce refund handle the reversal (data: order_id, funding_source, player_email, refunded_amount, amount_unit, fee_retained)
subscription.renewed every successfully charged subscription period, including period 1 (both rails) extend access to current_period_start (the new paid_through); split.partner_revenue_usd is what you earned; typed view SubscriptionRenewedData
subscription.payment_failed / .past_due / .authentication_required a failed attempt / entering dunning / a cardholder challenge (card rail) inform the member, relay confirmation_urlnever revoke on these (SubscriptionPaymentFailedData, SubscriptionPastDueData, SubscriptionAuthenticationRequiredData)
subscription.canceled / .expired a cancel request (either mode; canceled_by partner or steam) / the retry budget ran out revoke at access_until / at final_period_end (SubscriptionCanceledData, SubscriptionExpiredData)
subscription.refunded a subscription refund reverse per refund; revenue_share_attribution.net_attributed_amount_usd (SubscriptionRefundedData)
purchase.failed / .disputed / .refunded rail-dependent handle failures / disputes / refunds
transfer.* sends & transfers reconcile claim state

Resilience & observability

  • Automatic retries. Transient failures — network errors/timeouts, 429 (honoring retry_after, capped at 20s), and 5xx — are retried with exponential backoff + jitter. Configure with max_retries (default 2, 0 disables) and retry_base_delay. Mutating calls carry idempotency keys, so retries are safe; non-idempotent calls (e.g. hosted checkout creation) are never auto-retried.
  • Hooks. Best-effort tracing/metrics (a throwing hook never breaks a request):
from invonetwork import Hooks

server = InvoServer(
    game_secret=..., base_url=...,
    hooks=Hooks(
        on_request=lambda i: log(i.method, i.url, i.attempt),
        on_response=lambda i: metric(i.status, i.duration_ms, i.request_id),
        on_error=lambda i: log(i.error.status, i.will_retry),
    ),
)

Hook payloads include the request url, which for some calls embeds a player email. The game secret is a header and is never passed to hooks — redact url if you log payloads.

  • Request ids. InvoError.request_id carries the backend request id — quote it in support tickets.

Errors

Every failure raises InvoError with:

  • .code — stable machine code when present (some txn-state errors have none — branch on .message)
  • .status — HTTP status (0 for client-side validation and network errors)
  • .message — human-readable
  • .body — the raw parsed response
  • .request_id — backend request id, when present

.status == 0 means "no HTTP response" — and nothing else

This is the single most important thing to get right when handling INVO errors:

err.status What actually happened What to tell the developer
0 No HTTP response — DNS failure, connection refused, TLS failure, timeout, or a client-side guard that ran before any network call "couldn't reach INVO"
4xx The API answered, with a precise refusal. .code and .message are populated show .message — it says what to do
5xx The API answered with a server fault retry; escalate if it persists

The SDK never collapses a non-2xx into a transport error. A 403 arrives as status == 403 with .code and .message intact; only a genuine absence of a response produces status == 0. Covered by regression tests in both SDKs.

The failure mode to avoid is in your exception handler:

# ✗ Loses everything the API told you. A 403 with a precise, actionable refusal
#   renders as a network outage, and the developer debugs a 500 that never happened.
try:
    server.initiate_transfer(**payload)
except Exception:
    show_toast("Could not reach the server")

# ✓ Distinguish "no response" from "answered with a refusal".
try:
    server.initiate_transfer(**payload)
except InvoError as e:
    if e.status == 0:
        show_toast("Couldn't reach INVO — check your connection.")
    else:
        show_toast(e.message)      # the API already wrote the actionable text

This is not hypothetical. A developer once spent a debugging session hunting a 500 that didn't exist — the logs showed two clean 403s carrying exact remediation steps, and a catch-all in the integration layer had rendered them as "could not reach the server."

409 is usually "not ready" or "already done", not a failure

INVO uses 409 for retryable not-ready states, not errors: a duplicate client_request_id, an already-refunded order, an unverified domain. Branch on them (.is_duplicate_request, .is_already_refunded, .is_phone_share_approval_required, .is_phone_share_already_approved) and treat them as "retry" or "already done" — surfacing them as red error states misrepresents what happened.

Classifiers:

Helper Meaning
.is_token_expired player token expired — re-mint + retry
.is_receiver_not_enrolled recipient has no passkey → switch to claim-code entry
.is_insufficient_balance item purchase failed (400); required_amount + current_balance on .body
.is_duplicate_request idempotency-keyed request was a duplicate (409)
.is_not_platform_tenant Platform Commerce called by a non-platform tenant (403) — use purchase_item/create_checkout instead
.is_amount_below_minimum / .is_below_card_minimum / .is_above_card_maximum Platform Commerce amount too small for the fee to round up / card charge under $0.50 / card charge over $999.99 (400)
.is_already_refunded Platform Commerce refund of an already-refunded order (409) — treat as already done
.is_game_not_live / .is_active_subscription_exists / .is_idempotent_replay_mismatch / .is_steam_reauthorization_required / .is_not_a_card_subscription / .is_steam_channel_required / .is_subscription_terminal / .is_not_authorized / .is_steam_authorization_pending / .is_steam_refund_not_supported / .is_sandbox_clock_unauthorized / .is_flow_paused subscription refusals — see Errors you will branch on (.existing_subscription_id, .mismatched_fields carry the bodies' extras)
.is_phone_share_approval_required phone needs owner approval — at register/mint or at claim_transfer/claim_currency (contested receiver phone). Not a failure: money held, phone owner texted. Show .message (+ .phone_share_last4), re-issue the same claim after approval; sender refunded on denial/expiry
.is_phone_share_already_approved the phone-share (phone, requesting_email) pair was already approved
.is_steam_value_non_transferable initiate blocked (409): more than the non-Steam balance to a non-Steam destination → show .message, cap at .steam_transferable_max (.steam_origin_amount = Steam-locked portion)
.is_non_steam_value_into_steam_blocked initiate blocked (409): non-Steam value can't move into a Steam title → show .message, pick a non-Steam destination
.retry_after seconds to back off on a 429 throttle
.is_enrollment_authorization_required first-enrollment needs the OTP grant
.is_enrollment_proof_required another method exists → prove it via device link
.is_source_game_not_live (403) the caller's OWN game is in testing → self-serve: switch it to Live in the console (Game Settings > Status). Show .message; .game_status has the current state
.is_target_game_not_live (403) the DESTINATION game is in testing → the caller usually can't fix this (someone else's game). Show .message; steer to another destination via get_destinations. Deliberately distinct from .is_source_game_not_live — don't collapse them, the remediation differs
.is_webauthn_not_enabled_for_tenant (403) tenant has no verified RP ID → configuration state, not a failure. The browser half should fall back to the SMS/in-app path; see passkey prerequisites
from invonetwork import InvoError

try:
    server.purchase_item(...)
except InvoError as e:
    if e.is_insufficient_balance:
        show_top_up(e.body)  # {required_amount, current_balance}
    else:
        raise

API reference

InvoServer

Construct: InvoServer(game_secret, base_url, *, timeout=30, max_retries=2, retry_base_delay=0.25, user_agent=..., hooks=None, http=None, sandbox_clock_key=None)

Method Returns
mint_player_token(player_email, player_phone?) PlayerToken(token, expires_at, identity_id, raw) — session mint for an existing player (404 if unknown); player_phone optional/validated-if-present (see Player token)
initiate_send(...) InitiateResult(transaction_id, verification_method, guardian_approval, raw)
initiate_transfer(...) InitiateResult
create_checkout(player_email, usd_amount, rail?, success_url?, cancel_url?, metadata?) CreateCheckoutResult(session_id, checkout_url, expires_at, expires_in_seconds, raw)
purchase_currency(player_email, usd_amount, purchase_reference, rail?, payment_method_id?, saved_card_id?, player_name?, player_phone?, metadata?) PurchaseResult(status, client_secret?, payment_intent_id?, payment_url?, transaction_id?, order_id?, new_balance?, raw)
confirm_payment(payment_intent_id, order_id?) ConfirmPaymentResult(status, transaction_id?, new_balance?, raw)
get_order_details(order_id? | transaction_id?) OrderDetailsResult(order, financial_summary, status_timeline, raw)
purchase_item(...) PurchaseItemResult(status, transaction_id, order_id, new_balance, previous_balance, currency_name, financial_breakdown?, raw)game tenant spending game currency on an in-game item
get_item_purchase_history(player_email, limit?, offset?) ItemHistoryResult(history, pagination, raw)
get_item_order_details(order_id? | transaction_id? | client_request_id?) OrderDetailsResult
iterate_item_purchase_history(player_email, page_size?) generator of history rows (dict)
platform_commerce.purchase(*, client_request_id, funding_source, player_email, player_name, item_id, item_name, item_quantity, unit_price, total_price, player_phone?, item_description?, item_category?, success_url?, cancel_url?, metadata?) PlatformPurchaseResult(status, funding_source, order_id?, transaction_id?, new_balance?, financial_breakdown?, session_id?, checkout_url?, expires_at?, amount_usd?, idempotent_replay, raw)ecommerce (platform tenant); balance settles now, card returns a hosted-checkout checkout_url
platform_commerce.get_status(order_id) PlatformOrderStatusResult(order_id, status, game_currency_amount?, usd_amount?, payment_method?, created_at?, raw)
platform_commerce.refund(order_id? | client_request_id?, reason?) PlatformRefundResult(status, order_id?, funding_source?, refunded_amount?, amount_unit?, fee_retained?, raw) — pass exactly one id; INVO keeps its fee
subscriptions.create(*, client_request_id, player_email, player_name, item_id, amount_usd, player_phone?, item_name?, interval="month", interval_count=1, trial_days?, trial_end?, wallet_only?, player_card_id?, metadata?, consent?, revenue_share?) SubscriptionResult(subscription, first_charge, card?, idempotent_replay, warning?, raw) with FirstCharge(status, amount_usd?, paid_through?, paid_period_seq?, failure_code?, next_retry_at?, confirmation_url?, expires_at?, message?, raw)card rail; charges period 1 before responding; branch on first_charge.status (paid / requires_action / skipped_trial / failed / pending); key entitlement on paid_period_seq, not subscription.period_seq; idempotent on client_request_id (a replay is a 200 with idempotent_replay, never a second charge)
subscriptions.get(subscription_id, include_player=False) Subscription(subscription_id, status, amount_usd, pending_amount_usd?, interval, interval_count, item_id, item_name?, current_period_start?, current_period_end?, period_seq, next_charge_at?, paid_through?, cancel_at_period_end, trial_end?, canceled_at?, ended_at?, wallet_only, has_payment_method, funding_rail, steam_agreement_status?, client_request_id, game_id, player_id?, metadata?, consent, revenue_share?, created_at?, updated_at?, raw)paid_through is the entitlement boundary
subscriptions.list_for_player(player_email, *, status?, limit?, offset?, include_player=False) SubscriptionListResult(player_email, subscriptions, pagination, raw) — newest first; status may include live; unknown player = empty list
subscriptions.cancel(subscription_id, *, at_period_end=True, reason?) CancelResult(status, already_canceled, cancel_at_period_end, effective_at?, access_until?, paid_through?, terminated_immediately, downgrade_reason?, final_status?, steam_agreement_status?, steam_agreement_canceled?, subscription, raw) — at-period-end on an unpaid subscription is downgraded to immediate; a terminal row answers 200 with already_canceled
subscriptions.change_amount(subscription_id, amount_usd) AmountChangeResult(status, old_amount_usd, new_amount_usd, pending_amount_usd?, change_queued, applies_from?, applies_from_period_seq?, paid_through?, current_period_end?, prorated, subscription, raw)staged for the next renewal, no proration; a Steam increase raises err.is_steam_reauthorization_required
subscriptions.set_payment_method(subscription_id, *, player_card_id?, wallet_only?) PaymentMethodResult(status, replaced, wallet_only, card?, subscription, raw) — card rail only (err.is_not_a_card_subscription on Steam); used from the next charge
subscriptions.refund(subscription_id, *, client_request_id, period_seq?, amount_usd?, reason?) RefundResult(status, idempotent_replay, refund_key?, period_seq?, transaction_id?, refunded_amount_usd?, total_refunded_amount_usd?, remaining_refundable_usd?, is_full_refund, funding_shape?, balance_delta_coins?, card_refunded_usd?, processor_refund_reference?, processor_refund_adopted, invo_fee_retained, partner_revenue_reversed_usd?, partner_revenue_reversal_mode?, reason?, refunded_at?, new_balance?, revenue_share_attribution?, note?, raw) — card rail; idempotent on client_request_id; not for Steam-charged periods
subscriptions.steam_init(*, client_request_id, player_email, player_name, item_id, amount_usd, steam_id, user_session="client", player_ip?, item_name?, interval="month", interval_count=1, player_phone?, metadata?, consent?, revenue_share?) SteamInitResult(status, subscription_status, subscription_id, subscription, order_id?, steam_order_id, steam_transid?, pending_reuse, steam_checkout_url?, amount_usd?, amount_coins?, steam_charge_usd?, recurring_amount_usd?, first_charge, idempotent_replay, message?, raw)Steam rail; nothing charged; match the client callback on steam_order_id; show amount_coins; web needs player_ip + a top-level window; then steam_finalize within 24 h
subscriptions.steam_finalize(subscription_id) SubscriptionResult(..., already_processed, steam_agreement_status?, sandbox_auto_approved, warning?) — captures period 1 and settles it as a renewal; idempotent (already_processed); err.is_not_authorized (409) is the normal wait state
cards.begin_setup(*, player_email, setup_reference, payment_method_id?) CardSetupBegin(status, setup_intent_id, client_secret?, publishable_key?, card?, already_saved, message?, raw) — save a card for off-session billing; charges nothing; the player must already exist; idempotent on setup_reference
cards.confirm_setup(*, setup_intent_id) CardSetupConfirm(status, setup_intent_id, card?, already_saved, raw) — record the card after the client confirmed / authenticated; idempotent
cards.list(player_email) PlayerCardsResult(cards, raw) of PlayerCard(id, last_four, brand, exp_month, exp_year, created_at?, raw) — unexpired, newest first; id is the player_card_id
sandbox.subscriptions.advance_clock(subscription_id, intervals=1) / .force_renewal(subscription_id) / .force_failure(subscription_id, outcome="card_declined", *, failure_code?, failure_message?) / .force_auth_challenge(subscription_id, *, failure_message?) SandboxClockResult(status, action, engine_stats, subscription, before?, after?, period_seq?, attempt_no?, amount_usd?, amount_coins?, retired, intervals?, intervals_applied_to_window?, window_moved?, retry_budget_used?, retry_schedule_days?, retries_remaining?, auth_challenges_used?, max_auth_challenges?, downgraded_to_decline?, note?, raw)sandbox only, POST /subscriptions/<id>/... relative to the sandbox base (no /api), sends X-Sandbox-Clock-Key from sandbox_clock_key (refused before the network without it); never auto-retried
get_player_balance(player_email) PlayerBalanceResult(player, balances, summary, raw)by email only (no by-id route; use browser InvoClient.getBalance() client-side)
get_inbound_pending(player_email? | player_phone?) InboundPendingResult(inbound_pending, raw)
get_linked_identities(player_email? | player_phone?) LinkedIdentitiesResult(wallet_user_id, primary_email, primary_phone, is_minor, emails, not_found, raw)server-only (PII)
verify_sms_transfer(transaction_id, sms_pin) / verify_sms_send(...) Deprecated (3.1.0), removal at a future major. SmsVerifyResult — completes the SMS-PIN path when verification_method == "sms". Prefer passkey enrollment + browser approve; keep this only for users who can't enroll. Still fully functional, no runtime warning.
claim_transfer(*, claim_code, target_player_*, target_currency_id, target_player_id?) / claim_currency(*, claim_code, receiver_player_*, receiver_player_id?) ClaimResult — redeem a claim code (needs_account_selection + candidates on a multi-account phone)
get_transfer_status(transaction_id) / get_send_status(transaction_id) TransactionStatusResult — poll outbound state (verification_state)
get_guardian_approval_status(transaction_id) GuardianApprovalStatusResult — poll a guardian hold to resolution (state)
get_destinations(source_game_id, direction="transfer") DestinationsResult(status, source_game_id, source_game_name, ..., available_games, total_destinations, direction, linked_game_ids?, raw) — where a player can send/transfer FROM source_game_id, with DestinationGame metadata inline
recovery_begin(player_token) / recovery_complete(player_token, code) RecoveryBeginResult / RecoveryCompleteResultplayer-token passkey recovery relay ("lost/replaced my passkey"); after recovered, the browser re-runs enrollPasskey(). Recovered keys can't move money OUT for 24h (PASSKEY_RECOVERY_COOLDOWN)
begin_device_approval(*, player_token, transaction_id, flow, channel="qr") DeviceApprovalGrant(device_code, user_code, verification_uri, verification_uri_complete, expires_in, interval, channel, raw)player token, never the game secret; show verification_uri_complete as a QR
poll_device_approval(*, player_token, device_code) DeviceApprovalPollResult(status, interval?, enrollment?, transaction_id?, flow?, approved_at?, raw)status is one of approved / authorization_pending / slow_down / expired_token / access_denied; invalid_grant raises
confirm_device_enrollment(*, player_token, device_code, decision) ConfirmDeviceEnrollmentResult(status, raw) — answers the on-screen match-code prompt; decision is "approve" or "deny"
approve_with_device_code(*, player_token, transaction_id, flow, device_code) The money step. DeviceApprovalSettleResult — routes by flow to the transfer/send approve or confirm-receipt endpoint. TRANSACTION_NOT_PENDING comes back as status="not_pending" + already_settled + current_status, not raised
complete_device_approval(*, player_token, transaction_id, flow, device_code, interval=5, on_enrollment=None, should_stop=None, sleep=None) CompleteDeviceApprovalResult(status, settlement?, last_poll?) — convenience loop: polls honouring interval/slow_down, then calls approve_with_device_code
phone_share_initiate(phone, email) PhoneShareInitiateResultunauthenticated; send the fallback OTP for a phone-share (resolves a claim's 409 PHONE_SHARE_APPROVAL_REQUIRED)
phone_share_approve(approval_id, otp) PhoneShareApproveResultunauthenticated; approve with the OTP, then re-issue the original request
phone_share_status(phone, email) PhoneShareStatusResultunauthenticated; poll whether the (phone, email) pair is approved

Module-level

Function Returns
verify_webhook(raw_body, signature_header, secret_or_secrets, *, tolerance_seconds=300, now=None) WebhookEvent(event_id, idempotency_key, event_type, schema_version, created_at, tenant_id, data, raw) — raises InvoError on any failure

Every result keeps the full backend body on .raw for fields not surfaced explicitly.

Versioning & stability

Since 1.0.0 the public API is stable: it follows semver, and no breaking change ships without a major version bump + a migration note (two majors to date: 2.0.0 required player_phone at the token mint, which 2.2.0 later relaxed back to optional, and 3.0.0 moved the Platform Commerce card leg to INVO's hosted checkout). Deprecations get a documentation-only notice in a minor release first — verify_sms_transfer/verify_sms_send are deprecated as of 3.1.0 and keep working until a future major removes them. It's at parity with the JS SDK (3.x) — same server surface, same webhook scheme, and the passkey-recovery relay — and the wire contract is the same live INVO API, backward-compatible within a major. Safe to depend on in production; pin a version and watch releases for updates.

Development

python -m venv .venv && . .venv/bin/activate      # (Windows: .venv\Scripts\activate)
pip install -e ".[dev]"
python -m pytest        # tests
python -m ruff check .  # lint
python -m mypy          # types (strict)

License

Proprietary — © Invo Tech Inc. See LICENSE.

Release files for invonetwork 3.7.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for invonetwork 3.7.0
File Size Uploaded
invonetwork-3.7.0.tar.gz 237.9 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for invonetwork 3.7.0
File Interpreter ABI Platform
invonetwork-3.7.0-py3-none-any.whl Python 3 none any Details

Total release size: 368.0 kB

Release files / invonetwork-3.7.0.tar.gz

Download URL invonetwork-3.7.0.tar.gz
Size 237.9 kB
Tags Source
SHA-256 checksum
How to use checksums
9467dd989f54a5632f56ec868b4f65af649e840a743813dfe96615ba95a87d47
BLAKE2b-256 checksum
How to use checksums
6baa35a8d1cbe306228208687d5f16565f37393f1ad901888850e35a4c690a7b
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.12.10

Release files / invonetwork-3.7.0-py3-none-any.whl

Download URL invonetwork-3.7.0-py3-none-any.whl
Size 130.1 kB
Tags Python 3
SHA-256 checksum
How to use checksums
7a5738c0023ad1856ef54d7eb59e9993ee82499ced032793a94390ea6d3c8ef4
BLAKE2b-256 checksum
How to use checksums
6207bae2e2e74b351ffc621cadefc57671b6fc78e2504c02a41b852be223ba49
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.12.10

Release history Release notifications | RSS feed

3.13.2

2 release files

3.13.1

2 release files

3.13.0

2 release files

3.12.0

2 release files

3.11.1

2 release files

3.11.0

2 release files

3.10.0

2 release files

3.9.0

2 release files

3.8.0

2 release files

3.7.1

2 release files

This release

3.7.0 This release

2 release files

3.6.0

2 release files

3.5.1

2 release files

3.5.0

2 release files

3.3.0

2 release files

3.2.3

2 release files

3.2.2

2 release files

3.2.1

2 release files

3.2.0

2 release files

3.1.0

2 release files

3.0.0

2 release files

2.5.1

2 release files

2.5.0

2 release files

2.4.1

2 release files

2.4.0

2 release files

2.3.0

2 release files

2.2.0

2 release files

2.1.2

2 release files

2.1.1

2 release files

2.1.0

2 release files

2.0.0

2 release files

1.0.0

2 release files

0.3.0

2 release files

0.2.0

2 release files

0.1.2

2 release files

0.1.1

2 release files

0.1.0

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page