Metadata-Version: 2.4
Name: invonetwork
Version: 2.5.1
Summary: INVO Python server SDK -- currency purchase, item purchase, sends/transfers, and webhook verification for partner platforms.
Project-URL: Homepage, https://docs.invo.network
Project-URL: Documentation, https://docs.invo.network
Project-URL: Source, https://github.com/Invo-Technologies/invo-python-sdk
Project-URL: Issues, https://github.com/Invo-Technologies/invo-python-sdk/issues
Author: Invo Tech Inc.
License: Copyright (c) 2026 Invo Tech Inc. All rights reserved.
        
        This software and associated documentation files (the "Software") are the
        proprietary property of Invo Tech Inc. ("INVO"). The Software is licensed, not
        sold.
        
        GRANT. INVO grants you a non-exclusive, non-transferable, royalty-free license to
        install and use the Software, in unmodified form, solely to build and operate
        integrations with the INVO platform, subject to INVO's developer terms at
        https://invo.network.
        
        RESTRICTIONS. Except as expressly permitted above, you may not copy, modify,
        distribute, sublicense, sell, or create derivative works of the Software, or
        remove any proprietary notices, without INVO's prior written consent.
        
        THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
        IMPLIED. IN NO EVENT SHALL INVO BE LIABLE FOR ANY CLAIM, DAMAGES, OR OTHER
        LIABILITY ARISING FROM, OUT OF, OR IN CONNECTION WITH THE SOFTWARE.
License-File: LICENSE
Keywords: game-currency,invo,payments,sdk,webhooks
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Typing :: Typed
Requires-Python: >=3.9
Provides-Extra: dev
Requires-Dist: mypy>=1.8; extra == 'dev'
Requires-Dist: pytest>=7.4; extra == 'dev'
Requires-Dist: ruff>=0.4; extra == 'dev'
Description-Content-Type: text/markdown

# invonetwork

First-party **Python server SDK** for integrating **INVO** into partner backends. It is the
server-side counterpart to the [INVO JS/Web SDK](https://www.npmjs.com/package/@invonetwork/web-sdk):
same endpoints, same field mappings, and the same webhook HMAC scheme, so both hit the
same live backend interchangeably.

> **Status:** `2.5.1` — 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.1** hardens 2.5.0 from a second independent audit (PII-free
> validation errors, E.164 on the platform-commerce phone, corrected card-leg webhook fields,
> refund ``new_balance``/``processor_refund_id``); **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](CHANGELOG.md).
> 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](#install)
- [Get your account & game secret](#get-your-account--game-secret-invo-console)
- [Architecture](#architecture-this-sdk-is-the-server-half)
- [Before you go live](#before-you-go-live)
- [Configuration](#configuration)
- [Currency purchase (real money in)](#currency-purchase-real-money-in)
- [Item purchase (spend game currency)](#item-purchase-spend-game-currency)
- [Platform Commerce (ecommerce)](#platform-commerce-ecommerce)
- [Player balance](#player-balance)
- [Sends & transfers](#sends--transfers)
- [Inbound pending & linked identities](#inbound-pending--linked-identities)
- [Webhooks](#webhooks)
- [Resilience & observability](#resilience--observability)
- [Errors](#errors)
- [API reference](#api-reference)
- [Versioning & stability](#versioning--stability)

## Install

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

```bash
# macOS / Linux
python3 -m pip install invonetwork
```

```powershell
# Windows (PowerShell)
py -m pip install invonetwork
```

Recommended — inside a virtual environment:

```bash
# macOS / Linux
python3 -m venv .venv && source .venv/bin/activate && pip install invonetwork
```

```powershell
# Windows (PowerShell)
py -m venv .venv; .venv\Scripts\Activate.ps1; pip install invonetwork
```

Then import:

```python
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`](https://www.npmjs.com/package/@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`:

```python
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`](https://www.npmjs.com/package/@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?"**:

```python
# 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](#get-your-account--game-secret-invo-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](#player-token-session-mint-for-an-existing-player)).
- **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

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

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

```python
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**.

```python
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:

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

---

## Platform Commerce (ecommerce)

> **This is not [item purchase](#item-purchase-spend-game-currency).** 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.

```python
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.
```

```python
# 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

```python
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**:

```python
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.

```python
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):**

```python
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

```python
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

```python
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):

```python
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 |

```python
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](#player-token-session-mint-for-an-existing-player)) |
| `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` / `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](https://semver.org/), 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](https://www.npmjs.com/package/@invonetwork/web-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](https://github.com/Invo-Technologies/invo-python-sdk/releases) for updates.

## Development

```bash
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`](LICENSE).
