Skip to main content

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

pip install invonetwork
from invonetwork import InvoServer, InvoError, verify_webhook

No third-party runtime dependencies. Python 3.9 or newer.

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.

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.

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

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

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

invonetwork-0.1.0.tar.gz (36.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-0.1.0-py3-none-any.whl (30.6 kB view details)

Uploaded Python 3

File details

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

File metadata

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

File hashes

Hashes for invonetwork-0.1.0.tar.gz
Algorithm Hash digest
SHA256 f98d2c9a9a1f05425629d98e37d46438568c14e1dabef6772fd3b09667d709dd
MD5 908acdbc622113b9d00a1d42d95ca1d8
BLAKE2b-256 ad84afd34b9e3ec4d58414eaa25cff0be2deea29fc57878794ba0a36c4349984

See more details on using hashes here.

File details

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

File metadata

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

File hashes

Hashes for invonetwork-0.1.0-py3-none-any.whl
Algorithm Hash digest
SHA256 b17d7c5c35ca93302caa28fd04651546aac2e8ea7e1a116a5a3145f717ac48af
MD5 e4ba2256561b33c94f069629933e2e57
BLAKE2b-256 a2718805060b42308753b3710911410bd04574a62cbc57df1bd7bb05e39f4bdb

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 Pingdom Monitoring Sentry Error logging StatusPage Status page