@invonetwork/web-sdk 0.1.0 → 0.2.1

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.
package/CHANGELOG.md CHANGED
@@ -1,39 +1,69 @@
1
- # Changelog
2
-
3
- All notable changes to `@invonetwork/web-sdk` are documented here. This project follows
4
- [Semantic Versioning](https://semver.org/).
5
-
6
- ## [0.1.0] — 2026-06-30
7
-
8
- Initial scaffold.
9
-
10
- - `InvoServer` (`/server`): `mintPlayerToken`, `initiateSend`, `initiateTransfer`,
11
- `createCheckout`, `purchaseCurrency`, `confirmPayment`, `getOrderDetails`.
12
- - Client-side purchase guards (§4.7): USD amount `0 < x ≤ 999.99`, required
13
- `purchaseReference`, and `rail:"steam"` rejected before the network call
14
- (`INVALID_INPUT` / `MISSING_PURCHASE_REFERENCE` / `WRONG_RAIL_ENDPOINT`).
15
- - `InvoClient` (browser): `enrollPasskey`, `approveSend`/`approveTransfer`,
16
- `confirmReceiptSend`/`confirmReceiptTransfer`, and `linkDevice` for the
17
- interchangeable-methods flow (§4.6).
18
- - Optional `refreshToken` hook: transparently re-mints and retries once on
19
- `SDK_TOKEN_EXPIRED`11.2).
20
- - Shared: typed `InvoError`, isomorphic HTTP client, WebAuthn JSON⇄binary helpers.
21
- - Tests: HTTP layer, server request mapping + purchase guards, browser client
22
- flows (enroll/approve/link/token-refresh), and WebAuthn serialization.
23
- - Contracts extracted + auditor-verified against the INVO backend.
24
-
25
- ### Hardening (independent red-team pass)
26
- - **Guardian/minor `202` path no longer mismapped to `verificationMethod:"sms"`**
27
- `initiateSend`/`initiateTransfer` now return `verificationMethod: undefined` and a
28
- `guardianApproval` block on the guardian path, so callers don't route into the
29
- PIN UI by mistake (§4.3).
30
- - `usdAmount` validation tightened: rejects non-plain-decimal strings
31
- (`"0x10"`, `"1e2"`, whitespace) and >2 decimal places before any network call.
32
- - Load-bearing response fields (`token`, `checkout_url`) now throw `INVALID_RESPONSE`
33
- instead of silently surfacing as empty strings.
34
- - `getOrderDetails` requires at least one of `orderId`/`transactionId`.
35
- - Token refresh now re-runs the **whole** passkey ceremony on `SDK_TOKEN_EXPIRED`
36
- (never replays a single-use assertion) and single-flights concurrent refreshes.
37
- - `baseUrl` must be `https://` (localhost exempt) so the token/secret can't travel
38
- in cleartext.
39
- - Published tarball excludes sourcemaps (no proprietary source shipped).
1
+ # Changelog
2
+
3
+ All notable changes to `@invonetwork/web-sdk` are documented here. This project follows
4
+ [Semantic Versioning](https://semver.org/).
5
+
6
+ ## [0.2.1] — 2026-06-30
7
+
8
+ Docs only — no code change (republished so the npm page README is current).
9
+
10
+ - Rewrote the README into a complete integration/deployment guide: capability
11
+ overview, architecture, deployment prerequisites, per-flow sections (currency
12
+ purchase, item purchase, sends, transfers, passkeys), webhooks, errors, and a
13
+ full API reference for both entries.
14
+ - Added INVO console onboarding: `https://console.invo.network` (production) and
15
+ `https://dev.console.invo.network` (testing/sandbox), mapped to their API base URLs.
16
+
17
+ ## [0.2.0] — 2026-06-30
18
+
19
+ Adds **item purchase** (spend existing game currency on an in-game item, §4.8) — an
20
+ additive, server-only surface.
21
+
22
+ - `InvoServer` (`/server`): `purchaseItem`, `getItemPurchaseHistory`, `getItemOrderDetails`.
23
+ - Server-side, game-secret auth no passkey, no real money, no payment rail (it's a
24
+ balance debit). Grant the item off the `item.purchased` webhook.
25
+ - Client-side guards: required fields (trim-checked), `itemQuantity` integer `1..1000`,
26
+ prices `> 0` and `<= 999999.99` (magnitude-safe 2-decimal check), and
27
+ `totalPrice == unitPrice × itemQuantity (±0.01)` compared in integer cents.
28
+ - Load-bearing response fields (`transaction_id`, `order_id`) throw `INVALID_RESPONSE`
29
+ if missing on a 200.
30
+ - `InvoError` helpers: `isInsufficientBalance` (gated to 400; not the `429` throttle),
31
+ `isDuplicateRequest` (409), `retryAfter` (numeric or string `retry_after`); all
32
+ null-safe against non-JSON error bodies.
33
+ - Independently audited (2 agents) against handoff doc §4.8/§6/§8; contract verified,
34
+ error-classification edge cases fixed.
35
+
36
+ ## [0.1.0] 2026-06-30
37
+
38
+ Initial scaffold.
39
+
40
+ - `InvoServer` (`/server`): `mintPlayerToken`, `initiateSend`, `initiateTransfer`,
41
+ `createCheckout`, `purchaseCurrency`, `confirmPayment`, `getOrderDetails`.
42
+ - Client-side purchase guards (§4.7): USD amount `0 < x ≤ 999.99`, required
43
+ `purchaseReference`, and `rail:"steam"` rejected before the network call
44
+ (`INVALID_INPUT` / `MISSING_PURCHASE_REFERENCE` / `WRONG_RAIL_ENDPOINT`).
45
+ - `InvoClient` (browser): `enrollPasskey`, `approveSend`/`approveTransfer`,
46
+ `confirmReceiptSend`/`confirmReceiptTransfer`, and `linkDevice` for the
47
+ interchangeable-methods flow (§4.6).
48
+ - Optional `refreshToken` hook: transparently re-mints and retries once on
49
+ `SDK_TOKEN_EXPIRED` (§11.2).
50
+ - Shared: typed `InvoError`, isomorphic HTTP client, WebAuthn JSON⇄binary helpers.
51
+ - Tests: HTTP layer, server request mapping + purchase guards, browser client
52
+ flows (enroll/approve/link/token-refresh), and WebAuthn serialization.
53
+ - Contracts extracted + auditor-verified against the INVO backend.
54
+
55
+ ### Hardening (independent red-team pass)
56
+ - **Guardian/minor `202` path no longer mismapped to `verificationMethod:"sms"`** —
57
+ `initiateSend`/`initiateTransfer` now return `verificationMethod: undefined` and a
58
+ `guardianApproval` block on the guardian path, so callers don't route into the
59
+ PIN UI by mistake (§4.3).
60
+ - `usdAmount` validation tightened: rejects non-plain-decimal strings
61
+ (`"0x10"`, `"1e2"`, whitespace) and >2 decimal places before any network call.
62
+ - Load-bearing response fields (`token`, `checkout_url`) now throw `INVALID_RESPONSE`
63
+ instead of silently surfacing as empty strings.
64
+ - `getOrderDetails` requires at least one of `orderId`/`transactionId`.
65
+ - Token refresh now re-runs the **whole** passkey ceremony on `SDK_TOKEN_EXPIRED`
66
+ (never replays a single-use assertion) and single-flights concurrent refreshes.
67
+ - `baseUrl` must be `https://` (localhost exempt) so the token/secret can't travel
68
+ in cleartext.
69
+ - Published tarball excludes sourcemaps (no proprietary source shipped).
package/README.md CHANGED
@@ -1,8 +1,37 @@
1
1
  # @invonetwork/web-sdk
2
2
 
3
- INVO Web SDK for partner **web** platforms **currency purchase** + **passkey (WebAuthn)** verification for cross-game **sends/transfers**. The web analog of the Unity/UE plugins.
3
+ First-party TypeScript SDK for integrating **INVO** into partner **web** platforms (storefronts, web games, dashboards). It wraps INVO's web money flows behind a typed, versioned API — the web analog of the Unity/Unreal plugins.
4
4
 
5
- > **Status:** v0.1.0. The backend this wraps is **live** — the INVO-hosted checkout page, sessions, rails, and webhooks are deployed on sandbox + production, so you can build and test against sandbox today. Full, current API reference + flow docs: **https://docs.invo.network/docs/currency-purchase** and **https://docs.invo.network/docs/game-developer-integration**.
5
+ > **Status:** `v0.2.0`, published on npm. The backend it wraps is **live** on sandbox + production, so you can build and test against sandbox today.
6
+ > Canonical partner reference: **https://docs.invo.network/docs/currency-purchase** and **https://docs.invo.network/docs/game-developer-integration**.
7
+
8
+ ## What it does
9
+
10
+ Four money flows plus passkey (WebAuthn) authentication, split across a trusted server entry and an untrusted browser entry:
11
+
12
+ | Flow | Direction | Real money? | Passkey? | Where |
13
+ |---|---|---|---|---|
14
+ | **Currency purchase** | real money **→** game currency | yes (card/rails) | no | server initiates, browser opens hosted checkout |
15
+ | **Item purchase** | game currency **→** in-game item | no (balance debit) | no | server only |
16
+ | **Send** | currency **→** another player (cross-game) | no | yes (sender approves) | server initiates, browser approves/claims |
17
+ | **Transfer** | currency **→** another player (transfer rail) | no | yes (sender approves) | server initiates, browser approves/claims |
18
+
19
+ The **game secret stays on your server**; the browser only ever holds a short-lived, game-scoped **player token**.
20
+
21
+ ## Contents
22
+
23
+ - [Install](#install)
24
+ - [Architecture & the two entry points](#architecture--the-two-entry-points)
25
+ - [Deployment prerequisites](#deployment-prerequisites)
26
+ - [Configuration](#configuration)
27
+ - [Currency purchase (real money in)](#currency-purchase-real-money-in)
28
+ - [Item purchase (spend game currency)](#item-purchase-spend-game-currency)
29
+ - [Sends & transfers (move currency between players)](#sends--transfers-move-currency-between-players)
30
+ - [Passkeys (enroll, approve, link)](#passkeys-enroll-approve-link)
31
+ - [Webhooks](#webhooks)
32
+ - [Errors](#errors)
33
+ - [API reference](#api-reference)
34
+ - [Scripts & versioning](#scripts--versioning)
6
35
 
7
36
  ## Install
8
37
 
@@ -10,126 +39,346 @@ INVO Web SDK for partner **web** platforms — **currency purchase** + **passkey
10
39
  npm install @invonetwork/web-sdk
11
40
  ```
12
41
 
13
- > Published under the INVO-owned `@invonetwork` npm org. (Until the first `npm publish`, develop with `npm link` or a local install.)
42
+ Node 18 on the server (uses the global `fetch`). The browser build ships ESM + CJS + types.
14
43
 
15
- ## Two entry points (the game secret never reaches the browser)
44
+ ## Get your account & game secret (INVO console)
16
45
 
17
- | Import | Runs | Holds | Does |
18
- |---|---|---|---|
19
- | `@invonetwork/web-sdk/server` | your server (Node ≥18) | the **game secret** | mint player token, initiate send/transfer, currency purchase |
20
- | `@invonetwork/web-sdk` | the browser | a short-lived **player token** | enroll passkey, approve, self-claim |
46
+ Sign up, create your game, and copy its credentials (the **game secret**, plus your WebAuthn **RP ID / origins**) in the INVO console. Use the console that matches the environment you're building against:
21
47
 
22
- ## Base URLs
48
+ | Environment | Console — sign up, manage games, copy your game secret | API `baseUrl` |
49
+ |---|---|---|
50
+ | **Testing / sandbox** | **https://dev.console.invo.network** | `https://sandbox.invo.network/sandbox` |
51
+ | **Production** | **https://console.invo.network** | `https://invo.network` |
23
52
 
24
- - Production: `https://invo.network`
25
- - Sandbox: `https://sandbox.invo.network/sandbox`
53
+ Build and test against the **dev console + sandbox** first, then switch to the **production console + `https://invo.network`** for launch. **Each environment has its own game secret — never mix them**, and keep the secret server-side only.
26
54
 
27
- `baseUrl` must be `https://` (the game secret and player token travel in request headers); `http://localhost` is allowed for local development only.
55
+ ## Architecture & the two entry points
28
56
 
29
- ## Server (Node)
57
+ ```
58
+ ┌──────────────────────────────┐ ┌──────────────────────────────┐
59
+ │ YOUR SERVER (trusted) │ │ THE BROWSER (untrusted) │
60
+ │ @invonetwork/web-sdk/server │ │ @invonetwork/web-sdk │
61
+ │ │ mint │ │
62
+ │ • holds X-Game-Secret-Key │ ──────► │ • holds short-lived token │
63
+ │ • mintPlayerToken() │ token │ (~15 min, game-scoped) │
64
+ │ • initiateSend/Transfer() │ │ • enrollPasskey() │
65
+ │ • createCheckout() │ │ • approveSend/Transfer() │
66
+ │ • purchaseCurrency() │ │ • confirmReceipt*() │
67
+ │ • purchaseItem() │ │ • linkDevice() │
68
+ └───────────────┬───────────────┘ └───────────────┬──────────────┘
69
+ └──────────────► INVO BACKEND ◄────────────┘
70
+ ```
30
71
 
31
- ```ts
32
- import { InvoServer } from "@invonetwork/web-sdk/server";
72
+ | Import | Runs on | Holds | Responsibilities |
73
+ |---|---|---|---|
74
+ | `@invonetwork/web-sdk/server` | your backend (Node ≥18) | the **game secret** | mint player tokens; initiate sends/transfers; currency purchase; item purchase |
75
+ | `@invonetwork/web-sdk` | the browser | a short-lived **player token** | passkey enroll, approve, self-claim, device link |
33
76
 
34
- const invo = new InvoServer({
35
- gameSecret: process.env.INVO_GAME_SECRET!, // server-side only
36
- baseUrl: "https://sandbox.invo.network/sandbox",
37
- });
77
+ **Never import `/server` into browser code** — it carries the game secret. The two entries are built separately for exactly this reason.
38
78
 
39
- // 1. mint a short-lived token for the browser
40
- const { token } = await invo.mintPlayerToken({ playerEmail: "p@example.com" });
79
+ ## Deployment prerequisites
41
80
 
42
- // 2a. buy currency hosted checkout (recommended; INVO's page handles the processor)
43
- const { checkoutUrl } = await invo.createCheckout({
44
- playerEmail: "p@example.com",
45
- usdAmount: "20.00",
46
- rail: "platform", // optional: "platform" (card, default) | "game" | "steam"
47
- metadata: { yourOrderId: "ord_42" }, // echoed back on the purchase.completed webhook
48
- });
49
- // → open checkoutUrl in a WebView/redirect or an iframe (see "Consuming the checkout")
81
+ INVO provisions these per tenant before the flows go live (most are super_admin-only, set by INVO coordinate with your INVO contact):
50
82
 
51
- // 2b. or initiate a send; inspect verificationMethod
52
- const send = await invo.initiateSend({
53
- clientRequestId: crypto.randomUUID(),
54
- senderPlayerName: "P", senderPlayerEmail: "p@example.com", senderPlayerPhone: "+15555550100",
55
- receiverPlayerEmail: "q@example.com", receiverPlayerPhone: "+15555550111",
56
- receivingGameId: 123456, amount: "50",
57
- });
58
- if (send.verificationMethod === "in_app") {
59
- // hand send.transactionId + the player token to the browser to approve via passkey
60
- } else if (send.verificationMethod === "sms") {
61
- // sender not enrolled — fall back to the SMS-PIN UI
62
- } else if (send.guardianApproval) {
63
- // minor/guardian path (HTTP 202): pending guardian approvaldo NOT show the PIN UI
64
- }
65
- ```
83
+ **For sends/transfers + passkeys**
84
+ - `SDK_TRANSFER_VERIFICATION_ENABLED` (master) on.
85
+ - `SDK_WEBAUTHN_ENABLED` (master) — on.
86
+ - `SDK_TRANSFER_CONFIRM_RECEIPT_ENABLED` required for **transfer** self-claim.
87
+ - Tenant migrated (`games.sdk_verification_enabled`).
88
+ - **Per-tenant RP ID + origins** (`webauthn_rp_id`, `webauthn_origins`). There's no separate "webauthn on" flag — *the presence of a valid RP ID is the gate*. Until it's set, WebAuthn endpoints return `403 WEBAUTHN_NOT_ENABLED_FOR_TENANT`. **You must serve your integration from an origin listed in `webauthn_origins`,** or passkeys won't validate.
89
+
90
+ **For currency purchase**
91
+ - `platform` (card) rail is always on; `game`/`steam` rails are off by default and each gated by their own flag + per-game config.
92
+ - Honors the platform `purchases` kill switch (`503 flow_paused` when paused).
93
+
94
+ **For item purchase**
95
+ - The game must be in `live` or `testing` state (else `403`). No passkey/payment flags involved it's a balance debit.
96
+
97
+ **On your side:** store the game secret in server-side config/secrets (never ship it to the browser), and expose a small endpoint that calls `mintPlayerToken` so the browser can fetch/refresh its token.
66
98
 
67
- ## Browser
99
+ ## Configuration
68
100
 
69
101
  ```ts
102
+ import { InvoServer } from "@invonetwork/web-sdk/server";
70
103
  import { InvoClient } from "@invonetwork/web-sdk";
71
104
 
72
- const invo = new InvoClient({
73
- token,
105
+ const server = new InvoServer({
106
+ gameSecret: process.env.INVO_GAME_SECRET!, // server-side only
107
+ baseUrl: "https://sandbox.invo.network/sandbox", // prod: "https://invo.network"
108
+ timeoutMs: 30_000, // optional, default 30s
109
+ // fetch: customFetch, // optional override
110
+ });
111
+
112
+ const client = new InvoClient({
113
+ token, // from your /mint endpoint
74
114
  baseUrl: "https://sandbox.invo.network/sandbox",
75
- // Optional: player tokens live ~15 min. If a call fails with SDK_TOKEN_EXPIRED,
76
- // the SDK calls this once for a fresh token (re-minted by your backend) and
77
- // retries the request transparently.
78
- refreshToken: () => fetch("/invo/token").then((r) => r.json()).then((j) => j.token),
115
+ refreshToken: () => // optional: auto re-mint + retry on token expiry
116
+ fetch("/invo/token", { method: "POST" }).then((r) => r.json()).then((j) => j.token),
79
117
  });
118
+ ```
80
119
 
81
- await invo.enrollPasskey(); // once per user
82
- await invo.approveSend(transactionId); // or approveTransfer(...)
83
- await invo.confirmReceiptSend(transactionId); // recipient self-claim
120
+ **Base URLs** (manage each environment in its [console](#get-your-account--game-secret-invo-console))
121
+ - Production: `https://invo.network` console: `https://console.invo.network`
122
+ - Sandbox / testing: `https://sandbox.invo.network/sandbox` — console: `https://dev.console.invo.network` (sandbox prepends the `/sandbox` prefix; the SDK absorbs it via `baseUrl`)
84
123
 
85
- // Interchangeable methods (optional): prove an already-enrolled method (e.g. the
86
- // INVO app device key) to authorize adding this passkey, then enroll it.
87
- await invo.linkDevice(linkId); // { status: "authorized" }
88
- await invo.enrollPasskey();
89
- ```
124
+ `baseUrl` must be `https://` the game secret and player token travel in request headers, so plaintext is rejected. `http://localhost` is allowed for local dev only.
125
+
126
+ **Player tokens** live ~15 minutes and are game-scoped. Mint one per browser session. If you pass `refreshToken` to `InvoClient`, the SDK transparently re-mints and retries once on `SDK_TOKEN_EXPIRED` (it re-runs the whole passkey ceremony so it never replays a single-use challenge).
90
127
 
91
- Currency purchase has **no browser SDK method** — the server mints `checkoutUrl`; the browser just opens it.
128
+ ---
92
129
 
93
- ## Consuming the checkout (browser)
130
+ ## Currency purchase (real money in)
131
+
132
+ Buy game currency with real money. Authenticated by the **payment rail**, not a passkey — there's no WebAuthn step. Two paths:
133
+
134
+ ### Hosted checkout (recommended — PCI-light, you never touch card data)
135
+
136
+ ```ts
137
+ // SERVER
138
+ const { checkoutUrl, sessionId, expiresAt } = await server.createCheckout({
139
+ playerEmail: "p@example.com",
140
+ usdAmount: "20.00", // USD, 0 < x ≤ 999.99
141
+ rail: "platform", // optional: "platform" (default) | "game" | "steam"
142
+ successUrl: "https://you/buy/ok",
143
+ cancelUrl: "https://you/buy/cancel",
144
+ metadata: { yourOrderId: "ord_42" }, // echoed on the purchase.completed webhook
145
+ });
146
+ // → send the browser to checkoutUrl (single-use, ~15 min)
147
+ ```
94
148
 
95
- `createCheckout` returns a `checkoutUrl` (15-min, single-use). Open it either way:
149
+ Open `checkoutUrl` either way:
96
150
 
97
- - **WebView / full-page redirect** (works everywhere, no setup): on success the page redirects to your `successUrl`.
98
- - **Embedded `<iframe>`** — **works by default** (any https origin may frame it; no allow-listing): the page does *not* redirect your top window listen for the `INVO_CHECKOUT_COMPLETE` postMessage.
151
+ - **Full-page redirect / WebView** works everywhere; on success the page redirects to your `successUrl`.
152
+ - **Embedded `<iframe>`** — works by default from any https origin (no allow-listing). The page does *not* redirect your top window; listen for the `INVO_CHECKOUT_COMPLETE` postMessage:
99
153
 
100
154
  ```ts
155
+ // BROWSER
101
156
  const iframe = document.createElement("iframe");
102
157
  iframe.src = checkoutUrl;
103
158
  iframe.style.cssText = "width:440px;height:720px;border:0";
104
159
  document.body.appendChild(iframe);
105
160
 
106
161
  window.addEventListener("message", (e) => {
107
- if (e.origin !== "https://invo.network") return; // sandbox: "https://sandbox.invo.network"
162
+ if (e.origin !== "https://invo.network") return; // sandbox: "https://sandbox.invo.network"
108
163
  if (e.data?.type === "INVO_CHECKOUT_COMPLETE") {
109
- // UX hint only (unsigned). data = { status:'success', new_balance, currency_name, transaction_id }
164
+ // UX hint ONLY (unsigned). data = { status, new_balance, currency_name, transaction_id }
110
165
  refreshBalanceOptimistically(e.data.data.new_balance);
111
166
  }
112
167
  });
113
168
  ```
114
169
 
115
- The hosted page handles **card entry, saved cards, and 3-D Secure** (including a top-level break-out when embedded). You build no payment UI and never touch card data.
170
+ The hosted page handles card entry, saved cards, and 3-D Secure (with a top-level break-out when framed). **Grant currency off the `purchase.completed` webhook**, not the postMessage hint. Currency purchase has **no browser SDK method** the browser only opens the URL.
171
+
172
+ ### Payment rails (neutral names)
173
+
174
+ The `rail` selects the in-page experience — all branded INVO, no visible redirect:
175
+ - `"platform"` (default) — card checkout.
176
+ - `"game"` — regional / game-store methods.
177
+ - `"steam"` — Steam titles hand off to the in-client Steam flow.
178
+
179
+ Provider/processor names are an internal detail and never appear in the API.
180
+
181
+ ### Direct rail (advanced — you tokenize the card yourself)
182
+
183
+ ```ts
184
+ const purchase = await server.purchaseCurrency({
185
+ playerEmail: "p@example.com",
186
+ usdAmount: "20.00",
187
+ purchaseReference: crypto.randomUUID(), // idempotency key, required
188
+ rail: "platform",
189
+ paymentMethodId: "pm_...", // a tokenized payment method
190
+ });
191
+ // purchase.status:
192
+ // "success" → captured, purchase.newBalance updated
193
+ // "requires_action" → 3-D Secure: run the client action with purchase.clientSecret,
194
+ // then call server.confirmPayment({ paymentIntentId })
195
+ // "pending_payment" → redirect the browser to purchase.paymentUrl (game rail)
196
+ ```
197
+ `rail: "steam"` is rejected here (`WRONG_RAIL_ENDPOINT`) — Steam uses its own in-client flow. Reconcile with `server.getOrderDetails({ orderId })`. Most browser integrations should use hosted checkout instead.
198
+
199
+ ---
116
200
 
117
- **Completion is authoritative via the `purchase.completed` webhook** (HMAC-signed). **Dedupe on `X-Invo-Idempotency-Key`** (stable across retries/replays — *not* `X-Invo-Event-Id`, which changes per delivery). Treat `INVO_CHECKOUT_COMPLETE` as a UX hint; grant currency off the webhook (or re-read the balance).
201
+ ## Item purchase (spend game currency)
118
202
 
119
- ## Payment rails (neutral names)
203
+ 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** (not USD).
120
204
 
121
- The `rail` passed to `createCheckout` selects the in-page experience — all branded as INVO, no visible redirect:
122
- - `"platform"` (default) card checkout on the hosted page.
123
- - `"game"` regional / game-store methods, embedded in-page.
124
- - `"steam"` — Steam titles use the dedicated Steam flow; a `steam` session only shows an in-client hand-off, it doesn't drive the purchase.
205
+ ```ts
206
+ const item = await server.purchaseItem({
207
+ clientRequestId: crypto.randomUUID(), // idempotency key, unique per game
208
+ playerEmail: "p@example.com",
209
+ playerName: "P",
210
+ itemId: "sword_001",
211
+ itemName: "Legendary Sword",
212
+ itemQuantity: 1, // integer 1..1000
213
+ unitPrice: "100.00", // > 0 and ≤ 999999.99
214
+ totalPrice: "100.00", // must equal unitPrice × itemQuantity (±0.01)
215
+ // optional: playerPhone, itemDescription, itemCategory
216
+ });
217
+ // item.status === "success"
218
+ // item.newBalance / item.previousBalance / item.currencyName
219
+ // item.transactionId / item.orderId
220
+ // item.financialBreakdown { total_paid, developer_revenue, platform_fee }
221
+ ```
222
+
223
+ - **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.** The webhook fires atomically with the spend.
224
+ - **Idempotent** on `clientRequestId` — a duplicate throws `409` (`err.isDuplicateRequest`).
225
+ - **Insufficient balance** throws `400` (`err.isInsufficientBalance`; `required_amount` + `current_balance` on `err.body`).
226
+ - **Throttled** calls throw `429` with `err.retryAfter` (seconds).
227
+ - Client-side validation (missing fields, quantity outside `1..1000`, bad price, total ≠ unit×qty) throws `INVALID_INPUT` **before** any network call.
228
+ - Fee split: **90% developer / 10% INVO** by default (per-partner override). Not guardian-gated.
229
+
230
+ **Companion reads:** `server.getItemPurchaseHistory({ playerEmail, limit?, offset? })` and `server.getItemOrderDetails({ orderId | transactionId | clientRequestId })` (pass **exactly one** id — use `clientRequestId` for recovery: "did this purchase complete?").
231
+
232
+ ---
233
+
234
+ ## Sends & transfers (move currency between players)
235
+
236
+ Move already-owned game currency from one player to another, authorized by the **sender's passkey** (or an SMS PIN if they aren't enrolled). **Send** and **transfer** are parallel flows:
237
+
238
+ | | Send | Transfer |
239
+ |---|---|---|
240
+ | Initiate (server) | `initiateSend` | `initiateTransfer` |
241
+ | Parties | `sender*` / `receiver*` + `receivingGameId` | `source*` / `target*` + `targetGameId` |
242
+ | Approve (browser) | `approveSend` | `approveTransfer` — also returns the sender's **claim code** |
243
+ | Recipient claim (browser) | `confirmReceiptSend` | `confirmReceiptTransfer` |
244
+
245
+ ```ts
246
+ // 1. SERVER — initiate, then branch on how the sender must verify
247
+ const t = await server.initiateTransfer({
248
+ clientRequestId: crypto.randomUUID(),
249
+ sourcePlayerName: "P", sourcePlayerEmail: "p@example.com", sourcePlayerPhone: "+15555550100",
250
+ targetPlayerEmail: "q@example.com", targetPlayerPhone: "+15555550111",
251
+ targetGameId: 123456, amount: "50",
252
+ });
253
+ // (initiateSend uses sender*/receiver* + receivingGameId instead)
254
+
255
+ switch (true) {
256
+ case t.verificationMethod === "in_app": // sender is passkey-enrolled → approve in the browser
257
+ case t.verificationMethod === "sms": // not enrolled, a PIN was sent → show a PIN-entry fallback
258
+ case !!t.guardianApproval: // minor/guardian path (HTTP 202) → do NOT show the PIN UI
259
+ }
260
+
261
+ // 2. BROWSER — the sender approves with their passkey (give them t.transactionId + the player token)
262
+ const approved = await client.approveTransfer(t.transactionId); // or approveSend(...)
263
+ // approved.claimCode + approved.claimCodeExpiresAt (transfer only) — deliver if the recipient can't self-claim
264
+
265
+ // 3. BROWSER — the recipient claims with their passkey; fall back to the claim code if not enrolled
266
+ try {
267
+ await client.confirmReceiptTransfer(t.transactionId); // or confirmReceiptSend(...)
268
+ } catch (e) {
269
+ if (e instanceof InvoError && e.isReceiverNotEnrolled) {
270
+ // recipient has no passkey here → show claim-code entry using approved.claimCode
271
+ } else throw e;
272
+ }
273
+ ```
274
+
275
+ - **`verificationMethod`** (from initiate): `"in_app"` → passkey approve; `"sms"` → un-enrolled, PIN sent; `undefined` + `guardianApproval` → minor/guardian (HTTP 202), do **not** show the PIN UI.
276
+ - **Claim codes** are returned only by `approveTransfer`; they're the out-of-band fallback when the recipient isn't enrolled.
277
+ - **`err.isReceiverNotEnrolled`** on `confirmReceipt*` is the explicit signal to switch to claim-code entry.
278
+ - Transfer self-claim additionally requires `SDK_TRANSFER_CONFIRM_RECEIPT_ENABLED`; if it's off, surface the claim-code path.
279
+
280
+ ---
281
+
282
+ ## Passkeys (enroll, approve, link)
283
+
284
+ Passkeys (WebAuthn) replace the SMS PIN for approving sends/transfers. The browser SDK wraps `navigator.credentials.create/get`, base64url encoding, challenge round-trips, and error mapping.
285
+
286
+ ```ts
287
+ // Enroll once per user (no-op to call again — the backend excludes already-enrolled credentials)
288
+ await client.enrollPasskey();
289
+
290
+ // Interchangeable methods (optional): prove an already-enrolled method (e.g. the INVO app
291
+ // device key) to authorize adding THIS passkey, then enroll. Without this, enrolling a second
292
+ // method is blocked with ENROLLMENT_REQUIRES_PROOF.
293
+ await client.linkDevice(linkId); // → { status: "authorized" }
294
+ await client.enrollPasskey();
295
+ ```
296
+
297
+ - **User verification is required** on every approve/claim (a missing-UV assertion fails closed).
298
+ - Challenges are single-use and bound to `{flow}:{transactionId}`.
299
+ - The SDK passes the backend's WebAuthn options through unchanged (it does not hard-code `pubKeyCredParams`/`timeout`/`attestation`).
300
+
301
+ ---
302
+
303
+ ## Webhooks
304
+
305
+ The synchronous responses are for UX; **reconcile and grant value off webhooks.** They're HMAC-signed; **dedupe on the `X-Invo-Idempotency-Key` header** (stable across retries/replays — *not* `X-Invo-Event-Id`, which changes per delivery).
125
306
 
126
- Provider/processor names are an internal backend detail and never appear in the API. (`purchaseCurrency()` on the server is an advanced direct path for partners who tokenize cards themselves — most integrations should use `createCheckout`.)
307
+ | Event | Fires for | Use it to |
308
+ |---|---|---|
309
+ | `purchase.completed` | every currency-purchase rail | grant currency (payload: `transaction_id, order_id, player_email, identity_id, usd_amount, currency_amount, currency_name, new_balance, rail`) |
310
+ | `purchase.failed` / `purchase.disputed` | `platform` rail only | handle failures/disputes |
311
+ | `purchase.refunded` | `game` / `steam` rails | handle refunds |
312
+ | `item.purchased` | every item purchase | **grant the in-game item** (payload includes `transaction_id, order_id, player_email, identity_id, item_id, item_name, item_quantity, unit_price, total_price, currency_name, new_balance, fee_breakdown`) |
313
+
314
+ Don't block waiting on `purchase.failed` for the `game`/`steam` rails — they only emit `completed`/`refunded`. Reconcile off `*.completed` + the status endpoints.
315
+
316
+ ---
127
317
 
128
318
  ## Errors
129
319
 
130
- Every failure throws `InvoError` with `.code` (when present), `.status`, `.message`, `.body`. Branch on `.code`; for the handful of state errors with no `code` (e.g. `receiver_not_enrolled_use_claim_code`), use `.message` / the `.isReceiverNotEnrolled` helper. `.isTokenExpired` flags an expired token — if you pass `refreshToken` to `InvoClient` the SDK handles this for you (one re-mint + retry); otherwise re-mint server-side and retry. Client-side input guards (e.g. a USD amount outside `0 < x ≤ 999.99`, a missing `purchaseReference`, or `rail:"steam"` on `purchaseCurrency`) also throw `InvoError` with `.status === 0` before any network call.
320
+ Every failure throws **`InvoError`** with:
321
+ - `.code` — stable machine code when present (some txn-state errors have none — branch on `.message` for those)
322
+ - `.status` — HTTP status (`0` for client-side validation and network errors)
323
+ - `.message` — human-readable
324
+ - `.body` — the raw parsed response
325
+
326
+ Helpers:
327
+
328
+ | Helper | Meaning |
329
+ |---|---|
330
+ | `.isTokenExpired` | player token expired — re-mint + retry (automatic if `refreshToken` is set) |
331
+ | `.isReceiverNotEnrolled` | recipient has no passkey → switch to claim-code entry |
332
+ | `.isInsufficientBalance` | item purchase failed (400); `required_amount` + `current_balance` on `.body` |
333
+ | `.isDuplicateRequest` | idempotency-keyed request was a duplicate (409) |
334
+ | `.retryAfter` | seconds to back off on a 429 throttle |
335
+
336
+ Client-side guards (bad amount, missing idempotency key, `rail:"steam"` on `purchaseCurrency`, item validation) throw `InvoError` with `.status === 0` **before** any network call. Notable backend codes: `SDK_TOKEN_EXPIRED`, `TENANT_NOT_MIGRATED`, `WEBAUTHN_NOT_ENABLED_FOR_TENANT`, `WEBAUTHN_UV_REQUIRED`, `ENROLLMENT_REQUIRES_PROOF`, `WRONG_RAIL_ENDPOINT`, `flow_paused`.
337
+
338
+ ```ts
339
+ import { InvoError } from "@invonetwork/web-sdk"; // or "@invonetwork/web-sdk/server"
340
+ try {
341
+ await server.purchaseItem(/* … */);
342
+ } catch (e) {
343
+ if (e instanceof InvoError && e.isInsufficientBalance) {
344
+ showTopUp(e.body); // { required_amount, current_balance }
345
+ } else throw e;
346
+ }
347
+ ```
348
+
349
+ ---
350
+
351
+ ## API reference
352
+
353
+ ### `InvoServer` (`@invonetwork/web-sdk/server`)
131
354
 
132
- ## Scripts
355
+ | Method | Returns |
356
+ |---|---|
357
+ | `mintPlayerToken({ playerEmail })` | `{ token, expiresAt, identityId }` |
358
+ | `initiateSend(input)` | `{ transactionId, verificationMethod, guardianApproval?, raw }` |
359
+ | `initiateTransfer(input)` | `{ transactionId, verificationMethod, guardianApproval?, raw }` |
360
+ | `createCheckout(input)` | `{ sessionId, checkoutUrl, expiresAt, raw }` |
361
+ | `purchaseCurrency(input)` | `{ status, clientSecret?, paymentIntentId?, paymentUrl?, transactionId?, orderId?, newBalance?, raw }` |
362
+ | `confirmPayment({ paymentIntentId, orderId? })` | raw backend body |
363
+ | `getOrderDetails({ orderId? \| transactionId? })` | raw backend body |
364
+ | `purchaseItem(input)` | `{ status, transactionId, orderId, newBalance, previousBalance, currencyName, financialBreakdown?, raw }` |
365
+ | `getItemPurchaseHistory({ playerEmail, limit?, offset? })` | raw backend body |
366
+ | `getItemOrderDetails({ orderId? \| transactionId? \| clientRequestId? })` | raw backend body |
367
+
368
+ ### `InvoClient` (`@invonetwork/web-sdk`)
369
+
370
+ | Method | Returns |
371
+ |---|---|
372
+ | `enrollPasskey()` | `{ status, device, raw }` |
373
+ | `approveSend(txnId)` / `approveTransfer(txnId)` | `{ status, next, transactionId, claimCode?, claimCodeExpiresAt?, raw }` |
374
+ | `confirmReceiptSend(txnId)` / `confirmReceiptTransfer(txnId)` | `{ status, raw }` |
375
+ | `linkDevice(linkId)` | `{ status, raw }` |
376
+
377
+ Every method throws `InvoError` on failure. Full inline types ship with the package.
378
+
379
+ ---
380
+
381
+ ## Scripts & versioning
133
382
 
134
383
  ```bash
135
384
  npm run build # tsup → dist (ESM + CJS + d.ts)
@@ -137,6 +386,8 @@ npm run typecheck # tsc --noEmit
137
386
  npm test # vitest
138
387
  ```
139
388
 
389
+ The package follows **semver**: patch = fixes, minor = additive surface, major = breaking changes (rare, with a migration note). The server contract is backward-compatible within a major, so an old pinned SDK keeps working. Pin a version and subscribe to release notes for security updates. See [`CHANGELOG.md`](CHANGELOG.md).
390
+
140
391
  ## License
141
392
 
142
- Proprietary — © Invo Tech Inc. See `LICENSE` (the partner-distribution license is a business decision; swap to MIT/Apache-2.0 if preferred).
393
+ Proprietary — © Invo Tech Inc. See [`LICENSE`](LICENSE).
@@ -16,6 +16,32 @@ var InvoError = class _InvoError extends Error {
16
16
  get isTokenExpired() {
17
17
  return this.code === "SDK_TOKEN_EXPIRED";
18
18
  }
19
+ /**
20
+ * True if an item purchase failed because the player's balance was too low (§4.8 → 400).
21
+ * The backend carries `required_amount` + `current_balance` on the body for the UI.
22
+ * Gated to status 400 so the `429 insufficient_balance_blocked` abuse throttle (which
23
+ * is a rate-limit, not a top-up condition) is NOT misclassified as this.
24
+ */
25
+ get isInsufficientBalance() {
26
+ if (this.status !== 400) return false;
27
+ const b = this.bodyObject();
28
+ return this.code === "INSUFFICIENT_BALANCE" || "required_amount" in b || /insufficient[_ ]balance/i.test(this.message);
29
+ }
30
+ /** True if an idempotency-keyed request was a duplicate (item purchase → 409). */
31
+ get isDuplicateRequest() {
32
+ return this.code === "DUPLICATE_REQUEST" || this.status === 409 && /duplicate/i.test(this.message);
33
+ }
34
+ /** Seconds to wait before retrying, when the backend throttled the call (429 `retry_after`). */
35
+ get retryAfter() {
36
+ const v = this.bodyObject()["retry_after"];
37
+ const n = typeof v === "string" ? Number(v) : v;
38
+ return typeof n === "number" && Number.isFinite(n) && n >= 0 ? n : void 0;
39
+ }
40
+ /** `body` as a plain object, or `{}` if it's a string/number/null (non-JSON responses).
41
+ * The `in` operator throws on primitives, so callers must go through this. */
42
+ bodyObject() {
43
+ return this.body && typeof this.body === "object" ? this.body : {};
44
+ }
19
45
  };
20
46
  function errorFromResponse(status, body) {
21
47
  let message = `INVO request failed (HTTP ${status})`;
@@ -117,5 +143,5 @@ var Http = class {
117
143
  };
118
144
 
119
145
  export { Http, InvoError, assertSecureBaseUrl };
120
- //# sourceMappingURL=chunk-KUQVVH2P.js.map
121
- //# sourceMappingURL=chunk-KUQVVH2P.js.map
146
+ //# sourceMappingURL=chunk-A44O4KC3.js.map
147
+ //# sourceMappingURL=chunk-A44O4KC3.js.map