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.1.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.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 runtimeDeprecationWarning, so warnings-as-errors test suites are unaffected); 3.0.0 moves the Platform Commerce card leg to INVO's hosted checkout —purchase(funding_source="card")now returns acheckout_urlto send the buyer to (the 2.5.xBillingAddress/client_secretsurface is removed; see the CHANGELOG migration note); 2.5.0 adds Platform Commerce (ecommerce) — a platform tenant selling items funded by balance or card, withserver.platform_commerce.purchase/ get_status/refundand theplatform_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-share409onclaim_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(honoringretry_after), and5xx— for idempotent calls only. - Zero runtime dependencies — stdlib only (
urllib,hmac,json,dataclasses). Python 3.9+. - Fully typed — ships
py.typed; passesmypy --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 |
| Fallback | verify_sms_transfer / verify_sms_send only when the user can't enroll or declines |
the PIN as your default flow |
Prerequisite: passkeys are dark until your tenant verifies a domain. Passkey step-up is
enabled per tenant and stays off until you verify ownership of a domain (your RP ID) in the
developer console. Until then every passkey endpoint returns 403 WEBAUTHN_NOT_ENABLED_FOR_TENANT — classify it with err.is_webauthn_not_enabled_for_tenant
and treat it as configuration state, not a failure: the browser half should fall back to the
SMS/in-app path rather than showing the player an error.
This is the common case today, not an edge case. As of 2026-08-05 only 4 of 23 sandbox tenants and 0 of 3 production tenants have a verified RP ID. So the honest sequence is: verify your domain → enroll passkeys → you're on the gold-standard path. Until step one is done, the SMS path is carrying you whether you like it or not — which is exactly why the deprecation below has no removal date attached.
⚠️
verify_sms_transfer/verify_sms_sendare deprecated as of3.1.0and a future major version will remove them. They still work exactly as before — this release changes documentation only and deliberately emits noDeprecationWarning, sopytest -W errorsuites keep passing. Keep the PIN path as a genuine last resort for: users who cannot enroll (unsupported device, no platform authenticator), users who decline, and tenants that haven't verified a domain yet (see above).
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/enrollmentVerifygrant — 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.
Contents
- Passkeys are the gold standard — don't build on SMS
- Install
- Get your account & game secret
- Architecture
- Before you go live
- Configuration
- Currency purchase (real money in)
- Item purchase (spend game currency)
- Platform Commerce (ecommerce)
- Player balance
- Sends & transfers
- Inbound pending & linked identities
- Webhooks
- Resilience & observability
- Errors
- API reference
- Versioning & stability
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_transferrequire 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-sdkInvoClient(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_beginerrors:no_channel_on_file(422),rate_limited(429 — max 5 codes / 10 min).recovery_completeerrors:ENROLLMENT_CODE_INVALID(wrong/expired, attempt-capped),RECOVERY_FAILED(500, transient).- Step 3 — the WebAuthn
create()ceremony — can only run in the browser (JS SDKenrollPasskey()); 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_COOLDOWNfor 24 hours (SIM-swap protection). Branch onerr.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 aftererr.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_tokenso 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/steamrails 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). Get this done 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) |
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.purchasedwebhook, 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 raises409(err.is_duplicate_request). - Insufficient balance raises
400(err.is_insufficient_balance;required_amount+current_balanceonerr.body). - Client-side validation (missing fields, quantity outside
1..1000, bad price, total mismatch) raisesINVALID_INPUTbefore 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.purchasedwebhook, 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 raises409(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 sameclient_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 raiseserr.is_above_card_maximum. - Client-side validation (missing fields, bad
funding_source, quantity outside1..1000, total mismatch, card USD bounds, non-E.164player_phone) raisesINVALID_INPUTbefore 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 with their passkey via the JS SDK — the gold standard; a deprecated SMS-PIN fallback exists for senders who aren't enrolled. 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.
elif t.verification_method == "sms":
... # sender has NO passkey -> have the browser offer enrollPasskey() and approve;
... # fall back to a PIN pad only if they can't or won't enroll (deprecated path).
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.
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_gameis where it came from (another game/platform). Pairs with thetransfer.claim_pendingwebhook (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 arekind="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(honoringretry_after, capped at 20s), and5xx— are retried with exponential backoff + jitter. Configure withmax_retries(default2,0disables) andretry_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 — redacturlif you log payloads.
- Request ids.
InvoError.request_idcarries 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 (0for 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_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)
| 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 |
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 / RecoveryCompleteResult — player-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) |
PhoneShareInitiateResult — unauthenticated; send the fallback OTP for a phone-share (resolves a claim's 409 PHONE_SHARE_APPROVAL_REQUIRED) |
phone_share_approve(approval_id, otp) |
PhoneShareApproveResult — unauthenticated; approve with the OTP, then re-issue the original request |
phone_share_status(phone, email) |
PhoneShareStatusResult — unauthenticated; 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.
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file invonetwork-3.1.0.tar.gz.
File metadata
- Download URL: invonetwork-3.1.0.tar.gz
- Upload date:
- Size: 97.3 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.12.10
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
918c5158cb31cc6a030d8344b3f12882b54ea1ccb86141e52e1e421e52cf92d5
|
|
| MD5 |
6f9aabe251a414fc7d62a2594f0bc4d1
|
|
| BLAKE2b-256 |
b14d0a4a1587dda61cd014750bede05ae1f193c76d8781b0aa12dccb580d0e0e
|
File details
Details for the file invonetwork-3.1.0-py3-none-any.whl.
File metadata
- Download URL: invonetwork-3.1.0-py3-none-any.whl
- Upload date:
- Size: 57.4 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.12.10
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
2665aaa90167637dbc145062aa055080ce163f8b33f830ccf7d61456789d8bc0
|
|
| MD5 |
f29ba0f502d8f34e8c9b32c15272e654
|
|
| BLAKE2b-256 |
0d1fb7cc329a9676e0d97bc565d947f5897800b03dc90ada623330fb8ae57f7e
|