invonetwork 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,26 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *.egg-info/
5
+ *.egg
6
+ build/
7
+ dist/
8
+ .eggs/
9
+
10
+ # Virtualenvs
11
+ .venv/
12
+ venv/
13
+ env/
14
+
15
+ # Tooling caches
16
+ .pytest_cache/
17
+ .mypy_cache/
18
+ .ruff_cache/
19
+ .coverage
20
+ htmlcov/
21
+
22
+ # Editors / OS
23
+ .idea/
24
+ .vscode/
25
+ .DS_Store
26
+ Thumbs.db
@@ -0,0 +1,35 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented here. This project adheres to
4
+ [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
5
+
6
+ ## [0.1.0] - 2026-07-01
7
+
8
+ Initial release. Server-side Python SDK, mirroring the INVO JS SDK's server surface
9
+ and webhook verification exactly (same endpoints, field mappings, and HMAC scheme).
10
+
11
+ ### Added
12
+
13
+ - `InvoServer` (game-secret auth via `X-Game-Secret-Key`):
14
+ - `mint_player_token`
15
+ - `initiate_send`, `initiate_transfer` (guardian-202 precedence over `verification_method`)
16
+ - `create_checkout`, `purchase_currency`, `confirm_payment`, `get_order_details`
17
+ - `purchase_item`, `get_item_purchase_history`, `get_item_order_details`,
18
+ `iterate_item_purchase_history` (generator)
19
+ - `get_player_balance`, `get_inbound_pending`, `get_linked_identities`
20
+ - Client-side guards mirrored from the JS SDK: USD `0 < x <= 999.99`, item price bounds,
21
+ `total == unit * qty (+/-0.01)`, `item_quantity` integer `1..1000`, required
22
+ `purchase_reference`, `rail="steam"` rejected on `purchase_currency`, exactly-one/phone-wins
23
+ for linked identities, 404 -> `not_found` for linked identities, non-blank email checks.
24
+ - `verify_webhook(raw_body, signature_header, secret_or_secrets, *, tolerance_seconds=300, now=None)`:
25
+ constant-time HMAC-SHA256 over `f"{t}.{raw_body}"` (hex), `t=,v1=` parsing, replay window,
26
+ multi-secret rotation, `bytes`/`str` bodies, typed `WebhookEvent`.
27
+ - `InvoError` with `.code` / `.status` / `.message` / `.body` / `.request_id` and classifiers:
28
+ `is_token_expired`, `is_receiver_not_enrolled`, `is_insufficient_balance`,
29
+ `is_duplicate_request`, `is_enrollment_authorization_required`,
30
+ `is_enrollment_proof_required`, `retry_after` (+ `error_code` parsing and token promotion).
31
+ - Resilience: automatic retries on network errors/timeouts, `429` (honoring `retry_after`,
32
+ capped at 20s), and `5xx` -- idempotent requests only -- with exponential backoff + jitter.
33
+ - Observability hooks (`on_request` / `on_response` / `on_error`), best-effort.
34
+ - Injectable transport for testing (`http=` on `InvoServer`); zero runtime dependencies.
35
+ - `py.typed` marker; full type hints (`mypy --strict` clean).
@@ -0,0 +1,18 @@
1
+ Copyright (c) 2026 Invo Tech Inc. All rights reserved.
2
+
3
+ This software and associated documentation files (the "Software") are the
4
+ proprietary property of Invo Tech Inc. ("INVO"). The Software is licensed, not
5
+ sold.
6
+
7
+ GRANT. INVO grants you a non-exclusive, non-transferable, royalty-free license to
8
+ install and use the Software, in unmodified form, solely to build and operate
9
+ integrations with the INVO platform, subject to INVO's developer terms at
10
+ https://invo.network.
11
+
12
+ RESTRICTIONS. Except as expressly permitted above, you may not copy, modify,
13
+ distribute, sublicense, sell, or create derivative works of the Software, or
14
+ remove any proprietary notices, without INVO's prior written consent.
15
+
16
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17
+ IMPLIED. IN NO EVENT SHALL INVO BE LIABLE FOR ANY CLAIM, DAMAGES, OR OTHER
18
+ LIABILITY ARISING FROM, OUT OF, OR IN CONNECTION WITH THE SOFTWARE.
@@ -0,0 +1,492 @@
1
+ Metadata-Version: 2.4
2
+ Name: invonetwork
3
+ Version: 0.1.0
4
+ Summary: INVO Python server SDK -- currency purchase, item purchase, sends/transfers, and webhook verification for partner platforms.
5
+ Project-URL: Homepage, https://docs.invo.network
6
+ Project-URL: Documentation, https://docs.invo.network
7
+ Project-URL: Source, https://github.com/Invo-Technologies/invo-python-sdk
8
+ Project-URL: Issues, https://github.com/Invo-Technologies/invo-python-sdk/issues
9
+ Author: Invo Tech Inc.
10
+ License: Copyright (c) 2026 Invo Tech Inc. All rights reserved.
11
+
12
+ This software and associated documentation files (the "Software") are the
13
+ proprietary property of Invo Tech Inc. ("INVO"). The Software is licensed, not
14
+ sold.
15
+
16
+ GRANT. INVO grants you a non-exclusive, non-transferable, royalty-free license to
17
+ install and use the Software, in unmodified form, solely to build and operate
18
+ integrations with the INVO platform, subject to INVO's developer terms at
19
+ https://invo.network.
20
+
21
+ RESTRICTIONS. Except as expressly permitted above, you may not copy, modify,
22
+ distribute, sublicense, sell, or create derivative works of the Software, or
23
+ remove any proprietary notices, without INVO's prior written consent.
24
+
25
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
26
+ IMPLIED. IN NO EVENT SHALL INVO BE LIABLE FOR ANY CLAIM, DAMAGES, OR OTHER
27
+ LIABILITY ARISING FROM, OUT OF, OR IN CONNECTION WITH THE SOFTWARE.
28
+ License-File: LICENSE
29
+ Keywords: game-currency,invo,payments,sdk,webhooks
30
+ Classifier: Development Status :: 4 - Beta
31
+ Classifier: Intended Audience :: Developers
32
+ Classifier: Operating System :: OS Independent
33
+ Classifier: Programming Language :: Python :: 3
34
+ Classifier: Programming Language :: Python :: 3.9
35
+ Classifier: Programming Language :: Python :: 3.10
36
+ Classifier: Programming Language :: Python :: 3.11
37
+ Classifier: Programming Language :: Python :: 3.12
38
+ Classifier: Typing :: Typed
39
+ Requires-Python: >=3.9
40
+ Provides-Extra: dev
41
+ Requires-Dist: mypy>=1.8; extra == 'dev'
42
+ Requires-Dist: pytest>=7.4; extra == 'dev'
43
+ Requires-Dist: ruff>=0.4; extra == 'dev'
44
+ Description-Content-Type: text/markdown
45
+
46
+ # invonetwork
47
+
48
+ First-party **Python server SDK** for integrating **INVO** into partner backends. It is the
49
+ server-side counterpart to the [INVO JS/Web SDK](https://www.npmjs.com/package/@invonetwork/web-sdk):
50
+ same endpoints, same field mappings, and the same webhook HMAC scheme, so both hit the
51
+ same live backend interchangeably.
52
+
53
+ > **Status:** `0.1.0`. The backend it wraps is **live** on sandbox + production, so you can
54
+ > build and test against sandbox today.
55
+ > Canonical partner reference: **https://docs.invo.network**.
56
+
57
+ ## Highlights
58
+
59
+ - **Server money flows** — mint player tokens, initiate cross-game sends/transfers, run the
60
+ currency-purchase flow (hosted checkout + rail selector), and spend game currency on items.
61
+ - **Server-only reads** — player balances, inbound-pending "you have X to collect", and linked
62
+ wallet identities (PII, server-only).
63
+ - **Webhook verification** — constant-time HMAC-SHA256, replay window, multi-secret rotation.
64
+ - **Resilient** — automatic retries with backoff/jitter on network errors, `429`
65
+ (honoring `retry_after`), and `5xx` — for idempotent calls only.
66
+ - **Zero runtime dependencies** — stdlib only (`urllib`, `hmac`, `json`, `dataclasses`). Python 3.9+.
67
+ - **Fully typed** — ships `py.typed`; passes `mypy --strict`.
68
+
69
+ The **game secret stays on your server** — it authenticates every call here via the
70
+ `X-Game-Secret-Key` header and must never reach a browser.
71
+
72
+ ## Contents
73
+
74
+ - [Install](#install)
75
+ - [Get your account & game secret](#get-your-account--game-secret-invo-console)
76
+ - [Configuration](#configuration)
77
+ - [Currency purchase (real money in)](#currency-purchase-real-money-in)
78
+ - [Item purchase (spend game currency)](#item-purchase-spend-game-currency)
79
+ - [Player balance](#player-balance)
80
+ - [Sends & transfers](#sends--transfers)
81
+ - [Inbound pending & linked identities](#inbound-pending--linked-identities)
82
+ - [Webhooks](#webhooks)
83
+ - [Resilience & observability](#resilience--observability)
84
+ - [Errors](#errors)
85
+ - [API reference](#api-reference)
86
+
87
+ ## Install
88
+
89
+ ```bash
90
+ pip install invonetwork
91
+ ```
92
+
93
+ ```python
94
+ from invonetwork import InvoServer, InvoError, verify_webhook
95
+ ```
96
+
97
+ No third-party runtime dependencies. Python 3.9 or newer.
98
+
99
+ ## Get your account & game secret (INVO console)
100
+
101
+ Sign up, create your game, and copy its **game secret** in the INVO console. Use the console
102
+ that matches the environment you're building against:
103
+
104
+ | Environment | Console | API `base_url` |
105
+ |---|---|---|
106
+ | **Testing / sandbox** | `https://dev.console.invo.network` | `https://sandbox.invo.network/sandbox` |
107
+ | **Production** | `https://console.invo.network` | `https://invo.network` |
108
+
109
+ Build and test against the **dev console + sandbox** first, then switch to production for
110
+ launch. **Each environment has its own game secret — never mix them**, and keep the secret
111
+ server-side only.
112
+
113
+ ## Configuration
114
+
115
+ ```python
116
+ import os
117
+ from invonetwork import InvoServer, Hooks
118
+
119
+ server = InvoServer(
120
+ game_secret=os.environ["INVO_GAME_SECRET"], # server-side only
121
+ base_url="https://sandbox.invo.network/sandbox", # prod: "https://invo.network"
122
+ timeout=30, # optional, seconds (default 30)
123
+ max_retries=2, # optional, default 2 (0 disables)
124
+ retry_base_delay=0.25, # optional backoff base, seconds
125
+ user_agent="my-game/1.0", # optional; a sensible non-blocked UA is set by default
126
+ hooks=Hooks(), # optional observability (see below)
127
+ )
128
+ ```
129
+
130
+ `base_url` must be `https://` — the game secret travels in a request header, so plaintext is
131
+ rejected. `http://localhost` (and loopback) is allowed for local development only.
132
+
133
+ Construct one `InvoServer` and reuse it. All request methods are **keyword-only** for clarity.
134
+
135
+ ---
136
+
137
+ ## Currency purchase (real money in)
138
+
139
+ Buy game currency with real money. Authenticated by the **payment rail**, not a passkey.
140
+
141
+ ### Hosted checkout (recommended — you never touch card data)
142
+
143
+ ```python
144
+ result = server.create_checkout(
145
+ player_email="p@example.com",
146
+ usd_amount="20.00", # USD, 0 < x <= 999.99
147
+ rail="platform", # optional: "platform" (default) | "game" | "steam"
148
+ success_url="https://you/buy/ok",
149
+ cancel_url="https://you/buy/cancel",
150
+ metadata={"your_order_id": "ord_42"}, # echoed on the purchase.completed webhook
151
+ )
152
+ # -> send the browser to result.checkout_url (single-use, ~15 min)
153
+ ```
154
+
155
+ The INVO-hosted page handles card entry, saved cards, and 3-D Secure. **Grant currency off
156
+ the `purchase.completed` webhook**, not this response.
157
+
158
+ ### Direct rail (advanced — you tokenize the card yourself)
159
+
160
+ ```python
161
+ import uuid
162
+
163
+ purchase = server.purchase_currency(
164
+ player_email="p@example.com",
165
+ usd_amount="20.00",
166
+ purchase_reference=str(uuid.uuid4()), # idempotency key, required
167
+ rail="platform",
168
+ payment_method_id="pm_...", # a tokenized payment method
169
+ metadata={"your_order_id": "ord_42"},
170
+ )
171
+
172
+ if purchase.status == "success":
173
+ pass # captured; purchase.new_balance updated
174
+ elif purchase.status == "requires_action":
175
+ # 3-D Secure: run the client action with purchase.client_secret, then:
176
+ server.confirm_payment(payment_intent_id=purchase.payment_intent_id)
177
+ elif purchase.status == "pending_payment":
178
+ pass # redirect the browser to purchase.payment_url (game rail)
179
+ ```
180
+
181
+ `rail="steam"` is rejected here (`WRONG_RAIL_ENDPOINT`) — Steam uses its own in-client flow.
182
+ Reconcile with `server.get_order_details(order_id=...)`. Most integrations should prefer hosted
183
+ checkout.
184
+
185
+ ---
186
+
187
+ ## Item purchase (spend game currency)
188
+
189
+ Spend the currency a player **already owns** to buy an in-game item. A balance debit — **no real
190
+ money, no payment rail, no passkey** — server-side only. Amounts are in **game-currency units**.
191
+
192
+ ```python
193
+ import uuid
194
+
195
+ item = server.purchase_item(
196
+ client_request_id=str(uuid.uuid4()), # idempotency key, unique per game
197
+ player_email="p@example.com",
198
+ player_name="P",
199
+ item_id="sword_001",
200
+ item_name="Legendary Sword",
201
+ item_quantity=1, # integer 1..1000
202
+ unit_price="100.00", # > 0 and <= 999999.99
203
+ total_price="100.00", # must equal unit_price * item_quantity (+/-0.01)
204
+ # optional: player_phone, item_description, item_category
205
+ )
206
+ # item.status == "success"; item.new_balance / item.previous_balance / item.currency_name
207
+ # item.transaction_id / item.order_id; item.financial_breakdown
208
+ ```
209
+
210
+ - **Grant the item off the `item.purchased` webhook**, not just this response. INVO debits
211
+ currency and records the purchase; **your game owns the item catalog and grants the item.**
212
+ - **Idempotent** on `client_request_id` — a duplicate raises `409` (`err.is_duplicate_request`).
213
+ - **Insufficient balance** raises `400` (`err.is_insufficient_balance`; `required_amount` +
214
+ `current_balance` on `err.body`).
215
+ - Client-side validation (missing fields, quantity outside `1..1000`, bad price, total mismatch)
216
+ raises `INVALID_INPUT` **before** any network call.
217
+
218
+ **Companion reads:** `get_item_purchase_history(player_email=..., limit=?, offset=?)` and
219
+ `get_item_order_details(order_id | transaction_id | client_request_id)` (pass **exactly one** id
220
+ — use `client_request_id` for recovery: "did this purchase complete?"). To walk the full
221
+ history, iterate — it pages automatically:
222
+
223
+ ```python
224
+ for row in server.iterate_item_purchase_history(player_email="p@example.com"):
225
+ ...
226
+ ```
227
+
228
+ ---
229
+
230
+ ## Player balance
231
+
232
+ ```python
233
+ result = server.get_player_balance(player_email="p@example.com")
234
+ # or: server.get_player_balance(player_id=12345)
235
+ for b in result.balances:
236
+ print(b.currency_name, b.available_balance, b.total_balance)
237
+ ```
238
+
239
+ ---
240
+
241
+ ## Sends & transfers
242
+
243
+ Move already-owned game currency from one player to another. The sender approves in the browser
244
+ (passkey or SMS PIN) via the JS SDK; the server **initiates**:
245
+
246
+ ```python
247
+ import uuid
248
+
249
+ t = server.initiate_transfer(
250
+ client_request_id=str(uuid.uuid4()),
251
+ source_player_name="P",
252
+ source_player_email="p@example.com",
253
+ source_player_phone="+15555550100",
254
+ target_player_email="q@example.com",
255
+ target_player_phone="+15555550111",
256
+ target_game_id=123456,
257
+ amount="50",
258
+ )
259
+ # initiate_send uses sender_*/receiver_* + receiving_game_id instead.
260
+
261
+ # Check guardian_approval FIRST — the guardian path takes precedence.
262
+ if t.guardian_approval:
263
+ ... # minor/guardian path (HTTP 202): pending approval, do NOT show a PIN UI
264
+ elif t.verification_method == "in_app":
265
+ ... # sender is passkey-enrolled -> approve in the browser (JS SDK)
266
+ elif t.verification_method == "sms":
267
+ ... # not enrolled, a PIN was sent -> show a PIN-entry fallback
268
+ ```
269
+
270
+ On the guardian path `verification_method` is `None` (even though the raw 202 body also carries
271
+ `"sms"`) so `guardian_approval` wins — but branch on it first to be safe.
272
+
273
+ ## Inbound pending & linked identities
274
+
275
+ **"You have X to collect" (server, game-secret):**
276
+
277
+ ```python
278
+ pending = server.get_inbound_pending(player_email="p@example.com") # or player_phone=...
279
+ for row in pending.inbound_pending:
280
+ # Match row.to_phone to the logged-in player. row.to_identity_id is None when the phone
281
+ # maps to more than one of your players — don't require it.
282
+ print(row.transaction_id, row.net_amount, row.to_phone)
283
+ ```
284
+
285
+ Pairs with the `transfer.claim_pending` webhook (the webhook is the wake-up; this is the list).
286
+
287
+ **Linked wallet identities (server-only — returns PII):**
288
+
289
+ ```python
290
+ ident = server.get_linked_identities(player_email="p@example.com") # phone wins if both given
291
+ if ident.not_found:
292
+ ... # no in-game match (backend 404) — treat as "no linked identities", not an error
293
+ else:
294
+ print(ident.primary_email, ident.is_minor, [e.email for e in ident.emails])
295
+ ```
296
+
297
+ > ⚠️ Returns first-party PII (emails/phones) — never expose this to the browser.
298
+
299
+ ---
300
+
301
+ ## Webhooks
302
+
303
+ Synchronous responses are for UX; **reconcile and grant value off webhooks.** They're
304
+ HMAC-signed; **dedupe on `idempotency_key`** (stable across retries/replays).
305
+
306
+ `verify_webhook` does constant-time HMAC-SHA256 over `f"{t}.{raw_body}"`, enforces a 5-minute
307
+ replay window, and accepts a **list of secrets** during rotation. Pass the **raw** request bytes
308
+ (never a re-parsed object).
309
+
310
+ ### Flask
311
+
312
+ ```python
313
+ from flask import Flask, request, Response
314
+ from invonetwork import verify_webhook, InvoError
315
+
316
+ app = Flask(__name__)
317
+ seen = set() # replace with a durable store
318
+
319
+ @app.post("/invo/webhooks")
320
+ def invo_webhooks():
321
+ try:
322
+ event = verify_webhook(
323
+ request.get_data(), # raw bytes — do NOT use request.json
324
+ request.headers.get("X-Invo-Signature"),
325
+ os.environ["INVO_WEBHOOK_SECRET"], # or [old_secret, new_secret] during rotation
326
+ )
327
+ except InvoError as e:
328
+ return Response(e.code or "invalid_signature", status=400)
329
+
330
+ if event.idempotency_key in seen:
331
+ return Response(status=200) # already processed
332
+ seen.add(event.idempotency_key)
333
+
334
+ if event.event_type == "purchase.completed":
335
+ grant_currency(event.data) # event.data is a dict
336
+ elif event.event_type == "item.purchased":
337
+ grant_item(event.data)
338
+ # transfer.*, payout.status_changed, ...
339
+
340
+ return Response(status=200) # 2xx fast; offload slow work
341
+ ```
342
+
343
+ ### FastAPI
344
+
345
+ ```python
346
+ from fastapi import FastAPI, Request, Response
347
+ from invonetwork import verify_webhook, InvoError
348
+
349
+ app = FastAPI()
350
+
351
+ @app.post("/invo/webhooks")
352
+ async def invo_webhooks(request: Request):
353
+ raw = await request.body() # raw bytes
354
+ try:
355
+ event = verify_webhook(
356
+ raw,
357
+ request.headers.get("x-invo-signature"),
358
+ os.environ["INVO_WEBHOOK_SECRET"],
359
+ )
360
+ except InvoError as e:
361
+ return Response(e.code or "invalid_signature", status_code=400)
362
+
363
+ # de-dupe on event.idempotency_key, then grant value.
364
+ handle(event)
365
+ return Response(status_code=200) # raise / return 5xx to make INVO retry
366
+ ```
367
+
368
+ `verify_webhook` raises `InvoError` (all `status == 0`) with one of these codes on failure:
369
+ `WEBHOOK_SIGNATURE_MISSING`, `WEBHOOK_SECRET_MISSING`, `WEBHOOK_TIMESTAMP_EXPIRED`,
370
+ `WEBHOOK_SIGNATURE_INVALID`, `WEBHOOK_MALFORMED`. Return a `4xx` on those; return a `5xx` from
371
+ your own handler if you want INVO to retry.
372
+
373
+ ### Key event types
374
+
375
+ | Event | Fires for | Use it to |
376
+ |---|---|---|
377
+ | `purchase.completed` | every currency-purchase rail | grant currency (`data` includes `usd_amount`, `currency_amount`, `new_balance`, `rail`, `metadata`) |
378
+ | `item.purchased` | every item purchase | **grant the in-game item** (`data` includes `item_id`, `item_quantity`, `total_price`, `new_balance`, `fee_breakdown`) |
379
+ | `purchase.failed` / `.disputed` / `.refunded` | rail-dependent | handle failures / disputes / refunds |
380
+ | `transfer.*` | sends & transfers | reconcile claim state |
381
+
382
+ ---
383
+
384
+ ## Resilience & observability
385
+
386
+ - **Automatic retries.** Transient failures — network errors/timeouts, `429` (honoring
387
+ `retry_after`, capped at 20s), and `5xx` — are retried with exponential backoff + jitter.
388
+ Configure with `max_retries` (default `2`, `0` disables) and `retry_base_delay`. Mutating
389
+ calls carry idempotency keys, so retries are safe; non-idempotent calls (e.g. hosted checkout
390
+ creation) are **never** auto-retried.
391
+ - **Hooks.** Best-effort tracing/metrics (a throwing hook never breaks a request):
392
+
393
+ ```python
394
+ from invonetwork import Hooks
395
+
396
+ server = InvoServer(
397
+ game_secret=..., base_url=...,
398
+ hooks=Hooks(
399
+ on_request=lambda i: log(i.method, i.url, i.attempt),
400
+ on_response=lambda i: metric(i.status, i.duration_ms, i.request_id),
401
+ on_error=lambda i: log(i.error.status, i.will_retry),
402
+ ),
403
+ )
404
+ ```
405
+
406
+ > Hook payloads include the request `url`, which for some calls embeds a player email. The
407
+ > game secret is a header and is **never** passed to hooks — redact `url` if you log payloads.
408
+
409
+ - **Request ids.** `InvoError.request_id` carries the backend request id — quote it in support tickets.
410
+
411
+ ---
412
+
413
+ ## Errors
414
+
415
+ Every failure raises **`InvoError`** with:
416
+
417
+ - `.code` — stable machine code when present (some txn-state errors have none — branch on `.message`)
418
+ - `.status` — HTTP status (`0` for client-side validation and network errors)
419
+ - `.message` — human-readable
420
+ - `.body` — the raw parsed response
421
+ - `.request_id` — backend request id, when present
422
+
423
+ Classifiers:
424
+
425
+ | Helper | Meaning |
426
+ |---|---|
427
+ | `.is_token_expired` | player token expired — re-mint + retry |
428
+ | `.is_receiver_not_enrolled` | recipient has no passkey → switch to claim-code entry |
429
+ | `.is_insufficient_balance` | item purchase failed (400); `required_amount` + `current_balance` on `.body` |
430
+ | `.is_duplicate_request` | idempotency-keyed request was a duplicate (409) |
431
+ | `.retry_after` | seconds to back off on a 429 throttle |
432
+ | `.is_enrollment_authorization_required` | first-enrollment needs the OTP grant |
433
+ | `.is_enrollment_proof_required` | another method exists → prove it via device link |
434
+
435
+ ```python
436
+ from invonetwork import InvoError
437
+
438
+ try:
439
+ server.purchase_item(...)
440
+ except InvoError as e:
441
+ if e.is_insufficient_balance:
442
+ show_top_up(e.body) # {required_amount, current_balance}
443
+ else:
444
+ raise
445
+ ```
446
+
447
+ ---
448
+
449
+ ## API reference
450
+
451
+ ### `InvoServer`
452
+
453
+ Construct: `InvoServer(game_secret, base_url, *, timeout=30, max_retries=2, retry_base_delay=0.25, user_agent=..., hooks=None, http=None)`
454
+
455
+ | Method | Returns |
456
+ |---|---|
457
+ | `mint_player_token(player_email)` | `PlayerToken(token, expires_at, identity_id, raw)` |
458
+ | `initiate_send(...)` | `InitiateResult(transaction_id, verification_method, guardian_approval, raw)` |
459
+ | `initiate_transfer(...)` | `InitiateResult` |
460
+ | `create_checkout(player_email, usd_amount, rail?, success_url?, cancel_url?, metadata?)` | `CreateCheckoutResult(session_id, checkout_url, expires_at, raw)` |
461
+ | `purchase_currency(player_email, usd_amount, purchase_reference, rail?, payment_method_id?, saved_card_id?, player_name?, player_phone?, metadata?)` | `PurchaseResult(status, client_secret?, payment_intent_id?, payment_url?, transaction_id?, order_id?, new_balance?, raw)` |
462
+ | `confirm_payment(payment_intent_id, order_id?)` | `ConfirmPaymentResult(status, transaction_id?, new_balance?, raw)` |
463
+ | `get_order_details(order_id? \| transaction_id?)` | `OrderDetailsResult(order, financial_summary, status_timeline, raw)` |
464
+ | `purchase_item(...)` | `PurchaseItemResult(status, transaction_id, order_id, new_balance, previous_balance, currency_name, financial_breakdown?, raw)` |
465
+ | `get_item_purchase_history(player_email, limit?, offset?)` | `ItemHistoryResult(history, pagination, raw)` |
466
+ | `get_item_order_details(order_id? \| transaction_id? \| client_request_id?)` | `OrderDetailsResult` |
467
+ | `iterate_item_purchase_history(player_email, page_size?)` | generator of history rows (`dict`) |
468
+ | `get_player_balance(player_email? \| player_id?)` | `PlayerBalanceResult(player, balances, summary, raw)` |
469
+ | `get_inbound_pending(player_email? \| player_phone?)` | `InboundPendingResult(inbound_pending, raw)` |
470
+ | `get_linked_identities(player_email? \| player_phone?)` | `LinkedIdentitiesResult(wallet_user_id, primary_email, primary_phone, is_minor, emails, not_found, raw)` — **server-only (PII)** |
471
+
472
+ ### Module-level
473
+
474
+ | Function | Returns |
475
+ |---|---|
476
+ | `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 |
477
+
478
+ Every result keeps the full backend body on `.raw` for fields not surfaced explicitly.
479
+
480
+ ## Development
481
+
482
+ ```bash
483
+ python -m venv .venv && . .venv/bin/activate # (Windows: .venv\Scripts\activate)
484
+ pip install -e ".[dev]"
485
+ python -m pytest # tests
486
+ python -m ruff check . # lint
487
+ python -m mypy # types (strict)
488
+ ```
489
+
490
+ ## License
491
+
492
+ Proprietary — © Invo Tech Inc. See [`LICENSE`](LICENSE).