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.
- invonetwork-0.1.0/.gitignore +26 -0
- invonetwork-0.1.0/CHANGELOG.md +35 -0
- invonetwork-0.1.0/LICENSE +18 -0
- invonetwork-0.1.0/PKG-INFO +492 -0
- invonetwork-0.1.0/README.md +447 -0
- invonetwork-0.1.0/pyproject.toml +70 -0
- invonetwork-0.1.0/src/invonetwork/__init__.py +95 -0
- invonetwork-0.1.0/src/invonetwork/errors.py +165 -0
- invonetwork-0.1.0/src/invonetwork/hooks.py +63 -0
- invonetwork-0.1.0/src/invonetwork/http.py +323 -0
- invonetwork-0.1.0/src/invonetwork/py.typed +0 -0
- invonetwork-0.1.0/src/invonetwork/server.py +771 -0
- invonetwork-0.1.0/src/invonetwork/types.py +219 -0
- invonetwork-0.1.0/src/invonetwork/webhooks.py +193 -0
- invonetwork-0.1.0/tests/helpers.py +60 -0
- invonetwork-0.1.0/tests/test_balance_identities.py +105 -0
- invonetwork-0.1.0/tests/test_errors.py +80 -0
- invonetwork-0.1.0/tests/test_hooks.py +69 -0
- invonetwork-0.1.0/tests/test_inbound_pending.py +58 -0
- invonetwork-0.1.0/tests/test_item_purchase.py +176 -0
- invonetwork-0.1.0/tests/test_reads.py +105 -0
- invonetwork-0.1.0/tests/test_retry.py +85 -0
- invonetwork-0.1.0/tests/test_server.py +253 -0
- invonetwork-0.1.0/tests/test_webhooks.py +162 -0
|
@@ -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).
|