@invonetwork/web-sdk
v2.4.1
Published
INVO Web SDK — currency purchase + passkey (WebAuthn) verification for partner web platforms.
Maintainers
Readme
@invonetwork/web-sdk
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.
Status:
v2.4.1— stable, published on npm. The backend it wraps is live on sandbox + production. The API is covered by a stability commitment: no breaking changes without a major bump. Recent highlights: 2.2.0 reconciled with the backend (mintPlayerTokenis a session mint —playerPhoneoptional; send-by-phone-alone; email-onlygetPlayerBalance); 2.3.0 surfaces the claim-time phone-share409onconfirmReceipt*/claim*(+err.phoneShareLast4); 2.4.0 adds Steam transfer-policy handling (isSteamValueNonTransferable/isNonSteamValueIntoSteamBlocked+DestinationGame.acceptsSteamOriginValue). Full history in the CHANGELOG. Canonical partner reference: https://docs.invo.network/docs/currency-purchase and https://docs.invo.network/docs/game-developer-integration.
What it does
Four money flows plus passkey (WebAuthn) authentication, split across a trusted server entry and an untrusted browser entry:
| Flow | Direction | Real money? | Passkey? | Where | |---|---|---|---|---| | Currency purchase | real money → game currency | yes (card/rails) | no | server initiates, browser opens hosted checkout | | Item purchase | game currency → in-game item | no (balance debit) | no | server only | | Send | currency → another player (cross-game) | no | yes (sender approves) | server initiates, browser approves/claims | | Transfer | currency → another player (transfer rail) | no | yes (sender approves) | server initiates, browser approves/claims |
The game secret stays on your server; the browser only ever holds a short-lived, game-scoped player token.
Contents
- Install
- Architecture & the two entry points
- Before you go live
- Configuration
- Currency purchase (real money in)
- Item purchase (spend game currency)
- Player balance
- Sends & transfers (move currency between players)
- Passkeys (enroll, approve, link, recover)
- Webhooks
- Resilience & observability
- Errors
- API reference
- Scripts & versioning
Install
npm install @invonetwork/web-sdkNode ≥ 18 on the server (uses the global fetch). The browser build ships ESM + CJS + types.
Get your account & game secret (INVO console)
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:
| Environment | Console — sign up, manage games, copy your game secret | API baseUrl |
|---|---|---|
| Testing / sandbox | https://dev.console.invo.network | https://sandbox.invo.network/sandbox |
| Production | https://console.invo.network | https://invo.network |
Build and test against the dev console + sandbox first, then switch to 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.
Architecture & the two entry points
┌──────────────────────────────┐ ┌──────────────────────────────┐
│ YOUR SERVER (trusted) │ │ THE BROWSER (untrusted) │
│ @invonetwork/web-sdk/server │ │ @invonetwork/web-sdk │
│ │ mint │ │
│ • holds X-Game-Secret-Key │ ──────► │ • holds short-lived token │
│ • mintPlayerToken() │ token │ (~15 min, game-scoped) │
│ • initiateSend/Transfer() │ │ • enrollPasskey() │
│ • createCheckout() │ │ • approveSend/Transfer() │
│ • purchaseCurrency() │ │ • confirmReceipt*() │
│ • purchaseItem() │ │ • linkDevice() │
└───────────────┬───────────────┘ └───────────────┬──────────────┘
└──────────────► INVO BACKEND ◄────────────┘| Import | Runs on | Holds | Responsibilities |
|---|---|---|---|
| @invonetwork/web-sdk/server | your backend (Node ≥18) | the game secret | mint player tokens; initiate sends/transfers; currency purchase; item purchase |
| @invonetwork/web-sdk | the browser | a short-lived player token | passkey enroll, approve, self-claim, device link |
Never import /server into browser code — it carries the game secret. The two entries are built separately for exactly this reason.
Integration modes
InvoClient needs a token and a baseUrl — which lets you adopt it two ways. Same SDK, same code in your components; only the config differs.
Player token (session mint for an existing player)
mintPlayerToken mints a short-lived, game-scoped session token for a player who
already exists on your game. It is not a registration call — the backend looks the
player up by playerEmail and returns a token for their existing identity (or 404 if no
such player exists). It only needs playerEmail:
// your backend
const { token } = await invo.mintPlayerToken({ playerEmail: "[email protected]" });playerPhone is optional here (validated as E.164 only if you pass it, and ignored by this
endpoint) — an existing email-only player still mints a token.
Where phone actually matters. A player's INVO identity encodes their phone, and cross-game money routing keys off it — but that's enforced on the money calls, not the token mint:
initiateSend/initiateTransferrequire the sender's phone (E.164), and take the recipient's phone. You can send by the recipient's phone alone — they supply their email when they claim.- An account with no phone can't receive cross-game money and can't take part in the account-linking consent SMS, so make sure players have a phone at creation / enrollment (in your own player system), before they transact.
Token bootstrap (both modes)
Expose one small backend endpoint that calls mintPlayerToken (server-side, with the game secret) and hand the token to the browser:
// browser
const client = new InvoClient({
baseUrl: "https://api.invo.network",
token: await fetch("/your/sdk-token").then((r) => r.json()).then((j) => j.token),
// Called automatically on SDK_TOKEN_EXPIRED (~15 min TTL) — just re-fetch from your backend:
refreshToken: () => fetch("/your/sdk-token").then((r) => r.json()).then((j) => j.token),
});Mode A — browser-direct (default)
The browser holds the short-lived, game-scoped player token and calls INVO directly (baseUrl = the INVO API). Fewer moving parts. Requires INVO to CORS-allow your web origin and to have your RP ID / origins set up for passkeys (see Before you go live).
Mode B — behind your proxy (keep the token server-side)
If your architecture requires the INVO player token to never reach the browser, route the SDK through your own backend — no CORS setup needed, and no security-posture change:
- Set
baseUrlto your proxy (e.g.https://yourgame.com/invo), which forwards to the INVO API and injects the realAuthorizationheader server-side. - Give the browser a short-lived session/CSRF token (not the INVO token) as
token; your proxy validates it and swaps in the real player token. Or use cookie auth with a customfetch:
const client = new InvoClient({
baseUrl: "https://yourgame.com/invo", // your proxy → INVO
token: sessionToken, // your token; the proxy swaps in the real one
fetch: (url, init) => fetch(url, { ...init, credentials: "include" }), // send your cookie
});The WebAuthn ceremony still runs in the browser (it must — it's navigator.credentials), but transport/auth stays behind your proxy. You keep the SDK's ceremony handling, token-refresh, typed holds, and error classifiers either way.
Running InvoServer in the proxy (for the game-secret writes — initiate, checkout, purchase — so the secret leaves the browser): see the drop-in reference at examples/proxy-server.ts. Its actor resolver is pluggable:
- Real partner (one login = one player) — derive the acting player from your session.
- Trusted first-party rig (an internal tool that impersonates arbitrary test players) — the caller names
playerEmail, guarded by a shared-secret header (INVO_PROXY_TRUSTED_SECRET), the same trusted pattern as aplayer_emailtoken minter. Never expose this mode to end-user browsers.
Lowest-risk first step: adopt just
linkDevice(passkey ↔ app device interchange) — it's purely additive and needs no refactor. See Passkeys.
Before you go live
INVO enables each flow for your tenant in the console. What you need to do:
- Store the game secret server-side and expose a small endpoint that calls
mintPlayerTokenso the browser can fetch/refresh its token. Never ship the secret to the browser. - Make sure players have a phone (E.164) at creation/enrollment — it's required on the money calls (
initiateSend/initiateTransfer) and for cross-game receive, though not on the token mint itself (see Player token). - If you use browser-direct (Mode A): ask INVO to CORS-allow your web origin(s) so the browser can call the API directly. (Not needed for Mode B / proxy — those requests are same-origin to your backend.)
- For passkeys (sends/transfers): give INVO the RP ID + web origin(s) you'll serve from. Passkeys only validate on approved origins — if a call returns
WEBAUTHN_NOT_ENABLED_FOR_TENANT, your origins aren't set up yet; contact INVO. (Until enrolled, the sender falls back to the SMS-PIN path automatically.) - For currency purchase: card checkout works out of the box; ask INVO to enable the
game/steamrails if you need them. - For item purchase: nothing extra — it's a currency-balance debit.
If a flow isn't enabled for your tenant yet, calls return a clear InvoError (e.g. TENANT_NOT_MIGRATED, WEBAUTHN_NOT_ENABLED_FOR_TENANT, or flow_paused) — coordinate with your INVO contact to turn it on.
Configuration
import { InvoServer } from "@invonetwork/web-sdk/server";
import { InvoClient } from "@invonetwork/web-sdk";
const server = new InvoServer({
gameSecret: process.env.INVO_GAME_SECRET!, // server-side only
baseUrl: "https://sandbox.invo.network/sandbox", // prod: "https://invo.network"
timeoutMs: 30_000, // optional, default 30s
maxRetries: 2, // optional, default 2 (0 disables)
// retryBaseDelayMs: 250, // optional backoff base
// fetch: customFetch, // optional override
// hooks: { onRequest, onResponse, onError }, // optional observability (see below)
});
const client = new InvoClient({
token, // from your /mint endpoint
baseUrl: "https://sandbox.invo.network/sandbox",
refreshToken: () => // optional: auto re-mint + retry on token expiry
fetch("/invo/token", { method: "POST" }).then((r) => r.json()).then((j) => j.token),
});Base URLs (manage each environment in its console)
- Production:
https://invo.network— console:https://console.invo.network - Sandbox / testing:
https://sandbox.invo.network/sandbox— console:https://dev.console.invo.network(sandbox prepends the/sandboxprefix; the SDK absorbs it viabaseUrl)
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.
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).
Currency purchase (real money in)
Buy game currency with real money. Authenticated by the payment rail, not a passkey — there's no WebAuthn step. Two paths:
Hosted checkout (recommended — PCI-light, you never touch card data)
// SERVER
const { checkoutUrl, sessionId, expiresAt, expiresInSeconds } = await server.createCheckout({
playerEmail: "[email protected]",
usdAmount: "20.00", // USD, 0 < x ≤ 999.99
rail: "platform", // optional: "platform" (default) | "game" | "steam"
successUrl: "https://you/buy/ok",
cancelUrl: "https://you/buy/cancel",
metadata: { yourOrderId: "ord_42" }, // echoed on the purchase.completed webhook (all rails); order_id also reconciles
});
// → send the browser to checkoutUrl. Token TTL is expiresInSeconds (~900s / 15 min).
// Reloading the URL after a completed payment is idempotent — it shows an already-complete
// success screen (no error), so you don't have to guard against a refresh.Open checkoutUrl either way:
- Full-page redirect / WebView — works everywhere; on success the page redirects to your
successUrl. - Embedded
<iframe>— works by default from any https origin (no allow-listing). The page does not redirect your top window; listen for theINVO_CHECKOUT_COMPLETEpostMessage:
// BROWSER
const iframe = document.createElement("iframe");
iframe.src = checkoutUrl;
iframe.style.cssText = "width:440px;height:720px;border:0";
document.body.appendChild(iframe);
window.addEventListener("message", (e) => {
if (e.origin !== "https://invo.network") return; // sandbox: "https://sandbox.invo.network"
if (e.data?.type === "INVO_CHECKOUT_COMPLETE") {
// UX hint ONLY (unsigned). data = { status, new_balance, currency_name, transaction_id }
refreshBalanceOptimistically(e.data.data.new_balance);
}
});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.
Payment rails (neutral names)
The rail selects the in-page experience — all branded INVO, no visible redirect:
"platform"(default) — cards plus Apple Pay / Google Pay / Link (rendered automatically on supported devices) and international billing addresses. It's a web checkout, so there's no app-store commission."game"— regional / game-store methods (may redirect to the regional hosted page)."steam"— Steam titles hand off to the in-client Steam flow.
Provider/processor names are an internal detail and never appear in the API.
Direct rail (advanced — you tokenize the card yourself)
const purchase = await server.purchaseCurrency({
playerEmail: "[email protected]",
usdAmount: "20.00",
purchaseReference: crypto.randomUUID(), // idempotency key, required
rail: "platform",
paymentMethodId: "pm_...", // a tokenized payment method
metadata: { yourOrderId: "ord_42" }, // echoed on the purchase.completed webhook (all rails); order_id also reconciles
});
// purchase.status:
// "success" → captured, purchase.newBalance updated
// "requires_action" → 3-D Secure: run the client action with purchase.clientSecret,
// then call server.confirmPayment({ paymentIntentId })
// "pending_payment" → redirect the browser to purchase.paymentUrl (game rail)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.
Item purchase (spend game currency)
Spend the currency a player already owns to buy an in-game item. A balance debit — no real money, no payment rail, no passkey — server-side only. Amounts are in game-currency units (not USD).
const item = await server.purchaseItem({
clientRequestId: crypto.randomUUID(), // idempotency key, unique per game
playerEmail: "[email protected]",
playerName: "P",
itemId: "sword_001",
itemName: "Legendary Sword",
itemQuantity: 1, // integer 1..1000
unitPrice: "100.00", // > 0 and ≤ 999999.99
totalPrice: "100.00", // must equal unitPrice × itemQuantity (±0.01)
// optional: playerPhone, itemDescription, itemCategory
});
// item.status === "success"
// item.newBalance / item.previousBalance / item.currencyName
// item.transactionId / item.orderId
// item.financialBreakdown { total_paid, developer_revenue, platform_fee }- Grant the item off the
item.purchasedwebhook, 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. - Idempotent on
clientRequestId— a duplicate throws409(err.isDuplicateRequest). - Insufficient balance throws
400(err.isInsufficientBalance;required_amount+current_balanceonerr.body). - Throttled calls throw
429witherr.retryAfter(seconds). - Client-side validation (missing fields, quantity outside
1..1000, bad price, total ≠ unit×qty) throwsINVALID_INPUTbefore any network call. - Fee split: 90% developer / 10% INVO by default (per-partner override). Not guardian-gated.
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?"). To walk the full history, for await (const row of server.iterateItemPurchaseHistory({ playerEmail })) pages automatically.
Player balance
Read a player's currency balances (server-side, game-secret) by email:
const { balances, summary } = await server.getPlayerBalance({ playerEmail: "[email protected]" });
// balances: [{ currencyName, availableBalance, reservedBalance, totalBalance, currencySymbol, raw }]Lookup is by email only — there is no by-playerId balance route (a playerId is a per-game
internal id). For a client-side read, use InvoClient.getBalance() (player-token; identity
comes from the token, no PII).
Sends & transfers (move currency between players)
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:
| | Send | Transfer |
|---|---|---|
| Initiate (server) | initiateSend | initiateTransfer |
| Parties | sender* / receiver* + receivingGameId | source* / target* + targetGameId |
| Approve (browser) | approveSend | approveTransfer — also returns the sender's claim code |
| Recipient claim (browser) | confirmReceiptSend | confirmReceiptTransfer |
Where can I send? (browser, v0.7.0+) — before a player can move value, populate the "pick a destination" UI with client.getDestinations({ direction: "transfer" | "send" }). One player-token call returns every reachable game with display metadata inline — no per-game lookup:
const { availableGames, sourceCurrencyName } = await client.getDestinations({ direction: "transfer" });
// each: { gameId, gameName, gameIcon, currencyName, currencySymbol, minimumTransfer, maximumTransfer, … }What do I have? (browser, v0.8.0+) — client.getBalance() returns the player's balances for this game (amounts are decimal strings; rows carry currencySymbol/currencySymbolUrl). A player who hasn't transacted here yet returns an empty balances array (show 0.00) — not an error:
const { balances, hasFunds, totalValue } = await client.getBalance();
// each: { currencyId, currencyName, currencySymbol, availableBalance, reservedBalance, totalBalance }// 1. SERVER — initiate, then branch on how the sender must verify
const t = await server.initiateTransfer({
clientRequestId: crypto.randomUUID(),
sourcePlayerName: "P", sourcePlayerEmail: "[email protected]", sourcePlayerPhone: "+15555550100",
targetPlayerEmail: "[email protected]", targetPlayerPhone: "+15555550111",
targetGameId: 123456, amount: "50",
});
// (initiateSend uses sender*/receiver* + receivingGameId instead)
// Check guardianApproval FIRST — the guardian path takes precedence.
switch (true) {
case !!t.guardianApproval: // minor/guardian path (HTTP 202) → pending approval, no PIN UI
case t.verificationMethod === "in_app": // sender is passkey-enrolled → approve in the browser
case t.verificationMethod === "sms": // not enrolled, a PIN was sent → show a PIN-entry fallback
}
// 2. BROWSER — the sender approves with their passkey (give them t.transactionId + the player token)
const approved = await client.approveTransfer(t.transactionId); // or approveSend(...)
// approved.claimCode + approved.claimCodeExpiresAt (transfer only) — deliver if the recipient can't self-claim
// 3. BROWSER — the recipient claims with their passkey; fall back to the claim code if not enrolled
try {
await client.confirmReceiptTransfer(t.transactionId); // or confirmReceiptSend(...)
} catch (e) {
if (e instanceof InvoError && e.isReceiverNotEnrolled) {
// recipient has no passkey here → show claim-code entry using approved.claimCode
} else if (e instanceof InvoError && e.isPhoneShareApprovalRequired) {
// receiver phone is contested → funds held, phone owner texted to approve.
showPending(e.message, e.phoneShareLast4); // show e.message verbatim; last4 names the phone
// …then RETRY the same confirmReceipt*/claim call once approved (succeeds idempotently).
} else throw e;
}verificationMethod(from initiate):"in_app"→ passkey approve;"sms"→ un-enrolled, PIN sent;undefined+guardianApproval→ minor/guardian (HTTP 202), do not show the PIN UI. On the guardian path the SDK reportsverificationMethod: undefined(even though the raw 202 body also carries"sms") soguardianApprovalwins — but branch on it first to be safe.- Claim codes are returned only by
approveTransfer; they're the out-of-band fallback when the recipient isn't enrolled. err.isReceiverNotEnrolledonconfirmReceipt*is the explicit signal to switch to claim-code entry.err.isPhoneShareApprovalRequiredonconfirmReceipt*/claimTransfer/claimCurrency(409): the receiver phone is contested, so INVO holds the money and texts the phone owner to approve. Not a failure — showerr.messageverbatim (err.phoneShareLast4names the phone), then retry the same call once approved (it succeeds idempotently); on denial/expiry the sender is refunded. Same handling as the initiate-time phone-share 409.- Steam titles (dark until a title is Steam-distributed):
initiateSend/initiateTransfercan throwerr.isSteamValueNonTransferable(moving more than the non-Steam balance to a non-Steam destination — showerr.message, cap the amount aterr.steamTransferableMax;err.steamOriginAmountis the Steam-locked portion) orerr.isNonSteamValueIntoSteamBlocked(non-Steam value into a Steam title). Prevent both up front:getDestinationsrows carryacceptsSteamOriginValue— disable non-Steam destinations for a player holding Steam-purchased currency. - Transfer self-claim may be disabled for your tenant; if it is, surface the claim-code path instead.
- Holds: an approve/claim can return an HTTP 202 hold instead of success — inspect
result.holdReason("RISK_HOLD","GUARDIAN_APPROVAL_PENDING", and on confirm-receipt"RECIPIENT_IDENTITY_PENDING"); success has noholdReason(andnext: "pending_claim"). Terminal guardian outcomes throw (GUARDIAN_APPROVAL_REJECTED/_EXPIRED).
"You have X to collect"
- Browser (player-token):
client.getPendingCollect()returns the player's own pending items. Each row'skindtells you the action:"receiving_confirm"→ a peer/another game sent value TO this player (incoming) → callconfirmReceiptSend/confirmReceiptTransfer."identity_gate"→ this player initiated and it's awaiting them → callapproveSend/approveTransfer.
Seeing incoming transfers from other games? They come back as
kind: "receiving_confirm"— render both kinds (a UI that only showsidentity_gatewill miss all inbound). The list is scoped to the token's player, so mint the token for the recipient. PII-free (no claim code / phone). - Server (game-secret):
server.getInboundPending({ playerEmail | playerPhone })— richer, includestoPhone/toIdentityIdfor routing a notification to the right account. MatchtoPhoneto the player (toIdentityIdis null when the phone maps to >1 of your players).
Passkeys (enroll, approve, link, recover)
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.
// Enroll once per user (no-op to call again — the backend excludes already-enrolled credentials)
await client.enrollPasskey();
// Interchangeable methods (optional): prove an already-enrolled method (e.g. the INVO app
// device key) to authorize adding THIS passkey, then enroll. Without this, enrolling a second
// method is blocked with ENROLLMENT_REQUIRES_PROOF.
await client.linkDevice(linkId); // → { status: "authorized" }
await client.enrollPasskey();First-enrollment OTP grant. For tenants that require it, the first enrollPasskey()
throws ENROLLMENT_REQUIRES_AUTHORIZATION. Send a one-time code (to the player's phone +
email on file), verify it, then retry the same enrollPasskey() — the server-side
grant is consumed automatically:
try {
await client.enrollPasskey();
} catch (e) {
if (e instanceof InvoError && e.isEnrollmentAuthorizationRequired) {
await client.enrollmentBegin(); // → { status: "sent", channels: ["sms","email"] }
await client.enrollmentVerify(codeFromUser); // 6-digit OTP
await client.enrollPasskey(); // retry — grant auto-consumed
} else if (e instanceof InvoError && e.isEnrollmentProofRequired) {
await client.linkDevice(linkId); // a different method already exists
await client.enrollPasskey();
} else throw e;
}Lost or replaced passkey (recovery)
Deleting a passkey on the device never tells the server the key is gone, so the next
enrollPasskey() is blocked by the anti-takeover gate with ENROLLMENT_REQUIRES_PROOF —
and the player has nothing left to prove possession with. Offer a "Lost or replaced your
passkey?" action that runs the possession-proofed recovery flow:
try {
await client.enrollPasskey();
} catch (e) {
if (e instanceof InvoError && e.isEnrollmentProofRequired) {
// Player still HAS the other method → linkDevice (above). Lost/deleted it → recover:
await client.recoveryBegin(); // → { status:"sent", channels:["sms","email"] }
await client.recoveryComplete(codeFromUser); // → { status:"recovered", deactivatedCount }
await client.enrollPasskey(); // same call — now succeeds
} else throw e;
}recoveryBegin()errors:no_channel_on_file(422),rate_limited(429 — max 5 codes / 10 min).recoveryComplete(code)errors:ENROLLMENT_CODE_INVALID(wrong/expired, attempt-capped — let the user retry or resend),RECOVERY_FAILED(500, transient).- A recovery also sends the owner an out-of-band "your passkey was reset" SMS/email — that's the intended tamper alert; surface nothing special.
- If the OS fails to save a new passkey ("there is a problem saving your passkey"), have the user delete the stale entry in their password manager first (iPhone: Settings → Passwords; Android: Google Password Manager; Windows: Settings → Accounts → Passkeys).
⚠️ 24-hour money cooldown after recovery. A recovery-enrolled passkey logs in and collects funds immediately, but
approveSend/approveTransferreturn 403PASSKEY_RECOVERY_COOLDOWNfor 24 hours (SIM-swap protection). Branch onerr.isPasskeyRecoveryCooldownand show — "For your security, transfers are paused for 24 hours after a passkey reset. You can still receive funds. Try again aftererr.retryAfterAt." — do not treat it as a generic failure or retry-loop it. Unaffected: login,getPendingCollect, inboundconfirmReceipt*.
- User verification is required on every approve/claim (a missing-UV assertion fails closed).
- Challenges are single-use and bound to
{flow}:{transactionId}. - The SDK passes the backend's WebAuthn options through unchanged (it does not hard-code
pubKeyCredParams/timeout/attestation).
Webhooks
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).
Verify a webhook (server)
The SDK ships the signature check so you don't hand-roll HMAC. Pass the raw request bytes (never a re-serialized object) and the X-Invo-Signature header:
import { verifyWebhook, InvoError } from "@invonetwork/web-sdk/server";
// e.g. Express: app.post("/invo/webhooks", express.raw({ type: "application/json" }), handler)
function handler(req, res) {
let event;
try {
event = verifyWebhook(req.body, req.get("X-Invo-Signature"), process.env.INVO_WEBHOOK_SECRET!);
} catch (e) {
return res.status(400).send((e as InvoError).code); // bad signature / stale / malformed
}
if (alreadyProcessed(req.get("X-Invo-Idempotency-Key"))) return res.status(200).end(); // dedupe
switch (event.event_type) {
case "purchase.completed": grantCurrency(event.data); break; // event.data is typed
case "item.purchased": grantItem(event.data); break;
// transfer.* , payout.status_changed , …
}
res.status(200).end(); // 2xx fast; offload slow work
}verifyWebhook does constant-time HMAC-SHA256 over ${t}.${rawBody}, enforces a 5-minute replay window, and accepts an array of secrets during rotation (verifyWebhook(body, sig, [oldSecret, newSecret])). It returns a typed InvoWebhookEvent (discriminate on event_type) and throws InvoError (WEBHOOK_SIGNATURE_INVALID / WEBHOOK_TIMESTAMP_EXPIRED / WEBHOOK_MALFORMED / WEBHOOK_SECRET_MISSING) on any failure. De-dupe yourself on X-Invo-Idempotency-Key — the SDK verifies, it doesn't track delivery.
Edge / serverless (Cloudflare Workers, Deno, Vercel/Netlify Edge, Bun): verifyWebhook uses node:crypto, so use verifyWebhookAsync (Web Crypto) — same args/result, just await it — or the ready-made Fetch-API handler:
import { createWebhookHandler } from "@invonetwork/web-sdk/server";
// Next.js App Router — app/invo/webhooks/route.ts
export const POST = createWebhookHandler({
secret: process.env.INVO_WEBHOOK_SECRET!,
onEvent: async (event, { idempotencyKey }) => {
// de-dupe on idempotencyKey, then grant value. Throw to return 500 (Invo retries).
},
});createWebhookHandler returns (request: Request) => Promise<Response> and runs in Next.js, Workers, Deno, Hono, and Bun. Bad signature → 400; a throwing onEvent → 500.
Event types
| Event | Fires for | Use it to |
|---|---|---|
| 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, metadata) — metadata echoes what you passed to createCheckout/purchaseCurrency (all rails); order_id is also on every webhook as a secondary reconciliation key (getOrderDetails). |
| purchase.failed / purchase.disputed | platform rail only | handle failures/disputes |
| purchase.refunded | game / steam rails | handle refunds |
| 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) |
Don't block waiting on purchase.failed for the game/steam rails — they only emit completed/refunded. Reconcile off *.completed + the status endpoints.
Resilience & observability
- Automatic retries. Transient failures — network errors/timeouts,
429(honoringretry_after), and5xx— are retried with exponential backoff + jitter. Configure withmaxRetries(default2, set0to disable) andretryBaseDelayMs(default250). Mutating calls carry idempotency keys, so retries are safe; setmaxRetries: 0if you'd rather handle429abuse-throttles yourself. - Hooks. Pass
hooksto either client for tracing/metrics — all best-effort (a throwing hook never breaks a request):
new InvoServer({
/* … */,
hooks: {
onRequest: ({ method, url, attempt }) => {},
onResponse: ({ status, durationMs, requestId, attempt }) => {},
onError: ({ error, willRetry, attempt }) => {},
},
});Note: hook payloads include the request
url, which for some calls embeds a player email (e.g. balance-by-email). Tokens and the game secret are sent as headers and are never passed to hooks — but redact theurlif you log hook payloads.
- Request ids.
InvoError.requestIdcarries the backend request id (fromx-invo-request-id/x-request-id) — quote it in support tickets. - Cancellation. Every method takes an optional
{ signal }(anAbortSignal) as its last argument —server.getPlayerBalance({ playerEmail }, { signal }). Aborting throwsInvoErrorwith.code === "ABORTED", and an aborted call is never retried.
Errors
Every failure throws InvoError with:
.code— stable machine code when present (some txn-state errors have none — branch on.messagefor those).status— HTTP status (0for client-side validation and network errors).message— human-readable.body— the raw parsed response
Helpers:
| Helper | Meaning |
|---|---|
| .isTokenExpired | player token expired — re-mint + retry (automatic if refreshToken is set) |
| .isReceiverNotEnrolled | recipient has no passkey → switch to claim-code entry |
| .isInsufficientBalance | item purchase failed (400); required_amount + current_balance on .body |
| .isDuplicateRequest | idempotency-keyed request was a duplicate (409) |
| .retryAfter | seconds to back off on a 429 throttle |
| .isEnrollmentAuthorizationRequired | first-enrollment needs the OTP grant → enrollmentBegin/enrollmentVerify |
| .isEnrollmentProofRequired | another method exists → prove it via linkDevice |
| .isPhoneShareApprovalRequired | phone needs owner approval — at register/mint or at claim/confirmReceipt* (contested receiver phone) → show .message (+ .phoneShareLast4), retry the same call after approval |
| .isSteamValueNonTransferable | initiate blocked (409): would move more than the non-Steam balance to a non-Steam destination → show .message, cap input at .steamTransferableMax (.steamOriginAmount = Steam-locked portion) |
| .isNonSteamValueIntoSteamBlocked | initiate blocked (409): non-Steam value can't move into a Steam title → show .message, pick a non-Steam destination |
| .isPhoneShareAlreadyApproved | phone-share was already approved (e.g. from a /reject) |
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, ENROLLMENT_CODE_INVALID, PASSKEY_RECOVERY_COOLDOWN (403, retryAfterAt), no_channel_on_file, rate_limited, WRONG_RAIL_ENDPOINT, flow_paused.
import { InvoError } from "@invonetwork/web-sdk"; // or "@invonetwork/web-sdk/server"
try {
await server.purchaseItem(/* … */);
} catch (e) {
if (e instanceof InvoError && e.isInsufficientBalance) {
showTopUp(e.body); // { required_amount, current_balance }
} else throw e;
}API reference
InvoServer (@invonetwork/web-sdk/server)
| Method | Returns |
|---|---|
| mintPlayerToken({ playerEmail, playerPhone? }) | { token, expiresAt, identityId } — session mint for an existing player (404 if unknown); playerPhone optional/validated-if-present (see Player token) |
| initiateSend(input) | { transactionId, verificationMethod, guardianApproval?, raw } |
| initiateTransfer(input) | { transactionId, verificationMethod, guardianApproval?, raw } |
| createCheckout(input) | { sessionId, checkoutUrl, expiresAt, expiresInSeconds, raw } |
| purchaseCurrency(input) | { status, clientSecret?, paymentIntentId?, paymentUrl?, transactionId?, orderId?, newBalance?, raw } |
| confirmPayment({ paymentIntentId, orderId? }) | { status, transactionId?, newBalance?, raw } |
| getOrderDetails({ orderId? \| transactionId? }) | { order, financialSummary, statusTimeline, raw } |
| purchaseItem(input) | { status, transactionId, orderId, newBalance, previousBalance, currencyName, financialBreakdown?, raw } |
| getItemPurchaseHistory({ playerEmail, limit?, offset? }) | { history, pagination, raw } |
| getItemOrderDetails({ orderId? \| transactionId? \| clientRequestId? }) | { order, financialSummary, statusTimeline, raw } |
| getPlayerBalance({ playerEmail }) | { player, balances, summary, raw } — by email only (no by-id route; use InvoClient.getBalance() client-side) |
| getInboundPending({ playerEmail? \| playerPhone? }) | { inboundPending, raw } — live unclaimed inbound sends/transfers |
| getLinkedIdentities({ playerEmail? \| playerPhone? }) | { walletUserId, primaryEmail, primaryPhone, isMinor, emails, notFound, raw } — server-only (returns PII) |
| verifySmsTransfer(txnId, smsPin) / verifySmsSend(txnId, smsPin) | SmsVerifyResult — complete the SMS-PIN path when verificationMethod: "sms" |
| claimTransfer(input) / claimCurrency(input) | ClaimResult — redeem a claim code (needsAccountSelection + candidates on a multi-account phone) |
| getTransferStatus(txnId) / getSendStatus(txnId) | TransactionStatusResult — poll outbound state (verificationState) |
| getGuardianApprovalStatus(txnId) | GuardianApprovalStatusResult — poll a guardian hold to resolution (state) |
| getDestinations({ sourceGameId, direction? }) | DestinationsResult — server-side (game-secret) destinations; same rows as the browser variant |
| phoneShareInitiate({ phone, email }) / phoneShareApprove({ approvalId, otp }) / phoneShareStatus({ phone, email }) | phone-share OTP handshake (unauthenticated) to resolve a PHONE_SHARE_APPROVAL_REQUIRED (409); then re-issue the original request |
| iterateItemPurchaseHistory({ playerEmail, pageSize? }) | async iterator over all history rows |
| verifyWebhook(rawBody, signatureHeader, secret \| secrets, opts?) | typed InvoWebhookEvent (throws on bad signature) |
| verifyWebhookAsync(...) | same as verifyWebhook, Web Crypto (edge/Workers/Deno/Bun) |
| createWebhookHandler({ secret, onEvent }) | (Request) => Promise<Response> webhook route handler |
Every method also accepts an optional final { signal } (AbortSignal) for cancellation.
InvoClient (@invonetwork/web-sdk)
| Method | Returns |
|---|---|
| enrollPasskey() | { status, device, raw } |
| enrollmentBegin() / enrollmentVerify(code) | OTP grant for ENROLLMENT_REQUIRES_AUTHORIZATION (then retry enrollPasskey) |
| recoveryBegin() / recoveryComplete(code) | passkey recovery for ENROLLMENT_REQUIRES_PROOF when the passkey was lost/deleted (then retry enrollPasskey) — recovered keys can't move money OUT for 24h (PASSKEY_RECOVERY_COOLDOWN) |
| approveSend(txnId) / approveTransfer(txnId) | { status, next, transactionId, claimCode?, claimCodeExpiresAt?, holdReason?, risk?, guardianApproval?, pollEndpoint?, raw } |
| confirmReceiptSend(txnId) / confirmReceiptTransfer(txnId) | { status, holdReason?, raw } |
| linkDevice(linkId) | { status, raw } |
| getPendingCollect() | { pending, raw } — the player's own pending-to-collect list (player-token) |
| getDestinations({ direction? }) | { availableGames, sourceGameName, transferMode, … } — where the player can send/transfer, metadata inline |
| getBalance() | { gameId, gameName, balances, totalValue, currencyCount, hasFunds, raw } — the player's balances for this game |
Every method throws InvoError on failure and takes an optional final { signal }. Full inline types ship with the package.
Scripts & versioning
npm run build # tsup → dist (ESM + CJS + d.ts)
npm run typecheck # tsc --noEmit
npm test # vitestStability
Since 1.0.0 the public API is stable: it follows semver, and no breaking change ships without a major version bump + a migration note. Patch = fixes, minor = additive surface (new methods/fields, backward-compatible — e.g. 2.1.0's passkey recovery, 2.4.0's Steam-policy classifiers), major = breaking changes (rare — 2.0.0 is the only major to date, which required playerPhone at the token mint; 2.2.0 later relaxed that back to optional). The wire contract is the same live INVO API you'd call directly, and it's backward-compatible within a major — so a pinned SDK keeps working. Safe to depend on in production; pin a version and watch releases for security updates.
Full history: CHANGELOG · release notes (absolute links to main).
License
Proprietary — © Invo Tech Inc. See LICENSE.
