npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@invonetwork/web-sdk

v2.4.1

Published

INVO Web SDK — currency purchase + passkey (WebAuthn) verification for partner web platforms.

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 (mintPlayerToken is a session mint — playerPhone optional; send-by-phone-alone; email-only getPlayerBalance); 2.3.0 surfaces the claim-time phone-share 409 on confirmReceipt*/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

npm install @invonetwork/web-sdk

Node ≥ 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 / initiateTransfer require 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 baseUrl to your proxy (e.g. https://yourgame.com/invo), which forwards to the INVO API and injects the real Authorization header 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 custom fetch:
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 a player_email token 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 mintPlayerToken so 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/steam rails 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 /sandbox prefix; the SDK absorbs it via baseUrl)

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 the INVO_CHECKOUT_COMPLETE postMessage:
// 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.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.
  • Idempotent on clientRequestId — a duplicate throws 409 (err.isDuplicateRequest).
  • Insufficient balance throws 400 (err.isInsufficientBalance; required_amount + current_balance on err.body).
  • Throttled calls throw 429 with err.retryAfter (seconds).
  • Client-side validation (missing fields, quantity outside 1..1000, bad price, total ≠ unit×qty) throws INVALID_INPUT before 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 reports verificationMethod: undefined (even though the raw 202 body also carries "sms") so guardianApproval wins — 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.isReceiverNotEnrolled on confirmReceipt* is the explicit signal to switch to claim-code entry.
  • err.isPhoneShareApprovalRequired on confirmReceipt* / claimTransfer / claimCurrency (409): the receiver phone is contested, so INVO holds the money and texts the phone owner to approve. Not a failure — show err.message verbatim (err.phoneShareLast4 names 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/initiateTransfer can throw err.isSteamValueNonTransferable (moving more than the non-Steam balance to a non-Steam destination — show err.message, cap the amount at err.steamTransferableMax; err.steamOriginAmount is the Steam-locked portion) or err.isNonSteamValueIntoSteamBlocked (non-Steam value into a Steam title). Prevent both up front: getDestinations rows carry acceptsSteamOriginValue — 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 no holdReason (and next: "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's kind tells you the action:
    • "receiving_confirm" → a peer/another game sent value TO this player (incoming) → call confirmReceiptSend/confirmReceiptTransfer.
    • "identity_gate"this player initiated and it's awaiting them → call approveSend/approveTransfer.

    Seeing incoming transfers from other games? They come back as kind: "receiving_confirm" — render both kinds (a UI that only shows identity_gate will 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, includes toPhone/toIdentityId for routing a notification to the right account. Match toPhone to the player (toIdentityId is 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/approveTransfer return 403 PASSKEY_RECOVERY_COOLDOWN for 24 hours (SIM-swap protection). Branch on err.isPasskeyRecoveryCooldown and show — "For your security, transfers are paused for 24 hours after a passkey reset. You can still receive funds. Try again after err.retryAfterAt." — do not treat it as a generic failure or retry-loop it. Unaffected: login, getPendingCollect, inbound confirmReceipt*.

  • 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 onEvent500.

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 (honoring retry_after), and 5xx — are retried with exponential backoff + jitter. Configure with maxRetries (default 2, set 0 to disable) and retryBaseDelayMs (default 250). Mutating calls carry idempotency keys, so retries are safe; set maxRetries: 0 if you'd rather handle 429 abuse-throttles yourself.
  • Hooks. Pass hooks to 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 the url if you log hook payloads.

  • Request ids. InvoError.requestId carries the backend request id (from x-invo-request-id / x-request-id) — quote it in support tickets.
  • Cancellation. Every method takes an optional { signal } (an AbortSignal) as its last argument — server.getPlayerBalance({ playerEmail }, { signal }). Aborting throws InvoError with .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 .message for those)
  • .status — HTTP status (0 for 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            # vitest

Stability

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.