Metadata-Version: 2.5
Name: invonetwork
Version: 3.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:** `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](#passkeys-are-the-gold-standard--dont-build-on-sms)** and the SMS-PIN completion
> (`verify_sms_transfer`/`verify_sms_send`) is **deprecated and being phased out** (documentation
> only — no behavior change, and no runtime `DeprecationWarning`, 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 a ``checkout_url`` to send
> the buyer to (the 2.5.x ``BillingAddress``/``client_secret`` surface is **removed**; see
> the [CHANGELOG](CHANGELOG.md) migration note); **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.

## 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: which passkey path serves your tenant.** There are two places a passkey ceremony
can run, decided by whether you hold a partner passkey domain:

- **No partner domain (every new title):** INVO runs the ceremony **on its own domain** — the hosted
  approval page — for every platform. There is **no domain to verify and nothing to configure**;
  submitting one is refused (`409 PARTNER_RP_FROZEN`). Your server starts a device approval grant
  (`POST /api/sdk/approvals/device/begin` with the player's session token as `Authorization: Bearer`,
  never the game secret; [below](#consoles-and-tvs--approving-without-a-browser-rfc-8628))
  and the browser / console / phone opens the page. The in-app browser ceremonies return `403
  WEBAUTHN_NOT_ENABLED_FOR_TENANT` for you — classify it with
  **`err.is_webauthn_not_enabled_for_tenant`** and treat it as *the expected state, not a failure*
  (the body's `hosted_flow` points at `device_code`).
- **A domain verified before the freeze (existing tenants):** your passkeys are bound to that RP ID
  for life, so the in-app ceremonies keep working. You may still change or re-verify the domain;
  removing it is **irreversible** — it can never be re-added, and the hosted flow serves the game
  from then on.

So the honest sequence is: **start the transfer → approve on the hosted page (or in-app if you hold
a domain) → you're on the gold-standard path.** The SMS PIN carries only a player who cannot or will
not enrol — which is exactly why the deprecation below has no removal date attached.

> ⚠️ **`verify_sms_transfer` / `verify_sms_send` are deprecated as of `3.1.0` and a future major
> version will remove them.** They still work exactly as before — this release changes
> documentation only and deliberately emits **no** `DeprecationWarning`, so `pytest -W error`
> suites keep passing. Keep the PIN path as a genuine last resort for: users who **cannot**
> enroll (unsupported device, no platform authenticator) and users who decline.

**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](#passkey-recovery-relay-recovery_begin--recovery_complete); restores a passkey the user lost or deleted.
- `phone_share_initiate` / `phone_share_approve` — phone-**ownership consent**, not transaction authorization.
- The browser-side `enrollmentBegin` / `enrollmentVerify` grant — 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.

### Which method fits which platform

The right approval method is a property of the **client the player is sitting in front of**, not
of your title. Because the WebAuthn ceremony runs browser-side, this server SDK's job is to read
`initiate_*` and let the right client half take over — pick that half per platform:

| Where the player is | Approve with | Why |
|---|---|---|
| Mobile app & mobile web (iOS 16+ / Android 9+) | **a passkey, directly** | the platform authenticator (Face ID / Touch ID / fingerprint) is available in-client — strongest option, zero messaging cost |
| Desktop web (browser) | **a passkey, directly** | Touch ID / Windows Hello are available to the browser |
| Consoles (PlayStation / Xbox / Switch) | **the QR device-approval flow** ([RFC 8628](#consoles-and-tvs--approving-without-a-browser-rfc-8628), below) | no browser and no WebAuthn in-client — show the QR, the player scans it and completes the passkey on their phone, and your server polls for approval |
| Native Steam / desktop game clients (Mac & Windows) | **the QR device-approval flow** ([RFC 8628](#consoles-and-tvs--approving-without-a-browser-rfc-8628), below) | the OS supports passkeys, but the embedded game client can't invoke the platform authenticator from inside the engine — it is the **client**, not the OS, that forces QR here |

**When there is no passkey path at all** — a remote approval that can't run a ceremony (a
guardian, a phone's existing owner) — INVO goes **email first, SMS on request**: a signed link to
a hosted page to the oldest verified address, and a text only when there is no verified email,
the email could not be delivered, or the player asks for one. SMS is the last resort, not the
primary channel.

## Contents

- [Passkeys are the gold standard — don't build on SMS](#passkeys-are-the-gold-standard--dont-build-on-sms)
- [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 -> email on file first (text only if no email)
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` sends the code **email first**; a text goes out only when there is no email on file. The SDK method takes no channel argument — for a "text me instead" tap, POST `/api/sdk/device/recover/begin` yourself with the player token and body `{"channel": "sms"}`. 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).
- A player who lost the phone their INVO passkey was on can also recover from the hosted approval or claim page with no in-app call at all — see the console section below.
- 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. **The `steam` rail additionally requires
  the studio to register their own Steam app id + publisher Web API key and put a
  payment method on file** — see the Steam section below.
