Web / TypeScript SDK
@invonetwork/web-sdk is the official first-party SDK for integrating Invo into web platforms: web games, storefronts, and dashboards. It's the web analog of the Unity and Unreal plugins: a typed, versioned npm package that wraps Invo's currency purchase, item purchase, and passkey-verified send/transfer flows.
Not using a game engine? Start here.
If you're building on the web (React, Vue, plain JS, or any Node backend) rather than Unity or Unreal, this SDK is your integration path. Published on npm under the Invo-owned @invonetwork org.
Currency Purchase
Hosted checkout: real money to your currency, no payment UI to build
Item Purchase
Spend existing currency on your items
Sends & Transfers
Move currency between players, approved by passkey
Passkeys
WebAuthn enroll/approve for sends and transfers
Install
npm install @invonetwork/web-sdkRequires Node ≥ 18 on the server (uses the global fetch). Ships ESM + CJS + TypeScript types.
Backend not in Node? Use the first-party Python SDK (pip install invonetwork) for the server half (same endpoints and webhook scheme) and keep this package for the browser.
Two entry points (the game secret never reaches the browser)
@invonetwork/web-sdk/server
Runs on your backend (Node ≥ 18). Holds the game secret.
Mints player tokens; initiates sends/transfers; currency purchase; item purchase.
@invonetwork/web-sdk
Runs in the browser. Holds only 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.
Get your account & game secret
Sign up, create your title, and copy its credentials (the game secret) in the Invo console for the environment you're building against:
This applies to a game or a platform alike: the SDK calls the tenant a game and names its credential gameSecret, but a platform tenant uses the same field and the same flow.
| Environment | Console | API baseUrl |
|---|---|---|
| Testing / sandbox | https://console.invo.network/test | https://sandbox.invo.network/sandbox |
| Production | https://console.invo.network | https://invo.network |
Build and test on sandbox first, then switch to production for launch. Each environment has its own game secret. Keep it server-side only and never mix them. baseUrl must be https:// (http://localhost is allowed for local dev).
Key prefixes (3.34.0)
New game secrets name their environment: ivsdk_test_... is test mode (sandbox) and ivsdk_live_... is production. That includes a key you get by rotating. Keys you already have (ivsdk_... with no prefix) keep working exactly as before. environmentFromKeyPrefix(key) returns "production", "sandbox" or null (an older unprefixed key) with no network call, and baseUrlForKey(key) gives you the matching baseUrl.
import { InvoServer, baseUrlForKey, environmentFromKeyPrefix } from "@invonetwork/web-sdk/server";
const gameSecret = process.env.INVO_GAME_SECRET!;
environmentFromKeyPrefix(gameSecret); // "production" | "sandbox" | null
// Going live is swapping the key: the base URL follows it.
const invo = new InvoServer({ gameSecret, baseUrl: await baseUrlForKey(gameSecret) });If a test or live key is given a baseUrl on the other INVO server, new InvoServer(...) throws KeyEnvironmentMismatchError before any request, with a message naming the server to use. Check err.name === "KeyEnvironmentMismatchError" rather than instanceof, because the ES module and CommonJS builds each carry their own copy of the class.
A baseUrl pointing at your own proxy is never checked. If a request still reaches the wrong environment, INVO answers 401 KEY_FOR_OTHER_ENVIRONMENT; check it with err.isKeyForOtherEnvironment. It is not retried: send test keys to the test server and live keys to the live server.
Quick start
1. Server: mint a player token
import { InvoServer } from "@invonetwork/web-sdk/server";
const invo = new InvoServer({
gameSecret: process.env.INVO_GAME_SECRET!, // server-side ONLY
baseUrl: "https://sandbox.invo.network/sandbox", // prod: https://invo.network
});
// Hand this short-lived (~15 min), game-scoped token to the browser.
const { token } = await invo.mintPlayerToken({ playerEmail: "p@example.com", playerPhone: "+15555550111" });2. Browser: run a passkey flow
import { InvoClient } from "@invonetwork/web-sdk";
const invo = new InvoClient({
token, // fetched from your backend
baseUrl: "https://sandbox.invo.network/sandbox",
// Optional: auto re-mint + retry once if the token expires mid-session.
refreshToken: () => fetch("/invo/token", { method: "POST" }).then(r => r.json()).then(j => j.token),
});
await invo.enrollPasskey(); // once per user
await invo.approveSend(transactionId); // or approveTransfer(...)Two integration modes (v1.0+). Browser-direct (above) points baseUrl at the INVO API. Ask INVO to CORS-allow your origin. Or run behind your proxy: set baseUrl to your own backend (which injects the real player token server-side) and give the browser a session token. The INVO token never reaches the browser, and no CORS/security-posture change is needed. Same SDK either way. Lowest-risk first step: adopt just linkDevice (additive, no refactor).
To run the game-secret writes behind your proxy too (so the secret leaves the browser), there's a drop-in InvoServer router at examples/proxy-server.ts. Its actor resolver is pluggable: session (one login = one player) for real partners, or a trusted first-party mode (caller names playerEmail, guarded by a shared secret) for a dashboard/test rig that impersonates arbitrary players.
Currency purchase (real money in)
Server-side, no passkey. The recommended path is hosted checkout. Invo's page handles the card processor and 3-D Secure, so you never touch card data. Open the returned URL via redirect, WebView, or an <iframe>. Grant currency off the purchase.completed webhook.
usdAmount is your list price, and it is what the player pays (plus tax where INVO collects it); the coins are priced from it. INVO's card fee (3.5% + $0.15 on the Open tier) is charged to you, not the player. If you charge a card directly instead, quote the total first so the player sees any tax before paying: see the direct route and Tiers.
const { checkoutUrl, sessionId } = await invo.createCheckout({
playerEmail: "p@example.com",
usdAmount: "20.00", // USD, above 0 and within the current card limits
rail: "platform", // optional: "platform" | "game" | "steam"
metadata: { yourOrderId: "ord_42" }, // echoed on the purchase.completed webhook (all rails)
});
// → send the browser to checkoutUrl (single-use, ~15 min)See the Currency Purchase API for the direct rail selector, 3-D Secure, and order status. The minimum and maximum for a single card charge are platform settings rather than fixed parts of the API: read them as min_amount and max_amount from validate-game instead of hard-coding either.
Steam purchases (3.5.0+)
Steam titles sell INVO-defined packs, never prices, and the whole flow is server-side methods: catalogue, init, finalize. The player authorizes with Steam itself: the in-game overlay by default, or Steam's hosted checkout page in the browser where the overlay cannot render.
const { packs } = await invo.steamPacks({ steamid }); // priced for THIS player
const init = await invo.steamInitPurchase({
playerEmail: "p@example.com",
steamid,
packId: packs[0].packId, // never a price
purchaseReference: myIdempotencyKey, // reuse on every retry
metadata: { playerId: myPlayerId }, // echoed on purchase.completed
// usersession: "web", playerIp, // browser checkout -> init.steamCheckoutUrl
});
// after the player approves (overlay callback / checkout page):
const done = await invo.steamFinalizePurchase({ orderId: init.orderId });
// done.newBalance; a replay returns alreadyProcessed: true, never a double creditFinalize errors carry a poller taxonomy: err.isSteamAuthorizationPending (wait) vs err.isSteamAuthorizationDead (stop). Refusals like err.isPartnerCreditUnavailable fire before the player is charged. Full flow, browser checkout, and the error table: Steam Payments.
Item purchase (spend your currency)
Spend the currency a player already owns to buy one of your items: a balance debit, server-side only, no passkey or real money. Amounts are in your currency units. Grant the item off the item.purchased webhook; Invo debits currency, your title owns the catalog.
const item = await invo.purchaseItem({
clientRequestId: crypto.randomUUID(), // idempotency key, unique per game
playerEmail: "p@example.com",
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)
});
// item.status === "success", item.newBalance, item.transactionId, item.orderIdDuplicates throw 409 (err.isDuplicateRequest); insufficient balance throws 400 (err.isInsufficientBalance). Full contract: Item Purchase.
Player balance
Read a player's currency balances server-side (game-secret), by email or player id.
const { balances, summary } = await invo.getPlayerBalance({ playerEmail: "p@example.com" });
// balances: [{ currencyName, availableBalance, reservedBalance, totalBalance }] (currencySymbol is always "": the API returns no symbol)
// by email only: there is no by-id balance route, and passing playerId throws INVALID_INPUT.
// In the browser, read the player's own balance with InvoClient.getBalance() (player token).Full field reference: Get Player Balance.
Sends & transfers (move currency between players)
Initiate on the server, then have the sender approve in the browser with their passkey (or a hosted or QR device approval if they're not enrolled). The recipient claims with their own passkey, or via a claim code.
Where can I send? (v0.7.0+). Populate the destination picker with one player-token call, client.getDestinations({ direction: "transfer" | "send" }), which returns every reachable title with display metadata inline (name, icon, currency name/symbol, min/max limits), plus the source game/currency and transferMode. No per-title lookup, no backend round-trip.
What do I have? (v0.8.0+). client.getBalance() returns the player's balances for this title (player-token). Rows carry currencySymbol/currencySymbolUrl and availableBalance/reservedBalance/totalBalance (decimal strings), plus totalValue/hasFunds. A player who hasn't transacted here yet returns an empty list (show 0.00), not an error. Cross-game portfolio lives on the wallet app, not this SDK.
// SERVER: initiate, then branch on how the sender must verify
const t = await invo.initiateTransfer({
clientRequestId: crypto.randomUUID(),
sourcePlayerName: "P", sourcePlayerEmail: "p@example.com", sourcePlayerPhone: "+15555550100",
targetPlayerEmail: "q@example.com", targetPlayerPhone: "+15555550111",
targetGameId: 123456, amount: "50",
});
// t.verificationMethod: "in_app" | "sms" (legacy value; approve with a passkey either way) | undefined (guardian, HTTP 202)
// BROWSER: sender approves; recipient self-claims (fall back to claim code if not enrolled)
const approved = await invo.approveTransfer(t.transactionId); // returns claimCode
await invo.confirmReceiptTransfer(t.transactionId); // or use approved.claimCodeinitiateSend is the same shape with sender*/receiver* + receivingGameId. See Transfers and Sends.
"You have X to collect". Two ways to list a player's pending items:
- • Browser (v0.6.0+):
client.getPendingCollect(): the logged-in player's own list (player-token, PII-free). Each row'skind:identity_gate→ callapprove*,receiving_confirm→ callconfirmReceipt*. - • Server (v0.4.0+):
server.getInboundPending({ playerEmail | playerPhone }): richer, includestoPhone/toIdentityIdfor routing a notification. MatchtoPhoneto the player.
Incoming from another title? Those rows come back as kind: "receiving_confirm". Render both kinds, or a collect UI that only shows identity_gate will miss all inbound. The browser list is scoped to the token's player, so mint the token for the recipient. Both methods pair with the transfer.claim_pending webhook.
Holds (v0.6.0+). An approve/claim can return an HTTP 202 hold instead of success. Check result.holdReason (RISK_HOLD, GUARDIAN_APPROVAL_PENDING, and on confirm-receipt RECIPIENT_IDENTITY_PENDING); success has no holdReason. Terminal guardian outcomes throw.
Completion, recovery & phone-share (server, v1.1.0+). Alongside the passkey path,InvoServer covers the non-passkey flows:
- • Legacy PIN completion (deprecated):
verifySmsTransfer(txnId, pin)/verifySmsSend(...)still exist for older integrations. Do not build on them; approve with a passkey or QR device approval. - • Claim by code:
claimTransfer(input)/claimCurrency(input)(a200withneedsAccountSelection+candidatesmeans re-submit with the chosen id). - • Status polls:
getTransferStatus(txnId)/getSendStatus(txnId)(verificationState), andgetGuardianApprovalStatus(txnId)for guardian holds. - • Phone-share: on a
409 PHONE_SHARE_APPROVAL_REQUIRED, runphoneShareInitiate→phoneShareApprove→phoneShareStatus, then re-issue the original request. - • Server destinations:
server.getDestinations({ sourceGameId, direction })(game-secret) mirrors the browser call.
Passkeys (WebAuthn)
Passkeys approve sends and transfers on the web. The browser SDK wraps navigator.credentials, base64url encoding, challenge round-trips, and error mapping.
await invo.enrollPasskey(); // once per user
// Interchangeable methods: prove an existing method (e.g. the Invo app device key,
// or a passkey on your own domain) to authorize adding this one, then enroll.
// No arguments from 3.10.1 on: the server mints the link id.
await invo.linkDevice(); // → { status: "authorized" }
await invo.enrollPasskey();
// First-enrollment OTP grant (v0.6.0+): some tenants require it:
try {
await invo.enrollPasskey();
} catch (e) {
if (e.isEnrollmentAuthorizationRequired) { // 6-digit code to phone + email
await invo.enrollmentBegin();
await invo.enrollmentVerify(codeFromUser);
await invo.enrollPasskey(); // retry; grant auto-consumed
} else if (e.isEnrollmentProofRequired) { // they still HAVE a method: link, don't recover
await invo.linkDevice(); await invo.enrollPasskey();
} else throw e;
}No passkey domain? Use approveHosted(). New titles have no partner passkey domain to verify. Invo runs the ceremony on its own domain. One call runs the whole hosted approval from the browser with the player token it already holds: it starts the device approval grant with channel: "popup", opens Invo's approval page in a popup, polls, shows the match-code prompt on a first enrolment, and on approved calls the flow's approve endpoint with the device code (which the SDK holds and never exposes). Your server does nothing new.
// From a click handler: the popup is opened synchronously, before any network call.
const r = await invo.approveHosted({ transactionId: t.transactionId, flow: "transfer" });
// r.status: "approved" | "denied" | "expired" | "closed" | "blocked" | "redirected"
// r.approval: the same result approveTransfer / approveSend / confirmReceipt* returnFull contract on Device Approval (thepopup row) and in the package README.
In-app passkeys on your own domain (enrollPasskey, approveSend, approveTransfer) keep working for tenants that verified a WebAuthn RP ID + allowed origins before the partner-domain freeze; serve your integration from one of those origins or they won't validate. New domains are no longer accepted (409 PARTNER_RP_FROZEN). See Platform Step-Up (WebAuthn).
Webhooks: grant value here
Synchronous responses are for UX; reconcile and grant value off webhooks. They're HMAC-signed. Verify X-Invo-Signature and dedupe on X-Invo-Idempotency-Key. Delivery is at-least-once, so keep your handler idempotent.
import { verifyWebhook, InvoError } from "@invonetwork/web-sdk/server";
// Pass the RAW request bytes + the X-Invo-Signature header.
let event;
try {
event = verifyWebhook(rawBody, signatureHeader, process.env.INVO_WEBHOOK_SECRET);
} catch (e) {
return respond(400); // InvoError: WEBHOOK_SIGNATURE_INVALID | WEBHOOK_TIMESTAMP_EXPIRED | WEBHOOK_MALFORMED
}
// De-dupe yourself on X-Invo-Idempotency-Key, then handle:
switch (event.event_type) {
case "purchase.completed":
// a paid card subscription renewal also sends this for the coins it minted: grant NOTHING on it
if (event.data.metadata?.source === "subscription_renewal") break; // use subscription.renewed
grantCurrency(event.data); break; // event.data is typed
case "item.purchased": grantItem(event.data); break;
}verifyWebhook does the constant-time HMAC-SHA256 check, enforces a 5-minute replay window, and accepts an array of secrets during rotation (verifyWebhook(body, sig, [oldSecret, newSecret])), returning a typed InvoWebhookEvent. The SDK verifies; you de-dupe on X-Invo-Idempotency-Key.
Edge / serverless (v0.4.0+). verifyWebhook uses node:crypto; on Cloudflare Workers, Deno, Vercel/Netlify Edge, or Bun use verifyWebhookAsync (Web Crypto, same args, just await it) or the ready-made handler createWebhookHandler({ secret, onEvent }), which returns a Fetch-API (Request) => Promise<Response> (Next.js App Router, Workers, Deno, Hono, Bun).
Subscribe to every event with subscribed_events: ["*"] and filter server-side. New event types then reach you automatically. If you subscribe to a subset, you must include transfer.claim_pending (it's how you learn an inbound send/transfer is waiting to be collected, and skipping it is the most common integration gap).
| Event | Fires when |
|---|---|
| purchase.completed | A currency or item purchase cleared and the player was credited (every rail). A paid card subscription renewal also emits it with data.metadata.source "subscription_renewal": skip that one and grant on subscription.renewed. |
| purchase.failed | A card purchase attempt failed: declined, abandoned 3-D Secure, or cancelled. |
| purchase.refunded | A purchase was refunded (full or partial). Only unspent coins are debited, never below zero. |
| purchase.disputed | A chargeback / dispute changed state (carries a dispute_status field; a lost dispute reads "lost"). Card payments and Steam-rail chargebacks; on Steam the card-only ids are null and rail / steam_transid are added. A coin purchase close also carries spent_coins_charged_usd (the value of coins already spent, charged to you; "0.00" if none). From 2026-10-21 a card dispute close, on either tier, also carries dispute_fee_usd (string or null) and dispute_fee_charged_to_partner (boolean or null), typed as optional fields from @invonetwork/web-sdk 3.23.0 (npm) / invonetwork 3.21.0 (Python). |
| purchase.fraud_warning | A pre-dispute fraud warning was raised on a card charge. |
| item.purchased | A player spent currency on one of your items. |
| transfer.sent | A send / transfer was initiated from a player. |
| transfer.received | A send / transfer was credited to the destination player. |
| transfer.claim_pending | An inbound send/transfer is awaiting your player’s claim, which drives the "you have X to collect" notice. |
| transfer.claim_expired | An unclaimed send / transfer expired. Both directions, like claim_pending; carries expiry_reason. |
| transfer.refunded | An expired / unclaimed transfer was refunded to the sender. |
| payout.status_changed | A partner payout moved to a new lifecycle state (creator payouts carry payee_id). |
| payee.status_changed | A creator registered for creator payouts changed status. |
| webhook.test | A test event you triggered from the console. |
| subscription.* | Subscription lifecycle, including refund_requested / refund_approved / refund_rejected; see Subscription webhooks. |
There is no purchase.dispute_lost or balance.updated event: a lost dispute is purchase.disputed with dispute_status: "lost", and balance changes surface through the purchase.* / transfer.* events above. Subscribing to an event name not in this list is refused.
How SDK calls map to events: createCheckout/purchaseCurrency → purchase.* · purchaseItem → item.purchased · initiateSend/initiateTransfer → transfer.*. Full payloads, signature verification, retries & secret rotation: Receiving Webhooks.
Resilience & observability (v0.3.0+)
- Automatic retries on network errors,
429(honoringretry_after), and5xxwith exponential backoff. ConfiguremaxRetries(default 2,0disables) andretryBaseDelayMs. Only idempotent requests retry. Single-use passkey POSTs never do. - Observability hooks:
onRequest/onResponse/onError(best-effort; a throwing hook never breaks a request). InvoError.requestIdcarries the backend request id for support tickets.- Cancellation (v0.4.0+): every method takes an optional final
{ signal }(AbortSignal); an aborted call throwsInvoErrorcodeABORTEDand is never retried.
const invo = new InvoServer({
gameSecret: process.env.INVO_GAME_SECRET,
baseUrl: "https://sandbox.invo.network/sandbox",
maxRetries: 2, // default; 0 disables
hooks: {
onResponse: ({ status, durationMs, requestId }) => metrics(status, durationMs),
onError: ({ error, willRetry }) => log(error.code, error.requestId, willRetry),
},
});Errors
Every failure throws a typed InvoError with .code (when present), .status, .message, and .body, plus helpers:
| Helper | Meaning |
|---|---|
.isTokenExpired | Player token expired: re-mint + retry (automatic with refreshToken) |
.isReceiverNotEnrolled | Recipient has no passkey: fall back to claim-code entry |
.isInsufficientBalance | Item purchase 400: required_amount + current_balance on .body |
.isDuplicateRequest | Idempotency-keyed request was a duplicate (409) |
.retryAfter | Seconds to back off on a 429 throttle |
.requestId | Backend request id (from the response headers): quote it in support tickets |