Metadata-Version: 2.5
Name: invonetwork
Version: 3.13.1
Summary: INVO Python server SDK -- currency purchase, subscriptions, 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.13.1`, stable (`pip install invonetwork`). The backend it
> wraps is **live** on sandbox + production, so you can build and test against sandbox today.
> Recent highlights: **3.13.0** adds **[`credited` / `reason_code`](#credited-did-the-coins-arrive)**
> on `confirm_payment()` and `steam_finalize_purchase()` (a `success` status means the payment
> succeeded; `credited` says whether the coins were added), the Merchant of Record
> `receipt_number` / `receipt_url` fields, and `tax_treatment: "partner_responsible"` on the
> Open tier, and updates the **[Tiers](#tiers-open-and-merchant-of-record)**: subscription card
> charges are now **4% + $0.30** on Open and **5% + $0.50** on Merchant of Record, international
> card and currency conversion costs on subscriptions pass through at cost, chargeback handling by
> tier, and Merchant of Record is applied for from the console; **3.12.0** adds
> **[`quote_currency_purchase()` and `acknowledged_total_usd`](#invos-card-fee-quote-show-acknowledge)**
> for INVO's card fee on coin purchases (3.5% + $0.30 on the Open tier), the fee fields on
> `PurchaseResult`, **[paid trials](#paid-trials)** (`trial_amount_usd`) on card subscriptions,
> and the **[Tiers](#tiers-open-and-merchant-of-record)** section; **3.11.0** stops pinning the
> card maximum in the SDK; **3.10.0** adds the
> **[purchase status read](#did-the-purchase-go-through-the-status-read)**, where
> `server.get_purchase_status()` / `server.wait_for_purchase()` answer what happened to a
> hosted-checkout purchase in six words, four of them terminal, so a buyer is never shown a page
> that closes on nothing; stopping is decided by INVO's `terminal` and never re-derived from
> `status`, `result.nothing_was_charged` says when a **Try again** is safe,
> `create_checkout()` now also returns `session_token`, and the subscription fee is corrected to
> **7% + $0.30** (it is its own rate; item purchases stay at 10/90); **3.9.0** documents **card-only subscriptions** (the card pays the full price
> every period and the coin wallet never pays; `wallet_only` deprecated; new `failure_code`
> `wallet_negative`; a renewal's `purchase.completed` is not a purchase) and the
> **[subscription refund limits](#refunds)**: `subscriptions.refund` now returns a
> `RefundReceipt` or a 202 `RefundPendingApproval` (branch on `is_pending_approval`), with `warnings`, the
> processor fee you bear, `subscription_canceled` (a full refund cancels), six new classifiers
> (`is_refunds_not_enabled`, `is_refund_window_closed`, `is_refund_request_pending`,
> `is_refund_request_rejected`, `is_concurrent_request`, `is_refund_mismatch`) and typed
> `subscription.refund_requested` / `refund_approved` / `refund_rejected` events, all additive;
> **3.8.0** adds the **[INVO-hosted card page for subscriptions](#cards-for-subscriptions)**,
> `server.cards.create_setup_session`: mint a 10-minute link server-side, send the member to it,
> read the card back from `cards.list`, with no browser code and no card processor named anywhere
> (`begin_setup` / `confirm_setup` remain as the processor-bound alternative), plus the
> `is_player_not_found`, `is_invalid_player_email` and `is_invalid_input` classifiers and
> `Subscription.player_email` / `player_name` on `include_player` reads;
> **3.7.0** adds subscriptions on both rails (`server.subscriptions`, `server.cards`,
> `server.sandbox.subscriptions`); **3.6.0** makes the **device approval grant a first-class SDK surface** —
> `begin_device_approval`, `poll_device_approval`, `confirm_device_enrollment` and, the step
> that actually moves the money, **`approve_with_device_code`** (plus the optional
> `complete_device_approval` loop); a grant that is polled to `approved` and left there
> settles **nothing**, which is the failure this release exists to make impossible, and
> `TRANSACTION_NOT_PENDING` now comes back as a typed `status="not_pending"` result with
> `already_settled` rather than a raised error you would have to string-match; **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

- **Device approval (RFC 8628)** — the console / TV / native-client passkey path, driven
  end to end from your server: begin, poll, answer the on-screen match code, and
  **`approve_with_device_code`** — the call that settles the transaction.
- **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).
- **The purchase status read**: `get_purchase_status` / `wait_for_purchase` say what happened to
  a hosted-checkout purchase, including "we have the money and the coins are not there", so a
  buyer is never shown a page that closes on nothing.
- **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.

This applies to a game or a platform alike: the SDK calls the tenant a game and names the
argument `game_secret`, but a platform tenant uses the same argument and the same flow.

## 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 |
| Console / TV / native client | the QR device-approval grant, **then `server.approve_with_device_code`** | polling to `approved` and stopping — that settles nothing |
| Recipient collects | the browser `confirmReceipt*` (passkey), or the QR flow with `flow="send_receipt"` / `"transfer_receipt"` | claim code as the primary path |
| 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
  with **`server.begin_device_approval(...)`** (the player's session token, never the game secret;
  [below](#consoles-and-tvs--approving-without-a-browser-rfc-8628)) and the browser / console /
  phone opens the page — then polls, and **calls `server.approve_with_device_code(...)` when the
  poll says `approved`**, which is the step that actually moves the money. 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 title
  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, your server polls, and **your server then calls `approve_with_device_code`** — the poll proves who, that call moves the money |
| 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)
- [Tiers: Open and Merchant of Record](#tiers-open-and-merchant-of-record)
- [Currency purchase (real money in)](#currency-purchase-real-money-in)
  - [The card maximum is INVO's, not the SDK's](#the-card-maximum-is-invos-not-the-sdks)
  - [INVO's card fee: quote, show, acknowledge](#invos-card-fee-quote-show-acknowledge)
  - [Did the purchase go through? (the status read)](#did-the-purchase-go-through-the-status-read)
  - [Refunds of coin purchases](#refunds-of-coin-purchases)
- [Item purchase (spend game currency)](#item-purchase-spend-game-currency)
- [Platform Commerce (ecommerce)](#platform-commerce-ecommerce)
- [Subscriptions (recurring billing)](#subscriptions-recurring-billing)
  - [The two clocks](#the-two-clocks)
  - [Cards for subscriptions](#cards-for-subscriptions)
  - [The card road](#the-card-road)
  - [Paid trials](#paid-trials)
  - [The Steam road](#the-steam-road)
  - [Renewals: grant on `subscription.renewed`, revoke on `expired` / `canceled`](#renewals-grant-on-subscriptionrenewed-revoke-on-expired--canceled)
  - [Sandbox recipe (the clock tools)](#sandbox-recipe-the-clock-tools)
  - [Traps](#traps)
  - [Subscription packages](#subscription-packages)
- [Player balance](#player-balance)
- [Sends & transfers](#sends--transfers)
  - [Legacy fallback: the SMS PIN (being retired)](#legacy-fallback-the-sms-pin-being-retired)
- [Sends and transfers, stage by stage](#sends-and-transfers-stage-by-stage)
  - [What your server must call, in order](#what-your-server-must-call-in-order)
  - [Errors on the money path](#errors-on-the-money-path)
  - [Common mistakes](#common-mistakes)
- [Inbound pending & linked identities](#inbound-pending--linked-identities)
- [Consoles and TVs — approving without a browser (RFC 8628)](#consoles-and-tvs--approving-without-a-browser-rfc-8628)
- [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 title, 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 title: 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.

---

## Tiers: Open and Merchant of Record

Every INVO developer account is on one of two tiers. The tier decides who the seller is when a
player pays by card, who handles sales tax, VAT and GST on those sales, and INVO's fees. **The
integration is the same on both**: the same SDK calls, the same webhooks, the same payouts.

| | Open (default) | Merchant of Record (by application) |
|---|---|---|
| Who has it | Every account, from the day it is created | Accounts INVO has approved; apply from the Selling & Tax page in your INVO console |
| Seller on card sales | **You.** Your business is the seller | **INVO** is the seller |
| Sales tax, VAT, GST | **You handle it.** INVO does not calculate, collect or remit tax on your sales | **INVO handles it**: calculates, collects and remits consumption tax on your behalf, where INVO is registered |
| A player buys coins by card | **3.5% + $0.30**, on top of the price when you acknowledge the total, otherwise taken out of the coins | **5% + $0.30** |
| Subscriptions | **4% + $0.30** per card charge | **5% + $0.50** per card charge |
| Chargebacks | The disputed amount comes out of your share; on subscription charges the dispute fee is passed through too | INVO manages the dispute and pays the dispute fee; the disputed amount comes out of your share and is restored if INVO wins |
| A player buys an item with coins | **10%** (90% to you) | Same as Open |

On both tiers INVO processes the payment and pays your share out to your bank account, and Steam
and game storefront purchases are sold by the storefront, which charges the player, handles the
tax and takes its own cut (INVO adds no card fee there). Where a response or webhook reports a
fee or your share, read it there rather than recomputing it. On subscription card charges made
on a card issued outside the United States, or needing a currency conversion, the extra
processing cost is passed through to you at INVO's actual cost, reported separately from INVO's
fee. On Merchant of Record, if your chargeback rate (disputed card charges divided by successful
card charges in a calendar month) goes above 0.75%, dispute fees on your subscription charges pass through to you as on Open
and INVO reviews your approval. Full details: <https://docs.invo.network/docs/tiers/>

**What the tier changes in what the SDK returns** (all additive):

- **`tax_treatment`** on an Open account's card sales reads `"partner_responsible"` (you are the
  seller; INVO charged no tax). It appears as `breakdown.tax_treatment` on
  `quote_currency_purchase()` and `tax_treatment` on the `purchase.completed` webhook. Tax
  amounts stay `"0.00"`. Treat an unknown value as informational.
- **Receipts (Merchant of Record only).** INVO issues a receipt for every card sale and a refund
  receipt for every card refund, and emails it to the buyer when the buyer's email is known; the
  number is always issued (a pending refund's number follows once it settles) and always reaches
  you. The number and link reach you as `receipt_number` /
  `receipt_url` on `purchase_currency()`, `get_order_details()` (from `GET /order-details`, which
  returns `receipt_number` / `receipt_url`), `get_purchase_status()` (once `credited`),
  `platform_commerce.get_status()` and a paid `first_charge`, and in `data` of the
  `purchase.completed`, `platform_commerce.purchased` and `subscription.renewed` webhooks. The
  refund webhooks (`purchase.refunded`, `platform_commerce.refunded`, `subscription.refunded`)
  carry the refund receipt's. On Open, and on storefront rails, they are `None` / absent. Anyone
  with a `receipt_url` can read the receipt.
- **Chargeback reserve (Merchant of Record only).** Part of your share of each card-funded sale
  (Platform Commerce card sales, card subscription charges) is held, by default 10% for 90 days
  (terms may differ per account), then released automatically. It affects your withdrawable
  balance, not anything this SDK returns: see <https://docs.invo.network/docs/tiers/> for the
  balance fields.

## Currency purchase (real money in)

Buy game currency with real money. Authenticated by the **payment rail**, not a passkey.

### The card maximum is INVO's, not the SDK's

**Minimum: $0.50.** That is the card rail's own floor: below it a charge cannot settle at all, so this SDK checks it locally and always will.

**Maximum: an INVO setting, $100,000.00 by default.** It is changed by an INVO admin without a deploy, and it differs between sandbox and production, so **this package does not pin it and never refuses an amount for being too large.** Your amount is sent; INVO decides.

Before 3.11.0 the SDK enforced a hard-coded $999.99 and raised before any request was made, so a partner charging $50,000 failed inside the SDK and INVO never heard about it. A pinned limit is wrong by construction here: the moment anyone edits the setting, every installed copy of the SDK is stale, and the only fix is a release. Removing the local ceiling means the SDK can waste a round trip on an amount INVO refuses; keeping it meant the SDK could block a sale INVO would have taken. Only one of those is recoverable without a package upgrade.

**When INVO refuses an amount this SDK allowed**, you get a `400` and `err.is_above_card_maximum is True`. Nothing was charged.

```python
try:
    server.create_checkout(player_email=email, usd_amount="50000.00")
except InvoError as err:
    if err.is_above_card_maximum:
        # err.message carries the figure in force for THIS environment, e.g.
        # "Card purchases must be $100,000.00 or less." Do not retry it unchanged:
        # lower the amount, split the sale, or ask INVO to raise the setting.
        log.warning(err.message)
```

The live figure for a tenant is also the `max_amount` of the hosted checkout's `validate-game` read, alongside `min_amount`. Read it if you want to show a limit in your own UI rather than hard-coding one, and re-read it, because it can change under you.

Per-customer velocity limits sit underneath the per-charge maximum (hourly, daily and monthly, per player per game) and are INVO's too. Exceeding one is a `429`; see `err.retry_after`.

### INVO's card fee: quote, show, acknowledge

On the card rail a player pays **INVO's card fee on top of the price**: 3.5% + $0.30 on the Open
tier (see [Tiers](#tiers-open-and-merchant-of-record)), never less than what the card rail itself
costs. When you acknowledge the quoted total, the coins are priced off the price you list, so the
player gets every coin you listed; if you do not acknowledge it, the fee comes out of the coins (below).
Steam and game storefront purchases carry no INVO card fee.

- **Hosted checkout** (`create_checkout`) adds the fee to the price and shows it on the page as
  its own line before the buyer pays. Nothing to do on your side.
- **Direct rail** (`purchase_currency`) confirms the charge in the same request, so there is no
  moment in which INVO could show the player a new figure. You show it, from a quote:

```python
# 1. Quote. Moves no money and creates nothing; safe on every price render.
quote = server.quote_currency_purchase(
    usd_amount="10.00",                 # the price you list, the same usd_amount you will send
    player_email="p@example.com",       # optional here; INVO throttles quotes per player with it
    country="US",                       # optional billing country; omitted, the quote is untaxed
)
# quote.total_usd == "10.65"           price 10.00 + card fee 0.65: SHOW THIS
# quote.processing_fee_usd == "0.65"   quote.subtotal_usd == "10.00"   quote.coins == "100.00"
# quote.breakdown                      the lines to show (subtotal, fee, tax, total, notes)
# quote.if_unacknowledged              what happens if you skip step 2 (see below)