- **For sends/transfers with passkeys:** nothing to configure — new titles approve on INVO's
  hosted page (your server starts the device approval grant; the browser half uses
  `@invonetwork/web-sdk`'s `approveHosted()`, consoles show the QR, the mobile plugins open the
  system browser). Only a tenant that verified a partner domain before the freeze uses the in-app
  ceremony on its own origins. **Wire the approve step 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](#passkeys-are-the-gold-standard--dont-build-on-sms).
- **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`) |

> ### Steam titles: before any of this works, the studio has setup to do
>
> **Steam pays whoever owns the Steam application.** Every INVO title therefore
> sells currency through **its own Steam app**, and the money lands in the
> studio's own Steamworks account. INVO issues the player's currency the moment
> Steam captures the charge, then settles with the studio separately against a
> payment method they keep on file.
>
> Once per title, in the INVO dashboard:
>
> 1. Enable **in-game purchases (microtransactions)** for the app in Steamworks.
> 2. Create a **publisher Web API key** — a *publisher group* key, not a personal
>    user key, with the app in the group. A personal key fails verification.
> 3. Enter the **app id** and that **key** under the title's Steam settings. INVO
>    calls Steam to prove the pair before saving it.
> 4. Add a **payment method** on the Billing screen and accept the authorisation.
>
> Until all four are done, purchases are refused **before the player is charged**:
> `STEAM_NOT_CONFIGURED` (503) or `PARTNER_BILLING_NOT_SET_UP` (409). That is
> deliberate — the alternative is taking a player's money for currency that
> cannot be issued.
>
> **This changed in September 2026.** Versions before 3.4.0 said Steam purchases
> ran through an INVO-owned Steam app and there was nothing to register. That was
> true while one INVO application billed every title; it is not true now, and
> following the old text leaves the rail closed.

> **Steam titles: currency must be bought through Steam, and INVO sets the packs.**
>
> A title distributed on Steam may only sell currency on the `steam` rail — any other
> rail is refused with `409 STEAM_PURCHASE_LAYER_REQUIRED`. This is a platform
> requirement, not an INVO preference, and it applies to every storefront the title
> ships on because a player's balance is shared across them.
>
> **You do not set Steam prices and you do not send an amount.** INVO defines the pack
> catalogue for every game on the network. Fetch it with `GET /steam/packs?steamid=...`,
> render it, and pass the `pack_id` back to the purchase call.
>
> **The price is the same everywhere; the currency inside is not.** Steam prices are
> VAT-inclusive wherever VAT is collected, so more of a fixed price goes to tax in a
> high-VAT country and less is left to buy currency with — the same $9.99 pack yields
> 69 units in the US and 58 in France. Always pass `steamid` when fetching the
> catalogue, or the amounts are quoted with no VAT deducted and your store promises
> more than the purchase delivers. Do not cache one catalogue for all players and do
> not hard-code amounts: pack prices and VAT rates are both configuration.

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

### Steam purchases (packs, not prices)

Steam has first-class SDK methods as of 3.5.0: ``steam_packs()``,
``steam_init_purchase()``, ``steam_finalize_purchase()`` — all on ``InvoServer``,
all server-side. ``purchase_currency`` still rejects ``rail="steam"`` with
``WRONG_RAIL_ENDPOINT`` and points you here.

**INVO owns the pack catalogue.** You never send a price. Fetch the packs, show
them, pass a ``pack_id`` back.

```python
# 1. The catalogue, priced for THIS player. Always pass steamid.
result = server.steam_packs(steamid=steam_id)
# result.packs: [SteamPack(pack_id, label, price_usd, currency_amount), ...]

# 2. Start the purchase with the pack the player chose.
init = server.steam_init_purchase(
    player_email="player@example.com",
    steamid=steam_id,
    pack_id="steam_medium",                # NOT a price
    purchase_reference=my_idempotency_key, # unique per INTENDED purchase; reuse on retry
    metadata={"player_id": my_player_id},  # echoed on the purchase.completed webhook
)
# -> init.order_id, init.steam_transid, init.charged_usd, init.currency_amount

# 3a. CLIENT SESSION (default): Steam shows its in-game overlay; your game
#     client receives MicroTxnAuthorizationResponse_t and tells your backend.
# 3b. WEB SESSION: for platforms where the overlay does not render (observed:
#     Electron on macOS). Pass usersession="web" + player_ip (the PLAYER's IP,
#     never your server's) and open init.steam_checkout_url as a TOP-LEVEL
#     browser tab — it cannot be iframed.

# 4. Finalize: INVO re-checks the authorization with Steam, captures, credits.
done = server.steam_finalize_purchase(order_id=init.order_id)
# -> done.status == "success", done.new_balance, done.already_processed
```

**The price is identical everywhere; the currency inside is not.** Steam prices
are VAT-inclusive wherever VAT is collected, and the storefront's revenue share
comes out before currency is derived — the same ``$9.99`` pack yields **69**
units in the US and **58** in France, and a ``$4.99`` pack yields **34**, not
50. Render ``currency_amount`` verbatim; never compute currency from the
sticker price.

**Finalize is idempotent, and not every 409 means "try later".** A replay
returns ``already_processed=True`` and never credits twice. For a poller,
branch on the error:

| Signal | Meaning | Do |
|---|---|---|
| ``err.is_steam_authorization_pending`` | player has not approved yet (``Init``) | keep polling / wait for the callback |
| ``err.is_steam_authorization_dead`` | any settled status: ``Cancelled`` / ``Failed`` / ``Refunded`` / ``PartialRefund`` / ``Chargedback`` / anything else non-Init | **stop** — no later call can succeed |
| ``err.is_steam_app_changed`` | the title's registration changed under the order | stop; terminal for this order |
| HTTP 502 / 503 | transient | retry |
| ``"Order is not finalizable"`` (4xx, no ``not_authorized``) | the reconciler already settled the order's fate | **stop** |

A poller that treats every 409 as "not yet" polls a dead order forever. A missed
finalize is not a lost credit — INVO's reconciler independently detects the
authorized transaction and credits, on a delay.

**Refusals before any charge**, so a player is never charged for currency that
cannot be issued:

- ``err.is_partner_credit_unavailable`` — the studio's settlement float cannot
  cover it. Plain failure toast; do **not** hot-poll init (each attempt briefly
  re-reserves headroom), and reuse the same ``purchase_reference`` on a manual
  retry.
- ``err.is_partner_rail_suspended`` — terminal from the game's seat until the
  studio resolves it in the INVO console.
- ``err.is_unknown_steam_pack`` — re-fetch ``steam_packs()``; the body's
  ``valid_pack_ids`` lists what exists.
- ``err.is_steam_not_configured`` (503) / ``err.is_steam_rail_not_entitled``
  (403) — the studio's one-time Steam setup is incomplete.
- A locked Steam account is refused up front with ``409 STEAM_ACCOUNT_LOCKED``.

- **Always pass ``steamid``** to ``steam_packs()``. Without it the amounts are
  quoted with no VAT deducted — the most any pack yields — so your store
  promises more than the purchase delivers.
- **Do not cache one catalogue for all players**, and do not hard-code amounts.
  Pack prices and VAT rates are configuration and change without an SDK release.
- **On a Steam-distributed title, a channel key may only sell on its own
  channel's rail.** A mismatched caller is refused with
  ``409 STEAM_PURCHASE_LAYER_REQUIRED``. (The old absolute form -- "only the
  Steam rail, across every storefront" -- was the rule before 2026-08-31 and
  no longer holds for channel-keyed callers.)
- **Correlation is ``metadata``, not ``purchase_reference``.** The webhook
  payload deliberately omits your idempotency key; put your own player/order
  ids in ``metadata`` and read them back at ``data["metadata"]`` on
  ``purchase.completed`` -- note the Steam rail ADDS its own context keys
  (``steam_wallet_currency``, ``steam_country``, ``steam_account_status``,
  ``vat_rate_pct``) into the echoed object, so avoid those names in yours (``data["channel"]`` is the channel of the KEY you presented -- ``"steam"`` when you call with your Steam channel key; ``None`` on a legacy game key).

### 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. 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**.

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

```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 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 raises `409`
  (`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 *same* `client_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 raises `err.is_above_card_maximum`.
- Client-side validation (missing fields, bad `funding_source`, quantity outside `1..1000`, total
  mismatch, card USD bounds, non-E.164 `player_phone`) 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
with their **passkey** via the JS SDK — the
[gold standard](#passkeys-are-the-gold-standard--dont-build-on-sms); a deprecated SMS-PIN
fallback exists for senders who aren't enrolled. 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 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.

- **Recipients without the game collect from the SMS.** The claim text a send's receiver gets
  now ends with a link to a hosted INVO page ("Or collect here: …"). They see who sent what in
  which game, enter **one email** (the same thing in-game claim already requires), confirm it
  with an emailed 6-digit code, and their phone's fingerprint / face / screen lock creates an
  INVO passkey — a recipient who already has one just confirms with it. The currency lands in
  their balance for the receiving game; the page shows the new balance and tells them to sign
  in to the game with that email + phone. The in-game claim is unchanged — point a player at
  the link when they don't have the game open, at in-game claim when they do; both are the
  same claim with one lifetime. `transfer.received` fires the same either way, and you change
  nothing.
- **Resend the claim SMS — a plain REST call, not an SDK method.** Authed with the
  **sender's** player token (not the game secret), no body. It re-sends the identical message
  (same code, same link); nothing new is minted.

  ```python
  r = requests.post(
      f"{INVO_BASE}/api/sdk/send/{transaction_id}/resend-claim",
      headers={"Authorization": f"Bearer {sender_player_token}"},
  )
  # 200 {"status": "resent", "transaction_id": ..., "retry_after": 30}
  # 429 RESEND_COOLDOWN (+ retry_after) — 30-second cooldown; max 10 per transaction per hour
  # 400 CLAIM_EXPIRED / NOT_CLAIMABLE — the send can no longer be claimed; start a new send
  # 403 — the token is not the send's sender
  ```

- **Uncollected after 24 hours → refunded, and both parties are told.** The refund to the
  sender is as before; what's new is that the sender gets an email and the recipient a single
  text saying the currency was returned, instead of silence. `transfer.claim_expired` +
  `transfer.refunded` remain the source of truth — reconcile off those, not the messages.
- **Guardian approval goes by email first.** When a minor's initiate returns the 202, the
  guardian gets a signed, single-use link to a hosted INVO page with Approve / Decline — not a
  text. A reply-YES text goes out only when the guardian has no verified email or the email could
  not be delivered. The raw 202 `guardian_approval` block (`t.raw`) now carries
  **`consent_channel`** (`"email"` | `"sms"`) — use it for your waiting copy — and
  **`resend_endpoint`**. Poll `get_guardian_approval_status` exactly as before; the approval
  object's `consent_channel` says how it went out and `decision_source` is `email_link` for a
  page decision.
- **"Text my parent instead" — a plain REST call, not an SDK method.** Authed with the
  **minor's** player token (the session that started the transaction), once per approval:

  ```python
  r = requests.post(
      f"{INVO_BASE}/api/sdk/approvals/guardian/{approval_id}/resend",
      headers={"Authorization": f"Bearer {initiator_player_token}"},
      json={"channel": "sms"},   # the only accepted value
  )
  # 200 {"status": "sent", "channel": "sms"}
  # 400 INVALID_INPUT   — channel was not "sms"
  # 403 NOT_INITIATOR   — the token is not the minor who started it
  # 404 APPROVAL_NOT_FOUND (opaque)
  # 409 CHANNEL_WAS_SMS — it already went by text; hide the button when consent_channel == "sms"
  # 409 ALREADY_RESENT  — one text per approval
  # 410 APPROVAL_GONE   — already decided or expired
  # 503 DELIVERY_FAILED — retryable; does not use up the one allowance
  ```

  The emailed link keeps working alongside the text; whichever answers first wins.
- **Phone-share and recipient-identity consents use the same hosted page.** On
  `err.is_phone_share_approval_required` the phone's existing owner gets an emailed link
  (Allow / Decline) when INVO has itself proven an address for them — at most three holders, one
  email per request — and the 409 body carries `consent_channel: "email"`; the OTP text goes out
  instead only when no proven holder exists (`consent_channel: "sms"`). The
  `RECIPIENT_IDENTITY_PENDING` hold emails the phone owner's oldest verified address the same
  way. **Nothing changes for you:** same 409 / 202 codes, same poll-and-retry, and
  `phone_share_approve` (typed code) and the in-app approve still work.

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

---

## Consoles and TVs — approving without a browser (RFC 8628)

A console has no browser, so it cannot run a passkey ceremony at all. **Device
Approval** moves the ceremony to a device that can: your game shows a short code, the
player approves on their phone, your server polls until it hears back. It is the
[OAuth 2.0 Device Authorization Grant, RFC 8628](https://datatracker.ietf.org/doc/html/rfc8628)
— the same flow every console and TV app uses — so a standard client library understands
the responses.

**Not an SDK method** — it is a server-to-server REST flow, because the polling has to
happen on your backend, not in the game client.

**Available to every title, with nothing to configure.** The constraint is a property of
the *device*, not your game: a title with a website *and* a console build uses the normal
ceremony in the browser and this one on the console.

**Native Steam and desktop game clients (Mac & Windows) belong here too.** Even though the
operating system supports passkeys, an embedded game client can't invoke the platform
authenticator from inside the engine — so a native desktop title uses this QR flow exactly as
a console does. It is the **client** that forces QR here, not the OS.

```python
import requests

H = {"Authorization": f"Bearer {sdk_session_token}"}

# 1. Your server starts an approval for ONE transaction.
#    flow: "transfer" | "send" | "send_receipt" | "transfer_receipt"
#    channel (optional, default "qr"): how the page is delivered —
#      "qr"          console / TV / native desktop client renders the QR (this example)
#      "app_browser" the Unity / Unreal plugin opens the system browser; INVO derives the
#                    return scheme invo-sdk-<game_id> itself (never caller-supplied)
#      "popup"       the BROWSER must call begin itself (its https Origin header becomes
#                    the popup's opener) — that is the JS SDK's approveHosted(); a
#                    server-to-server call with channel "popup" is refused (400)
start = requests.post(
    f"{INVO_BASE}/api/sdk/approvals/device/begin",
    headers=H, json={"transaction_id": transfer_id, "flow": "transfer", "channel": "qr"},
).json()
# -> {"device_code", "user_code", "verification_uri",
#     "verification_uri_complete", "expires_in", "interval", "channel"}

# 2. The game renders `verification_uri_complete` as a QR and prints `user_code`
#    under it. The QR already contains the code, so most players never type.

# 3. Poll from your SERVER, never faster than `interval` seconds.
poll = requests.post(
    f"{INVO_BASE}/api/sdk/approvals/device/poll",
    headers=H, json={"device_code": start["device_code"]},
).json()
# approved              -> {"status": "approved", "transaction_id", "flow"}
# authorization_pending -> keep polling
# slow_down             -> back off, then resume
# expired_token         -> start a new approval
# access_denied         -> the player declined; do not retry silently
# invalid_grant         -> unknown or not yours
```

- **One approval authorises one transaction.** Unlike the plain RFC 8628 grant, which
  authorises a *client*, an INVO device code is bound to the transaction you named and is
  consumed when used. It cannot approve anything else and cannot be reused.
- **`user_code` is meant to be seen; `device_code` is not.** Poll from your backend.
- **One live code per transaction.** Starting a second returns
  `409 DEVICE_APPROVAL_ALREADY_PENDING` — reuse the one you have or let it expire. Once an
  *approved* code's own window has passed, `begin` works again for the same transaction
  (useful if your server lost the `device_code` after the player approved — that used to
  409 until the transaction itself expired).
- **Starting an approval extends the transaction's window.** For `transfer` and `send`,
  `begin` pushes the transaction's own approval window past the code's expiry, so an
  approval at minute nine can't land on a transfer that has already expired and been
  refunded.
- **Subscribe to `device_approval.approved`** to learn the moment an approval lands
  instead of waiting for the next poll. Polling still works alone; the webhook is a
  latency improvement, not a replacement.
- **First-time approvers enrol on the spot — your only job is one Yes/No prompt.** The
  approval page uses a passkey registered with INVO, on INVO's own domain; a passkey the
  player set up on *your* domain is a different credential and cannot be used there — that
  is how passkeys work. So the first scan enrols one, and the proof it's really them is
  **your game screen**: the phone says "Confirm on your game screen", your game shows "Set
  up INVO on this phone: iPhone?", the player presses Yes on the console, and their phone's
  fingerprint / face / screen lock creates the passkey and approves. No code to type, no
  SMS, ever. Every later scan is scan, biometric, done. Same `begin`/`poll`, same
  `device_approval.approved` webhook, same `method` value `device_grant_webauthn`, same
  approve call with the `device_code` afterwards.
- **Poll: the `enrollment` object.** While a phone is waiting on your screen, the
  `authorization_pending` body gains it (absent when no phone asked — an already-enrolled
  phone never triggers it):

  ```json
  {"error": "authorization_pending",
   "enrollment": {"state": "awaiting_screen", "device_label": "iPhone",
                  "match_code": "48-27", "recovery": false,
                  "requested_at": "2026-09-03T18:04:12+00:00"}}
  ```

  Show the prompt while `state` is `awaiting_screen` — "Set up INVO on iPhone? Code 48-27.
  Say Yes only if the phone you just scanned shows this code." — and remove it the moment
  it changes (`confirmed` / `denied`) or the object disappears. The player confirms a
  *match*, so draw `match_code` large. `device_label` is from a fixed list (iPhone, iPad,
  Android phone, Android device, Mac, Windows PC, Chromebook, Linux device, phone), derived
  from the scanning phone's browser — a hint, not an identity. Lead the copy with "scanned
  just now". **`recovery: true`** means the phone declared it is *replacing* a lost INVO
  passkey — word the prompt "Replace your INVO passkey with this iPhone? Code 48-27" and
  answer through the same `confirm-enrollment` (see *Recovering a lost passkey* below).
- **Answer it: `POST /api/sdk/approvals/device/confirm-enrollment`** — same auth as
  begin/poll (the sender's SDK session token).

  ```python
  r = requests.post(
      f"{INVO_BASE}/api/sdk/approvals/device/confirm-enrollment",
      headers=H, json={"device_code": start["device_code"], "decision": "approve"},  # or "deny"
  )
  # 200 {"status": "confirmed" | "denied"}
  # 409 DEVICE_APPROVAL_ENROLLMENT_ALREADY_DECIDED {"decided", "via"}
  #     -> the backup email answered first; take the prompt down quietly
  # 409 DEVICE_APPROVAL_NO_ENROLLMENT_PENDING -> no phone has asked
  # 400 invalid_grant / expired_token
  ```

  **Deny ends the grant**: poll returns `access_denied`, and the phone says it was declined
  on the game screen.
- **Backup email.** At the scan, an email goes to the address on file — "confirm on your game
  screen; can't see the prompt? tap here; wasn't you? tap here; ignore this if you already
  confirmed". Opened after the screen answered, it says "you already confirmed this". A
  backup for a missed prompt, not the proof channel; nothing for you to do.
- **Mobile (`channel: "app_browser"`): enrolment is auto-confirmed, no prompt.** The Unity /
  Unreal plugins open the same page in the system browser and return on
  `invo-sdk-<game_id>://done`, which carries nothing. Because the phone running the page *is*
  the device that opened it, the game's prompt is unreachable and a match code proves nothing
  — so INVO confirms the enrolment itself: the poll's `enrollment` object appears already
  `confirmed` and goes straight to `approved`; the backup email still goes out as the "if this
  wasn't you" alert. Start polling on `interval` the moment `begin` returns — the return is
  only a hint to poll sooner.
- **Recovering a lost passkey — from the page, nothing new on your side.** A player whose only
  INVO passkey was on a phone they no longer have taps "Recover my passkey by email" on the
  page. Recovery needs **both** halves of the identity: your game screen confirms the phone
  (the `recovery: true` prompt above — auto-confirmed on mobile), and *only then* a
  single-use recovery link is emailed to the address on file (never an address the page
  supplies; one recovery per identity per 24 h). Opened on the same phone, that tab confirms
  and the page carries on: INVO deactivates the old INVO passkeys, notifies the owner on every
  channel, enqueues an **`identity.passkey_reset`** webhook to your game, and the phone enrols
  a fresh passkey and settles the approval — your poll reports `approved` and
  `device_approval.approved` fires as usual. The hosted pages are browser-only by design;
  there is no server-side call to make and nothing for this SDK to wrap.

  > ⚠️ **Money out is paused for 24 hours after a recovery.** The approval settles, but the
  > approve call for a `transfer` / `send` is refused with `403 PASSKEY_RECOVERY_COOLDOWN`
  > (`err.is_passkey_recovery_cooldown`, `err.retry_after_at`). The page tells the player
  > "recovered; start this transfer again after the hold" — expect a fresh initiate later,
  > not a retry. Receiving and collecting are unaffected.
- **An identity with a method elsewhere must vouch for the new one.** If the identity already
  has an active approver — a passkey on a partner domain, the INVO app's device key — the page
  refuses to enrol beside it with `409 ENROLLMENT_REQUIRES_PROOF` and tells the player to add
  this phone from where that method lives (device link), then scan again. An identity with
  no method enrols freely.
- **The code box on the page is the RFC 8628 `user_code`,** there for a TV with no camera.
  When the QR pre-fills it, the box isn't shown. It is not an SMS field.
- **Currency purchases on a console go through that console's store**, as the platform
  holder requires. This flow is for approving transfers and sends.

---

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

### `.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:

```python
# ✗ 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 `403`s 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](#passkeys-are-the-gold-standard--dont-build-on-sms) |

```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?, 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](https://semver.org/), 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](https://www.npmjs.com/package/@invonetwork/web-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](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).
