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.

Install and call in minutes

npm install @invonetwork/web-sdk — Node ≥ 18. View on npm and GitHub.

Currency Purchase

Hosted checkout — real money to game currency, no payment UI to build

Item Purchase

Spend existing game currency on in-game items

Sends & Transfers

Move currency between players, approved by passkey

Passkeys

WebAuthn enroll/approve — the web alternative to SMS PINs

Install

Terminal
npm install @invonetwork/web-sdk

Requires 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 game, and copy its credentials (the game secret plus your WebAuthn RP ID / origins) in the Invo console for the environment you're building against:

EnvironmentConsoleAPI baseUrl
Testing / sandboxhttps://dev.console.invo.networkhttps://sandbox.invo.network/sandbox
Productionhttps://console.invo.networkhttps://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).

Quick start

1. Server — mint a player token

server.ts
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" });

2. Browser — run a passkey flow

client.ts
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.

server.ts
const { checkoutUrl, sessionId } = await invo.createCheckout({
  playerEmail: "p@example.com",
  usdAmount: "20.00",                  // USD, 0 < x ≤ 999.99
  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.

Item purchase (spend game currency)

Spend the currency a player already owns to buy an in-game item — a balance debit, server-side only, no passkey or real money. Amounts are in game-currency units. Grant the item off the item.purchased webhook; Invo debits currency, your game owns the catalog.

server.ts
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.orderId

Duplicates 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.

server.ts
const { balances, summary } = await invo.getPlayerBalance({ playerEmail: "p@example.com" });
// balances: [{ currencyName, availableBalance, reservedBalance, totalBalance, currencySymbol }]
// or look up by id: invo.getPlayerBalance({ playerId: 12345 })

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 an SMS PIN 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 game with display metadata inline (name, icon, currency name/symbol, min/max limits), plus the source game/currency and transferMode. No per-game lookup, no backend round-trip.

What do I have? (v0.8.0+). client.getBalance() returns the player's balances for this game (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 the game SDK.

transfer flow
// 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" (passkey) | "sms" (PIN fallback) | 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.claimCode

initiateSend 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's kind: identity_gate → call approve*, receiving_confirm → call confirmReceipt*.
  • Server (v0.4.0+): server.getInboundPending({ playerEmail | playerPhone }) — richer, includes toPhone/toIdentityId for routing a notification. Match toPhone to the player.

Incoming from another game? 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:

  • SMS-PIN completionverifySmsTransfer(txnId, pin) / verifySmsSend(...) when initiate returned verificationMethod: "sms".
  • Claim by codeclaimTransfer(input) / claimCurrency(input) (a 200 with needsAccountSelection + candidates means re-submit with the chosen id).
  • Status pollsgetTransferStatus(txnId) / getSendStatus(txnId) (verificationState), and getGuardianApprovalStatus(txnId) for guardian holds.
  • Phone-share — on a 409 PHONE_SHARE_APPROVAL_REQUIRED, run phoneShareInitiatephoneShareApprovephoneShareStatus, then re-issue the original request.
  • Server destinationsserver.getDestinations({ sourceGameId, direction }) (game-secret) mirrors the browser call.

Passkeys (WebAuthn)

Passkeys replace the SMS PIN for approving sends/transfers on the web. The browser SDK wraps navigator.credentials, base64url encoding, challenge round-trips, and error mapping.

client.ts
await invo.enrollPasskey();              // once per user

// Interchangeable methods: prove an existing method (e.g. the Invo app device key)
// to authorize adding this passkey, then enroll.
await invo.linkDevice(linkId);           // → { 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) {
    await invo.linkDevice(linkId); await invo.enrollPasskey();
  } else throw e;
}

Prerequisite: Invo sets your tenant's WebAuthn RP ID + allowed origins. You must serve your integration from one of those origins or passkeys won't validate. 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.

server.ts — verify with the SDK (v0.3.0+)
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": 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 — the most common integration gap).

EventFires when
purchase.completedA currency or item purchase cleared and the player was credited (every rail).
purchase.failedA card purchase attempt failed — declined, abandoned 3-D Secure, or cancelled.
purchase.refundedA purchase was refunded (full or partial); the balance was debited.
purchase.disputedA card chargeback / dispute changed state (carries a dispute_status field).
purchase.fraud_warningA pre-dispute fraud warning was raised on a card charge.
item.purchasedA player spent game currency on one of your in-game items.
transfer.sentA send / transfer was initiated from a player.
transfer.receivedA send / transfer was credited to the destination player.
transfer.claim_pendingAn inbound send/transfer is awaiting your player’s claim — drives the "you have X to collect" notice.
transfer.claim_expiredAn unclaimed send / transfer expired.
transfer.refundedAn expired / unclaimed transfer was refunded to the sender.
payout.status_changedA partner payout moved to a new lifecycle state.
webhook.testA test event you triggered from the console.

Reserved (schema-defined, not currently emitted): purchase.dispute_lost, balance.updated — their effects surface through the purchase.* / transfer.* events above.

How SDK calls map to events: createCheckout/purchaseCurrencypurchase.* · purchaseItemitem.purchased · initiateSend/initiateTransfertransfer.*. Full payloads, signature verification, retries & secret rotation: Receiving Webhooks.

Resilience & observability (v0.3.0+)

  • Automatic retries on network errors, 429 (honoring retry_after), and 5xx with exponential backoff. Configure maxRetries (default 2, 0 disables) and retryBaseDelayMs. Only idempotent requests retry — single-use passkey POSTs never do.
  • Observability hooksonRequest / onResponse / onError (best-effort; a throwing hook never breaks a request).
  • InvoError.requestId carries the backend request id for support tickets.
  • Cancellation (v0.4.0+) — every method takes an optional final { signal } (AbortSignal); an aborted call throws InvoError code ABORTED and is never retried.
config
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:

HelperMeaning
.isTokenExpiredPlayer token expired — re-mint + retry (automatic with refreshToken)
.isReceiverNotEnrolledRecipient has no passkey — fall back to claim-code entry
.isInsufficientBalanceItem purchase 400 — required_amount + current_balance on .body
.isDuplicateRequestIdempotency-keyed request was a duplicate (409)
.retryAfterSeconds to back off on a 429 throttle
.requestIdBackend request id (from the response headers) — quote it in support tickets