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

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

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 -> phone/email on file
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 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).
  • 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.
  • For sends/transfers with passkeys: give INVO the web origin(s) your browser front-end serves from (that half uses @invonetwork/web-sdk). Until enrolled, senders fall back to SMS-PIN.
  • 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)

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

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. The card leg's browser confirmation (mount a card element, confirm the client_secret) lives in the JS web SDK (@invonetwork/web-sdk) — a Python backend hands the returned client_secret to its web front end.

import uuid
from invonetwork import BillingAddress

# 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 client_secret; NOT yet paid. total_price is USD; billing_address REQUIRED.
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",
    billing_address=BillingAddress(
        line1="1 Main St", city="Austin", postal_code="78701", country="US",
        # optional: name, line2, state
    ),
)
# r.status == "requires_payment"; hand r.client_secret to your web front end to confirm the card.
# 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 browser confirmation — the sale is real only once the payment settles.
  • Idempotent on client_request_id — a duplicate raises 409 (err.is_duplicate_request).
  • Lost card-purchase response? If the network drops the card-leg response you won't have the client_secret or order_id, and retrying the same client_request_id returns 409 (the order already exists). Reconcile via your platform_commerce.purchased webhook — don't retry to recover the client_secret. (The balance leg is fully retry-safe.)
  • INVO fee: 3.5% flat (balance) · 3.5% + $0.30 (card). Below-minimum amounts raise err.is_amount_below_minimum; a card charge under $0.50 raises err.is_below_card_minimum.
  • Client-side validation (missing fields, bad funding_source, quantity outside 1..1000, total mismatch, missing/short billing_address on the card leg) raises INVALID_INPUT before any network call.

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. The sender approves in the browser (passkey or SMS PIN) via the JS SDK; 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 is passkey-enrolled -> approve in the browser (JS SDK)
elif t.verification_method == "sms":
    ...  # not enrolled, a PIN was sent -> show a PIN-entry fallback

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.

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.


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)
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

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 Platform Commerce amount too small for the fee to round up / card charge under $0.50 (400)
.is_already_refunded Platform Commerce refund of an already-refunded order (409) — treat as already done
.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
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)

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?, billing_address?) PlatformPurchaseResult(status, funding_source, order_id, transaction_id?, new_balance?, financial_breakdown?, client_secret?, amount_usd?, raw)ecommerce (platform tenant); balance settles now, card returns a client_secret (+ requires a BillingAddress)
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
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(...) SmsVerifyResult — complete the SMS-PIN path when verification_method == "sms"
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)
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 (2.0.0 is the only major to date, which required player_phone at the token mint; 2.2.0 later relaxed that back to optional). It's at parity with the JS SDK (2.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.

Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

invonetwork-2.5.0.tar.gz (82.2 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

invonetwork-2.5.0-py3-none-any.whl (51.2 kB view details)

Uploaded Python 3

File details

Details for the file invonetwork-2.5.0.tar.gz.

File metadata

  • Download URL: invonetwork-2.5.0.tar.gz
  • Upload date:
  • Size: 82.2 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.2.0 CPython/3.12.10

File hashes

Hashes for invonetwork-2.5.0.tar.gz
Algorithm Hash digest
SHA256 1213c7158ee89b1447a7d70082c9cda7bd5003255c64cf771e453f53fc663b54
MD5 f25ff61f7b0adf629f5aae0c6927326c
BLAKE2b-256 924e03b64304cdec48cefd5add37b3dccb977399018a2676ecbc9b83d5d01789

See more details on using hashes here.

File details

Details for the file invonetwork-2.5.0-py3-none-any.whl.

File metadata

  • Download URL: invonetwork-2.5.0-py3-none-any.whl
  • Upload date:
  • Size: 51.2 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.2.0 CPython/3.12.10

File hashes

Hashes for invonetwork-2.5.0-py3-none-any.whl
Algorithm Hash digest
SHA256 eb5b2de3d52d54681373825ebf6ad83756925047eace23765af77183fee94c9d
MD5 a8d8f4cfd5c4df5d9e2548a7e3ade33a
BLAKE2b-256 9fdb73190aeec7afe76684a7182feaacbf6f9dfcb4de77721e443f49f4e7b4c8

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page