# 2. Purchase with the total the player saw.
purchase = server.purchase_currency(
    player_email="p@example.com",
    usd_amount="10.00",
    acknowledged_total_usd=quote.total_usd,   # the card is charged exactly this: 10.65
    purchase_reference=str(uuid.uuid4()),
    payment_method_id="pm_...",
)
# purchase.charge_total_usd == "10.65"   purchase.processing_fee_usd == "0.65"
# purchase.fee_taken_from == "charge"    purchase.coins_credited == "100.00"
```

**Without `acknowledged_total_usd` the card is charged exactly `usd_amount`, never more**, and the
fee comes out of the coins instead. $10.00 unacknowledged charges $10.00, prices the coins at
$9.37 and credits **93.70 coins** at the default rate of 10 per USD, with a $0.63 fee
(`fee_taken_from == "coins"`); `quote.if_unacknowledged` shows that outcome in advance. An
unacknowledged amount too small to carry the fee is refused before anything is charged
(`400 AMOUNT_BELOW_FEE`, `err.is_amount_below_fee`).

**If the total changed since you quoted it** (a fee or tax setting moved), nothing is charged and
the call raises `409 QUOTE_STALE` (`err.is_quote_stale`; `err.expected_total_usd` is the new
figure). Quote again, show the new total, and purchase with a **new** `purchase_reference`: the
old one is tied to the refused order. Same for `AMOUNT_BELOW_FEE`.

Read `charge_total_usd` for what the card was charged. The older `purchase_details["usd_charged"]`
keeps its old meaning, the `usd_amount` you sent, and does not include the fee.

### Hosted checkout (recommended — you never touch card data)

```python
result = server.create_checkout(
    player_email="p@example.com",
    usd_amount="20.00",                 # USD. Minimum $0.50. The maximum is an INVO setting
                                        # ($100,000.00 by default), enforced on the request.
    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).
# result.session_token is the credential for the status read below (INVO's own field).
```

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.

### Did the purchase go through? (the status read)

`get_purchase_status` answers, in six words, what a checkout purchase is doing. Poll it until
INVO says it is finished and the buyer always has something true on screen, **including the case
where INVO has their money and they do not have their coins**. That case used to close the page
on nothing: no coins, no error, no receipt.

```python
result = server.wait_for_purchase(
    session_id=checkout.session_id,
    session_token=checkout.session_token,   # the credential; the id in the path is a label
    interval=1.5,                           # seconds; the limit is 600 reads/min per IP
    timeout=300,                            # the cap; on it you get the last result, never a raise
    on_update=render,                       # every read, terminal or not
)

if result.is_credited:
    grant(result.coins_credited, result.currency_name, result.new_balance)
elif result.message:
    show_verbatim(result.message, result.order_id)      # never paraphrase it
    if result.nothing_was_charged:
        offer_try_again()                                # ONLY when INVO says nothing was taken
    else:
        offer_contact_support()
```

`server.get_purchase_status(session_id=..., session_token=...)` reads once instead. Both are
authenticated by the **session token**, not the game secret. They are the same call the buyer's
own page makes.

| `status` | `terminal` | What it means | What to show |
|---|---|---|---|
| `pending` | no | No payment seen for this checkout yet | keep the spinner |
| `paid_pending_credit` | no | A charge is open, the coins are not in the balance yet, normally seconds | keep the modal open ("Finishing your payment and adding your coins"); after ~60s add the order id and a **Contact support** link, and **keep polling** |
| `credited` | yes | Charged and credited | `coins_credited`, `currency_name`, `new_balance` when not `None` |
| `refused` | yes | This purchase will not credit; `reason_code` says why | `message` verbatim + `order_id` + **Contact support** |
| `refunded` | yes | The money went back | `message` verbatim + `order_id` |
| `expired` | yes | Never paid, can no longer be paid | `message` verbatim; offer a new checkout |

Rules worth taking literally:

- **Stop on `result.terminal`, never on `status`.** `terminal` is INVO's answer and this SDK
  never recomputes it, so a status a later release adds cannot make this build decide a purchase
  finished, or finished *well*. An unknown status is never read as a success.
- **`paid_pending_credit` is not a failure, however long it takes.** INVO's reconciler recovers a
  stalled credit. Telling a buyer they lost money they are about to receive is worse than the
  silence.
- **A failed poll is not a failed purchase.** `wait_for_purchase` polls through a dropped
  connection, a timeout, a 429 and a 5xx; it raises a `401`, `403` or `410` at once
  (`err.is_wrong_session_purpose`, `err.is_checkout_session_mismatch`,
  `err.is_checkout_session_expired`).
- **Render `message` verbatim and never show `reason_code` on its own.** The six post-charge
  refusal codes all read the same to a buyer (they paid and the coins are not there), and the
  code exists for support to quote. Offer a **Try again** only where `result.nothing_was_charged`
  is `True` (`PAYMENT_DECLINED`, `PAYMENT_CANCELLED`, `expired`); every other refusal happened
  *after* the card was charged.
- **`expired` is not declared the second the link lapses.** A payment made in the last minutes of
  a checkout can leave no order row until a recovery sweep finds it, so an unpaid checkout reads
  `pending` for about 30 minutes past expiry. Poll through it.
- **`amount_charged_usd` minus `listed_usd`** is the processing fee the buyer pays on the card
  rail. A buyer billed 207.30 for a 200.00 pack will ask; both numbers are here.
- **Storefront (in-client) purchases do not reach this read.** Those orders are keyed on your own
  purchase reference rather than on a checkout session, so a lookup by session id finds nothing
  and answers `pending`. Do not conclude the purchase never happened.
- `new_balance` is `None` once the session is past its expiry: the token rides in the checkout
  URL, so a stale link must not serve a live wallet. `coins_credited` and `transaction_id`, which
  are about *this* purchase, still come back.
- `wait_for_purchase` blocks, so it suits a worker or a job rather than a request handler with a
  short timeout. Pass `should_stop` to end it early, and `sleep` to drive it in tests.

### 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, minimum **$0.50**; the maximum is an INVO setting ($100,000.00 by default) and is enforced on the request, not in this package. See [The card maximum is INVO's, not the SDK's](#the-card-maximum-is-invos-not-the-sdks).

### 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, done.credited
# done.credited is True means the currency is in; see "credited: did the coins arrive?".
```

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

quote = server.quote_currency_purchase(usd_amount="20.00", player_email="p@example.com")
# show the player quote.total_usd ("21.00": 20.00 + the 1.00 card fee)

purchase = server.purchase_currency(
    player_email="p@example.com",
    usd_amount="20.00",
    acknowledged_total_usd=quote.total_usd,  # optional; without it the fee comes out of the coins
    purchase_reference=str(uuid.uuid4()),  # idempotency key, required
    rail="platform",
    payment_method_id="pm_...",            # a tokenized payment method
    metadata={"your_order_id": "ord_42"},  # keys starting with _invo_ are reserved and dropped
)

if purchase.status == "success":
    pass  # captured; purchase.new_balance, .coins_credited, .charge_total_usd, .processing_fee_usd
elif purchase.status == "requires_action":
    # 3-D Secure: run the client action with purchase.client_secret, then:
    c = server.confirm_payment(payment_intent_id=purchase.payment_intent_id)
    # read c.credited, not c.status (see below)
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.

#### `credited`: did the coins arrive?

`confirm_payment()` and `steam_finalize_purchase()` keep `status == "success"` whenever the
**payment** succeeded. That alone does not prove the coins were added, so both results carry
`credited` and `reason_code` (backends from 2026-09-24):

```python
c = server.confirm_payment(payment_intent_id=pi_id)
if c.credited is True:
    pass  # the coins are in: show "purchase complete"
elif c.credited is False and (c.reason_code or "").startswith("CREDIT_REFUSED_"):
    # e.g. "CREDIT_REFUSED_MINT_CEILING": the money was taken and no coins were added.
    # INVO is alerted. Do NOT ask the buyer to pay again.
    pass
elif c.credited is False and c.reason_code:
    pass  # "REFUNDED" or "CHARGEBACK_LOST": the payment went back to the buyer
elif c.credited is False:
    pass  # reason_code is None: still being finished; poll get_order_details until "completed"
else:
    pass  # credited is None: an older backend; fall back to the order status
```

Show "purchase complete" only when `credited is True`. `already_processed` is also `True`
whenever the call added nothing (a refused credit included), so do not read success from it.

### Refunds of coin purchases

**Only coins the player has not spent can be refunded. Spent coins are never refunded.**

- If part of a purchase has been spent, a refund covers the unspent part only, in proportion.
- Coins that left the balance after the purchase (items, transfers, sends) count as spent first.
  Coins received later do not make a spent purchase refundable again.
- A refund never takes the balance below zero.
- Refunds of card coin purchases are issued by INVO; this SDK has no call for them. To request
  one for a player, contact INVO support with the `order_id`. The `purchase.refunded` webhook
  reports what happened.
- Steam and game storefront purchases are refunded by the storefront, on its side.

Platform Commerce and subscription refunds have their own calls and rules, in their sections.

---

