@invonetwork/web-sdk
v3.16.0
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:
v3.16.0, 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: 3.16.0 addswarningsoncreateCheckout()(item metadata on a coin session),err.isDuplicateClientRequestId,err.isTrialAmountBelowFeeFlooranderr.isIdentityCheckUnavailable; 3.14.0 addscredited/reasonCodeonconfirmPayment()andsteamFinalizePurchase(), Merchant of Record receipt fields, Platform Commerce card refunds in parts, and updates the tiers: subscription card charges are now 4% + $0.30 on Open and 5% + $0.50 on Merchant of Record, international card and currency conversion costs on subscriptions pass through at cost, chargeback handling by tier, and Merchant of Record is applied for from the console; 3.13.0 documents the two tiers (Open by default, Merchant of Record by application), adds INVO's card fee on coin purchases (3.5% + $0.30):quoteCurrencyPurchase(),acknowledgedTotalUsdonpurchaseCurrency(), the newprocessingFeeUsd/feeTakenFrom/coinsCredited/chargeTotalUsdfields anderr.isAmountBelowFee/err.isQuoteStale, states the coin refund rule (only unspent coins), and adds paid trials on card subscriptions (trialAmountUsd, the disclosed trial consent fields,is_trial/expire_causeon the events,err.isPaymentMethodRequiredand theisTrialAmount*helpers); 3.12.0 removes the SDK's own card maximum (INVO's is a runtime setting, $100,000.00 by default); 3.11.0 adds the purchase status read, whereinvo.checkout.getPurchaseStatus()/waitForPurchase()(and the standaloneCheckoutClient) answer what happened to a hosted-checkout purchase in six words, four of them terminal, so a buyer is never shown a page that closes on nothing; stopping is decided by INVO'sterminaland never re-derived fromstatus,nothingWasCharged()says when a Try again is safe,createCheckout()now also returnssessionToken, and the subscription fee is corrected to 7% + $0.30 (it is its own rate; item purchases stay at 10/90); 3.10.0 documents card-only subscriptions (the card pays the full price every period, the coin wallet never pays;walletOnlydeprecated; newfailure_codewallet_negative; a renewal'spurchase.completedis not a purchase) and the subscription refund limits:subscriptions.refundcan now return a 202pending_approvalrefund request (isRefundPendingApproval), withwarnings, the processor fee you bear,subscriptionCanceled(a full refund cancels), new classifiers (isRefundsNotEnabled,isRefundWindowClosed,isRefundRequestPending,isRefundRequestRejected,isConcurrentRequest,isRefundMismatch) and typedsubscription.refund_requested/refund_approved/refund_rejectedevents, all additive; 3.9.0 adds the INVO-hosted card page for subscriptions,invo.cards.createSetupSession: mint a 10-minute link server-side, send the member to it, read the card back fromcards.list, with no browser code and no card processor named anywhere (beginSetup/confirmSetupremain as the processor-bound alternative), plus theisPlayerNotFound,isInvalidPlayerEmailandisInvalidInputclassifiers and a fix that makesincludePlayerreads actually surfaceplayerEmail/playerName; 3.8.0 adds Subscriptions as a first-class server surface on both rails —invo.subscriptions.create/get/listForPlayer/cancel/changeAmount/setPaymentMethod/refund/steamInit/steamFinalize, the off-session card capture (invo.cards.beginSetup/confirmSetup/list), the sandbox clock tools (invo.sandbox.subscriptions.*, which post to/subscriptions/<id>/...relative to the sandbox base, not under/api, with the newsandboxClockKey), typeddatafor all sevensubscription.*webhook events, and eleven new error classifiers; 3.7.0 makes the device approval grant a first-class SDK surface —beginDeviceApproval,pollDeviceApproval,confirmDeviceEnrollmentand, the step that actually moves the money,approveWithDeviceCode(plus the optionalcompleteDeviceApprovalloop); a grant that is polled toapprovedand left there settles nothing, which is the failure this release exists to make impossible, andTRANSACTION_NOT_PENDINGnow comes back as a typedstatus: "not_pending"result withalreadySettledrather than a thrown error you would have to string-match; 3.4.0 documents that a Steam title now bills through its own Steam application — the studio registers their appid + publisher Web API key and keeps a payment method on file, because Valve pays the studio and INVO settles afterwards (newisSteamNotConfigured,isPartnerBillingNotSetUp,isPartnerSettlementUnavailable,isSteamAppChangederror helpers); 3.2.3 corrects the base path and auth header in the Steam snippets (/api/currency-purchases,X-Game-Secret-Key) — 3.2.2 published both wrong, and copying them returned a 404; 3.2.x documents Steam currency purchases on a pack model — INVO owns the catalogue, you pass apack_idand never a price, and the currency inside a pack varies by country because Steam prices are VAT-inclusive; 3.1.0 makes the authentication guidance explicit — passkeys are the gold standard and the SMS-PIN completion (verifySmsTransfer/verifySmsSend) is deprecated and being phased out (documentation only — no behavior change, no runtime warnings); 3.0.0 moves the Platform Commerce card leg to INVO's hosted checkout — the serverpurchase()now returns acheckoutUrland the browser just sends the buyer there (redirect ormountCheckoutiframe); the 2.5.x card-element surface (confirmCardPayment/mountCardPayment,billingAddress,commercePublishableKey) is removed — see the CHANGELOG migration note; 2.5.0 added Platform Commerce (ecommerce) — a platform tenant selling items funded by balance or card, withinvo.platformCommerce.purchase/getStatus/refund(server) and theplatform_commerce.*webhooks; 2.4.0 adds Steam transfer-policy handling (isSteamValueNonTransferable/isNonSteamValueIntoSteamBlocked+DestinationGame.acceptsSteamOriginValue); 2.3.0 surfaces the claim-time phone-share409onconfirmReceipt*/claim*(+err.phoneShareLast4). 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
Six 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 | | Platform Commerce (ecommerce) | balance or card → a storefront item | card leg only | no | server creates; buyer pays on INVO's hosted checkout | | Subscriptions | a fixed USD price on a fixed interval → game currency each period (card or Steam rail) | yes (card: the full price every period; Steam: the wallet shortfall) | no | server creates, cancels, reprices; renewals arrive by webhook | | 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 |
Item purchase vs Platform Commerce — don't mix them up. They share a body shape (
itemId,unitPrice,totalPrice,clientRequestId) but are different products:
- Item purchase — a game tenant. A player spends game currency they already hold on an in-game item (a sword, a skin). Always a balance debit — never a card, never new money, no refunds.
server.purchaseItem(...)→/api/item-purchases.- Platform Commerce — a platform tenant (a non-game app: vertical video, creator merch, marketplace). A storefront sale funded by INVO balance or a real card (new money), with refunds. Who is the seller of record depends on your tier.
server.platformCommerce.purchase(...)→/api/platform-commerce. A game tenant calling it gets403 NOT_A_PLATFORM_TENANT.
The game secret stays on your server; the browser only ever holds a short-lived, game-scoped player token (and, for the card leg, a per-purchase checkoutUrl).
This applies to a game or a platform alike: the SDK calls the tenant a game and names the option
gameSecret, but a platform tenant uses the same option and the same flow.
Passkeys are the gold standard — don't build on SMS
If you take one thing from this README: authorize money movement with a passkey. Do not design your verification UX around the SMS PIN.
A passkey is a WebAuthn assertion — phishing-resistant, bound to your origin, and backed by the device's secure hardware. An SMS PIN is a shared secret delivered over a channel exposed to SIM swap, SS7 interception, and social engineering. They are not two equivalent ways to approve a transfer; one is materially weaker, and INVO treats it that way (see the 24-hour money-out cooldown after a passkey recovery — that gate exists precisely because phone-based possession can be stolen).
So:
| | Do this | Not this |
|---|---|---|
| Sender approves a send/transfer | client.enrollPasskey() once, then client.approveSend / approveTransfer | a PIN pad as the default flow |
| initiate* returns verificationMethod: "sms" | read it as "this user has no passkey" → offer enrollment | route straight to PIN entry |
| Recipient collects | client.confirmReceiptSend / confirmReceiptTransfer (passkey), or the QR flow with flow: "send_receipt" / "transfer_receipt" | claim code as the primary path |
| Console / TV / native client | the QR device-approval grant, then server.approveWithDeviceCode | polling to approved and stopping — that settles nothing |
Prerequisite: which passkey path serves your tenant
Read this before you wire up enrollPasskey(). There are two places a passkey ceremony can run, and which one serves your title is decided by whether you hold a partner passkey domain:
- No partner domain (every new title): INVO runs the ceremony on its own domain — the hosted approval page — for every platform. There is no domain to verify and nothing to configure; submitting one is refused (
409 PARTNER_RP_FROZEN). On the web the path isapproveHosted(); on consoles it is the QR flow; on iOS/Android the mobile plugins open the same page. The in-appenrollPasskey()/approveSend/approveTransfercalls return403 WEBAUTHN_NOT_ENABLED_FOR_TENANTfor you — that is the expected state, not a setup gap (the body'shosted_flowsays so and points atdevice_code). - A domain verified before the freeze (existing tenants): your passkeys are bound to that RP ID for life, so
enrollPasskey()and the in-app approves keep working exactly as documented below. You may still change or re-verify the domain. Removing it is irreversible: it can never be re-added, and the hosted flow serves the title from then on.
WEBAUTHN_NOT_ENABLED_FOR_TENANT is configuration state, not an error — the in-app ceremony was never available to fail. Don't show the player a failure; use the hosted page:
try {
await client.enrollPasskey();
} catch (e) {
if (e instanceof InvoError && e.isWebauthnNotEnabledForTenant) {
// Not a failure — this tenant has no partner passkey domain, which is now the norm.
// Approve on INVO's hosted page instead (nothing to configure):
await client.approveHosted({ transactionId, flow: "transfer" });
} else throw e;
}So the honest sequence is: start the transfer → approveHosted() (or the in-app approve if you hold a domain) → you're on the gold-standard path. The SMS PIN is what carries a player who cannot or will not enrol — which is exactly why the deprecation below has no removal date attached.
⚠️
verifySmsTransfer/verifySmsSendare deprecated as of3.1.0and a future major version will remove them. They still work exactly as before — this release changes documentation only, emits no runtime warning, and breaks nothing. Keep the PIN path as a genuine last resort for: users who cannot enroll (unsupported device, no platform authenticator), users who decline, and players who will not use the hosted page (see above). Just don't make it the road everyone walks down once passkeys are available to you.
What is not discouraged. These use a one-time code, but they are the on-ramp to a passkey — not a substitute for one. Use them freely:
enrollmentBegin/enrollmentVerify— the first-enrollment grant. This is how a user gets a passkey.recoveryBegin/recoveryComplete— restores a passkey after the user loses or deletes it.phoneShareInitiate/phoneShareApprove— phone-ownership consent, not transaction authorization.
Beyond security, this is also an economics story: SMS costs real money per message at every scale, and INVO's architecture targets passkey/in-app verification as the primary path so the platform never depends on carrier delivery. A passkey-first integration is faster for your users, cheaper to run, and won't need migrating later.
Which method fits which platform
The right approval method is a property of the client the player is sitting in front of, not of your title. Pick it per platform:
| Where the player is | Approve with | Why |
|---|---|---|
| Mobile app & mobile web (iOS 16+ / Android 9+) | a passkey, directly | the platform authenticator (Face ID / Touch ID / fingerprint) is available in-client — strongest option, zero messaging cost |
| Desktop web (browser) | a passkey, directly | Touch ID / Windows Hello are available to the browser |
| Web, player has no in-app passkey yet | INVO's hosted page in a popup (approveHosted, below) | the same page a console scans, opened by the SDK from the browser; the player enrols an INVO passkey on INVO's own domain and the SDK polls and approves — one call, nothing new on your server |
| Consoles (PlayStation / Xbox / Switch) | the QR device-approval flow (RFC 8628, below) | no browser and no WebAuthn in-client — show the QR, the player scans it and completes the passkey on their phone, your server polls, and your server then calls approveWithDeviceCode — the poll proves who, that call moves the money |
| Native Steam / desktop game clients (Mac & Windows) | the QR device-approval flow (RFC 8628, below) | the OS supports passkeys, but the embedded game client can't invoke the platform authenticator from inside the engine — it is the client, not the OS, that forces QR here |
When there is no passkey path at all — a remote approval that can't run a ceremony (a guardian, a phone's existing owner) — INVO goes email first, SMS on request: a signed link to a hosted page to the oldest verified address, and a text only when there is no verified email, the email could not be delivered, or the player asks for one. SMS is the last resort, not the primary channel.
Contents
- Passkeys are the gold standard — don't build on SMS
- Install
- Architecture & the two entry points
- Before you go live
- Configuration
- Tiers
- Currency purchase (real money in)
- Item purchase (spend game currency)
- Platform Commerce (ecommerce)
- Subscriptions (recurring billing)
- Player balance
- Sends & transfers (move currency between players)
- Sends and transfers, stage by stage
- 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 title, 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 titles, 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 title. 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): nothing to configure — new titles approve on INVO's hosted page (
approveHosted()); there is no partner domain to verify. Only a tenant that verified an RP ID + web origin(s) before the freeze uses the in-appenrollPasskey()/approveSendpath, and those passkeys only validate on the approved origins. Wire the approve step before launch: until a sender is enrolled the backend falls back to the deprecated SMS-PIN path, which is the flow you don't want your users on (see Passkeys are the gold standard). - For currency purchase: card checkout works out of the box; ask INVO to enable the
game/steamrails if you need them. Thesteamrail additionally requires the studio to register their own Steam app id + publisher Web API key and put a payment method on file — see Steam purchases. - For item purchase: nothing extra — it's a currency-balance debit.
- For subscriptions: the game must be
live(atestinggame cannot create one), a webhook target must be registered first (severalsubscription.*events carry data you cannot fetch any other way), and a card subscription needs a card captured off-session viacards.createSetupSession(the INVO-hosted card page;cards.beginSetupis the processor-bound alternative); the hosted checkout does not save one. See Cards for subscriptions.
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)
// sandboxClockKey: process.env.INVO_SANDBOX_CLOCK_KEY, // optional: the sandbox subscription clock tools only
});
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).
Tiers
Every INVO developer account is on one of two tiers. The SDK calls are the same on both; what differs is who the seller is, who handles tax, and the fees.
| | Open (default) | Merchant of Record (by application) | |---|---|---| | Who it is for | every account, including all current ones | accounts INVO has approved | | Who the seller is | you | INVO | | Tax on sales | you are responsible for it; INVO collects none | INVO calculates, collects and remits it | | Card purchase of coins | 3.5% + $0.30 | 5% + $0.30 | | Subscriptions | 4% + $0.30 per card charge | 5% + $0.50 per card charge | | Chargebacks | the disputed amount comes out of your share; on subscription charges the dispute fee is passed through too | INVO manages the dispute and pays the dispute fee; the disputed amount comes out of your share and is restored if INVO wins | | Item purchases (coins spent on an item) | 10% | 10% |
On both tiers every card charge runs on INVO's card rail and INVO pays you out, as today. On subscription card charges made on a card issued outside the United States, or needing a currency conversion, the extra processing cost is passed through to you at INVO's actual cost, reported separately from INVO's fee. On Merchant of Record, if your chargeback rate for the previous calendar month (disputes opened, excluding inquiries, divided by card charges completed, in UTC months) was above 0.75%, the dispute fee on subscription charges disputed this month is charged to you as on Open, and INVO reviews your approval. Merchant of Record is applied for per developer account, not per game, from the Selling & Tax page in your INVO console, and INVO reviews each application. Full details: https://docs.invo.network/docs/tiers/
What the tier changes in what the SDK returns (all additive):
tax_treatmenton an Open account's card sales reads"partner_responsible"(you are the seller; INVO charged no tax). It appears asbreakdown.taxTreatmentonquoteCurrencyPurchase()andtax_treatmenton thepurchase.completedwebhook. Tax amounts stay"0.00". Treat an unknown value as informational.- Receipts (Merchant of Record only). INVO issues a receipt for every card sale and a refund receipt for every card refund, and emails it to the buyer when the buyer's email is known; the number is always issued (a pending refund's number follows once it settles) and always reaches you. The number and link reach you as
receiptNumber/receiptUrlonpurchaseCurrency(),getOrderDetails()(fromGET /order-details, which returnsreceipt_number/receipt_url),CheckoutClient.getPurchaseStatus()(oncecredited),platformCommerce.getStatus()and a paidfirstCharge, and asreceipt_number/receipt_urlon thepurchase.completed,platform_commerce.purchasedandsubscription.renewedwebhooks. The refund webhooks (purchase.refunded,platform_commerce.refunded,subscription.refunded) carry the refund receipt's. On Open, and on storefront rails, the fields are absent. Anyone with areceiptUrlcan read the receipt. - Chargeback reserve (Merchant of Record only). Part of your share of each card-funded sale (Platform Commerce card sales, card subscription charges) is held, by default 10% for 90 days (terms may differ per account), then released automatically. It affects your withdrawable balance, not anything this SDK returns: see https://docs.invo.network/docs/tiers/ for the balance fields.
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:
The card maximum is INVO's, not the SDK's
Minimum: $0.50. That is the card rail's own floor: below it a charge cannot settle at all. (The SDK checks it locally on the Platform Commerce card leg.) A direct purchaseCurrency sent without an acknowledged total must also be large enough to carry INVO's card fee, or INVO refuses it before charging anything (400 AMOUNT_BELOW_FEE, see below).
Maximum: an INVO setting, $100,000.00 by default. It is changed by an INVO admin without a deploy, and it differs between sandbox and production, so this package does not pin it and never refuses an amount for being too large. Your amount is sent; INVO decides.
Before 3.12.0 the SDK enforced a hard-coded $999.99 and threw before any request was made, so a partner charging $50,000 failed inside the SDK and INVO never heard about it. A pinned limit is wrong by construction here: the moment anyone edits the setting, every installed copy of the SDK is stale, and the only fix is a release. Removing the local ceiling means the SDK can waste a round trip on an amount INVO refuses; keeping it meant the SDK could block a sale INVO would have taken. Only one of those is recoverable without a package upgrade.
When INVO refuses an amount this SDK allowed, you get a 400 and err.isAboveCardMaximum === true. Nothing was charged.
try {
await server.createCheckout({ playerEmail, usdAmount: "50000.00" });
} catch (err) {
if (err.isAboveCardMaximum) {
// err.message carries the figure in force for THIS environment, e.g.
// "Card purchases must be $100,000.00 or less." Do not retry it unchanged:
// lower the amount, split the sale, or ask INVO to raise the setting.
console.warn(err.message);
}
}The live figure for a tenant is also the max_amount of the hosted checkout's validate-game read, alongside min_amount. Read it if you want to show a limit in your own UI rather than hard-coding one, and re-read it, because it can change under you.
Per-customer velocity limits sit underneath the per-charge maximum (hourly, daily and monthly, per player per game) and are INVO's too. Exceeding one is a 429; see err.retryAfter.
Hosted checkout (recommended — PCI-light, you never touch card data)
// SERVER
const { checkoutUrl, sessionId, sessionToken, expiresAt, expiresInSeconds } = await server.createCheckout({
playerEmail: "[email protected]",
usdAmount: "20.00", // USD. Minimum $0.50. The maximum is an INVO setting
// ($100,000.00 by default) and is enforced on the request.
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).
// sessionToken is the credential for the status read below (INVO's own session_token field).
// A checkout session SELLS GAME CURRENCY. metadata is opaque: it is echoed back, never read as
// an item. Sending item_id / item_name in it returns a `warnings` entry
// (code ITEM_METADATA_ON_CURRENCY_SESSION); to sell an item use platformCommerce.purchase.
// 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. No browser SDK method charges (the browser only opens the URL), but it can now read what happened, below.
Did the purchase go through? (the status read)
invo.checkout answers, in six words, what a checkout purchase is doing. Poll it until INVO says it is finished and the buyer always has something true on screen, including the case where INVO has their money and they do not have their coins. That case used to close the page on nothing: no coins, no error, no receipt.
// BROWSER (or Node: it needs no DOM)
import { CheckoutClient, isPurchaseCredited, nothingWasCharged } from "@invonetwork/web-sdk";
// `sessionId` and `sessionToken` come from the server's createCheckout(); the hosted page
// reads the token out of its own ?session= parameter. The token is the credential; the id
// in the path is only a label.
const checkout = new CheckoutClient({ baseUrl: "https://invo.network" });
const r = await checkout.waitForPurchase({
sessionId, sessionToken,
intervalMs: 1500, // every 1 to 2 seconds; the limit is 600 reads/min per IP
timeoutMs: 300_000, // the cap; on it you get the last result, never a throw
signal: controller.signal, // optional
onUpdate: (u) => render(u), // every read, terminal or not; keep the modal open
});
if (isPurchaseCredited(r)) {
show(`${r.coinsCredited} ${r.currencyName} added`, r.newBalance);
} else if (r.message) {
showVerbatim(r.message, r.orderId); // never paraphrase it
if (nothingWasCharged(r)) offerTryAgain(); // ONLY when INVO says nothing was taken
else offerContactSupport();
}invo.checkout is the same object on an InvoClient you already built, if the page has a player token. Read once instead with checkout.getPurchaseStatus({ sessionId, sessionToken }).
| status | terminal | What it means | What to show |
|---|---|---|---|
| pending | no | No payment seen for this checkout yet | keep the spinner |
| paid_pending_credit | no | A charge is open, the coins are not in the balance yet, normally seconds | keep the modal open ("Finishing your payment and adding your coins"); after ~60s add the order id and a Contact support link, and keep polling |
| credited | yes | Charged and credited | coinsCredited, currencyName, newBalance when not null; close on the buyer's action |
| refused | yes | This purchase will not credit; reasonCode says why | message verbatim + orderId + Contact support |
| refunded | yes | The money went back | message verbatim + orderId |
| expired | yes | Never paid, can no longer be paid | message verbatim; offer a new checkout |
Rules worth taking literally:
- Stop on
result.terminal, never onstatus.terminalis INVO's answer and the SDK never recomputes it, so a status a later release adds cannot make this build decide a purchase finished, or finished well. An unknown status is never read as a success. paid_pending_creditis not a failure, however long it takes. INVO's reconciler recovers a stalled credit. Telling a buyer they lost money they are about to receive is worse than the silence.- A failed poll is not a failed purchase.
waitForPurchasepolls through a dropped connection, a timeout, a 429 and a 5xx; it raises a401,403or410at once (err.isWrongSessionPurpose,err.isCheckoutSessionMismatch,err.isCheckoutSessionExpired). - Render
messageverbatim and never showreasonCodeon its own. The six post-charge refusal codes all read the same to a buyer (they paid and the coins are not there), and the code exists for support to quote. Offer a Try again only wherenothingWasCharged(r)is true (PAYMENT_DECLINED,PAYMENT_CANCELLED,expired); every other refusal happened after the card was charged. expiredis not declared the second the link lapses. A payment made in the last minutes of a checkout can leave no order row until a recovery sweep finds it, so an unpaid checkout readspendingfor about 30 minutes past expiry. Poll through it.amountChargedUsdminuslistedUsdis INVO's card fee the buyer pays on the card rail (3.5% + $0.30 on the Open tier). A buyer billed 207.30 for a 200 pack will ask; both numbers are here.- Storefront (in-client) purchases do not reach this read. Those orders are keyed on your own purchase reference rather than on a checkout session, so a lookup by session id finds nothing and answers
pending. Do not conclude the purchase never happened. newBalanceisnullonce the session is past its expiry: the token rides in the checkout URL, so a stale link must not serve a live wallet.coinsCreditedandtransactionId, which are about this purchase, still come back.
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.
Steam titles: before any of this works, the studio has setup to do
Steam pays whoever owns the Steam application. Every INVO title therefore sells currency through its own Steam app, and the money lands in the studio's own Steamworks account. INVO issues the player's currency the moment Steam captures the charge, then settles with the studio separately against a payment method they keep on file.
Once per title, in the INVO dashboard:
- Enable in-game purchases (microtransactions) for the app in Steamworks.
- Create a publisher Web API key — a publisher group key, not a personal user key, with the app in the group. A personal key fails verification.
- Enter the app id and that key under the title's Steam settings. INVO calls Steam to prove the pair before saving it.
- Add a payment method on the Billing screen and accept the authorisation.
Until all four are done, purchases are refused before the player is charged:
STEAM_NOT_CONFIGURED(503) orPARTNER_BILLING_NOT_SET_UP(409). That is deliberate — the alternative is taking a player's money for currency that cannot be issued.This changed in September 2026. Versions before 3.4.0 said Steam purchases ran through an INVO-owned Steam app and there was nothing to register. That was true while one INVO application billed every title; it is not true now, and following the old text leaves the rail closed.
Steam titles: currency must be bought through Steam, and INVO sets the packs.
A title distributed on Steam may only sell currency on the
steamrail — any other rail is refused with409 STEAM_PURCHASE_LAYER_REQUIRED. This is a platform requirement, not an INVO preference, and it applies to every storefront the title ships on because a player's balance is shared across them.You do not set Steam prices and you do not send an amount. INVO defines the pack catalogue for every game on the network. Fetch it with
GET /steam/packs?steamid=..., render it, and pass thepack_idback to the purchase call.The price is the same everywhere; the currency inside is not. Steam prices are VAT-inclusive wherever VAT is collected, so more of a fixed price goes to tax in a high-VAT country and less is left to buy currency with — the same $9.99 pack yields 69 units in the US and 58 in France. Always pass
steamidwhen fetching the catalogue, or the amounts are quoted with no VAT deducted and your store promises more than the purchase delivers. Do not cache one catalogue for all players and do not hard-code amounts: pack prices and VAT rates are both configuration.
Provider/processor names are an internal detail and never appear in the API.
Steam purchases (packs, not prices)
Steam has first-class SDK methods as of 3.5.0: steamPacks(), steamInitPurchase(),
steamFinalizePurchase() — all on InvoServer, all server-side, because the game
secret must never reach a client. purchaseCurrency still rejects rail: "steam"
with WRONG_RAIL_ENDPOINT and points you here.
INVO owns the pack catalogue. You never send a price. Fetch the packs, show them,
pass a packId back.
// 1. The catalogue, priced for THIS player. Always pass steamid.
const { packs, country, vatRatePct } = await server.steamPacks({ steamid: steamId });
// packs: [{ packId, label, priceUsd, currencyAmount }, ...]
// 2. Start the purchase with the pack the player chose.
const init = await server.steamInitPurchase({
playerEmail: "[email protected]",
steamid: steamId,
packId: "steam_medium", // NOT a price
purchaseReference: myIdempotencyKey, // unique per INTENDED purchase; reuse on retry
metadata: { playerId: myPlayerId }, // echoed on the purchase.completed webhook
});
// -> { orderId, steamTransid, packId, chargedUsd, currencyAmount }
// 3a. CLIENT SESSION (default): Steam shows its in-game overlay. Your game
// client receives MicroTxnAuthorizationResponse_t and tells your backend.
// 3b. WEB SESSION: for platforms where the overlay does not render (observed:
// Electron on macOS). Pass usersession: "web" + playerIp (the PLAYER's IP,
// never your server's) and open the returned steamCheckoutUrl as a
// TOP-LEVEL browser tab — it cannot be iframed.
// 4. Finalize: INVO re-checks the authorization with Steam, captures, credits.
const done = await server.steamFinalizePurchase({ orderId: init.orderId });
// -> { status: "success", newBalance, alreadyProcessed, credited }
// credited === true means the currency is in; see "credited: did the coins arrive?" above.The price is identical everywhere; the currency inside is not. Steam prices are
VAT-inclusive wherever VAT is collected, and the storefront's revenue share comes out
before currency is derived — so the same $9.99 pack yields 69 units in the US
and 58 in France, and a $4.99 pack yields 34, not 50. Render
currencyAmount verbatim; never compute currency from the sticker price.
Finalize is idempotent, and not every 409 means "try later". A replay returns
alreadyProcessed: true and never credits twice. For a poller, branch on the error:
| Signal | Meaning | Do |
|---|---|---|
| err.isSteamAuthorizationPending | player has not approved yet (Init) | keep polling / wait for the callback |
| err.isSteamAuthorizationDead | any settled status: Cancelled / Failed / Refunded / PartialRefund / Chargedback / anything else non-Init | stop — no later call can succeed |
| err.isSteamAppChanged | the title's registration changed under the order | stop; terminal for this order |
| HTTP 502 / 503 | transient | retry |
| "Order is not finalizable" (4xx, no not_authorized) | the reconciler already settled the order's fate | stop |
A poller that treats every 409 as "not yet" polls a dead order forever. And a missed finalize is not a lost credit — INVO's reconciler independently detects the authorized transaction and credits, on a delay.
Refusals before any charge, so a player is never charged for currency that cannot be issued:
err.isPartnerCreditUnavailable— the studio's settlement float cannot cover it. Plain failure toast; do not hot-poll init (each attempt briefly re-reserves headroom), and reuse the samepurchaseReferenceon a manual retry.err.isPartnerRailSuspended— terminal from the game's seat until the studio resolves it in the INVO console.err.isUnknownSteamPack— re-fetchsteamPacks(); the body'svalid_pack_idslists what exists. The catalogue is INVO's and changes without an SDK release.err.isSteamNotConfigured(503) /err.isSteamRailNotEntitled(403) — the studio's one-time Steam setup is incomplete.A locked Steam account is refused up front with
409 STEAM_ACCOUNT_LOCKED.Always pass
steamidtosteamPacks(). Without it the amounts are quoted with no VAT deducted — the most any pack yields — so your store promises more than the purchase delivers.Do not cache one catalogue for all players, and do not hard-code amounts. Pack prices and VAT rates are both configuration and change without an SDK release.
On a Steam-distributed title, a channel key may only sell on its own channel's rail. A mismatched caller is refused with
409 STEAM_PURCHASE_LAYER_REQUIRED. (The old absolute form — "only the Steam rail, across every storefront" — was the rule before 2026-08-31 and no longer holds for channel-keyed callers.)Correlation is
metadata, notpurchase_reference. The webhook payload deliberately omits your idempotency key; put your own player/order ids inmetadataand read them back atdata.metadataonpurchase.completed— note the Steam rail ADDS its own context keys (steam_wallet_currency,steam_country,steam_account_status,vat_rate_pct) into the echoed object, so avoid those names in yours (data.channelis the channel of the KEY you presented —"steam"when you call with your Steam channel key;nullon a legacy game key).
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 }) and read `credited`
// "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.
credited: did the coins arrive?
confirmPayment() and steamFinalizePurchase() keep status: "success" whenever the payment succeeded. That alone does not prove the coins were added, so both results carry credited and reasonCode (backends from 2026-09-24):
const c = await server.confirmPayment({ paymentIntentId });
if (c.credited === true) {
// the coins are in: show "purchase complete"
} else if (c.credited === false && c.reasonCode?.startsWith("CREDIT_REFUSED_")) {
// e.g. "CREDIT_REFUSED_MINT_CEILING": the money was taken and no coins were added.
// INVO is alerted. Do NOT ask the buyer to pay again; show support with the order id.
} else if (c.credited === false && c.reasonCode) {
// "REFUNDED" or "CHARGEBACK_LOST": the payment went back to the buyer.
} else if (c.credited === false) {
// reasonCode === null: the credit is still being finished. Poll getOrderDetails until "completed".
} else {
// credited === undefined: an older backend. Fall back to the order status.
}Show "purchase complete" only when credited is true. alreadyProcessed is also true whenever the call added nothing (a refused credit included), so do not read success from it.
INVO's card fee: quote, show, acknowledge
A card purchase of coins carries INVO's card fee: 3.5% + $0.30 of the price on the Open tier, never less than the card cost. The hosted checkout adds it to the total on its own page, so there is nothing to do there. purchaseCurrency charges in the same request it is called, so it cannot show the buyer anything first. You choose what happens by what you send:
- Quote, show, acknowledge (recommended). Ask
quoteCurrencyPurchase()for the total, show the player that total, then send the same figure back asacknowledgedTotalUsd. The card is charged that total and the player gets coins for the full price. - Send only
usdAmount. The card is charged exactlyusdAmount, never more, and the fee comes out of the coins instead.
// 1) SERVER: quote. Moves no money and creates nothing: safe on every price render.
const q = await server.quoteCurrencyPurchase({
playerEmail: "[email protected]",
usdAmount: "10.00",
// country: "US", subdivision: "NY", // optional: the buyer's billing place, for any tax
});
// q.subtotalUsd "10.00" + q.processingFeeUsd "0.65" = q.totalUsd "10.65" (q.coins "100.00")
// q.breakdown: every line, with taxNote / processingFeeNote sentences you can show as is
// q.ifUnacknowledged: { chargeTotalUsd: "10.00", processingFeeUsd: "0.63", coins: "93.70", feeTakenFrom: "coins" }
// 2) Show the player q.totalUsd ("$10.65"). 3) Charge exactly what they saw:
const purchase = await server.purchaseCurrency({
playerEmail: "[email protected]",
usdAmount: "10.00", // the price; the coins are priced off this
acknowledgedTotalUsd: q.totalUsd, // "10.65": the card is charged this, to the cent
purchaseReference: crypto.randomUUID(),
paymentMethodId: "pm_...",
});
// On "success": purchase.chargeTotalUsd "10.65", purchase.processingFeeUsd "0.65",
// purchase.feeTakenFrom "charge", purchase.coinsCredited "100.00"The same $10.00 unacknowledged charges the card $10.00, takes the $0.63 fee out of the coins and credits 93.70 coins at the default rate of 10 per USD (feeTakenFrom: "coins"). Sending $10.65 unacknowledged buys the same $10.00 of coins as the acknowledged path: the fee is never charged on the fee.
| Error | When | What to do |
|---|---|---|
| 409 QUOTE_STALE (err.isQuoteStale) | the acknowledged total is not the total INVO would charge now | nothing was charged; quote again, show err.expectedTotalUsd, retry with the new total and a new purchaseReference |
| 400 AMOUNT_BELOW_FEE (err.isAmountBelowFee) | unacknowledged, and the amount cannot carry the fee (well under a dollar) | nothing was charged; send a larger amount, or quote and acknowledge |
Only a success response carries the four fee fields; requires_action and pending_payment do not. Storefront rails bill no INVO card fee: the storefront takes its own cut.
Refunds of coin purchases
Only coins that have not been spent are refundable. Spent coins are never refunded, including when new coins have arrived in the balance since. A refund of a card purchase of coins returns the price of the unspent coins and takes those coins back; if every coin from the purchase has been spent, nothing is refunded. Refunds are issued by INVO: this SDK has no call that refunds a coin purchase, so ask INVO support.
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). totalPriceis rounded to cents. A spend so small that INVO's fee would round to zero throws400(err.isAmountBelowMinimum).- 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.
Platform Commerce (ecommerce)
This is not item purchase. Item purchase is a game tenant spending a player's existing game currency on an in-game item — always a balance debit, never a card, no refunds. Platform Commerce is a platform tenant (a non-game app: vertical video, creator merch, marketplace) running a storefront. The buyer pays with INVO balance or a real card (new money), and refunds exist. Who is the seller of record, and who handles tax, depends on your tier. Only platform tenants may call it — a game tenant gets
403(err.isNotPlatformTenant).
The funding source is resolved server-side under lock — the client can request balance or card, but the backend verifies the real balance before any value moves. Everything money-shaped is a server call (holds the secret, creates the purchase/refund); the browser's only job on the card leg is to send the buyer to INVO's hosted checkout page. Reached via the platformCommerce namespace on each entry point.
Balance leg — one server call, settles now
const r = await server.platformCommerce.purchase({
clientRequestId: crypto.randomUUID(), // idempotency key, unique per tenant
fundingSource: "balance",
playerEmail: "[email protected]",
playerName: "Ada",
itemId: "sticker_pack_01",
itemName: "Sticker Pack",
itemQuantity: 1, // integer 1..1000
unitPrice: "5.00", // BALANCE leg: the tenant's network-currency amount
totalPrice: "5.00", // must equal unitPrice × itemQuantity (±0.01)
});
// r.status === "success" — settled synchronously
// r.newBalance / r.previousBalance / r.currencyName
// r.orderId / r.transactionId
// r.financialBreakdown { total_paid, developer_revenue, platform_fee } // INVO fee: 3.5% flatCard leg — server creates, INVO hosts the checkout
INVO hosts the entire card checkout — card fields, Apple Pay / Google Pay, the international billing-address collector, and 3-D Secure all live on INVO's page. There is no billing address in the request, no card element, no processor dependency, and no client payment code to write: the server call returns a checkoutUrl and you send the buyer there. totalPrice is USD, at least $0.50. The maximum is an INVO setting, $100,000.00 by default, so it is enforced on the request and not pinned in this package.
// 1) server — returns a hosted-checkout session, NOT yet paid
const r = await server.platformCommerce.purchase({
clientRequestId: crypto.randomUUID(),
fundingSource: "card",
playerEmail: "[email protected]",
playerName: "Ada",
itemId: "sticker_pack_01",
itemName: "Sticker Pack",
itemQuantity: 1,
unitPrice: "5.00", // CARD leg: USD
totalPrice: "5.00",
successUrl: "https://app.example/thanks", // optional: where the buyer lands after paying
cancelUrl: "https://app.example/cart", // optional: where the buyer lands on cancel
metadata: { cartId: "c_9" }, // optional: echoed back on the webhook
});
// r.status === "requires_payment"
// r.checkoutUrl → send the buyer here r.sessionId / r.expiresAt (unix seconds)// 2) browser — EITHER just redirect…
location.href = r.checkoutUrl;
// …OR embed the hosted page with the optional helper (iframe + completion listener):
const handle = invo.platformCommerce.mountCheckout("#checkout", checkoutUrl, {
onComplete: () => showProcessing(), // fires on the page's INVO_CHECKOUT_COMPLETE message
});
// later: handle.destroy();mountCheckout accepts completion messages only from the checkout page's own origin, and onComplete is a UX signal — show "processing", then reconcile server-side.
⚠️ The card sale is real only on the
platform_commerce.purchasedwebhook — never on the client return or the in-frame completion event (a client can be tampered with). Fulfill on the webhook.
Status & refunds
const s = await server.platformCommerce.getStatus(r.orderId);
// s.status: "completed" (balance immediately; card after the webhook) | "pending_payment" | "refunded"
const ref = await server.platformCommerce.refund({ orderId: r.orderId, reason: "customer request" });
// or refund({ clientRequestId }). Pass EXACTLY ONE id.
// INVO retains its fee (ref.feeRetained === true); the customer is made whole minus that fee.
// A second refund of the same order throws 409 (err.isAlreadyRefunded) — treat as already done.Card sales refunded in parts. A card refund normally returns the whole charge in one go, and the result is as above. A sale ends up refunded in parts only when part of the charge was already refunded outside INVO's API: the next refund() records that existing refund and moves no new money (ref.adopted === true, ref.partialRefund === true, ref.refundedTotalUsd, ref.chargeTotalUsd, ref.remainingUsd; the order stays completed). Every figure is then that refund's own share, and partialRefund / refundedTotalUsd / chargeTotalUsd appear on each part (partialRefund: false on the last); on a one-shot full refund none of the parts keys appear; adopted: true can still appear if the call recorded an existing full refund.
// Refund what is left, on purpose:
await server.platformCommerce.refund({ orderId, refundRemaining: true });
// Within 120 s of the order's last refund, also confirmAdditionalRefund: true.err.isRefundRemainingRequiresConfirmation(409REFUND_REMAINING_REQUIRES_CONFIRMATION): sendrefundRemaining: true;refunded_total_usd/remaining_usdare onerr.body. Nothing moved.err.isRefundRecentlyIssued(409REFUND_RECENTLY_ISSUED): a refund of this order was applied in the last 120 seconds. Re-read the order; sendconfirmAdditionalRefund: trueto refund anyway.err.isRefundNotCompleted(502REFUND_NOT_COMPLETED): the card refund failed, was canceled or still needs an action. Nothing was recorded; retry (a refund that was in fact created is recorded on the retry, never issued twice).platform_commerce.refundedfor a sale refunded in parts
