INVO Python server SDK -- currency purchase, item purchase, sends/transfers, and webhook verification for partner platforms.
Project description
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:
0.1.0. The backend it wraps is live on sandbox + production, so you can build and test against sandbox today. 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), and spend game currency on items.
- 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.
Contents
- Install
- Get your account & game secret
- Architecture
- Before you go live
- Configuration
- Currency purchase (real money in)
- Item purchase (spend game currency)
- 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.
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. - 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). 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
)
# -> send the browser to result.checkout_url (single-use, ~15 min)
The INVO-hosted page handles card entry, saved cards, and 3-D Secure. 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 processor (default) | Works out of the box; hosted checkout + direct rail |
"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"):
...
Player balance
result = server.get_player_balance(player_email="p@example.com")
# or: server.get_player_balance(player_id=12345)
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):
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)
Pairs with the transfer.claim_pending webhook (the webhook is the wake-up; this is the list).
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) |
item.purchased |
every item purchase | grant the in-game item (data includes item_id, item_quantity, total_price, new_balance, fee_breakdown) |
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
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) |
.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) |
PlayerToken(token, expires_at, identity_id, raw) |
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, 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) |
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) |
get_player_balance(player_email? | player_id?) |
PlayerBalanceResult(player, balances, summary, raw) |
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) |
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
Follows semver. While on 0.x the surface may still gain additive
changes as it tracks the JS SDK toward a
stable 1.0 (at which point breaking changes require a major bump + migration note). The
wire contract is the same live INVO API the JS SDK uses and is backward-compatible within a
major, so pinning a version is safe. 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.
Project details
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-0.1.1.tar.gz.
File metadata
- Download URL: invonetwork-0.1.1.tar.gz
- Upload date:
- Size: 38.0 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.12.10
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
cd54fb253fde4641200e926d9e602f12f865e3eb1c0137bbc5488fbf2080d2e7
|
|
| MD5 |
188697953f59de2fc050ac0ca869dfe3
|
|
| BLAKE2b-256 |
cb095aeaea0695e9475bed9237166d1f78390d748f50b71e11f403ab8ae9169e
|
File details
Details for the file invonetwork-0.1.1-py3-none-any.whl.
File metadata
- Download URL: invonetwork-0.1.1-py3-none-any.whl
- Upload date:
- Size: 32.2 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 |
c44c898fb788a66673f83d345b503527feb77a22202adf35b1feddb840b5ec91
|
|
| MD5 |
ee71043e6333bf3a28a25384ab50c0ef
|
|
| BLAKE2b-256 |
d09475ae91760c26fde67b1a7baffe97a73b107f68b23f74ed8e7331c547b906
|