## 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), and **refunds** exist. Who the seller of record is
> on the card leg depends on your [tier](#tiers-open-and-merchant-of-record). 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, min $0.50;
# the maximum is an INVO setting ($100,000.00 by default), enforced on the request.
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.
```

**Card sales refunded in parts.** A card refund normally returns the whole charge in one go, and
the result is as above. A sale ends up refunded in parts only when part of the charge was already
refunded outside INVO's API: the next `refund()` records that existing refund and moves no new
money (`ref.adopted is True`, `ref.partial_refund is True`, `ref.refunded_total_usd`,
`ref.charge_total_usd`, `ref.remaining_usd`; the order stays `completed`). Every figure is then
that refund's own share, and `partial_refund` / `refunded_total_usd` / `charge_total_usd` are set
on each part (`partial_refund is False` on the last); on a one-shot full refund the parts fields
are `None`, and `adopted` can still be `True` if the call recorded an existing full refund.

```python
# Refund what is left, on purpose:
server.platform_commerce.refund(order_id=order_id, refund_remaining=True)
# Within 120 s of the order's last refund, also confirm_additional_refund=True.
```

- `err.is_refund_remaining_requires_confirmation` (409 `REFUND_REMAINING_REQUIRES_CONFIRMATION`):
  send `refund_remaining=True`; `refunded_total_usd` / `remaining_usd` are on `err.body`. Nothing
  moved.
- `err.is_refund_recently_issued` (409 `REFUND_RECENTLY_ISSUED`): a refund of this order was
  applied in the last 120 seconds. Re-read the order; send `confirm_additional_refund=True` to
  refund anyway.
- `err.is_refund_not_completed` (502 `REFUND_NOT_COMPLETED`): the card refund failed, was canceled
  or still needs an action. Nothing was recorded; retry (a refund that was in fact created is
  recorded on the retry, never issued twice).
- `platform_commerce.refunded` for a sale refunded in parts also carries `partial_refund`,
  `refunded_total_usd` and `charge_total_usd` (`refunded_amount` is that refund's own amount); a
  one-shot full refund sends none of them.

- **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
  INVO's maximum single card charge comes back as `err.is_above_card_maximum` (400) with the live
  figure in `err.message`.
- Client-side validation (missing fields, bad `funding_source`, quantity outside `1..1000`, total
  mismatch, card total under $0.50, non-E.164 `player_phone`) raises `INVALID_INPUT` **before** any
  network call. The card **maximum** is deliberately not among them: see
  [The card maximum is INVO's, not the SDK's](#the-card-maximum-is-invos-not-the-sdks).

---

## Subscriptions (recurring billing)

A subscription is a standing agreement your server creates: INVO bills one of your players a
fixed **USD** price on a fixed interval and grants them your title's currency each period. **You
keep the entitlement** (what the member gets in your title). **INVO keeps the clock, the charges,
the retries, the notifications and the revenue split.** Each period INVO charges the rail behind
the subscription and splits the period's coins between INVO's platform fee and your revenue,
settled through your normal partner payout.

Two rails, fixed at creation:

| `funding_rail` | Who pays each period | Created by |
|---|---|---|
| `card` | the member's saved card on file with INVO, charged the **full price** every period | `server.subscriptions.create(...)` |
| `steam` | the member's Steam wallet, through a recurring agreement they authorise once (the INVO wallet is spent first; Steam tops up the shortfall) | `server.subscriptions.steam_init(...)` then `steam_finalize(...)` |

- **Card rail: card only (since 2026-09-17).** The card is charged the full price every period,
  whatever the member's coin wallet holds. The coins that charge buys are minted and spent in the
  same step, so **the wallet never pays for a card subscription and never moves**. There is no
  shortfall, no rounding and no minimum top-up. On the Open tier INVO's fee is
  4% plus $0.30, on the full price ($9.99 pays INVO $0.70 and you $9.29); 5% plus $0.50 on the
  Merchant of Record tier (see [Tiers](#tiers-open-and-merchant-of-record)). Subscriptions are their own rate: item
  purchases stay at 10/90.
- **Steam rail: unchanged.** INVO spends the member's wallet first and tops up from Steam for the
  shortfall only.
- **Save a card before `subscriptions.create`.** A member with coins but no saved card is still
  subscribed (the create succeeds), but the first charge fails with
  `failure_code "no_payment_method"` and the subscription enters dunning. Coins never pay it.
- **`wallet_only` is deprecated.** It is still accepted and returned, but a `wallet_only=True`
  card subscription never charges a card and so can never be paid: every charge fails with
  `failure_code "wallet_only"` and it lapses to `expired`. Do not send it on new integrations.

**One `item_id` per recurring product, shared across rails.** The one-live-subscription rule is
per (game, player, `item_id`) and is **rail-agnostic**: a member with a live card subscription to
`guild-42-membership` cannot start a Steam one to the same `item_id`, and vice versa. So the
subscription's `item_id` is the *recurring* product's handle, the same on both roads, and it
must differ from any one-off pack id you sell (a Steam pack, a currency bundle, an in-game item).

> **Before you create your first subscription:** the title must be **`live`** in the environment
> you are calling (`testing` is refused with `403 GAME_NOT_LIVE`, `err.is_game_not_live`; make
> it live in the developer console, in sandbox too), and you must have a **webhook target
> registered**, renewals, failures, authentication challenges, cancellations, expiries and
> refunds are all reported by webhook, and some carry data you cannot fetch any other way.

### The two clocks

Two different dates live on every subscription and must not be confused:

| Field | Meaning | Exists when |
|---|---|---|
| `current_period_start` / `current_period_end` | the **billing window** INVO is currently billing or about to bill, a projection | from the instant of creation, before any money has moved |
| `paid_through` | the **entitlement boundary**: the furthest date the member has actually paid for | only after a period has been charged successfully; `None` before that |

**Grant access from `paid_through`, never from `current_period_end`.** A subscription created a
minute ago has a `current_period_end` a month out and a `paid_through` of `None` until its first
charge lands.

Statuses: `trialing`, `active`, `past_due` (a charge failed, INVO is retrying, access retained)
and `awaiting_authentication` (a card charge needs the cardholder, nothing charged, not a
failure) are **live**. `pending_steam_authorization` (Steam, waiting for the member) is not.
`canceled` and `expired` are terminal. One live subscription per (player, `item_id`).

### Cards for subscriptions

A card subscription needs a card saved for later, **off-session** use. **The recommended way to
save one is the INVO-hosted card page:** two server-to-server calls and one redirect, **no
browser code**, and nothing in your integration references a card processor or loads its script,
so INVO can change processor without you changing anything. Your PCI scope does not change: the
subscription endpoints never receive card data and neither does this SDK.

**1. Mint a card-setup session on your server.** The member must already exist in your title;
this call never creates players.

```python
session = server.cards.create_setup_session(
    player_email="member@example.com",
    success_url="https://your.game/billing/card-saved?member=7",   # optional: where the page sends the member after the save; put your own correlation id here
)
# session.session_id, session.card_setup_url, session.expires_at (10 minutes from now)
```

**2. Send the member to `session.card_setup_url`.** Open it as a top-level page (a redirect or a
new tab); **do not frame it**. The page says in words that no money moves. The member enters the
card, authenticates with their issuer if the issuer asks, and INVO saves the card and then
redirects to your `success_url` (or shows "Card saved, you can close this window" when you gave
none). You write no browser code for this step and you never see a client secret, a publishable
key or a processor name.

**3. Read the card back and subscribe.** A card saved this way
**appears in `cards.list` immediately**, carries the off-session consent a subscription needs,
and is indistinguishable from one saved any other way. The `id` is the only handle INVO returns
for a card.

```python
cards = server.cards.list("member@example.com").cards   # unexpired, newest first
r = server.subscriptions.create(
    client_request_id="guild-42-member-7",
    player_email="member@example.com",
    player_name="Member",
    item_id="guild-42-membership",
    amount_usd="9.99",
    player_card_id=cards[0].id,      # or omit it: INVO attaches the member's newest unexpired saved card
)
```

**A brand-new member (no player in your title yet).** The card page needs an existing player and
there is no create-player call, so bring the member into your title first. Two ways that move no
money: `subscriptions.create` with `trial_days` creates the player and a `trialing` subscription
(`first_charge.status == "skipped_trial"`); or a card-less `subscriptions.create` (no
`player_card_id`, no saved card yet) creates the player and the subscription, and its first charge
is recorded as failed with `failure_code "no_payment_method"` (outcome `insufficient_funds`), so
the subscription sits in dunning with access retained. Then mint the card session and send the
member to the page. After the save either call `subscriptions.set_payment_method(id,
player_card_id=...)` or do nothing: INVO adopts the member's newest saved card at the next attempt
and attaches it. A currency purchase also creates the player, and with `save_card=True` saves the
card in the same step.

**What to expect.**

- **The session lives 10 minutes.** Entering a card is one sitting. Mint the link when the member
  is about to use it, not ahead of time. An expired link shows the member a message asking for a
  new one; a new `create_setup_session` call is all it takes.
- **The link may be reloaded freely before the card is saved.** A reload after the issuer has
  authorised the card opens directly on the final "finish saving" step rather than asking for the
  card again.
- **Once the card is saved the link is spent.** Reopening it returns the same saved card and
  redirects to `success_url` again; nothing is saved twice.
- **If saving fails after the issuer authorised the card,** the page tells the member to try
  again and the same link keeps working; nothing is recorded until the save succeeds. If what the
  member entered is not a card (a bank account, for instance) the page stops with a terminal
  message and a new link is needed.
- **There is no webhook for a saved card.** `cards.list` is the signal: read it when the member
  lands on `success_url`, or right before the next `subscriptions.create`.
- **`create_setup_session` is never auto-retried.** Each call mints a fresh link, so on a timeout
  or 5xx just call it again.
- **`success_url` is redirected to verbatim**, with nothing appended, and `metadata` is stored on
  the session and not returned anywhere today (no read returns the session). Put your own
  correlation id in `success_url`. `cancel_url` is carried on the session but not used by the
  page today.

**Errors from `create_setup_session`.** Match on `err.code`, never on the message.

| Helper / code | HTTP | Meaning |
|---|---|---|
| `.is_invalid_player_email` (`INVALID_PLAYER_EMAIL`) | 400 | `player_email` is not an email address (INVO checks for an `@`). A missing or blank `player_email` never reaches INVO: the SDK raises `INVALID_INPUT` with `.status == 0` first |
| `.is_player_not_found` (`PLAYER_NOT_FOUND`) | 404 | the member does not exist in this title yet. Create the player first (any call that creates players, for example a currency purchase); the card page never creates one |
| `.is_invalid_input` (`INVALID_INPUT`) | 400 | `success_url` or `cancel_url` uses a script scheme (`javascript:`, `data:`, `vbscript:`). http(s) and app deep-link schemes are fine. The SDK refuses the same values before the network with `.status == 0` |
| no code, `err.status == 401` | 401 | the game key is missing or wrong for this environment (body is `{"message": ...}`) |
| no code, `err.status == 403` | 403 | the game is not `live` or `testing` |
| `err.status == 503` (the `error_code` names the cause) | 503 | card setup is not available right now; retry shortly |

Rate limit: 2000 per minute per game key with a per-IP floor; on a 429 honour `err.retry_after`.

**The processor-bound alternative: `cards.begin_setup` / `cards.confirm_setup`.** Use these only
if you already run a card form in your own client against INVO's current card processor.
`begin_setup` hands back a `client_secret` that only that processor's client library can consume,
so the browser code you write for it is bound to the processor of the day and will need rework
when INVO changes processor. `create_setup_session` does not have that problem, which is why it is
the recommended path. `begin_setup` charges nothing and the player must already exist in your
title (this endpoint does not create players).

```python
import uuid

setup = server.cards.begin_setup(
    player_email="member@example.com",
    setup_reference=str(uuid.uuid4()),   # idempotency anchor, reuse it on retries
    payment_method_id=payment_method_id, # a card tokenised on the client by the card form (optional)
)
if setup.status == "succeeded":
    card_id = setup.card.id              # the ONLY handle for the card; pass it as player_card_id
else:
    # "requires_action" / "requires_confirmation" / "requires_payment_method":
    # hand setup.client_secret (+ setup.publishable_key) to the card form on the client,
    # let it confirm / authenticate, then record the card:
    confirmed = server.cards.confirm_setup(setup_intent_id=setup.setup_intent_id)
    card_id = confirmed.card.id          # idempotent: confirmed.already_saved on a repeat

cards = server.cards.list("member@example.com")   # unexpired cards, newest first
# cards.cards[0].id / .brand / .last_four / .exp_month / .exp_year
```

Failures: `CARD_DECLINED`, `SETUP_FAILED`, `INVALID_PAYMENT_METHOD`, `RAW_CARD_NOT_SUPPORTED`
(send a tokenised card, never raw card numbers), `PLAYER_NOT_FOUND` (404), `SETUP_REFERENCE_REUSED`
(409, same reference with different parameters), `CARD_PERSIST_FAILED` (500, the card was
authorised but not recorded; call `confirm_setup` again with the same `setup_intent_id`).
While the client has not finished, `confirm_setup` answers `400 {"status": "still_requires_action"}`.

**Saving the card during a purchase** also works: a currency purchase with a new
`payment_method_id` and `save_card=True` saves it with the off-session consent as a side effect
(the purchase response carries `card_saved: true`; fetch the `id` from `cards.list`).

### The card road

#### 1. Capture a card (`server.cards`)

Three ways to save one, described in [Cards for subscriptions](#cards-for-subscriptions) above:
the INVO-hosted card page through `cards.create_setup_session` (recommended), the processor-bound
`cards.begin_setup` / `cards.confirm_setup` pair, or a purchase with `save_card=True`. Whichever
you use, the card's `id` comes from `cards.list`.
**Hosted checkout does not save a card for subscriptions**, and cards saved through
`saved_card_id` purchases or before this behaviour shipped do not carry the off-session consent a
subscription needs, the member must save the card again.

```python
cards = server.cards.list("member@example.com")   # unexpired cards, newest first
# cards.cards[0].id / .brand / .last_four / .exp_month / .exp_year
```

#### 2. Create the subscription and handle `first_charge`

`create` **charges the first period before responding** (unless it is a trial), expect it to
take as long as a card charge. It is idempotent on `client_request_id`.

```python
import uuid

key = str(uuid.uuid4())   # generate ONCE per subscription, persist it, reuse it on every retry

r = server.subscriptions.create(
    client_request_id=key,
    player_email="member@example.com",
    player_name="Ada",                        # used only if the player does not exist yet
    item_id="guild-42-membership",            # your entitlement handle; one live sub per (player, item)
    item_name="Guild 42 membership",          # optional, echoed on events
    amount_usd="9.99",                        # decimal string; keep at or below 25000.00 PER CHARGE to be chargeable
    interval="month",                         # "month" (default) | "year"
    interval_count=1,                         # 1..36; 3 + "month" bills quarterly
    player_card_id=card_id,                   # optional: omitted, INVO picks the newest saved card
    metadata={"guild_id": "42"},              # optional, echoed on every read and event (not `_invo`)
    consent={                                 # optional but SEND IT, a disputed charge is argued from this
        "consent_at": "2026-09-06T14:01:50+00:00",
        "consent_ip": "203.0.113.7",          # the MEMBER's IP as your server saw it
        "consent_user_agent": "Mozilla/5.0 ...",
        "disclosed_amount_usd": "9.99",
        "disclosed_interval": "month",
        "terms_version": "2026-09",
    },
)

sub, first = r.subscription, r.first_charge
if first.status == "paid":
    grant_access(sub.subscription_id, until=sub.paid_through)   # money moved; expect subscription.renewed
elif first.status == "requires_action":
    # NOTHING has been charged. Send the member to the link before it expires; do not grant paid
    # access yet (paid_through is None). On completion you receive subscription.renewed.
    send_link(first.confirmation_url, expires_at=first.expires_at)   # a bearer link, do not log it
elif first.status == "skipped_trial":
    grant_trial_access(sub.subscription_id, until=sub.trial_end)     # first charge runs at trial end
elif first.status == "failed":
    # failure_code: "no_payment_method" (save a card; coins never pay), "card_declined", "wallet_negative", ...
    tell_member_to_fix_card(first.failure_code, next_retry_at=first.next_retry_at)  # past_due, in dunning
else:  # "pending"
    pass  # unresolved; wait for subscription.renewed / .payment_failed (INVO resolves it within ~30 min)

# r.idempotent_replay is True when this response REPLAYED an earlier create for the same key.
# r.card is the card backing the subscription (or None). r.warning set => read it with get().
```

After a successful first charge `sub.period_seq` already reads `2`: period 1 was just paid and
`current_period_*` is the **next** window; `paid_through` is the end of the window just paid.
**Key entitlement on `first.paid_period_seq`** (`1` here; `None` on every status but `paid`) or on
the `subscription.renewed` webhook's `period_seq` (which reports the period **just paid**) , 
never on `subscription.period_seq`, which on both roads points at the *next* period after a
create or finalize.

**Replay before retry.** On any timeout or 5xx, call `create` again with the **same**
`client_request_id`, the backend returns the same body (`idempotent_replay=True`) and charges
nothing again. A new key inside a retry loop lands on `err.is_active_subscription_exists`
(`err.existing_subscription_id` carries the live one) at best and a second subscription at
worst. The same key with different terms raises `err.is_idempotent_replay_mismatch`
(`err.mismatched_fields`).

Trials: `trial_days=7` (1..365) or `trial_end="2026-10-01T00:00:00+00:00"` (takes precedence)
creates the subscription `trialing` with `first_charge.status == "skipped_trial"`: a **free**
trial. Add `trial_amount_usd` for a [paid trial](#paid-trials). Not available on Steam. `wallet_only=True` is **deprecated**: it never charges a card, so since 2026-09-17 such a
subscription can never be paid (every charge fails with `failure_code "wallet_only"`); it cannot
be combined with `player_card_id`.
`revenue_share={"recipient_player_email": "founder@example.com", "percent": "70"}` records an
attribution to another player in your title, **INVO pays nothing to the recipient**
(`settled_by_invo` is always `False`); the figure to pay them from is on every renewal event.

### Paid trials

Send `trial_amount_usd` with `trial_days` or `trial_end` to charge a small amount for the trial
window, then the regular price: "$1.00 for 7 days, then $29.00 a month" is **one subscription
with one card**.

```python
r = server.subscriptions.create(
    client_request_id=key,
    player_email="member@example.com",
    player_name="Ada",
    item_id="pro-membership",
    amount_usd="29.00",                       # the REGULAR price, charged from period 2
    trial_days=7,                             # or trial_end=...
    trial_amount_usd="1.00",                  # period 1 (the trial window), charged NOW
    player_card_id=card_id,                   # the member must already have a saved card
    consent={
        "consent_at": "2026-09-23T14:01:50+00:00",
        "disclosed_amount_usd": "29.00",
        "disclosed_interval": "month",
        "disclosed_trial_amount_usd": "1.00",  # the trial terms you showed the member
        "disclosed_trial_days": 7,             # or "disclosed_trial_end": "<ISO 8601>"
    },
)
# r.subscription.status == "trialing"      r.first_charge.status == "paid"
# r.first_charge.amount_usd == "1.00"      r.first_charge.paid_period_seq == 1
# r.subscription.amount_usd == "29.00"     r.subscription.trial_amount_usd == "1.00"
# r.subscription.paid_through == r.subscription.trial_end
# r.subscription.trial_amount_coins_estimate: what the trial period is worth in coins
```

- **Rules** (INVO's, also checked by this SDK before the request, with the same codes): only with
  `trial_days` or `trial_end` (`TRIAL_AMOUNT_REQUIRES_TRIAL`); card only, never with
  `wallet_only=True` and never on Steam (`TRIAL_AMOUNT_NOT_SUPPORTED_FOR_FUNDING`); at least
  `0.50` (the card minimum) and less than `amount_usd` (`TRIAL_AMOUNT_INVALID`).
- **Save the card first.** The trial price is charged inside `create`, so the member needs a usable
  saved card. Without one INVO refuses with `400 PAYMENT_METHOD_REQUIRED`
  (`err.is_payment_method_required`) and creates nothing. (A free trial, or a subscription without
  a trial, is still created card-less.)
- **Period 1 is the trial window** and is reported like any first charge; `skipped_trial` is never
  returned for a paid trial. `subscription.renewed` fires for period 1 with `is_trial: True` and
  `amount_usd: "1.00"`.
- **At the trial end INVO charges `amount_usd` as period 2** (`subscription.renewed` with
  `period_seq: 2`, `is_trial: False`) and the subscription becomes `active`. This is not a price
  change: nothing is staged in `pending_amount_usd`.
- **If the trial charge is refused** (declined, or no chargeable card at that moment), the
  subscription does not go live: it ends at once in `expired`, `first_charge.status == "failed"`
  with `next_retry_at` `None`, and you receive `subscription.payment_failed` then
  `subscription.expired` with `expire_cause: "trial_payment_failed"`. Subscribe the member again
  with a **new** `client_request_id` once they have a working card. A temporary error on INVO's or
  the card network's side is retried like any other charge instead.
- Events about period 1 of a paid trial (`payment_failed`, `past_due`, `authentication_required`,
  `expired`) carry `is_trial: True` and `trial_amount_usd`, because their shared `amount_usd` is
  the regular price. The keys are absent on every other event of those types.
- `trial_amount_usd` is a material term: replaying a key with a different value, or without it
  against a paid trial, raises `err.is_idempotent_replay_mismatch`.

### The Steam road

A Steam subscription is funded from the member's Steam wallet through a recurring agreement the
member authorises once, in Steam. INVO still owns the clock, same schedule, retries and events
as the card rail. The title must be enabled for Steam in INVO (Steam app id and publisher Web API
key verified, billing on file) and you must use the **primary game key or the `steam` channel
key** (`err.is_steam_channel_required` otherwise). Not available on Steam: trials,
`interval_count` other than `1`, `wallet_only`, card fields, price increases, refunds of
Steam-charged periods.

```python
import uuid

init = server.subscriptions.steam_init(
    client_request_id=str(uuid.uuid4()),
    player_email="member@example.com",
    player_name="Ada",
    item_id="guild-42-membership",
    amount_usd="9.99",
    steam_id="76561198000000000",     # the member's 64-bit Steam id
    user_session="client",            # "client" (Steam overlay in-game) | "web" (hosted checkout URL)
    # player_ip="203.0.113.7",        # REQUIRED for "web": the PLAYER's IP, never your server's
    metadata={"guild_id": "42"},
)
# init.subscription.status == "pending_steam_authorization"; nothing charged yet.
# init.amount_coins   -> what one period is worth for THIS member (price-derived for their country,
#                        VAT included: 9.99 USD is 69 coins for a US member, 58 for a German one).
#                        Show it. Never price x 10.
# init.steam_order_id -> the uint64 the Steam CLIENT callback echoes as order_id. MATCH THE
#                        CALLBACK ON THIS to learn which subscription the member authorised
#                        (or abandoned), i.e. which subscription_id to finalize.
# init.steam_transid  -> Steam's transaction reference for period 1, for your support tooling only.
# init.pending_reuse  -> True when INVO handed back an EXISTING pending authorisation for this
#                        player + item with the same terms (new key, no new agreement). Finalize it.
pending[init.steam_order_id] = init.subscription_id

# The client step: "client" -> the Steam overlay presents the authorisation inside the game;
# "web" -> open init.steam_checkout_url as a TOP-LEVEL tab/window (it cannot be framed).

# Then finalize. Steam does not tell INVO the member authorised, YOUR game calls this after the
# overlay or checkout closes, and again if it returned not_authorized. The member has 24 hours.
try:
    fin = server.subscriptions.steam_finalize(init.subscription_id)
except InvoError as e:
    if e.is_not_authorized:        # 409: the normal wait state, call again after they authorise
        ...
    elif e.is_subscription_terminal:   # canceled / expired (the 24 h lapsed), start a new one
        ...
    else:
        raise
else:
    if fin.first_charge.status == "paid":
        grant_access(fin.subscription.subscription_id, until=fin.subscription.paid_through)
        # fin.first_charge.paid_period_seq == 1; fin.subscription.period_seq is already 2 (the NEXT
        # period) and the subscription.renewed webhook for this charge reports period_seq 1.
    # fin.already_processed -> already finalised (or a concurrent finalize won): treat as success
    # fin.warning           -> Steam charged but period 1 could not settle in one step: the money
    #                          IS taken, do not retry the charge, watch for subscription.renewed
    # fin.steam_agreement_status is normally "active"; fin.sandbox_auto_approved in sandbox only
```

Funding on Steam: **period 1 always charges the full price** (that is what creates the
agreement). Later periods are wallet-first, wallet covers the period: no Steam charge; wallet
empty: the full advertised price; wallet partly funded: the smallest charge that buys the missing
coins (never above the price, never below the rail's own minimum top-up, which is an INVO
setting). A replayed `steam_init`
(same key) returns the row's **current** status (`init.idempotent_replay`), which may already be
`active`, `canceled` or `expired`. A **new** key while an authorisation for the same player +
item is still pending: with the **same terms** INVO hands the pending row back
(`init.pending_reuse is True`, no new agreement, finalize it); with **different terms** it
raises `409 STEAM_AUTHORIZATION_PENDING` (`err.is_steam_authorization_pending`,
`err.existing_subscription_id` names the pending row, finalize or cancel it first). An
unfinalised row expires on its own after 24 hours.

### Renewals: grant on `subscription.renewed`, revoke on `expired` / `canceled`

Billing is **in advance**: each later period is charged at `next_charge_at`. The renewal engine
runs every minute; INVO adds up to an hour of random offset to the first period so a cohort does
not renew on the same instant. **Do not poll for renewals**, handle the events. There is **no
`subscription.created`** and **no `subscription.amount_changed`**: the create/finalize response
is your creation signal, and the first period's `subscription.renewed` is the first event.

| Event | Fires | Your move |
|---|---|---|
| `subscription.renewed` | once per successfully charged period, including period 1 | **extend access** to `current_period_start` (= `period_end` of the paid period = the new `paid_through`). `is_trial` is `True` only on period 1 of a [paid trial](#paid-trials). `funding` says how it was paid (card rail: `card_charged_usd` is the full price, `balance_applied_coins` is always `"0.00"`, `mint_order_id` is never `None`); `split.partner_revenue_usd` is what you earned |
| `subscription.payment_failed` | every failed attempt, including the first | tell the member; **do not revoke** (`retry_at`, `retries_remaining`, `grace_period_end`). `failure_code` includes `no_payment_method` (no saved card), `wallet_negative` (coin balance below zero; nothing charged), `card_declined` |
| `subscription.past_due` | once, when the subscription enters `past_due` | **do not revoke** |
| `subscription.authentication_required` | a card issuer wants the cardholder to authenticate (nothing charged, no retry consumed) | relay `confirmation_url` to the member before `expires_at`; **not a failure** |
| `subscription.canceled` | once, at the moment cancellation is requested, both modes; also after a full refund (`cancel_cause: "full_refund"`) | **revoke at `access_until`**; `canceled_by` is `partner` or `steam` |
| `subscription.expired` | the retry budget ran out, or a Steam row was never authorised: `failure_code` `steam_authorization_abandoned`, which arrives **without** a `payment_failed` before it. (`failure_code` `steam_containment` is **no longer returned**: a balance is spendable in any title whatever rail bought it, so no renewal is deferred or expired for that reason) | **revoke at `final_period_end`** (the last paid instant; `None` if nothing was ever paid). `expire_cause: "trial_payment_failed"` when a paid trial's own charge was refused |
| `subscription.refunded` | a refund | reverse what `refund` describes: `refund.partner_total_reversed_usd` is your revenue reversed plus the card processor fee you bear (older events: `refund.partner_revenue_reversed_usd`); `subscription_canceled` says a full refund ended it; `revenue_share_attribution.net_attributed_amount_usd` is the corrected figure for your recipient |
| `subscription.refund_requested` / `subscription.refund_approved` / `subscription.refund_rejected` | a refund over your daily self-serve limit became a request / INVO approved it (sent together with `subscription.refunded`, and `subscription.canceled` on a full refund, in any order) / INVO rejected it (`decision_reason`) | track the request by `request_id`; nothing moves until it is approved |

**A card renewal also sends `purchase.completed`.** Every paid card renewal emits
`purchase.completed` for the coins its charge minted, with `data["metadata"]["source"] ==
"subscription_renewal"` (and `metadata["subscription_id"]`). **Do not grant currency or access from
it**: the renewal spends those coins in the same step. Act on `subscription.renewed`; the two share
the mint order id (`order_id` there is `mint_order_id` here). If a renewal's card charge succeeds
but INVO cannot finish the renewal in the same step, you may see `subscription.payment_failed` with
a settlement `failure_code` (for example `settlement_error`); the retry then completes the period
**without charging the card again**, and `subscription.renewed` follows.

Default dunning: retry after 2 days, then 3, then 2, then expire, three retries over seven days,
access retained through the whole window. Only `subscription.expired` and `subscription.canceled`
revoke access; nothing else does.

```python
from typing import cast
from invonetwork import (
    verify_webhook, InvoError, SUBSCRIPTION_EVENT_TYPES,
    SubscriptionRenewedData, SubscriptionExpiredData, SubscriptionCanceledData,
)

@app.post("/invo/webhooks")
def invo_webhooks():
    try:
        event = verify_webhook(request.get_data(), request.headers.get("X-Invo-Signature"),
                               os.environ["INVO_WEBHOOK_SECRET"])
    except InvoError as e:
        return Response(e.code or "invalid_signature", status=400)
    if already_seen(event.idempotency_key):       # dedupe on X-Invo-Idempotency-Key / idempotency_key
        return Response(status=200)

    if event.event_type == "subscription.renewed":
        d = cast(SubscriptionRenewedData, event.data)
        extend_access(d["subscription_id"], until=d["current_period_start"])   # the new paid_through
        record_revenue(d["split"]["partner_revenue_usd"], period_seq=d["period_seq"])
        # d["funding"]["card_charged_usd"] / ["steam_charged_usd"] / ["balance_applied_coins"]
        # d["amount_usd"] is the price this period was billed at (a staged change shows here)
    elif event.event_type == "subscription.expired":
        d = cast(SubscriptionExpiredData, event.data)
        revoke_access(d["subscription_id"], at=d["final_period_end"])    # NOT at ended_at
    elif event.event_type == "subscription.canceled":
        d = cast(SubscriptionCanceledData, event.data)
        revoke_access(d["subscription_id"], at=d["access_until"])
    elif event.event_type == "subscription.authentication_required":
        send_link(event.data["confirmation_url"], expires_at=event.data["expires_at"])
    elif event.event_type == "subscription.refunded":
        refund = event.data["refund"]      # older events carry no partner_total_reversed_usd
        record_reversal(refund.get("partner_total_reversed_usd", refund["partner_revenue_reversed_usd"]))
        # event.data.get("subscription_canceled"): a full refund cancels (subscription.canceled is sent with it)
    elif event.event_type == "purchase.completed":
        if (event.data.get("metadata") or {}).get("source") == "subscription_renewal":
            pass  # a card renewal's mint leg: grant NOTHING here, subscription.renewed is the signal
        else:
            grant_currency(event.data)
    elif event.event_type in SUBSCRIPTION_EVENT_TYPES:
        pass  # payment_failed / past_due / refund_requested / refund_approved / refund_rejected: inform, never revoke here
    return Response(status=200)
```

Every money and coin figure on these payloads is a **decimal string**, never a number. On Steam,
`split.total_usd` is what the coins are worth after Valve's share and VAT (`basis: "steam_net"`),
not the price; the top-level `amount_usd` is always the price.

### Reading, cancelling, repricing, changing the card

```python
sub = server.subscriptions.get("SUB_1757155200_A1B2C3D4")
# sub.status, sub.paid_through (entitlement), sub.next_charge_at, sub.period_seq,
# sub.pending_amount_usd (a staged price change), sub.has_payment_method, sub.funding_rail

page = server.subscriptions.list_for_player("member@example.com", status=["live", "canceled"], limit=50)
# page.subscriptions (newest first), page.pagination["total_count"] / ["has_more"]
# an unknown player is an empty list, not a 404

c = server.subscriptions.cancel(sub.subscription_id, at_period_end=True, reason="member request")
# at_period_end=True  -> stays live with cancel_at_period_end, retired at paid_through, no more charges
#                        BUT if nothing has been paid it is DOWNGRADED to immediate:
#                        c.terminated_immediately is True, c.downgrade_reason == "NO_PAID_PERIOD"
# at_period_end=False -> ended now; keep access until c.access_until
# already canceled / expired -> 200 with c.already_canceled and c.final_status (no special case)
# Steam: c.steam_agreement_status / c.steam_agreement_canceled

a = server.subscriptions.change_amount(sub.subscription_id, "14.99")
# STAGED: applied on the next successful renewal, the current window bills at the old price.
# a.change_queued, a.pending_amount_usd, a.applies_from (None until something has been paid),
# a.applies_from_period_seq. Sending the current price clears a staged change.
# Steam: an INCREASE raises err.is_steam_reauthorization_required (the price is a ceiling the
# member consented to, start a new Steam subscription and cancel this one); a decrease stages.
# More than double the last agreed price, or above 25000.00, is staged but never applied.

pm = server.subscriptions.set_payment_method(sub.subscription_id, player_card_id=new_card_id)
# card rail only (Steam raises err.is_not_a_card_subscription). Used from the next charge,
# including a pending retry in dunning. On a wallet_only subscription pass wallet_only=False
# together with the card to convert it (do this for any you have: wallet_only is deprecated and
# such a subscription can never be paid). pm.replaced / pm.card / pm.wallet_only
```

### Refunds

```python
ref = server.subscriptions.refund(
    sub.subscription_id,
    client_request_id=str(uuid.uuid4()),   # one per refund; reuse it on retries
    period_seq=2,                          # optional: defaults to the most recent billed period
    amount_usd="9.99",                     # optional: defaults to the full remaining refundable amount
    reason="member request",               # recommended now, required after a notice period
)
if ref.is_pending_approval:                # a RefundPendingApproval; status == "pending_approval"
    # Over the game's daily self-serve limit: NOTHING moved. INVO reviews request ref.request_id.
    # Wait for subscription.refund_approved (sent with subscription.refunded) or subscription.refund_rejected.
    mark_refund_pending(ref.request_id, ref.requested_amount_usd)
else:
    # ref.status == "refunded" (a RefundReceipt).
    # ref.is_full_refund, ref.funding_shape ("card" for every card period since 2026-09-17),
    # ref.card_refunded_usd (card-only periods: equals the amount, partial included),
    # ref.balance_delta_coins, ref.remaining_refundable_usd, ref.new_balance,
    # ref.partner_revenue_reversed_usd (your revenue is reversed; INVO's fee is retained),
    # ref.processing_fee_usd (the card processor fee you bear), ref.partner_total_reversed_usd (both),
    # ref.subscription_canceled (a full refund cancels the subscription),
    # ref.processor_refund_reference (the neutral card-refund reference for your reconciliation)
    # A repeat with the same client_request_id -> ref.idempotent_replay (or a reduced body with ref.note)
    ...
for w in ref.warnings:                     # "REASON_RECOMMENDED" | "REASON_TRUNCATED"
    log.info(w.code)
```

**What is refunded.** A card subscription is card only (since 2026-09-17), so its period is
refunded **in cash, full or partial**: the amount goes back to the card (`card_refunded_usd` equals
the amount, `balance_delta_coins` is `"0.00"`), and partials plus the refund that completes them add
up to exactly the card charge. A period charged **before** 2026-09-17, or a Steam period the wallet
covered, keeps the earlier rule: a full refund returns the whole card charge and reclaims the coins
that charge minted (`balance_delta_coins` is signed and can go negative), a partial refund credits
coins at the currency's rate (10 coins per USD by default). A fully refunded period no longer counts toward `paid_through`.

**What else happens.**

- **A full refund cancels the subscription** immediately (`subscription_canceled` is `True`,
  `subscription_status == "canceled"`), and you receive `subscription.canceled`
  (`canceled_by: "partner"`, `cancel_cause: "full_refund"`) together with `subscription.refunded`;
  the two are sent together and may arrive in any order. A partial
  refund leaves it live. Before 2026-09-17 a full refund did not cancel.
- **You bear the card processor fee** on any refund that returns money to a card:
  `processing_fee_usd` (`processing_fee_basis` `actual` or `estimated`,
  `processing_fee_borne_by == "partner"`) is deducted from your revenue as its own line.
  `partner_total_reversed_usd` is your revenue reversed plus that fee. A coin refund has no fee.
- **INVO emails the member** when a refund executes (the amount, where it went, and whether the
  subscription ended). Your `reason` is not in that email.
- **Refunds of Steam-charged periods are not supported yet:** a period charged through Steam is
  refused with `409 STEAM_REFUND_NOT_SUPPORTED` (`err.is_steam_refund_not_supported`). Periods of a
  Steam subscription that the wallet covered entirely are refundable in coins.

**Limits.** Self-serve refunds run inside four limits:

1. **Enabled per game.** On by default for every game; INVO can turn them off for a game
   (`403 REFUNDS_NOT_ENABLED`, `err.is_refunds_not_enabled`).
2. **30 days.** A period can be refunded for 30 days after it was charged
   (`409 REFUND_WINDOW_CLOSED`, `err.is_refund_window_closed`, with `err.period_charged_at`,
   `err.refund_window_days`, `err.refund_window_closed_at`). A retry of a refund that already ran
   still returns its receipt.
3. **A daily limit per game** (per UTC day, in sandbox and production). By default it is never lower
   than the largest single subscription charge (`25000.00` USD today), so any single period can
   always be refunded on its own; if INVO raises the per-charge maximum, the limit rises with it.
   INVO can set a specific limit for your game, which applies as set, even if lower. A refund that would exceed it does **not** run: the call answers HTTP 202 with
   `status == "pending_approval"` (`ref.is_pending_approval`) and `request_id`, carrying every
   receipt field with nothing moved, and INVO reviews the request. Approved: the refund runs as if
   your call had (the 30 days count from when you asked), you receive `subscription.refunded`,
   `subscription.refund_approved` and, for a full refund, `subscription.canceled`, sent together and may arrive in any order,
   and a retry of your key returns the receipt. Rejected: you receive
   `subscription.refund_rejected` with `decision_reason`, and your key raises
   `409 REFUND_REQUEST_REJECTED` (`err.is_refund_request_rejected`); use a new key to try again.
   While a request is pending, other refunds of that period raise `409 REFUND_REQUEST_PENDING`
   (`err.is_refund_request_pending`, the id on `err.refund_request_id`). The same key returns the
   same 202 with `idempotent_replay`.
4. **A reason.** Recommended now, required after a notice period. Without it the refund still runs
   (`reason` is `None`) and `warnings` carries `REASON_RECOMMENDED`; a reason longer than 500
   characters is cut, with `REASON_TRUNCATED` (this SDK refuses one over 500 before the network).

Other refusals: `409 NO_PAID_PERIOD` is the normal state of a brand-new subscription;
`409 ALREADY_REFUNDED`, `409 PERIOD_NOT_REFUNDABLE`, `400 AMOUNT_EXCEEDS_REMAINING`
(`remaining_refundable_usd` on `.body`), `409 CONCURRENT_REQUEST` (`err.is_concurrent_request`,
retry with the same key) and `502 REFUND_MISMATCH` (`err.is_refund_mismatch`, contact INVO).

Your developer console session can list your refund requests
(`GET /api/dev/subscriptions/refund-requests?status=pending|approved|rejected|all`); that console
read is not wrapped by this SDK, which authenticates with the game key.

### Sandbox recipe (the clock tools)

Sandbox runs the renewal engine every minute exactly as production does, so a subscription you
create there renews on its own a month later. Four **sandbox-only** tools move a subscription's
billing clock and drive the **real** renewal engine so you can get there in minutes:

| Method | What it does |
|---|---|
| `sandbox.subscriptions.advance_clock(id, intervals=1)` | makes the next period due now (`0` = due now without moving the window, use it to trigger a retry) |
| `sandbox.subscriptions.force_renewal(id)` | runs the renewal immediately through the real money path, **charges the test card** |
| `sandbox.subscriptions.force_failure(id, outcome)` | synthesises a failed attempt and runs real dunning (no charge) |
| `sandbox.subscriptions.force_auth_challenge(id)` | synthesises a cardholder authentication challenge (no charge) |

> **Note the path.** These live **directly under the sandbox base, not under `/api`**: the full URL
> is `https://sandbox.invo.network/sandbox/subscriptions/<id>/advance-clock`. The SDK resolves
> them relative to your `base_url` (which already ends in `/sandbox`). They do not exist in
> production at all (404).

Every call needs a **second credential**, `X-Sandbox-Clock-Key` (`ivclk_...`), a separate
per-game key: in the sandbox developer console open the game, **Game Settings**, the
**"Sandbox clock key"** card (generated the first time you reveal it; the same card rotates it).
Pass it as `InvoServer(..., sandbox_clock_key=...)`. A missing key is refused before the network
(`INVALID_INPUT`); a wrong or never-issued one is `401 SANDBOX_CLOCK_UNAUTHORIZED`
(`err.is_sandbox_clock_unauthorized`). Keep it out of client builds and source control.

```python
server = InvoServer(
    game_secret=os.environ["INVO_SANDBOX_GAME_SECRET"],
    base_url="https://sandbox.invo.network/sandbox",
    sandbox_clock_key=os.environ["INVO_SANDBOX_CLOCK_KEY"],   # ivclk_... from the sandbox console
)
clock = server.sandbox.subscriptions

# 1. Save a card: create_setup_session(...) and open card_setup_url in a browser (the sandbox page
#    runs against the test-mode card processor: enter a test card there, never a real one; the
#    sandbox recipe on the public docs site is https://docs.invo.network/docs/subscriptions-sandbox),
#    or on the processor-bound path begin_setup(payment_method_id="pm_card_visa")
#    (succeeds immediately; "pm_card_threeDSecure2Required" returns requires_action: confirm on the
#    client, then confirm_setup).
# 2. create(...) with that player_card_id -> first_charge.status == "paid", period_seq == 2,
#    and a subscription.renewed delivery with period_seq 1. Replay the same body -> idempotent_replay.
# 3. Renew as many periods as you like:
clock.advance_clock(sub_id)                       # due now
r = clock.force_renewal(sub_id)                   # r.engine_stats == {"renewed": 1}; period_seq advances
# 4. Walk the dunning ladder to expired (payment_failed x4, past_due once, expired with final_period_end):
for _ in range(4):
    f = clock.force_failure(sub_id, "card_declined")   # f.retries_remaining, f.retry_schedule_days
# 5. On a fresh subscription: force_auth_challenge -> awaiting_authentication; then
#    advance_clock(sub_id, intervals=0) + force_renewal to watch the challenge lapse into dunning.
# 6. change_amount, advance_clock, force_renewal -> old price on that renewal, new price on the next.
# 7. cancel(at_period_end=True), advance_clock, force_renewal -> r.action == "retired-at-period-end".
# 8. refund(...) on a paid period -> a receipt and subscription.refunded. A full refund cancels the
#    subscription, so refund a partial amount first if you want to keep testing it. The daily limit
#    applies in sandbox too; a refund over it answers status "pending_approval". At the default
#    limit a single period always runs, so to see one, ask INVO to set a low limit for the game.
# 9. create(..., trial_days=7) -> skipped_trial; advance_clock(intervals=1) + force_renewal -> paid period 2.
# 10. create(..., trial_days=7, trial_amount_usd="1.00") with a saved card -> paid, first_charge.amount_usd "1.00",
#     status "trialing"; advance_clock(intervals=1) + force_renewal -> period 2 at amount_usd, status "active".
```

Steam in sandbox: `steam_finalize` **does not call Steam**, it auto-approves, seeds a stand-in
agreement and runs the real settlement (`fin.sandbox_auto_approved is True`), and a later
`force_renewal` treats the Steam charge as captured (`engine_stats` shows `steam_charged` and
`sandbox_synthetic`). Sandbox proves INVO's currency path (coins, split, events, dunning), not
Valve's charge, do one real Steam sandbox authorisation before launch.

### Errors you will branch on

| Helper | Meaning |
|---|---|
| `.is_game_not_live` | `create` / `steam_init` refused: the title is `testing` (403), make it live in the console |
| `.is_active_subscription_exists` | the player already has a live subscription to this `item_id` (409); `.existing_subscription_id` carries it. Usually a regenerated key inside a retry loop |
| `.is_idempotent_replay_mismatch` | same key, different material terms (409); `.mismatched_fields` names them |
| `.is_steam_reauthorization_required` | `change_amount` tried to raise a Steam price (409), new subscription |
| `.is_not_a_card_subscription` | `set_payment_method` on a Steam subscription (409) |
| `.is_steam_channel_required` | `steam_init` with a non-Steam storefront key (409) |
| `.is_subscription_terminal` | canceled / expired (409), from `change_amount`, `set_payment_method`, `steam_finalize` |
| `.is_not_authorized` | `steam_finalize` before the member authorised (409), the normal wait state |
| `.is_steam_authorization_pending` | `steam_init` with a new key while a pending authorisation for the same player + item has **different** terms (409 `STEAM_AUTHORIZATION_PENDING`); `.existing_subscription_id` names it |
| `.is_steam_refund_not_supported` | `refund` on a period charged through Steam (409), not supported yet |
| `.is_refunds_not_enabled` | `refund`: refunds are turned off for this game (403); contact INVO |
| `.is_refund_window_closed` | `refund`: the period was charged more than 30 days ago (409); `.period_charged_at`, `.refund_window_days`, `.refund_window_closed_at` |
| `.is_refund_request_pending` | `refund`: a refund request for this period is waiting for INVO (409); `.refund_request_id` |
| `.is_refund_request_rejected` | `refund`: this key was a refund request INVO rejected (409); `.refund_request_id`, `.decision_reason`; use a new key |
| `.is_concurrent_request` | two requests collided (409: two creates for a new player, or two refund requests for one period); retry with the **same** key |
| `.is_refund_mismatch` | `refund`: a card refund needs manual reconciliation (502); contact INVO, do not retry-loop |
| `.is_payment_method_required` | `create` with `trial_amount_usd` and no usable saved card (400); nothing was created, save a card first |
| `.is_trial_amount_requires_trial` / `.is_trial_amount_not_supported_for_funding` / `.is_trial_amount_invalid` | `trial_amount_usd` without a trial / with `wallet_only` or on Steam / not at least 0.50 and less than `amount_usd` (400, or raised locally at `status == 0`) |
| `.is_consent_disclosed_trial_invalid` | a `disclosed_trial_*` consent field could not be read (400); `err.code` says which |
| `.is_sandbox_clock_unauthorized` | missing / wrong `X-Sandbox-Clock-Key` (401) |
| `.is_flow_paused` | maintenance pause (503, both wire shapes), retry later with the same key |
| `.is_phone_share_approval_required` | the `player_phone` you supplied belongs to another identity (409), run the phone-share approval, then retry |
| `.is_player_not_found` | `cards.create_setup_session` (also `begin_setup`): the member does not exist in this title yet (404); the card page never creates players |
| `.is_invalid_player_email` | `cards.create_setup_session`: `player_email` is missing or not an email (400) |
| `.is_invalid_input` | `cards.create_setup_session`: `success_url` / `cancel_url` uses a script scheme (400); also the code of every client-side guard in this SDK, at `status == 0` |

Other codes come through on `err.code` unchanged: the create validation codes
(`CLIENT_REQUEST_ID_RESERVED`, `AMOUNT_TOO_LARGE`, `TRIAL_NOT_SUPPORTED_ON_STEAM`, ...),
`PAYMENT_METHOD_NOT_FOUND` (404), `WALLET_ONLY_SUBSCRIPTION` (409, `wallet_only` with a card), `STEAM_RAIL_NOT_ENTITLED`
(403, `err.is_steam_rail_not_entitled`), `STEAM_NOT_CONFIGURED` (503, `err.is_steam_not_configured`),
`PARTNER_BILLING_NOT_SET_UP` / `PARTNER_RAIL_SUSPENDED` / `PARTNER_CREDIT_UNAVAILABLE` (409, the
partner-settlement helpers), and the refund and sandbox refusals listed above. Client-side guards
(a reserved `sub_` key, a bad interval, `interval_count` outside `1..36` or not `1` on Steam,
`wallet_only` with a card, a non-E.164 `player_phone`, a `web` session without `player_ip`, a
missing clock key) raise `INVALID_INPUT` **before** any network call.

### Traps

1. **Replay before retry.** On any timeout or 5xx from `create`, send the same body again. Never
   mint a new `client_request_id` inside a retry loop.
2. **`requires_action` is not `paid`.** Nothing has been charged. Do not grant paid access until
   `subscription.renewed`.
3. **`pending` is not `failed`.** Wait for the event; INVO resolves it.
4. **`current_period_end` is not entitlement.** Use `paid_through`.
5. **`payment_failed`, `past_due` and `authentication_required` do not revoke access.** Only
   `expired` (at `final_period_end`) and `canceled` (at `access_until`) do.
6. **Do not poll for renewals.** Handle `subscription.renewed`.
7. **One live subscription per item.** Cancel the old one before creating a new one for the same
   `item_id`, or change the price with `change_amount`.
8. **At-period-end cancel on an unpaid subscription is immediate.** Read `terminated_immediately`.
9. **Price changes apply next period, and big jumps do not apply at all** (more than double, or
   above `25000.00`, stays pending forever). Raise prices in steps.
10. **A card saved without off-session consent cannot back a subscription**, save it again with
    `cards.create_setup_session` (the INVO-hosted card page), the processor-bound
    `cards.begin_setup`, or a `save_card=True` purchase.
11. **`wallet_only` is absolute, and deprecated.** A card attached later is ignored until you pass
    `wallet_only=False` on `set_payment_method`, and since 2026-09-17 a `wallet_only` card
    subscription can never be paid. Coins never pay a card subscription: save a card before
    `create`.
12. **`sub_` is reserved** as a prefix for every idempotency key on the platform; `metadata` is
    yours except the key `_invo`.
13. **Send `consent`.** A disputed recurring charge is argued from that record. On a paid trial,
    record the trial terms you showed (`disclosed_trial_amount_usd` and `disclosed_trial_days` or
    `disclosed_trial_end`).
14. **Do not store or log `confirmation_url`** beside anything you publish. It is a bearer link.
15. **Steam: finalize is your call**, within 24 hours; match the client callback on
    `steam_order_id`; `web` sessions need `player_ip` and a top-level window; the price is a
    ceiling (increases are refused); coins per period depend on the member's country and are fixed
    at init (show `amount_coins`); a member can cancel from Steam (`canceled_by: "steam"`);
    refunds of Steam-charged periods are refused (`STEAM_REFUND_NOT_SUPPORTED`).
16. **The clock tools are not under `/api`.** `POST /subscriptions/<id>/advance-clock` and friends
    are relative to the **sandbox base** (`https://sandbox.invo.network/sandbox/subscriptions/...`),
    need `X-Sandbox-Clock-Key`, and do not exist in production.
17. **A renewal's `purchase.completed` is not a purchase.** Skip it when
    `data["metadata"]["source"] == "subscription_renewal"`; grant on `subscription.renewed`.
18. **`pending_approval` is not `refunded`.** Nothing moved (`ref.is_pending_approval`); wait for
    `subscription.refund_approved` or `subscription.refund_rejected`.

### Subscription packages

A package (the API calls it a plan) is what you sell: a name, a price, a billing interval and the
`item_id` your game checks. Full guide:
[docs.invo.network/docs/subscriptions-packages](https://docs.invo.network/docs/subscriptions-packages/).

- **Packages are managed in your INVO dashboard, not in this SDK.** Creating, editing, closing and
  reopening a package is authenticated by your dashboard sign-in, not by your game secret or a
  player token. This SDK has no `packages` or `plans` namespace, by design: a game secret on your
  server should never be able to reprice what your members pay.
- **Your integration does not change.** `server.subscriptions.create(...)` and `server.subscriptions.steam_init(...)` still subscribe a member to an
  `item_id` with your game secret. Webhooks carry the same payloads, and entitlement still comes from
  `subscription.renewed`, keyed on `item_id`.
- **The `item_id` is the link.** A package describes one `item_id` in one title, and only one live
  package may hold a given `item_id` per title (a second is refused with `ITEM_ID_IN_USE`).
- **A new package adopts the members already on its item.** Subscriber counts and the monthly
  revenue figure are measured per title and `item_id`, so a package created over existing members
  shows them immediately. There is no linking step, and their subscriptions do not change.
- **Two counts.** `subscriber_count` (basis `active`) is members being charged.
  `live_subscriber_count` (basis `live`) is everyone still subscribed, including trials and members
  cancelling at period end. A package whose members are all in trial shows `0` and `400`. Both are
  correct.
- **A price change must say who it applies to.** New subscribers only is available now, and current
  members keep the price they are on. Moving existing subscribers too (each at their own next
  renewal) is not available yet and is refused with a named code rather than half-applied.
  Renaming a package is not a price change.
- **Closing a package cancels nobody.** It stops new subscriptions only. Existing members renew and
  are billed exactly as before, at their own price. A package with members can be closed but never
  deleted, and a closed package can be reopened. If a package price and a member's price disagree,
  the member pays their own price.

---

## 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, authorized by the **sender's
passkey** — the [gold standard](#passkeys-are-the-gold-standard--dont-build-on-sms).

**There are exactly two approval paths, and both are passkeys:**

- **In-app passkey** — wherever WebAuthn can run in the client: the browser, via the JS SDK
  (`approveSend` / `approveTransfer`), or INVO's hosted approval page (`approveHosted`).
- **Device approval (QR)** — wherever it cannot: consoles, TVs, native Steam and desktop
  clients. **This SDK drives that one end to end** — the player approves on their phone and
  your server then makes the call that moves the money. See
  [Consoles and TVs](#consoles-and-tvs--approving-without-a-browser-rfc-8628) and
  [Sends and transfers, stage by stage](#sends-and-transfers-stage-by-stage).

A legacy SMS-PIN fallback still exists for a sender who can use neither — it is
[being retired](#legacy-fallback-the-sms-pin-being-retired) and new integrations should not
build on it.

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.
else:
    ...  # sender has NO passkey yet -> enrol one (browser enrollPasskey() / approveHosted()),
    ...  # or run the QR device-approval grant on a console. See "stage by stage" below.
```

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 titles 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** title (self-serve: switch it to Live in the console
under Game Settings > Status), `err.is_target_game_not_live` is the **destination** title, 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.

- **The one message that is still a text: the claim link to a phone-only recipient.** A
  send can be addressed to someone INVO has never met — the sender knows their phone number
  and nothing else, so there is no email to write to and no session to notify. That is the
  only remaining place INVO sends an SMS by default; everything else that needs a human
  (guardian consent, enrolment backup, phone-share) goes by **email first**. 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 title, enter **one email** (the same thing in-app 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 title; the page shows the new balance and tells them to sign
  in to the title with that email + phone. The in-app claim is unchanged: point a player at
  the link when they don't have your title open, at in-app 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.


### Legacy fallback: the SMS PIN (being retired)

**This is the legacy path. It is being retired, and new integrations should not build on
it.** It is kept working only for a sender who genuinely cannot approve with a passkey: an
unsupported device with no platform authenticator, or a player who declines both the in-app
ceremony and the QR flow.

When a sender has no enrolled approver, `initiate_send` / `initiate_transfer` return
`verification_method == "sms"` and a one-time PIN is texted. The completion calls are
`server.verify_sms_transfer(transaction_id, pin)` / `verify_sms_send(...)` — **deprecated
since 3.1.0**, still fully functional, and with no runtime `DeprecationWarning` so
warnings-as-errors suites are unaffected. They will be removed at some future major; no date
is being promised.

Why it is going away: a PIN in a text is a shared secret on a channel exposed to SIM swap,
SS7 interception and social engineering, it costs real money per message at every scale, and
it depends on carrier delivery INVO does not control. A passkey — in the client where
WebAuthn can run, on the player's phone by QR where it cannot — is stronger, faster and free.
Read `verification_method == "sms"` as *"this sender has no passkey yet"* and offer them one;
show a PIN pad only when there is no other way to serve that specific player.

## Sends and transfers, stage by stage

One reference for the whole money path — **both flows** (send and transfer) and **both
sides** (the sender who authorises, the recipient who collects). It exists because the
stages are easy to read as three when there are four, and the fourth one is the money.

> Companion page, same title, on the docs site:
> [**Sends and transfers, stage by stage**](https://docs.invo.network/docs/sends-and-transfers-stage-by-stage)
> — the same walkthrough with the raw REST calls, for stacks that do not use this package.

**The four stages.**

1. **Initiate** (your server). Nothing has moved; a transaction now exists, pending the
   sender's approval.
2. **Prove who is approving.** A passkey ceremony in the browser (the JS SDK), on INVO's
   hosted page, or — where no browser exists — a device approval grant on the player's
   phone, driven from here.
3. **Settle** (your server, for the grant path). **A proof is not a payment.** The approval
   says *who*; a separate call moves the value and runs every gate (guardian, risk,
   recovery cooldown). In the browser the approve call does both at once, which is why the
   split only becomes visible on the grant path.
4. **Collect.** The recipient confirms receipt (their own passkey, or the grant), or
   redeems the claim code. Reconcile off webhooks, never off stage 3's response alone.

### What your server must call, in order

| # | Stage | Send | Transfer | Auth |
|---|---|---|---|---|
| 1 | Initiate | `server.initiate_send(...)` | `server.initiate_transfer(...)` | game secret |
| 2 | Mint the player's session | `server.mint_player_token(player_email=...)` | same | game secret |
| 3a | Sender approves — **browser** | JS SDK `approveSend(txnId)` | JS SDK `approveTransfer(txnId)` | player token |
| 3b | Sender approves — **web, hosted page** | JS SDK `approveHosted({ flow: "send" })` | `... flow: "transfer"` | player token |
| 3c | Sender approves — **console / TV / native** | `server.begin_device_approval(flow="send")` -> `poll_device_approval` | `... flow="transfer"` | **player** token |
| 4 | **Settle the grant** (3c only) | **`server.approve_with_device_code(flow="send", device_code=...)`** | **`... flow="transfer"`** | **player** token |
| 5a | Recipient collects — browser | JS SDK `confirmReceiptSend(txnId)` | JS SDK `confirmReceiptTransfer(txnId)` | player token |
| 5b | Recipient collects — console | `begin`/`poll` `flow="send_receipt"` -> **`approve_with_device_code`** | `... flow="transfer_receipt"` -> **`approve_with_device_code`** | **player** token |
| 5c | Recipient collects — claim code | `server.claim_currency(claim_code=..., ...)` | `server.claim_transfer(claim_code=..., ...)` | game secret |
| 6 | Reconcile | `transfer.claim_pending` -> `transfer.received` webhooks | same | webhook secret |

Steps 3c, 4 and 5b take the **player token explicitly**: on a console your *server*
holds the token it minted, and the game secret is never sent on any of them.

### The four device-approval flows

`flow` picks which endpoint stage 4 settles against. Begin with one flow and settle with
the same one: a grant approved for `transfer` settles nothing else.

| Side | `flow` | What `approve_with_device_code` calls | Success looks like |
|---|---|---|---|
| Sender, send | `send` | `POST /api/sdk/send/{id}/approve` | `status="approved"`, `next="pending_claim"` |
| Sender, transfer | `transfer` | `POST /api/sdk/transfers/{id}/approve` | `status="approved"`, `next="pending_claim"`, `claim_code` |
| Recipient, send | `send_receipt` | `POST /api/sdk/send/{id}/confirm-receipt` | `status="completed"`, `amount_received` |
| Recipient, transfer | `transfer_receipt` | `POST /api/sdk/transfers/{id}/confirm-receipt` | `status="completed"`, `amount_received` |

### Errors on the money path

| Code | HTTP | Means | Do |
|---|---|---|---|
| `TRANSACTION_NOT_PENDING` | 400 | The transaction is not at this step. Three different situations: your earlier attempt **already landed**; the transaction is **dead** (expired / refunded); or — on a receipt flow — it has **not got here yet**, because the sender's own approval is still pending or held at a guardian. | **Not raised by `approve_with_device_code`** — it comes back as `status="not_pending"` with `already_settled` and `current_status`. `already_settled` separates the first case from the other two. When it is `False`, go read `get_transfer_status` / `get_send_status` rather than assuming; never retry-loop. |
| `DEVICE_APPROVAL_NOT_APPROVED` | 400 | The `device_code` was unknown, another player's, bound to a different transaction, bound to a different **flow**, or the player has not approved yet. One answer for all five, deliberately — distinguishing them would leak which transactions have a live approval. | `err.is_device_approval_not_approved`. Check the `flow` matches the `begin`, and that the poll actually said `approved`. |
| `DEVICE_APPROVAL_ALREADY_PENDING` | 409 | An **approved** grant for this transaction and flow is still inside its window. The player already approved. | `err.is_device_approval_already_pending`, `err.device_approval_expires_at`. You are missing stage 4, not a new code. (A merely *pending* earlier grant does **not** 409; `begin` supersedes it.) |
| `PASSKEY_RECOVERY_COOLDOWN` | 403 | Money **out** is paused for 24 h after a passkey recovery (SIM-swap protection). Receiving and collecting are unaffected. | `err.is_passkey_recovery_cooldown`, `err.retry_after_at` (ISO; the body also carries `retry_after_seconds`). Expect a fresh initiate later — do not retry-loop. |
| `SEND_APPROVE_FAILED`, `TRANSFER_APPROVE_FAILED`, `CONFIRM_RECEIPT_FAILED` | 400 | A **fault** on INVO's side, not a rule. `err.is_settle_fault`; the body carries **`error_ref`** (`err.error_ref`). | Never retryable. See the ambiguity note below — do **not** tell the player it failed until you have read the transaction. Quote `error_ref` to INVO support; it names the exact server log line. |
| guardian hold | 202 | A minor's transaction is waiting on a guardian. **The money is held, not refused.** | Returned as a result with `hold_reason`; poll `get_guardian_approval_status`. Terminal outcomes (`GUARDIAN_APPROVAL_REJECTED` / `_EXPIRED`) raise as 410. |

**Also reachable from a receipt settle** (`send_receipt` / `transfer_receipt`) — a console
recipient built only from the table above ships with no fallback for these:

| Code | HTTP | Means | Do |
|---|---|---|---|
| `receiver_not_enrolled_use_claim_code` | 409 | The recipient has no passkey in this title, so there is no in-app confirm to run. | `err.is_receiver_not_enrolled`. Fall back to the claim code: show it, or point them at the hosted claim link from their text. |
| `not_intended_receiver` | 403 | The token's identity is not the addressed recipient. | Not retryable. You minted the token for the wrong player, or the send was addressed to a different phone. |
| `PHONE_SHARE_APPROVAL_REQUIRED` | 409 | The receiver phone is contested, so INVO **holds the money** and asks its owner to approve. | `err.is_phone_share_approval_required`. Not a failure: show `err.message` verbatim (`err.phone_share_last4` names the phone), then **retry the same call** once approved — it then succeeds idempotently. On denial/expiry the sender is refunded. |
| `RECIPIENT_IDENTITY_PENDING` | 202 | Same shape, on the identity check: **money held**, the phone's owner emailed. | Comes back as a result with `hold_reason`, not a raise. Keep the collect pending and retry later. |
| `RECIPIENT_IDENTITY_DECLINED` | 403 | The phone's owner said no. | Terminal. The sender is refunded; do not retry. |

> ⚠️ **A settle FAILURE is ambiguous about the money.** INVO commits the transfer and
> *then* builds its `200` by reading the transaction back — so a fault raised after that
> commit answers `400 *_FAILED` for money that **already moved**. An `err.status == 0`
> (timeout, dropped connection) is ambiguous for exactly the same reason, and this call is
> deliberately never auto-retried. On either: read `get_transfer_status` /
> `get_send_status`, or wait for the `transfer.claim_pending` webhook, **before** you tell
> the player it failed — and never auto-start a replacement transaction.

### "Already settled" is not a refusal

The integrator question this section was written for: *did the guard stop me, or is her
gold stuck?* You must be able to answer that without matching on message text.

`approve_with_device_code` **does not raise** for `TRANSACTION_NOT_PENDING`. It returns:

```python
r = server.approve_with_device_code(
    player_token=player_token,
    transaction_id=transaction_id,
    flow="transfer",
    device_code=device_code,
)
if r.status == "not_pending":
    if r.already_settled:
        # This step already happened — typically your first attempt landed and the
        # response was lost. r.current_status says where it is now
        # (pending_claim, completed, ...).
        ...
    else:
        # NOT settled. Either dead (expired / failed / refunded), or — on a receipt flow —
        # not there YET: the sender has not approved, or their approval is held at a
        # guardian. It is also what an unrecognised status returns, deliberately.
        # Go and read it; do not guess and do not start a replacement transaction:
        t = server.get_transfer_status(transaction_id)
        ...
else:
    # status "approved" (+ next="pending_claim", r.claim_code on a transfer)
    # or "completed" (+ r.amount_received) on the receipt flows
    # or a 202 hold — r.hold_reason names it.
    ...
```

Every other refusal is a normal raised `InvoError` with a typed property, so a
`try`/`except` around this call still means "something is wrong". Nothing in your code has
to read an English sentence to tell the two apart.

**`already_settled` fails closed.** It is `True` only for a status INVO knows to be past
this flow's step — and the two receipt flows have a *different* set from the two sender
flows, because the four endpoints gate on different statuses. Anything else, including a
status this SDK version has never seen, answers `False` with `current_status` set. Cautious
and wrong costs you a status lookup; confident and wrong credits goods for money that never
arrived.

*(Older INVO deployments answered this case with only a message — "Transaction not pending
PIN verification…", "…cannot be confirmed…" — and no machine code. The SDK matches those
shapes too, so `already_settled` is correct against an older backend without any
string-matching in your code. That fallback requires HTTP 400, one of those two phrases,
**and** a readable `(current status: …)` / `(status: …)` clause: "cannot be confirmed" is
ordinary English, so a phrase match alone is never allowed to assert that money moved.)*

### Common mistakes

> **1. Polling to `approved` and stopping.** The one that costs real money. A device
> approval grant is a **factor**, not a payment: `approved` means the player proved who
> they are, and the transaction is still sitting at `pending_pin_verification`. **You must
> call `approve_with_device_code`.** Nothing else in this SDK settles it, and no webhook
> does it for you — `device_approval.approved` is the *cue* to call it. Left alone, the
> transaction expires on its own window and is refunded.
>
> **2. Treating "already settled" as a failure.** A repeat call after a crash or a lost
> response answers `TRANSACTION_NOT_PENDING`. That is your earlier attempt succeeding, not
> a refusal. Branch on `r.already_settled` — never on the message.
>
> **3. Sending the game secret on the grant calls.** Every device-approval call
> authenticates with the **player token**. A game secret in a console build is a game
> secret in a disassembler.
>
> **4. Beginning a second grant instead of settling the first.** If `begin` answers
> `409 DEVICE_APPROVAL_ALREADY_PENDING`, the player has already approved; you need stage
> 4, not another QR.
>
> **5. Polling faster than `interval`.** You get `slow_down`; add 5 seconds and resume.
>
> **6. Using the wrong `flow` at stage 4.** The sender's grant (`transfer`) cannot settle
> the recipient's step (`transfer_receipt`) — it answers `DEVICE_APPROVAL_NOT_APPROVED`,
> which reads like a broken code and is really a wrong flow.
>
> **7. Granting value off stage 3's HTTP response.** Reconcile off webhooks.

## 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 title shows a short code, the
player approves on their phone, your server polls until it hears back — **and then your
server makes the call that actually moves the money.** 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.

> **The grant is a factor, not a payment.** Reaching `approved` proves *who* approved and
> settles **nothing**. The transaction is still waiting. `approve_with_device_code` is the
> money step, and without it the transaction expires and is refunded. This is the single
> most common integration failure on this flow — see
> [Sends and transfers, stage by stage](#sends-and-transfers-stage-by-stage).

**Five SDK methods, since 3.6.0** (`begin_device_approval`, `poll_device_approval`,
`confirm_device_enrollment`, **`approve_with_device_code`**, and the optional
`complete_device_approval` loop). Earlier versions documented this as raw REST only, which
is how a grant could be polled to `approved` and then dropped. The REST endpoints are
unchanged and still usable directly.

Every one of these calls authenticates with the **player token**, never the game secret —
on a console it is your *server* that holds the token it minted for the player.

**The endpoints underneath**, for a stack that is not using this package. All four take
`Authorization: Bearer <player token>`; none takes the game secret:

| SDK method | Endpoint |
|---|---|
| `begin_device_approval` | `POST /api/sdk/approvals/device/begin` |
| `poll_device_approval` | `POST /api/sdk/approvals/device/poll` |
| `confirm_device_enrollment` | `POST /api/sdk/approvals/device/confirm-enrollment` |
| **`approve_with_device_code`**, `flow="send"` | **`POST /api/sdk/send/{transaction_id}/approve`** |
| **`approve_with_device_code`**, `flow="transfer"` | **`POST /api/sdk/transfers/{transaction_id}/approve`** |
| **`approve_with_device_code`**, `flow="send_receipt"` | **`POST /api/sdk/send/{transaction_id}/confirm-receipt`** |
| **`approve_with_device_code`**, `flow="transfer_receipt"` | **`POST /api/sdk/transfers/{transaction_id}/confirm-receipt`** |

Each settle call takes the body `{"device_code": "..."}` and the player's bearer token —
the same token that began the grant.

**Available to every title, with nothing to configure.** The constraint is a property of
the *device*, not your title: one 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 time
from invonetwork import InvoError

player_token = session["invo_player_token"]   # minted by mint_player_token()

# 1. BEGIN — start an approval for ONE transaction.
#    flow:    "transfer" | "send" | "send_receipt" | "transfer_receipt"
#    channel: "qr" (default — console / TV / native desktop) or "app_browser"
#             (the Unity / Unreal plugin opens the system browser; INVO derives the
#             return scheme invo-sdk-<game_id> itself, never caller-supplied).
#             "popup" is BROWSER-only — the page must call begin itself so its https
#             Origin becomes the popup's opener; that is the JS SDK's approveHosted().
#             This SDK refuses it before the network.
grant = server.begin_device_approval(
    player_token=player_token,
    transaction_id=transaction_id,
    flow="transfer",
)
# grant.device_code / user_code / verification_uri / verification_uri_complete
#      / expires_in / interval / channel

# 2. SHOW — render grant.verification_uri_complete as a QR and print grant.user_code
#    under it. The QR already carries the code, so most players never type.
#    Keep grant.device_code on your server: it is a bearer credential.

# 3. POLL — never faster than grant.interval seconds.
interval = grant.interval
while True:
    time.sleep(interval)
    poll = server.poll_device_approval(
        player_token=player_token, device_code=grant.device_code
    )

    if poll.status == "authorization_pending":
        # A first-time phone may be waiting on YOUR screen — see `enrollment` below.
        if poll.enrollment:
            show_match_prompt(poll.enrollment)
        if poll.interval:
            interval = poll.interval
        continue
    if poll.status == "slow_down":        # RFC 8628 §3.5
        interval += 5
        continue
    if poll.status == "access_denied":
        return player_declined()
    if poll.status == "expired_token":
        return start_over()               # begin again

    # 4. APPROVED -> **CALL THE MONEY STEP.** Nothing has moved until this returns.
    settle = server.approve_with_device_code(
        player_token=player_token,
        transaction_id=transaction_id,
        flow="transfer",                  # the SAME flow you began with
        device_code=grant.device_code,
    )

    if settle.status == "not_pending":
        # Your earlier attempt already landed (settle.already_settled) or the
        # transaction is dead (expired / refunded). Read settle.current_status.
        # NOT a refusal.
        ...
    else:
        # "approved" + next="pending_claim" (+ settle.claim_code on a transfer), or
        # "completed" + settle.amount_received on the receipt flows, or a 202 hold
        # (settle.hold_reason).
        ...
    break
```

**Or let the SDK run stages 3 and 4** — `complete_device_approval` polls on `interval`,
adds 5 s on `slow_down`, surfaces the enrolment prompt, and on `approved` calls
`approve_with_device_code` for you:

```python
def on_enrollment(enrollment, respond):
    if enrollment is None:
        return hide_match_prompt()
    show_match_prompt(enrollment)        # "Set up INVO on <device_label>? Code <match_code>"
    respond("approve" if player_said_yes() else "deny")

done = server.complete_device_approval(
    player_token=player_token,
    transaction_id=transaction_id,
    flow="transfer",
    device_code=grant.device_code,
    interval=grant.interval,
    on_enrollment=on_enrollment,
    should_stop=lambda: shutting_down,   # optional
)
# done.status: "approved" (done.settlement carries the result) | "denied" | "expired" | "stopped"
#   ("stopped" is your should_stop callback; the JS SDK calls its equivalent "aborted",
#    because there it is an AbortSignal. Same situation, different mechanism.)
```

> **Convenience only.** It blocks for as long as the player takes, so it suits a worker or
> a job, not a request handler with a short timeout. And because it moves money from inside
> a loop, **record the attempt in your own store before you call it** — if the process dies
> between the approve call and its response, the only way to learn whether the money moved
> is a record you wrote first, plus `get_transfer_status` / `get_send_status`. Servers that
> cannot block should use the three calls directly and keep `device_code` in their own
> store.

- **One approval authorises one transaction.** Unlike the plain RFC 8628 grant, which
  authorises a *client*, an INVO device code is bound to the transaction **and the flow**
  you named. It cannot approve anything else.
- **When the code is spent: at the money step, not at approval.** The `device_code` stops
  being usable when `approve_with_device_code` settles the transaction — because the
  transaction is then no longer pending, and that is what the settle call checks under a
  row lock. Polling to `approved` consumes nothing; a *second* `approve_with_device_code`
  with the same code answers `TRANSACTION_NOT_PENDING` (returned as
  `status="not_pending"`, not raised) — which, on a sender flow, is how you confirm that a
  settle whose response you lost did in fact land.
- **`user_code` is meant to be seen; `device_code` is not.** Poll from your backend, never
  from the client, and never log or render the device code.
- **One live code per transaction and flow.** Calling `begin` again while an earlier grant
  is merely *pending* **supersedes** it — you get a fresh code and a page still open on the
  old one is told to scan again (the right answer when a player retries after a crash or an
  abandoned phone ceremony). If the earlier grant was already **approved**, the begin is
  refused with `409 DEVICE_APPROVAL_ALREADY_PENDING` + `expires_at`
  (`err.is_device_approval_already_pending`, `err.device_approval_expires_at`): the player
  has approved and you are missing the money step, not a new code. Once that approved
  grant's own window passes, `begin` works again for the same transaction. The sender's
  grant and the recipient's are scoped separately, so `transfer` and `transfer_receipt`
  never block each other.
- **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 — then **call `approve_with_device_code`**. The
  webhook is a latency improvement, not the money step and not a replacement for it; the
  `transfer.*` lifecycle events that follow are what you reconcile against.
- **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: `confirm_device_enrollment`** — the same player token as begin/poll
  (`POST /api/sdk/approvals/device/confirm-enrollment` underneath).

  ```python
  r = server.confirm_device_enrollment(
      player_token=player_token,
      device_code=grant.device_code,
      decision="approve",              # or "deny"
  )
  # r.status: "confirmed" | "denied"
  # raises 409 DEVICE_APPROVAL_ENROLLMENT_ALREADY_DECIDED {"decided", "via"}
  #     -> the backup email answered first; take the prompt down quietly
  # raises 409 DEVICE_APPROVAL_NO_ENROLLMENT_PENDING -> no phone has asked
  # raises 400 invalid_grant / expired_token -> the grant is gone; the next poll says so
  ```

  Inside `complete_device_approval` the `respond` callable does this for you and swallows
  exactly those four "the prompt is moot" refusals, because a prompt nobody answered in
  time must not break the loop.

  **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 backend, 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, **and every paid card subscription renewal** | 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`). **Skip it when `metadata["source"]` is `"subscription_renewal"`**: those coins are spent by the renewal itself; grant on `subscription.renewed`. |
| `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`) |
| `subscription.renewed` | every successfully charged subscription period, including period 1 (both rails) | **extend access** to `current_period_start` (the new `paid_through`); `split.partner_revenue_usd` is what you earned; typed view `SubscriptionRenewedData` |
| `subscription.payment_failed` / `.past_due` / `.authentication_required` | a failed attempt / entering dunning / a cardholder challenge (card rail) | inform the member, relay `confirmation_url` — **never revoke** on these (`SubscriptionPaymentFailedData`, `SubscriptionPastDueData`, `SubscriptionAuthenticationRequiredData`) |
| `subscription.canceled` / `.expired` | a cancel request (either mode; `canceled_by` `partner` or `steam`) or a full refund (`cancel_cause: "full_refund"`) / the retry budget ran out | **revoke** at `access_until` / at `final_period_end` (`SubscriptionCanceledData`, `SubscriptionExpiredData`) |
| `subscription.refunded` | a subscription refund | reverse per `refund` (`refund.partner_total_reversed_usd` = revenue reversed plus the card processor fee you bear; `refund.processing_fee_usd`; `subscription_canceled`, `initiation`, `refund_request_id`); `revenue_share_attribution.net_attributed_amount_usd` (`SubscriptionRefundedData`) |
| `subscription.refund_requested` / `.refund_approved` / `.refund_rejected` | a refund over the daily self-serve limit became a request / INVO approved it / INVO rejected it | track the request (`SubscriptionRefundRequestData`, `SubscriptionRefundApprovedData` adds `decided_at, transaction_id`, `SubscriptionRefundRejectedData` adds `decided_at, decision_reason`) |
| `purchase.failed` / `.disputed` / `.refunded` | rail-dependent | handle failures / disputes / refunds |
| `transfer.*` | sends & transfers | reconcile claim state |

On a Merchant of Record account, `purchase.completed`, `platform_commerce.purchased` and
`subscription.renewed` for a card sale also carry `receipt_number` and `receipt_url` (the receipt
INVO issued to the buyer), and `purchase.refunded`, `platform_commerce.refunded` and
`subscription.refunded` carry the refund receipt's. Absent on Open. `purchase.completed` also
carries `tax_treatment` (`"partner_responsible"` on Open).

---

## 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` | amount too small for the fee to round up / card charge under $0.50 / card charge over INVO's maximum single card charge, which is a runtime setting (400) |
| `.is_already_refunded` | Platform Commerce refund of an already-refunded order (409) — treat as already done |
| `.is_game_not_live` / `.is_active_subscription_exists` / `.is_idempotent_replay_mismatch` / `.is_steam_reauthorization_required` / `.is_not_a_card_subscription` / `.is_steam_channel_required` / `.is_subscription_terminal` / `.is_not_authorized` / `.is_steam_authorization_pending` / `.is_steam_refund_not_supported` / `.is_refunds_not_enabled` / `.is_refund_window_closed` / `.is_refund_request_pending` / `.is_refund_request_rejected` / `.is_concurrent_request` / `.is_refund_mismatch` / `.is_sandbox_clock_unauthorized` / `.is_flow_paused` | subscription refusals — see [Errors you will branch on](#errors-you-will-branch-on) (`.existing_subscription_id`, `.mismatched_fields` carry the bodies' extras) |
| `.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 |
| *(spending)* | **not restricted by origin, and never an error.** A player can spend any balance on items in any title, Steam-distributed or not, including currency bought through Steam. The two codes above are about currency **moving between titles**, so an item purchase has nothing to branch on |
| `.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 title 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 title is in testing → the caller usually **can't fix this** (someone else's title). 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, sandbox_clock_key=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, session_token)`, where `session_token` is INVO's own field (falling back to the `checkout_url` query string on a backend that predates it) and is the credential for the [status read](#did-the-purchase-go-through-the-status-read) |
| `get_purchase_status(session_id, session_token)` | `PurchaseStatusResult(status, terminal, order_id, session_id, coins_credited, transaction_id, new_balance, reason_code, message, amount_charged_usd, listed_usd, currency_name, raw)`: what happened to a hosted-checkout purchase. Authenticated by the SESSION token, not the game secret; properties `is_credited` / `is_refused` / `is_refunded` / `is_expired` / `nothing_was_charged` |
| `wait_for_purchase(session_id, session_token, interval=1.5, timeout=180, max_attempts?, should_stop?, on_update?, sleep?)` | the same result, polled until INVO's `terminal` is `True` (never re-derived from `status`); on the cap it returns the last result rather than raising |
| `quote_currency_purchase(*, usd_amount, player_email?, country?, subdivision?)` | `CurrencyPurchaseQuote(total_usd, amount_cents, subtotal_usd, processing_fee_usd, coins, currency_name, breakdown, if_unacknowledged?, raw)`: what a card coin purchase will cost, INVO's card fee included; moves no money. Show `total_usd`, send it back as `acknowledged_total_usd` |
| `purchase_currency(player_email, usd_amount, purchase_reference, rail?, payment_method_id?, saved_card_id?, player_name?, player_phone?, metadata?, acknowledged_total_usd?)` | `PurchaseResult(status, client_secret?, payment_intent_id?, payment_url?, transaction_id?, order_id?, new_balance?, raw, processing_fee_usd?, fee_taken_from?, coins_credited?, charge_total_usd?, receipt_number?, receipt_url?)`; see [INVO's card fee](#invos-card-fee-quote-show-acknowledge) (`err.is_quote_stale`, `err.is_amount_below_fee`); receipt fields on Merchant of Record only |
| `confirm_payment(payment_intent_id, order_id?)` | `ConfirmPaymentResult(status, transaction_id?, new_balance?, raw, credited?, reason_code?)`; report success only on `credited is True` |
| `get_order_details(order_id? \| transaction_id?)` | `OrderDetailsResult(order, financial_summary, status_timeline, raw, receipt_number?, receipt_url?)`; the receipt fields (from `GET /order-details`) on a Merchant of Record currency purchase only |
| `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, receipt_number?, receipt_url?)` |
| `platform_commerce.refund(order_id? \| client_request_id?, reason?, refund_remaining?, confirm_additional_refund?)` | `PlatformRefundResult(status, order_id?, funding_source?, refunded_amount?, amount_unit?, fee_retained?, raw, partial_refund?, adopted, refunded_total_usd?, charge_total_usd?, remaining_usd?)`; pass **exactly one** id; INVO keeps its fee; `partial_refund`, `refunded_total_usd`, `charge_total_usd`, `remaining_usd` only on a card sale refunded in parts; `adopted` is `True` whenever the call recorded an existing refund (a full one included) |
| `subscriptions.create(*, client_request_id, player_email, player_name, item_id, amount_usd, player_phone?, item_name?, interval="month", interval_count=1, trial_days?, trial_end?, wallet_only?, player_card_id?, metadata?, consent?, revenue_share?, trial_amount_usd?)` | `SubscriptionResult(subscription, first_charge, card?, idempotent_replay, warning?, raw)` with `FirstCharge(status, amount_usd?, paid_through?, paid_period_seq?, failure_code?, next_retry_at?, confirmation_url?, expires_at?, message?, raw)` — **card rail**; charges period 1 before responding; branch on `first_charge.status` (`paid` / `requires_action` / `skipped_trial` / `failed` / `pending`); key entitlement on `paid_period_seq`, not `subscription.period_seq`; idempotent on `client_request_id` (a replay is a 200 with `idempotent_replay`, never a second charge) |
| `subscriptions.get(subscription_id, include_player=False)` | `Subscription(subscription_id, status, amount_usd, pending_amount_usd?, interval, interval_count, item_id, item_name?, current_period_start?, current_period_end?, period_seq, next_charge_at?, paid_through?, cancel_at_period_end, trial_end?, canceled_at?, ended_at?, wallet_only, has_payment_method, funding_rail, steam_agreement_status?, client_request_id, game_id, player_id?, metadata?, consent, revenue_share?, created_at?, updated_at?, player_email?, player_name?, raw, trial_amount_usd?, trial_amount_coins_estimate?)`: **`paid_through` is the entitlement boundary**; `include_player=True` fills `player_email` / `player_name` (INVO puts them on the object; there is no separate block) |
| `subscriptions.list_for_player(player_email, *, status?, limit?, offset?, include_player=False)` | `SubscriptionListResult(player_email, subscriptions, pagination, raw)` — newest first; `status` may include `live`; unknown player = empty list |
| `subscriptions.cancel(subscription_id, *, at_period_end=True, reason?)` | `CancelResult(status, already_canceled, cancel_at_period_end, effective_at?, access_until?, paid_through?, terminated_immediately, downgrade_reason?, final_status?, steam_agreement_status?, steam_agreement_canceled?, subscription, raw)` — at-period-end on an unpaid subscription is **downgraded to immediate**; a terminal row answers 200 with `already_canceled` |
| `subscriptions.change_amount(subscription_id, amount_usd)` | `AmountChangeResult(status, old_amount_usd, new_amount_usd, pending_amount_usd?, change_queued, applies_from?, applies_from_period_seq?, paid_through?, current_period_end?, prorated, subscription, raw)` — **staged** for the next renewal, no proration; a Steam increase raises `err.is_steam_reauthorization_required` |
| `subscriptions.set_payment_method(subscription_id, *, player_card_id?, wallet_only?)` | `PaymentMethodResult(status, replaced, wallet_only, card?, subscription, raw)` — card rail only (`err.is_not_a_card_subscription` on Steam); used from the next charge |
| `subscriptions.refund(subscription_id, *, client_request_id, period_seq?, amount_usd?, reason?)` | a `RefundResult` subclass, branch on `status`: `RefundReceipt` (`"refunded"`) or `RefundPendingApproval` (`"pending_approval"`, HTTP 202, over the daily limit, nothing moved; adds `request_id, request_status, requested_amount_usd?, requested_at?, daily_cap_usd?, executed_today_usd?, message?`). Both carry `status, idempotent_replay, refund_key?, period_seq?, transaction_id?, refunded_amount_usd?, total_refunded_amount_usd?, remaining_refundable_usd?, is_full_refund, funding_shape?, balance_delta_coins?, card_refunded_usd?, processor_refund_reference?, processor_refund_adopted, invo_fee_retained, partner_revenue_reversed_usd?, partner_revenue_reversal_mode?, reason?, refunded_at?, new_balance?, revenue_share_attribution?, note?, warnings, subscription_canceled?, subscription_status?, processing_fee_usd?, processing_fee_basis?, processing_fee_borne_by?, processing_fee_transaction_id?, partner_total_reversed_usd?, initiation?, refund_request_id?, raw`. Card rail; idempotent on `client_request_id` (a replay, `idempotent_replay=True`, never carries `new_balance` or `revenue_share_attribution`); a full refund cancels the subscription; **not for Steam-charged periods** |
| `subscriptions.steam_init(*, client_request_id, player_email, player_name, item_id, amount_usd, steam_id, user_session="client", player_ip?, item_name?, interval="month", interval_count=1, player_phone?, metadata?, consent?, revenue_share?)` | `SteamInitResult(status, subscription_status, subscription_id, subscription, order_id?, steam_order_id, steam_transid?, pending_reuse, steam_checkout_url?, amount_usd?, amount_coins?, steam_charge_usd?, recurring_amount_usd?, first_charge, idempotent_replay, message?, raw)` — **Steam rail**; nothing charged; match the client callback on `steam_order_id`; show `amount_coins`; `web` needs `player_ip` + a top-level window; then `steam_finalize` within 24 h |
| `subscriptions.steam_finalize(subscription_id)` | `SubscriptionResult(..., already_processed, steam_agreement_status?, sandbox_auto_approved, warning?)` — captures period 1 and settles it as a renewal; idempotent (`already_processed`); `err.is_not_authorized` (409) is the normal wait state |
| `cards.create_setup_session(*, player_email, success_url?, cancel_url?, metadata?)` | `CardSetupSession(session_id, card_setup_url, expires_at, raw)`: **the recommended card capture for subscriptions**; mint a 10-minute link to the INVO-hosted card page, send the member to `card_setup_url` (top-level), then read the card's `id` from `cards.list`; no browser code, no processor named; never auto-retried; `err.is_player_not_found` / `is_invalid_player_email` / `is_invalid_input` |
| `cards.begin_setup(*, player_email, setup_reference, payment_method_id?)` | `CardSetupBegin(status, setup_intent_id, client_secret?, publishable_key?, card?, already_saved, message?, raw)`: the **processor-bound** card capture (you run the card form); **charges nothing**; the player must already exist; idempotent on `setup_reference` |
| `cards.confirm_setup(*, setup_intent_id)` | `CardSetupConfirm(status, setup_intent_id, card?, already_saved, raw)`: record the card after the client confirmed / authenticated on the processor-bound path; idempotent |
| `cards.list(player_email)` | `PlayerCardsResult(cards, raw)` of `PlayerCard(id, last_four, brand, exp_month, exp_year, created_at?, raw)` — unexpired, newest first; `id` is the `player_card_id` |
| `sandbox.subscriptions.advance_clock(subscription_id, intervals=1)` / `.force_renewal(subscription_id)` / `.force_failure(subscription_id, outcome="card_declined", *, failure_code?, failure_message?)` / `.force_auth_challenge(subscription_id, *, failure_message?)` | `SandboxClockResult(status, action, engine_stats, subscription, before?, after?, period_seq?, attempt_no?, amount_usd?, amount_coins?, retired, intervals?, intervals_applied_to_window?, window_moved?, retry_budget_used?, retry_schedule_days?, retries_remaining?, auth_challenges_used?, max_auth_challenges?, downgraded_to_decline?, note?, raw)` — **sandbox only**, `POST /subscriptions/<id>/...` relative to the sandbox base (no `/api`), sends `X-Sandbox-Clock-Key` from `sandbox_clock_key` (refused before the network without it); never auto-retried |
| `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`) |
| `begin_device_approval(*, player_token, transaction_id, flow, channel="qr")` | `DeviceApprovalGrant(device_code, user_code, verification_uri, verification_uri_complete, expires_in, interval, channel, raw)` — **player token**, never the game secret; show `verification_uri_complete` as a QR |
| `poll_device_approval(*, player_token, device_code)` | `DeviceApprovalPollResult(status, interval?, enrollment?, transaction_id?, flow?, approved_at?, raw)` — `status` is one of `approved` / `authorization_pending` / `slow_down` / `expired_token` / `access_denied`; `invalid_grant` raises |
| `confirm_device_enrollment(*, player_token, device_code, decision)` | `ConfirmDeviceEnrollmentResult(status, raw)` — answers the on-screen match-code prompt; `decision` is `"approve"` or `"deny"` |
| **`approve_with_device_code(*, player_token, transaction_id, flow, device_code)`** | **The money step.** `DeviceApprovalSettleResult` — routes by `flow` to the transfer/send approve or confirm-receipt endpoint. `TRANSACTION_NOT_PENDING` comes back as `status="not_pending"` + `already_settled` + `current_status`, **not** raised |
| `complete_device_approval(*, player_token, transaction_id, flow, device_code, interval=5, on_enrollment=None, should_stop=None, sleep=None)` | `CompleteDeviceApprovalResult(status, settlement?, last_poll?)` — convenience loop: polls honouring `interval`/`slow_down`, then calls `approve_with_device_code` |
| `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).
