Game economy infrastructure for Unity 6
Quick Start • Features • Installation • Security • Server-Side Proxy • Support
This plugin is a client. Several flows need a server that you host, and one of them — real-money purchases — cannot function at all until you build it. The honest, complete list of what is not in the box lives in InvoSDK-README.md → What You Must Wire Yourself. This document is the longer tutorial; that one is the specification. Where they differ, that one wins.
SMS verification has been removed. Sends and transfers are now approved on the player's phone: a QR code on desktop, Steam and console builds, and INVO's page in the system browser on iOS and Android. No code is texted, and nothing is typed.
Developers must update their scenes and prefabs by hand. 3.0.0 was written and compile-checked without the Unity editor, so no scene or prefab in this repository was changed.
SenderCurrencyPanelandTransferCurrencyPanelstill contain the old SMS code boxes, and the new fields (approvalView,retryApprovalButton,collectPendingButton,collectPendingText) are unassigned. The panels hide the legacy widgets and fall back to a plain built-in QR overlay, so nothing crashes, but it will not look like your game. Follow the checklist in InvoSDK-README.md → Updating your UI for 3.0.0 before you ship.Production now needs your game server. The SDK no longer sends the game secret from a production build; set
gameServerUrl. See Server-Side Proxy.
- Overview
- Architecture Overview
- Features
- Quick Start
- Installation
- Configuration
- Authentication
- API Reference
- Error Handling
- Not Every 2xx Is a Success
- Phone Numbers
- Money and Locales
- Idempotency
- Complete API Endpoints
- UI Components
- Editor Tools
- Security
- Server-Side Proxy
- Hosted Checkout (Real-Money Purchases)
- Hosted approval on mobile (system browser)
- Troubleshooting
- Examples
- Support
InvoSDK is a Unity 6 plugin that connects your game to the Invo Network platform:
- Player balances — per-currency balances with polling and a bindable label
- Item purchases — spend the player's in-game currency on catalog items
- Player-to-player sends — address a recipient by phone, settle with a claim code
- Cross-game transfers — move a player's own balance between games on the network
- Hosted checkout — real-money currency packs, via an endpoint you host
It is a thin, honest transport layer plus a set of reference UI panels. It is not a drop-in storefront, and it is not a complete production integration on its own — see Security and Server-Side Proxy.
┌─────────────────────────────────────────────────────────────────────────────┐
│ YOUR UNITY GAME │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ │
│ │ UI Components │ │ APIManager │ │ InvoSDKConfig │ │
│ │ │───▶│ (Singleton) │◀───│ (ScriptableObj) │ │
│ │ • Item Purchase │ │ │ │ │ │
│ │ • Transfer Flow │ │ • X-Game-Secret │ │ • SDK Key │ │
│ │ • Send Flow │ │ • Env routing │ │ • Game Settings │ │
│ │ • Featured Shop │ │ • Balance poll │ │ • useProduction │ │
│ └─────────────────┘ └────────┬────────┘ └─────────────────┘ │
│ │ │
│ │ HTTPS + X-Game-Secret-Key │
│ │ (no login, no tokens, no cookies) │
└──────────────────────────────────┼──────────────────────────────────────────┘
│
┌──────────────────────────┴───────────────────────────┐
│ │
│ PRODUCTION: you put YOUR SERVER here. │
│ It holds the key; the game never sees it. │
│ See "Server-Side Proxy". │
│ │
└──────────────────────────┬───────────────────────────┘
│
┌──────────────────────────────────▼──────────────────────────────────────────┐
│ INVO NETWORK CLOUD │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ Sandbox │ │ Production │ │ Hosted │ │ Sends and │ │
│ │ Environment │ │ Environment │ │ Checkout │ │ Transfers │ │
│ │ │ │ │ │ │ │ │ │
│ │ Test cards │ │ Real money │ │ Signed URL, │ │ QR / phone │ │
│ │ Isolated DB │ │ Isolated DB │ │ 15-min TTL │ │ approval │ │
│ └──────────────┘ └──────────────┘ └──────────────┘ └──────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
Sandbox and production are fully isolated databases with independent SDK keys. A sandbox key does not work against production, and vice versa.
| Feature | Description |
|---|---|
| Player Balances | Per-currency balances, single-flight polling with exponential backoff |
| Item Purchases | Spend in-game currency, decimal prices, caller-supplied idempotency key |
| P2P Sends | Phone-addressed sends, approved on the sender's phone by QR; the receiver collects with their own approval |
| Phone Approval | QR on desktop / Steam / console, system browser on iOS / Android (RFC 8628 device grant). No SMS. |
| Cross-Game Transfers | Move a player's own balance to another game on the network |
| Hosted Checkout | Real-money packs via a checkout session your server mints |
| Reference UI | Panels for purchase, send and transfer that you can read, fork or replace |
| Feature | Description |
|---|---|
| Setup Wizard | Guided configuration, stray-asset detection, production-key warning |
| Editor Tools | Test purchases, balance lookups, and catalog editing inside Unity |
| Async/Await | Every call is async Task<T>; callbacks are optional, not required |
| Typed Errors | Non-2xx throws InvoApiException with ErrorCode, ErrorId, RetryAfterSeconds |
| Locale-Safe Wire Format | InvoFormat and InvoPhone keep amounts and numbers valid on every device |
| Dual Environment | One boolean switches host and route prefix |
- Sign up at dev.console.invo.network (sandbox) or console.invo.network (production)
- Create a game project
- Copy your Game ID and your sandbox SDK Key
Use a sandbox key. The key you paste into the config asset is compiled into every player build you make. See Security.
Assets/
├── InvoSDK/ ← Copy this folder
│ └── Resources/
│ └── InvoSDKConfig.asset ← Created by the Setup Wizard
└── APIManager.cs ← Copy this file
Open InvoSDK → Setup Wizard from the Unity menu and fill in your credentials.
The config asset is created at Assets/InvoSDK/Resources/InvoSDKConfig.asset. It is gitignored — every developer configures their own, and it will not arrive with a clone.
using InvoSDK;
using UnityEngine;
var api = APIManager.Instance;
// Read a balance. available_balance is the SPENDABLE figure.
var balance = await api.GetPlayerBalanceAsync("player@email.com");
Debug.Log($"Balance: {balance.balances[0].available_balance}");
// Buy an item with in-game currency.
// The client request id is minted ONCE per user intent and reused on every retry.
string requestId = APIManager.NewClientRequestId();
try
{
var result = await api.PurchaseItemAsync(
clientRequestId: requestId,
playerEmail: "player@email.com",
playerName: "PlayerOne",
itemId: "sword_001",
itemName: "Legendary Sword",
quantity: 1,
unitPrice: 9.99m,
totalPrice: 9.99m);
if (result.IsSuccess)
Debug.Log($"Purchased. New balance: {result.new_balance}");
else
Debug.LogWarning($"Not complete: {result.status}"); // a 2xx is not always a success
}
catch (InvoApiException ex) when (ex.IsDuplicate)
{
// 409 on a purchase means it ALREADY SUCCEEDED. Treat as success.
Debug.Log("Already purchased.");
}
catch (InvoApiException ex)
{
Debug.LogError(ex); // ToString() is log-safe — it excludes the response body
}What this quick start does not give you. In-game-currency purchases work straight from the client, and that is what the sandbox demo does. Real-money purchases do not — they need a checkout endpoint you host (Hosted Checkout) — and a production launch needs a server-side proxy regardless.
| Requirement | Version |
|---|---|
| Unity | 6000.0+ (Unity 6) |
| .NET | Standard 2.1 |
| Newtonsoft.Json | 3.2.1+ (via Package Manager) |
| TextMeshPro | Bundled with com.unity.ugui 2.0.0 in Unity 6 |
-
Import the SDK
Copy the following into your Unity project's
Assetsfolder:Assets/InvoSDK/(entire folder)Assets/APIManager.cs
-
Install Dependencies
Open Window → Package Manager and install:
com.unity.nuget.newtonsoft-json -
Run Setup Wizard
InvoSDK → Setup Wizard. It creates the config asset if it does not exist.
-
Add APIManager to a scene
APIManageris aMonoBehavioursingleton withDontDestroyOnLoad. It loadsInvoSDKConfigfromResourcesinAwake(). Without it in the scene,APIManager.Instanceis null and every panel logs an error and stops.
The versions this project is built against:
{
"com.unity.nuget.newtonsoft-json": "3.2.1",
"com.unity.ugui": "2.0.0",
"com.unity.inputsystem": "1.14.2"
}The canonical path is Assets/InvoSDK/Resources/InvoSDKConfig.asset.
If a second asset named InvoSDKConfig exists under any other Resources root — Assets/Resources/InvoSDKConfig.asset is the usual stray — Resources.Load resolution becomes undefined and you can end up building against the wrong key. The Setup Wizard detects this and offers to select the stray for you. Delete it.
Assets/InvoSDK/Resources/InvoSDKConfig.asset:
| Field | Type | Description |
|---|---|---|
sdkKey |
string | Your game's ivsdk_… secret. Sandbox only. Ships in your build — see Security. |
gameId |
string | Your game identifier. Not secret. |
gameName |
string | Display name for your game |
playerName |
string | Default player display name (development convenience) |
playerEmail |
string | Default player email (development convenience) |
playerPhone |
string | Default player phone (development convenience) |
gameIconUrl |
string | URL to your game's icon |
gameCurrencyName |
string | Name of your in-game currency |
gameCurrencyUrl |
string | URL to the currency icon |
gameCurrencyID |
string | Currency identifier |
gameVersion |
string | Your game's version string |
useProduction |
bool | false (sandbox) by default. APIManager copies this in Awake(). |
checkoutSessionEndpoint |
string | https URL of your hosted-checkout endpoint. Set it once in the Setup Wizard; see the note below for per-panel overrides. |
playerPasswordis gone. It existed only to feed the deleted/auth/loginhandshake. If you have an older config asset with a value in it, nothing reads it.
playerEmail/playerName/playerPhoneare development conveniences. In a shipping game these come from your own player session, not from a build constant.
The field exists in two places, and the panel resolves them in order:
InvoSDKFeaturedPanel.checkoutSessionEndpoint— an optional per-panel override in the Inspector. Leave it blank unless one store surface needs a different endpoint from the rest.InvoSDKConfig.checkoutSessionEndpoint— the shared value the Setup Wizard writes. Used whenever the panel's own field is empty.Setting it once in the wizard is enough for most projects. Either way it must be an https URL, or checkout stays disabled and the panel logs an actionable error.
┌─────────────────────────────────────────────────────────────────┐
│ ENVIRONMENT ROUTING │
├─────────────────────────────────────────────────────────────────┤
│ │
│ useProduction = false (SANDBOX) │
│ ├── API base: https://sandbox.invo.network/sandbox/api │
│ └── Console: https://dev.console.invo.network │
│ │
│ useProduction = true (PRODUCTION) │
│ ├── API base: https://invo.network/api │
│ └── Console: https://console.invo.network │
│ │
│ Auth: X-Game-Secret-Key header on every request. That is all. │
│ No /auth routes. No tokens. No cookies. │
│ │
└─────────────────────────────────────────────────────────────────┘
The two API roots are compile-time constants in APIManager, not Inspector fields — a mistyped host would silently send your game secret somewhere that is not Invo.
⚠️ Use Sandbox during development. Only switch to production behind a server-side proxy. A build that carries ansdkKeyand hasuseProduction = trueships a production secret to every player.
There are two credentials, and they are never mixed.
| Game secret | Player token | |
|---|---|---|
| Header | X-Game-Secret-Key: ivsdk_… |
Authorization: Bearer <token> |
| Authenticates | your game | one player, for 15 minutes |
| Used for | initiate send/transfer, item purchase, catalog, balance, claims, minting player tokens | the phone approval (QR), the approve / confirm-receipt calls that move money, the pending list |
| Lives | on your server (production); in sdkKey (sandbox only) |
on the device; minted and re-minted by the SDK |
In production every game-secret call goes to <gameServerUrl>/api/<Invo path>, and your server adds the key. In sandbox, with no gameServerUrl, the SDK may call Invo directly with sdkKey. It refuses to do that in production.
(POST /v1/game-items/list reads its secret from the request body as game_secret, not from the header. APIManager adds it in sandbox; your server adds it in production.)
APIManager.Instance.SetActivePlayer(email, name, phone); // on login and on account switchThe SDK mints a token through POST /api/sdk/player-token the first time a token-authed call needs one. In production that goes through your game server. It caches the token, re-mints it a minute before expiry, and on a 401 SDK_TOKEN_* re-mints once and retries. There is no refresh endpoint, and the player is never prompted. To mint through your own login API instead, set APIManager.PlayerTokenProvider.
The old /auth/login + /auth/csrf-token handshake was deleted in 2.0.0, along with playerPassword. Those headers were never read by the game API, and /auth/login authenticates developer-console accounts, not players. The player token above is a different thing: it is minted by your server from your session and is read by the /api/sdk/* routes.
var api = APIManager.Instance;Every method is async Task<T>. Each takes an optional onSuccess callback and an optional onError of type Action<InvoApiException> — not Action<string>. On a non-2xx the method invokes onError and then throws, so await in a try/catch and use the callbacks only where a callback style is genuinely more convenient.
Useful members:
string ApiBase { get; } // resolved environment root
string ApiKey { get; } // from config
string GameId { get; }
string GetPlayerEmail();
string GetPlayerName();
string GetGameName();
string GetGameCurrency();
static string NewClientRequestId(); // mint ONE per user intent
static Task<Sprite> LoadSpriteAsync(string url, Image target = null);
void StartBalancePolling();
void StopBalancePolling();await api.GetPlayerBalanceAsync(
email: "player@email.com",
onSuccess: response => {
foreach (var b in response.balances)
Debug.Log($"{b.currency_name}: {b.available_balance}");
},
onError: ex => Debug.LogError(ex)
);Response model:
public class PlayerBalanceResponse {
public Player player;
public List<Balance> balances;
public Summary summary;
public string last_updated;
}
public class Balance {
public int currency_id;
public string currency_name;
public string available_balance; // SPENDABLE — display this one
public string reserved_balance; // locked by in-flight sends/transfers
public string total_balance; // available + reserved. NOT spendable.
}Display
available_balance, nevertotal_balance.total_balanceincludes funds already committed to pending sends. Showing it makes a purchase look affordable right up until the server refuses it, which is how players end up in insufficient-balance lockouts.
Balances arrive as decimal-formatted strings, not numbers. Parse with InvoFormat.TryParseAmount into a decimal.
Polling. APIManager polls the balance for config.playerEmail and writes available_balance into an optional playerbalance label. Polling is single-flight — one request in flight at a time — with exponential backoff up to maxBalancePollInterval on failure, honouring Retry-After. Control it with StartBalancePolling() / StopBalancePolling(); StopBalancePolling() lets an in-flight request finish.
await api.GetGameItemsAsync(
onSuccess: response => {
foreach (var item in response.items)
Debug.Log($"{item.item_name}: ${item.price_usd}");
},
onError: ex => Debug.LogError(ex)
);Response model (abridged):
public class GameItem {
public string item_id;
public string game_id; // owning game, as a STRING; null for template items
public string item_name;
public string display_name;
public string item_category; // featured, weekly, daily, standard
public string item_type; // bundle, currency_pack, daily_deal, special_offer
public string image_url;
public bool is_featured;
public bool is_most_popular;
public bool is_best_value;
public int? discount_percentage; // INTEGER 0-100, not a float
public float price_usd;
public float? original_price_usd;
public double currency_amount;
public double bonus_amount;
public string item_rarity;
public bool is_limited_time;
public int? time_remaining_seconds;
public string time_remaining_display; // "2d 3h 15m", "15m", "Expired"
public JObject metadata; // arbitrary JSONB — values are not all strings
}This returns all active items across all games, not just yours. Filter on
game_idif you only want your own.discount_percentageis a nullable int, not a float.
The server catalog and the item-purchase UI are separate paths.
GetGameItemsAsync()returns real-moneyGameItems destined for hosted checkout. The item purchase panels read a localInvoSDKItemCatalogScriptableObject ofInvoSDKItems priced in game currency. They do not interoperate and no mapper is provided — the twopricefields are in different units, so a naive mapper is a silent-mispricing hazard. See InvoSDK-README §8.
Spends the player's in-game currency. For real money, see Hosted Checkout.
Task<PurchaseItemResponse> PurchaseItemAsync(
string clientRequestId,
string playerEmail,
string playerName,
string itemId,
string itemName,
int quantity,
decimal unitPrice,
decimal totalPrice,
string itemCategory = null,
string itemDescription = null,
Action<PurchaseItemResponse> onSuccess = null,
Action<InvoApiException> onError = null)await api.PurchaseItemAsync(
clientRequestId: requestId, // minted once per intent, reused across retries
playerEmail: "player@email.com",
playerName: "PlayerOne",
itemId: "item_001",
itemName: "Power Boost",
quantity: 1,
unitPrice: 4.99m, // decimal, not float
totalPrice: 4.99m,
itemCategory: "consumable", // optional
itemDescription: "Restores 50 energy",
onSuccess: r => Debug.Log($"Transaction: {r.transaction_id}, balance: {r.new_balance}"),
onError: ex => Debug.LogError(ex)
);Prices are decimal and are serialized through InvoFormat.Amount, so they always go out with a period separator. The backend requires total_price == unit_price × quantity exactly, both positive and under 999999.99.
Read the post-purchase balance from the top-level new_balance. balance_info.new_balance is legacy and retained only for older callers. Check response.IsSuccess before granting the item — it is an ordinal, case-insensitive equality against "success".
Task<AvailableDestinationsResponse> GetTransferDestinationsAsync(...)
Task<InitiateTransferResponse> InitiateTransferAsync(
string clientRequestId, string sourceName, string sourceEmail, string sourcePhone,
string targetPhone, string targetEmail, string targetGameId, string amount, ...)
Task<TransactionStatusResponse> GetTransferStatusAsync(string transactionId, ...)
Task<ClaimTransferResponse> ClaimTransferAsync(
string claimCode, string targetPlayerName, string targetPlayerEmail,
string targetPlayerPhone, string targetCurrencyId, ...)Task<AvailableDestinationsResponse> GetSendDestinationsAsync(...)
Task<InitiateSendResponse> InitiateSendAsync(
string clientRequestId, string senderName, string senderEmail, string senderPhone,
string receiverPhone, string receiverEmail, string receivingGameId, string amount, ...)
Task<TransactionStatusResponse> GetSendStatusAsync(string transactionId, ...)
Task<ClaimCurrencyResponse> ClaimCurrencyAsync(
string claimCode, string receiverName, string receiverEmail, string receiverPhone,
int? receiverPlayerId = null, ...)They are two distinct backend flows with two distinct sets of routes. Pass the matching flow (
InvoApprovalFlow.SendorInvoApprovalFlow.Transfer) to the phone approval, which then settles on the right endpoint.The SMS methods (
VerifySendSmsAsync,VerifyTransferSmsAsync,ResendSendPinAsync,ResendTransferPinAsync) are[Obsolete]. Invo has retired that path.
_clientRequestId ??= APIManager.NewClientRequestId();
string senderPhone = InvoPhone.Normalize(rawSenderPhone);
string receiverPhone = InvoPhone.Normalize(rawReceiverPhone);
if (senderPhone == null || receiverPhone == null) { /* show InvoPhone.DescribeProblem(...) */ return; }
var response = await api.InitiateSendAsync(
clientRequestId: _clientRequestId,
senderName: api.GetPlayerName(),
senderEmail: api.GetPlayerEmail(),
senderPhone: senderPhone,
receiverPhone: receiverPhone,
receiverEmail: null, // optional; omitted from the body when blank
receivingGameId: targetGameId,
amount: InvoFormat.Amount(amount));
// A 2xx does not mean "approved" — check the status first.
switch (response.status)
{
case InvoStatus.Success:
// Funds are reserved. Nothing moves until the sender approves on their phone.
await ApproveOnPhone(response.transaction_id);
break;
case InvoStatus.PendingGuardianApproval: // HTTP 202
ShowGuardianWaitingScreen(response.guardian_approval);
break;
case InvoStatus.PendingConfirmation: // HTTP 202
ShowRecipientConfirmationScreen();
break;
}response.transaction_id is what every subsequent call in the flow keys off. send_details.fees_preview (or transfer_details.fees_preview) carries the authoritative fees — display those, not a local estimate.
var result = await InvoDeviceApproval.RunAsync(transactionId, InvoApprovalFlow.Send, myApprovalView);
if (result.Succeeded) ShowSent();RunAsync shows the QR (or opens the INVO page on a phone), polls, shows the first-time match-code prompt, and then makes the settle call that actually moves the money. An approved poll alone moves nothing. Outcomes, holds, retries and the ambiguous-failure rule are in InvoSDK-README → Phone approval (QR).
The receiver collects in the receiving game with the same phone approval:
var pending = await api.GetPendingActionsAsync();
var item = pending.pending.Find(p => p.kind == PendingAction.KindReceivingConfirm);
var result = await InvoDeviceApproval.RunAsync(item.transfer_id, item.ApprovalFlow); // SendReceipt / TransferReceiptUse this for a receiver who has no account in the receiving game yet (409 receiver_not_enrolled_use_claim_code):
var claimed = await api.ClaimCurrencyAsync(
claimCode: "ABC123",
receiverName: "ReceiverPlayer",
receiverEmail: "receiver@email.com",
receiverPhone: InvoPhone.Normalize(rawReceiverPhone), // SAME value used at initiate
receiverPlayerId: null);
if (claimed.status == InvoStatus.NeedsAccountSelection)
{
// HTTP 200, but NOTHING was credited. Show a picker and call again with the chosen id.
ShowAccountPicker(claimed.candidates); // AccountCandidate { player_id, email_hint }
return;
}
Debug.Log($"New balance: {claimed.new_balance}");ClaimTransferAsync requires all five of claimCode, targetPlayerName, targetPlayerEmail, targetPlayerPhone, targetCurrencyId.
Claim codes authenticate with the receiving game's key. Same-game (peer-to-peer) claims work; a cross-game claim
404s with "not intended for this game". The phone-approval collect has no such limit, because the receiving game's own build runs it. See InvoSDK-README §4.
Peer-to-peer sends. The destinations endpoint omits your own game, but initiate-send permits same-game sends. Pass receivingGameId: api.GameId directly.
InitiateSendResponse and InitiateTransferResponse both carry verification_method, either "in_app" or "sms". Approve on the phone either way. "sms" only means Invo also texted a legacy PIN, which happens for a brand-new tenant before its first approval. Do not build a PIN screen. QR is the approval's channel, not a verification method.
Sprite sprite = await APIManager.LoadSpriteAsync(imageUrl, targetImage);
string email = api.GetPlayerEmail();
string name = api.GetPlayerName();
string gameName = api.GetGameName();
string currency = api.GetGameCurrency();Any non-2xx response throws InvoApiException. The exception is also handed to onError first, if you supplied one.
try
{
await api.PurchaseItemAsync(...);
}
catch (InvoApiException ex)
{
if (ex.IsDuplicate) { /* 409 — the purchase ALREADY SUCCEEDED. Treat as success. */ }
else if (ex.IsRateLimited) { /* 429 — back off for ex.RetryAfterSeconds */ }
else if (ex.IsNetworkError) { /* 0 — never reached the server; safe to retry with the SAME id */ }
else { /* inspect ex.ErrorCode */ }
Debug.LogError(ex); // ToString() is log-safe: status, error_code, error_id, message. No body.
}| Member | Meaning |
|---|---|
StatusCode |
HTTP status. 0 means the request never reached the server (offline, DNS, TLS). |
ErrorCode |
Machine-readable code from the body, e.g. PHONE_SHARE_APPROVAL_REQUIRED. May be null. |
ErrorId |
Support correlation id. Quote it to Invo support — it maps to a server log entry. |
RetryAfterSeconds |
From the body (retry_after / retry_after_seconds) or the Retry-After header. |
Body |
Raw response body. May contain PII. |
IsDuplicate |
StatusCode == 409 |
IsRateLimited |
StatusCode == 429 |
IsNetworkError |
StatusCode == 0 |
Match on ErrorCode, never on message text. Messages are not a stable contract.
Never render ex.Body to a player. A 409 PHONE_SHARE_APPROVAL_REQUIRED body contains another user's phone number and masked email.
Replaying a
client_request_idreturns409with the original transaction. That is the deduplication working as designed — the money moved on the first attempt.Rendering it as "Purchase Failed" produces a support ticket and, once the player disputes the charge they were told did not happen, a chargeback on a completed sale. Treat
ex.IsDuplicateon a purchase as success and reconcile the balance.
A 2xx body that cannot be deserialized throws with ErrorCode == "SDK_RESPONSE_PARSE_ERROR" and the body preserved on the exception.
The API deliberately returns HTTP 200 and 202 for outcomes that are not completion. APIManager decides success from the HTTP status only and hands you the parsed body; branching on the status string is yours to do.
status |
HTTP | Meaning |
|---|---|---|
success |
200 / 201 | Completed. |
needs_account_selection |
200 | Several accounts share the receiver's phone. Nothing was credited — the session is rolled back. Show a picker from candidates and call again with the chosen player id. |
pending_guardian_approval |
202 | The player is a minor. Held for a guardian's approval. The sender approves on their phone once it clears. |
pending_confirmation |
202 | Recipient identity step-up in progress. |
resent |
200 | Legacy: a PIN resend succeeded. Only the [Obsolete] resend methods return it. |
Use the InvoStatus constants rather than string literals:
public static class InvoStatus
{
public const string Success = "success";
public const string NeedsAccountSelection = "needs_account_selection";
public const string PendingGuardianApproval = "pending_guardian_approval";
public const string PendingConfirmation = "pending_confirmation";
public const string Resent = "resent";
}You cannot detect minor status from your side, so handle 202 defensively on every initiate call, in every title, whether or not you think you have underage players.
Also watch for 403 GUARDIAN_REQUIRED — a minor with no usable guardian on file. That one is terminal, not a waiting state: do not poll, do not retry, tell the player the transfer cannot proceed.
Use InvoPhone:
string phone = InvoPhone.Normalize(rawInput); // strict E.164, or null
if (phone == null)
{
ShowFieldError(InvoPhone.DescribeProblem(rawInput));
return; // do NOT send
}Normalize returns "+" followed by 10–15 digits, or null when the input cannot be valid. IsValid and Mask (keeps the last four digits, for display and logging) are also available.
The API will not infer a country code, and neither does this SDK. Two rules matter:
- Never truncate. A previous version capped numbers at 12 digits, which turned a legitimate 13–15 digit international number into a different, valid number. The API accepts it, the claim code binds to a phone that does not exist, and you get a 2xx with no error anywhere in the system.
- Never guess a country code. A number without a leading
+is rejected locally rather than silently prefixed. - Use the same normalized value at initiate and at claim. The backend compares digits exactly and returns
403on a mismatch.
Use InvoFormat for everything that touches the wire:
string wire = InvoFormat.Amount(9.99m); // always "9.99"
bool ok = InvoFormat.TryParseAmount(userInput, out decimal amount); // accepts "9,99" toovalue.ToString("F2") uses the device locale. On a German, French, Spanish, Portuguese, Italian, Russian or Turkish device that emits "9,99", which the backend's decimal parser rejects with 400 "Invalid price format" — and that failure increments the player's failed-attempt counter toward an anti-abuse lockout. A locale bug becomes a lockout bug.
Prefer decimal over float for money throughout. PurchaseItemAsync takes decimal for exactly this reason.
Note that balances and amounts come back as strings, not numbers, and may be null. Parse them with InvoFormat.TryParseAmount; do not model them as decimal in your own DTOs.
Every write takes a caller-supplied clientRequestId. Mint it once per user intent and reuse it across every retry of that intent:
// When the user confirms — once.
_clientRequestId ??= APIManager.NewClientRequestId();
// On every attempt, including retries after a timeout.
await api.InitiateSendAsync(_clientRequestId, ...);Minting a fresh id per attempt defeats server-side deduplication: a retry after a dropped response becomes a genuinely new transaction, with the funds reserved twice.
This is why a network error is safe to retry with the same id, and why a 409 means you already succeeded — the two facts are the same mechanism seen from either side.
Do not use a client_request_id beginning with sub_; that prefix is reserved for subscription renewals and is rejected with 400 CLIENT_REQUEST_ID_RESERVED.
All paths are relative to the environment base — https://invo.network/api or https://sandbox.invo.network/sandbox/api.
| Method | HTTP | Endpoint |
|---|---|---|
GetPlayerBalanceAsync |
GET | /player-balances/player/by-email/{email} |
GetGameItemsAsync |
POST | /v1/game-items/list |
PurchaseItemAsync |
POST | /item-purchases/purchase-item |
GetSendDestinationsAsync |
POST | /currency-sends/available-destinations |
InitiateSendAsync |
POST | /currency-sends/initiate-send |
GetSendStatusAsync |
GET | /currency-sends/{transaction_id}/status |
ClaimCurrencyAsync |
POST | /currency-sends/claim-currency |
GetTransferDestinationsAsync |
POST | /transfers/available-destinations |
InitiateTransferAsync |
POST | /transfers/initiate-transfer |
GetTransferStatusAsync |
GET | /transfers/{transaction_id}/status |
ClaimTransferAsync |
POST | /transfers/claim-transfer |
Player-token routes, sent straight to Invo with Authorization: Bearer:
| Method | HTTP | Endpoint |
|---|---|---|
GetPlayerTokenAsync (mint) |
POST | /sdk/player-token (game secret, through your server) |
BeginDeviceApprovalAsync |
POST | /sdk/approvals/device/begin |
PollDeviceApprovalAsync |
POST | /sdk/approvals/device/poll |
ConfirmDeviceEnrollmentAsync |
POST | /sdk/approvals/device/confirm-enrollment |
SettleDeviceApprovalAsync |
POST | /sdk/transfers/{id}/approve, /sdk/send/{id}/approve, /sdk/send/{id}/confirm-receipt, /sdk/transfers/{id}/confirm-receipt |
GetPendingActionsAsync |
GET | /sdk/transfers/pending |
Retired, kept [Obsolete]: VerifySendSmsAsync / VerifyTransferSmsAsync (/…/verify-sms), ResendSendPinAsync / ResendTransferPinAsync (/…/resend-pin).
Not called by the plugin, but part of a complete integration — see InvoSDK-README:
| Purpose | HTTP | Endpoint |
|---|---|---|
| Mint a hosted-checkout session (server-side) | POST | /api/checkout/sessions |
| Approve a phone share after a 409 | POST | /api/wallet/phone-share/approve |
| Poll guardian approval | GET | /api/transactions/{id}/approval-status |
Under Assets/InvoSDK/Scripts/UI/. Treat them as working references, not a finished storefront.
Assets/InvoSDK/Scripts/UI/
├── Core/
│ └── InvoSDKWindowManager.cs # Panel navigation
├── Config/
│ └── InvoSDKWindowConfig.cs
├── ItemPurchase/
│ ├── ItemPurchasePanel.cs # Item grid (local catalog)
│ ├── ItemPurchaseConfirmPanel.cs # Purchase confirmation
│ └── ItemCardView.cs # Individual item card
├── Windows/
│ ├── SenderCurrencyPanel.cs # Currency send UI
│ ├── InvoSDKFeaturedPanel.cs # Real-money store + hosted checkout
│ └── FeaturedItemCard.cs
├── Featured/
│ ├── FeatureDailyItemCard.cs
│ └── FeatureWeeklyItemCard.cs
├── Components/
│ ├── InvoApprovalStep.cs # Shared phone-approval step (holds, status read-back)
│ ├── InvoStepController.cs
│ ├── InvoUIButtonBinder.cs
│ └── VerificationCodeInput.cs # Legacy; no longer used by the panels
├── TransferCurrencyPanel.cs # 4-step transfer wizard
└── TransferStep.cs
Assets/InvoSDK/Scripts/Runtime/DeviceApproval/
├── InvoDeviceApproval.cs # RunAsync: begin → QR / browser → poll → settle
├── InvoDeviceApprovalView.cs # IInvoDeviceApprovalView, InvoDeviceApprovalPanelView (uGUI), overlay
├── InvoQrCode.cs / InvoQrTexture.cs # Dependency-free QR encoder → Texture2D
└── InvoDeviceApprovalCore.cs # Pure rules (settled-status allow-list, poll pacing)
┌─────────────────────────────────────────────────────────────────┐
│ CURRENCY TRANSFER FLOW │
├─────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ │
│ │ Step 1 │───▶│ Step 2 │───▶│ Step 3 │───▶│ Step 4 │ │
│ │ │ │ │ │ │ │ │ │
│ │ Enter │ │ Review │ │ Approve │ │ Success │ │
│ │ Details │ │ Summary │ │ on phone│ │ Confirm │ │
│ │ │ │ │ │ │ │ │ │
│ │ • From │ │ • Fees │ │ • QR │ │ • Collect│ │
│ │ • To │ │ • Net │ │ (desk)│ │ in the │ │
│ │ • Amount│ │ • Total │ │ • page │ │ other │ │
│ │ │ │ │ │ (phone)│ │ game │ │
│ │ │ │ │ │ guardian│ │ │ │
│ └─────────┘ └─────────┘ └─────────┘ └─────────┘ │
│ │
│ [TransferCurrencyPanel.cs — one 4-step wizard] │
│ │
│ Step 3 has no SMS code. A 202 guardian hold comes first; │
│ the player approves once it clears. │
│ │
└─────────────────────────────────────────────────────────────────┘
⚠️ 3.0.0 needs new wiring. Add anInvoDeviceApprovalPanelView(squareRawImage+ texts + Cancel) to step 3, and assignapprovalView,retryApprovalButtonand, on the send panel's claim side,collectPendingButton/collectPendingText. Then delete the old SMS widgets. The full checklist is in InvoSDK-README → Updating your UI for 3.0.0.
Several older fields in the bundled MainScene are also unassigned, including the transfer panel's phone inputs and status label. The panels log a clear error naming the specific missing field rather than throwing a swallowed NullReferenceException.
Optional fields worth wiring on TransferCurrencyPanel: senderPhoneErrorText, receiverPhoneErrorText, amountErrorText, destinationsStatusText, feeEstimateNoticeText, guardianApprovalGroup, claimCodeText, claimCodeExpiryText.
Claim codes are a fallback now; receivers collect with their own phone approval. They are still never logged — Unity writes
Player.logto disk in release builds and Android logs to logcat, and claim codes are bearer credentials.
Panels show a local estimate (serviceFeePercent) before initiate, then replace it with fees_preview from the response. Always display the returned figures: per-tenant rate overrides mean a client-side calculation can be wrong, and the send and transfer flows even use different key names for the three fee legs (sending_game_fee/receiving_game_fee/platform_fee versus source_game_fee/target_game_fee/invo_fee). total_fee and net_amount are common to both.
Catalog artwork must be served over https, and is skipped with a warning if it exceeds 4 MB or 4096 px on a side (FeaturedItemCard.MaxImageBytes / MaxDimension).
public class InvoSDKWindowManager : MonoBehaviour {
public List<PanelEntry> panels; // { string panelId; GameObject panel; }
public void ShowPanel(string id);
public void HideAll();
}Purchases are fixed at quantity 1, though the API accepts 1–1000. To add a stepper, replace the PurchaseQuantity constant in ItemPurchaseConfirmPanel; totalPrice is already derived from it in decimal.
| Menu | Window |
|---|---|
| InvoSDK → Setup Wizard | Configuration, stray-config detection, production-key warning |
| InvoSDK → Item Catalog | Edit the local InvoSDKItemCatalog — items, prices, rarities, sprites |
| InvoSDK → Test Purchase | Fire a purchase against the configured environment |
| InvoSDK → Check Player Balance | Look up a balance without entering play mode |
These are Editor-only and never ship in a player build, so they use the SDK key directly — which is correct for a development tool.
Test Purchase handles 200 with status: "requires_action" correctly: that means 3-D Secure is required and no money has been charged. Use a test card that does not trigger 3DS (pm_card_visa) if you want a straight-through result.
This is not a warning about carelessness. It is a description of how Unity works.
Unity bundles everything under a Resources/ folder into the player. InvoSDKConfig.asset lives under Assets/InvoSDK/Resources/, so the value of sdkKey is present in resources.assets in every build you produce, on every platform. Anyone with a copy of your game can recover it with strings in about a minute. There is no obfuscation that changes this, because the process has to be able to read it at runtime.
An ivsdk_ key is a full tenant credential, not a scoped client token. Someone holding it can:
- charge cards through your checkout, in your name;
- move any player's balance — initiate sends and transfers for accounts that are not theirs;
- request a bank payout of your accumulated developer revenue;
- mint player tokens for any player in your game (
POST /api/sdk/player-token), and act as them; - reconfigure your webhooks, redirecting your settlement notifications.
There is no per-scope restriction, no IP allowlist, and no rate limit that meaningfully constrains a determined holder of the key.
Sandbox and production keys are visually identical. Nothing in the string tells you which environment it belongs to, and no tooling will catch a misplaced value for you. A production key pasted into a sandbox build is a production key in a shipped binary. Check where a key came from before you paste it, every time.
POST /api/item-purchases/purchase-item validates only that total_price == unit_price × quantity, that both are positive, and that they are under 999999.99. It does not look up the item's real price — item_id is an opaque string.
That is sound for a trusted server-side caller, which is what the endpoint documents itself as. Called from a game client, it means a modified build sets its own prices.
The default rotation keeps the old key working for a 7-day grace window. That grace does two unhelpful things at once: it keeps the leaked key alive for a week, and it is not honored uniformly across endpoints — some accept the previous key during the window while others reject it immediately, so you cannot even rely on the grace period behaving as a grace period.
For a leaked key, take the hard cutover: rotate with immediate: true and update your server in the same change.
Sandbox key in InvoSDKConfig |
Fine. This is what the field is for. |
Production key in InvoSDKConfig |
Never. Treat any key you put there as public. |
| Production integration | Requires a server-side proxy. |
This is a build-order list, not a tick-box list. Items 1–3 are work you have to do before the rest are even meaningful.
- 1. Stand up your server — see Server-Side Proxy. Nothing below is achievable without it.
- 2. Move the
ivsdk_key to that server. ClearsdkKeyin the config asset. Confirm withstringson a built player that noivsdk_string remains. - 3. Point the client at your server, not at
invo.network. Authenticate the player with your own session before proxying anything. - 4. Set prices server-side from your own catalog. Never trust a client-supplied
unit_price. - 5. Host your checkout-session endpoint and set it on every
InvoSDKFeaturedPanel— Hosted Checkout. - 6. Receive and verify webhooks. HMAC-SHA256 over
"{timestamp}.{raw_body}"inX-Invo-Signature, reject outside a 5-minute window, dedupe onX-Invo-Idempotency-Key. This is your source of truth for real-money credit, not the browser. - 7. Handle the non-completion states — 200
needs_account_selection, 202pending_guardian_approval, 202pending_confirmation, 403GUARDIAN_REQUIRED, 409PHONE_SHARE_APPROVAL_REQUIRED. - 8. Treat a 409 on purchase as success. Verify this in your own UI before launch.
- 9. Pass
client_request_idthrough your proxy unchanged so idempotency survives the extra hop. - 10. Test the whole flow in sandbox first, including the failure paths, on a comma-decimal locale device.
- 11. Confirm nothing logs claim codes, device codes, player tokens, or raw response bodies.
- 12. Only then set
useProduction = true.
The Setup Wizard's "Read: shipping keys safely" button links here.
The rule that generates every requirement below:
The SDK secret key is a server-side credential. A Unity player build cannot hold it safely.
The supported production topology:
Unity client ──(your session auth)──▶ Your server ──(X-Game-Secret-Key)──▶ Invo API
│
◀──(webhook, HMAC-signed)───────┘
Required for production. Set InvoSDKConfig.gameServerUrl. The plugin then sends every game-secret call to <gameServerUrl>/api/<Invo path> with Invo's own method and body. Your server authenticates the player, checks the path against an allow-list, adds X-Game-Secret-Key, forwards the call to Invo and returns the response unchanged. Attach your session with APIManager.GameServerRequestDecorator.
The allow-list, and the per-path checks your server must make, are in InvoSDK-README → Server-side proxy. The two that matter most:
POST /api/sdk/player-token: ignoreplayer_emailin the body and use your session's player. Otherwise anyone can mint a token for anyone.POST /api/item-purchases/purchase-item: set the price from your own catalog. Invo charges whatever it is sent.
The phone approval does not pass through your server. It runs on the player token, straight to Invo.
- The
ivsdk_key exists only on your server. Never in a build, never in a URL, never in a log, never in a repo. - Authenticate the player yourself before forwarding. Invo authenticates your game, not your player.
- Pass the client's
client_request_idthrough unchanged, so idempotency survives the extra hop. - Treat webhooks as the source of truth for anything you credit (
device_approval.approved,transfer.claim_pending,purchase.completed).
The console.invo.network/stripe-checkout/?game_secret=… pattern has been removed from the plugin, with no fallback. It was wrong twice over: it placed your master secret in a browser URL — which lands in history, referrers and proxy logs — and it no longer functions anyway, because the path redirects and the checkout page reads only ?session=.
Real-money purchases now use hosted checkout sessions, and minting one requires the secret, which means it happens on your server.
┌───────────────────────────────────────────────────────────────────────────┐
│ HOSTED CHECKOUT FLOW │
├───────────────────────────────────────────────────────────────────────────┤
│ │
│ Unity Your Server Invo API Browser │
│ │ │ │ │ │
│ │─POST {player_email, usd_amount}─▶ │ │ │
│ │ │ │ │ │
│ │ │─POST /api/checkout/sessions──▶ │ │
│ │ │ X-Game-Secret-Key │ │ │
│ │ │◀──201 {checkout_url}──│ │ │
│ │◀──{checkout_url}──│ │ │ │
│ │ │ │ │ │
│ │──────────Application.OpenURL(checkout_url)──────────────────▶│ │
│ │ │ │◀───pays──────────│ │
│ │ │◀══ WEBHOOK (signed) ══│ │ │
│ │ │ ↑ THIS is the truth │ │ │
│ │◀─your own "credit granted" signal─│ │ │ │
│ │
└───────────────────────────────────────────────────────────────────────────┘
Set checkoutSessionEndpoint to an https URL on your server — once in InvoSDK → Setup Wizard. An individual InvoSDKFeaturedPanel can override it in the Inspector ("Real-Money Checkout"); blank means "use the shared config value". checkoutTimeoutSeconds alongside it clamps to 5–120 seconds.
The plugin POSTs:
{ "player_email": "player@email.com", "usd_amount": "9.99" }and expects:
{ "checkout_url": "https://..." }The returned URL must be https — the panel rejects anything else, because a checkout URL carries a signed session token and must not travel in clear. If the endpoint is unset or is not https, the panel shows "Real-money purchases need a server endpoint — see README" and logs an explanatory error once.
Server-side, call:
POST https://invo.network/api/checkout/sessions
Headers: X-Game-Secret-Key: ivsdk_…
Body: { "player_email", "usd_amount", "rail": "platform",
"success_url", "cancel_url", "metadata": { … } }
→ 201 { "session_id", "checkout_url", "expires_at", "expires_in_seconds": 900 }
Constraints:
usd_amountmust be greater than 0 and at most 999.99metadataunder 8 KB- the returned JWT is signed, single-use, and expires in 15 minutes
- the path is
/api/checkout/sessions. Some older docs say/api/checkout-sessions— that does not exist.
rail: "platform" gives cards plus Apple Pay, Google Pay and Link with no app-store commission. That is a fee statement, not a policy clearance — Apple and Google generally require in-app purchase for digital goods. Review a WebView checkout with counsel before shipping to either store.
The checkout page posts an INVO_CHECKOUT_COMPLETE message to '*' with no origin validation, so any page that can frame the checkout could forge it. Treat it as a UX hint — dismiss a spinner, show a "thanks" screen — and nothing more.
Grant currency on the server-to-server webhook. Verify X-Invo-Signature (HMAC-SHA256 over "{timestamp}.{raw_body}"), reject anything outside a 5-minute window, and dedupe on X-Invo-Idempotency-Key.
The bundled WebViewObject (GREE unity-webview) is a demo dependency, and it cannot carry this flow:
- Unsupported on Windows, Linux and the Windows Editor. You cannot test in-app checkout in a Windows Editor session at all.
- It implements
Unity.call(msg)aswindow.location = 'unity:' + msg, which is not DOMpostMessage— soINVO_CHECKOUT_COMPLETEnever reaches Unity even in principle. - It delivers to
CallFromJS(string)with no origin, so origin validation is impossible on the Unity side.
For an in-app completion signal you need native bridges — an iOS WKScriptMessage handler and an Android addJavascriptInterface. Neither ships here. The plugin therefore opens checkout with Application.OpenURL (the system browser) and relies on your webhook.
| Platform | Checkout | Notes |
|---|---|---|
| iOS | System browser via Application.OpenURL |
Review store policy before shipping |
| Android | System browser via Application.OpenURL |
Review store policy before shipping |
| macOS / Windows / Linux | System browser | Bundled WebView is unsupported on Windows and Linux |
| Unity Editor | System browser | Bundled WebView is unsupported in the Windows Editor |
Transfers, sends and collects are approved with a passkey on Invo's hosted approval page. On iOS and Android the plugin opens that page in the system browser and learns when it is done.
Since 3.0.0 you usually do not need this section.
InvoDeviceApproval.RunAsyncruns the whole mobile flow on the client with the player token: begin withchannel: "app_browser", open the page, poll, and settle. See InvoSDK-README → Phone approval (QR). The server-held variant described below is still supported for games that wantdevice_codeand the settle call to stay on their server.
┌──────────────────────────────────────────────────────────────────────────────┐
│ HOSTED APPROVAL (MOBILE) │
├──────────────────────────────────────────────────────────────────────────────┤
│ Unity Your Server Invo API Browser │
│ │ │ │ │ │
│ │──"approve tx 123"───▶│ │ │ │
│ │ │─POST /api/sdk/approvals/device/begin──▶ │ │
│ │ │ {transaction_id, flow, channel:"app_browser"} │
│ │ │◀─{device_code, verification_uri_complete, │
│ │ │ user_code, expires_in, interval} │ │
│ │◀─handoff (NO device_code) │ │ │
│ │ │ │ │ │
│ │──InvoHostedApproval.OpenHostedApproval(verification_uri_complete)──▶│ │
│ │ │ │◀──passkey─────│ │
│ │ │──poll (device_code)─────▶│ │ │
│ │◀─poll relay {enrollment?} │ │ │
│ │ match-code prompt → decision │ │ │
│ │──decision───────────▶│─POST .../confirm-enrollment {device_code,decision}│
│ │ │ │ │ │
│ │◀════ invo-sdk-<game_id>://done (carries NOTHING) ═════════════│ │
│ │──"poll now"─────────▶│──poll → approved────────▶│ │ │
│ │ │─POST /transfers/<id>/approve {device_code}──▶ │
└──────────────────────────────────────────────────────────────────────────────┘
Server half (yours). Call POST /api/sdk/approvals/device/begin with {transaction_id, flow, "channel": "app_browser"}. The response is RFC 8628-shaped: keep device_code on the server — it is what polls and what settles — and hand the client only verification_uri_complete, user_code, expires_in and interval (HostedApprovalHandoff). Start polling on interval the moment begin returns, not when the client says the page is done: the page can finish while the game is backgrounded, and the return signal is only a hint to poll sooner. When the poll says approved, call the approve endpoint with device_code.
Client half (this plugin). InvoHostedApproval does two things and nothing else:
using InvoSDK;
public class ApprovalScreen : MonoBehaviour
{
void OnEnable()
{
InvoHostedApproval.ApprovalPageFinished += OnPageFinished;
// Cold start: the browser returned while the game was not running. The plugin's
// startup listener latched it; nobody was subscribed yet.
if (InvoHostedApproval.HasPendingReturn) OnPageFinished();
}
void OnDisable() { InvoHostedApproval.ApprovalPageFinished -= OnPageFinished; } // always unsubscribe
void Approve(HostedApprovalHandoff handoff) // handoff came from YOUR server's begin call
{
// iOS: an authentication session (a system-owned sheet; Safari on <13).
// Android: Custom Tabs when androidx.browser is in the build, else the default browser.
// Never an embedded WebView: WebAuthn does not run in one, so the passkey ceremony
// would fail before it started.
if (!InvoHostedApproval.OpenHostedApproval(handoff.verification_uri_complete))
ShowFallbackCode(handoff.user_code); // https + numeric gameId are required
}
void OnPageFinished()
{
InvoHostedApproval.ClearPendingReturn();
MyServer.PollSoonerPlease(); // wake-up only; your server was already polling
}
}ApprovalPageFinishedfires when the page navigates toinvo-sdk-<game_id>://done, when the iOS sheet closes, or when the game comes back to the foreground while a return is outstanding (a back-press out of a Custom Tab, a browser that refuses the custom scheme, another app that owns it — the game never hangs). It means "poll now" — never "approved". Approved, denied and expired all return on the same bare URL; the truth is your server's poll. The plugin reads nothing beyond the scheme, so a value smuggled onto the URL cannot tell the game anything.HasPendingReturnis the same signal as a latch, for the cold start and for any scene that subscribes late. Clear it once you have acted on it.ApprovalPageDismissed(iOS only) fires when the player swipes the sheet away. Still poll; the phone may have finished on its own. If the grant is still pending, reopen the sameverification_uri_complete— it is valid untilexpires_in.Cancel()cancels the iOS sheet and stops waiting; neither event fires for a session the game cancelled itself.- Games with their own deep-link plumbing can call
InvoHostedApproval.HandleDeepLink(url)orNotifyApprovalPageFinished()instead.
For channel: "app_browser" the phone running the page is the device that opened it, so Invo auto-confirms the enrolment: the poll goes straight from pending to approved and the game shows nothing. There is no "dismiss the sheet, answer on the game screen, reopen" dance on mobile.
The match-code prompt API below exists for games that run the same flow on a screen that is not the phone (tablet/desktop builds of a mobile title, where the poll carries enrollment: {state, device_label, match_code, requested_at}). It is not required on iOS/Android.
state |
The plugin shows | Player action |
|---|---|---|
awaiting_screen |
"Set up INVO on "<device_label>"? Code <match_code>. Say Yes only if the phone you just opened shows this code." with Yes / No |
Decision goes to your server as approve / deny |
confirmed |
"Finishing on "<device_label>"…" with one button, Stop it (wrong code) |
Stop sends deny — the server lets a deny override a pending confirm |
denied / absent |
nothing | — |
// Each poll tick, relay the poll's `enrollment` block (null when absent). The plugin
// shows the prompt, hides it, or does nothing when the block is unchanged.
InvoHostedApproval.ApplyEnrollmentState(poll.enrollment, decision =>
MyServer.ConfirmEnrollment(InvoHostedApprovalCore.DecisionWire(decision))); // "approve" | "deny"ShowEnrollmentPrompt(label, code, onDecision) and ShowEnrollmentFinishing(label, onStop) are also callable directly. The default surface is an IMGUI overlay (InvoEnrollmentPromptOverlay) that needs no prefab or canvas; it dims the screen visually but does not block uGUI / Input System touches underneath. To draw the prompt in your own UI (uGUI, UI Toolkit, TMP), implement IInvoEnrollmentPromptView and assign InvoHostedApproval.PromptView — the copy and labels are passed in, so your view only lays them out. Labels and codes come from the server and are sanitised for display (tags, control and Unicode format characters removed; the label is capped at 32 characters and quoted, codes at 64).
The return scheme is invo-sdk-<gameId>, derived from the numeric gameId in InvoSDKConfig and nothing else; the server derives the same value. InvoHostedApprovalBuildPostprocessor registers it automatically on every iOS and Android build (idempotent). If you maintain your own Info.plist / AndroidManifest.xml, these are the equivalent snippets — replace 12345 with your game id:
iOS — Info.plist (the postprocessor also links AuthenticationServices.framework; iOS 13+ for the authentication session, Safari below that):
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLName</key>
<string>invo-sdk-12345</string>
<key>CFBundleURLSchemes</key>
<array>
<string>invo-sdk-12345</string>
</array>
</dict>
</array>Android — AndroidManifest.xml, inside the launcher <activity>:
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="invo-sdk-12345" />
</intent-filter>Custom Tabs are optional. Add implementation 'androidx.browser:browser:1.8.0' to your mainTemplate.gradle dependencies to get them; without it the plugin uses the default browser, which works the same way. A custom scheme can be claimed by another app on Android; that is why the callback carries nothing.
- It never holds
device_code, never polls Invo directly, and never calls the approve endpoint. Those belong to your server. - It does not open the page in the bundled
WebViewObjector any WebView. - It does not decide anything from the return URL.
| Symptom | Cause / fix |
|---|---|
Config not found! Please run Setup Wizard. |
No InvoSDKConfig.asset under a Resources folder. It is gitignored — create your own. |
| Config changes have no effect | A second InvoSDKConfig.asset under another Resources root. Delete the stray; the wizard will point at it. |
APIManager.Instance is null |
The APIManager component is not in the scene. |
400 "Invalid price format" |
A device locale emitted a comma decimal. Use InvoFormat.Amount(...), and decimal not float. |
400 "Phone number must start with country code" |
Missing +. Use InvoPhone.Normalize(...) and surface DescribeProblem(...). |
SDK_GAME_SERVER_REQUIRED |
Production build with no gameServerUrl. See Server-Side Proxy. |
403 TENANT_NOT_MIGRATED |
Invo has not enabled SDK verification for your game yet. Ask for the sandbox switch first. |
Claim returns 403 |
The claim phone does not match the initiate phone digit-for-digit. |
Claim returns 404 cross-game |
Cross-game claims need the receiving game's key. See InvoSDK-README §4. |
| Purchase shows "failed" but the balance dropped | A 409 was rendered as an error. ex.IsDuplicate means it succeeded. |
| QR shows but scanning does nothing | The QR was cropped, tinted or stretched. Keep the RawImage square with the texture's white border intact. |
| Approved on the phone, but the game never finishes | The settle call failed or was never made. An approved poll moves nothing; read the transaction status, and see Phone approval (QR). |
| Flow stalls after initiate with no error | A 202 (pending_guardian_approval / pending_confirmation) was treated as success. Branch on status. |
| Claim "works" but credits nothing | HTTP 200 needs_account_selection. Show candidates, re-call with the chosen player id. |
401 on every call |
Missing or wrong sdkKey, or a sandbox key against production. Not a session problem — there are no sessions. |
403 "Game is not active" |
game_status is not live or testing. Check the console. |
Repeated 429 |
An anti-abuse lockout. Respect ex.RetryAfterSeconds and stop re-enabling the button. |
| Real-money purchase does nothing | checkoutSessionEndpoint is not set in the Setup Wizard (or on the panel), or is not https. See Hosted Checkout. |
| Catalog image missing | Not https, over 4 MB, or over 4096 px. |
| JSON parse errors | Verify com.unity.nuget.newtonsoft-json is installed and included in the build. |
All SDK logs are prefixed with [InvoSDK].
Unity writes
Player.logto disk in release builds, and Android logs to logcat. Never log claim codes, device codes, player tokens, or raw response bodies.InvoApiException.ToString()is log-safe by design — it prints status, error code, error id and message, and deliberately omitsBody. URLs containing a player email are redacted at/by-email/.
InvoApiException.IsNetworkError (StatusCode == 0) means the request never reached the server. That is the one failure that is always safe to retry — with the same clientRequestId.
if (Application.internetReachability == NetworkReachability.NotReachable) {
ShowOfflineMessage();
return;
}using System;
using UnityEngine;
using InvoSDK;
public class ShopController : MonoBehaviour
{
private string _clientRequestId; // one per confirmed intent
public async void OnBuyButtonClicked(InvoSDKItem item)
{
var api = APIManager.Instance;
if (api == null) { Debug.LogError("APIManager missing from scene"); return; }
// 1. Read the SPENDABLE balance.
var balanceResponse = await api.GetPlayerBalanceAsync(api.GetPlayerEmail());
if (balanceResponse.balances == null || balanceResponse.balances.Count == 0) return;
if (!InvoFormat.TryParseAmount(balanceResponse.balances[0].available_balance,
out decimal available))
return;
// 2. Price in decimal, derived exactly as the server will re-derive it.
const int quantity = 1;
decimal unitPrice = (decimal)item.priceUSD; // NB: this field holds GAME currency
decimal totalPrice = unitPrice * quantity;
if (available < totalPrice)
{
ShowInsufficientBalance();
return;
}
// 3. Mint the idempotency key ONCE, and keep it across retries.
_clientRequestId ??= APIManager.NewClientRequestId();
try
{
var response = await api.PurchaseItemAsync(
clientRequestId: _clientRequestId,
playerEmail: api.GetPlayerEmail(),
playerName: api.GetPlayerName(),
itemId: item.itemId,
itemName: item.itemName,
quantity: quantity,
unitPrice: unitPrice,
totalPrice: totalPrice,
itemCategory: item.category,
itemDescription: item.itemDescription);
// 4. A 2xx is not automatically a completed purchase.
if (!response.IsSuccess)
{
Debug.LogWarning($"[Shop] status '{response.status}' — not granting.");
ShowRetry();
return;
}
GrantItem(item.itemId);
UpdateBalanceLabel(response.new_balance); // top-level, canonical
_clientRequestId = null; // intent complete
}
catch (InvoApiException ex) when (ex.IsDuplicate)
{
// 409: this exact request already went through. It is a SUCCESS.
Debug.Log("[Shop] Duplicate request — already purchased.");
GrantItem(item.itemId);
_clientRequestId = null;
}
catch (InvoApiException ex) when (ex.IsRateLimited)
{
ShowCooldown(ex.RetryAfterSeconds ?? 60);
}
catch (InvoApiException ex)
{
// Keep _clientRequestId: retrying with it cannot double-charge.
Debug.LogError(ex); // log-safe
ShowRetry();
}
}
private void GrantItem(string itemId) { /* your game logic */ }
private void UpdateBalanceLabel(string newBalance) { }
private void ShowInsufficientBalance() { }
private void ShowCooldown(int seconds) { }
private void ShowRetry() { }
}using UnityEngine;
using InvoSDK;
public class SendController : MonoBehaviour
{
[SerializeField] private InvoDeviceApprovalPanelView approvalView; // see "Updating your UI for 3.0.0"
private string _transactionId;
private string _clientRequestId;
public async void StartSend(string rawReceiverPhone, string targetGameId, string rawAmount)
{
var api = APIManager.Instance;
// Phones still address the send. Normalize; never truncate, never guess a country code.
string senderPhone = InvoPhone.Normalize(api.GetPlayerPhone());
string receiverPhone = InvoPhone.Normalize(rawReceiverPhone);
if (senderPhone == null || receiverPhone == null)
{
ShowError(InvoPhone.DescribeProblem(receiverPhone == null ? rawReceiverPhone : api.GetPlayerPhone()));
return;
}
if (!InvoFormat.TryParseAmount(rawAmount, out decimal amount) || amount <= 0m)
{
ShowError("Enter a valid amount.");
return;
}
// One intent, one id, one transaction - even across retries.
if (_transactionId == null)
{
_clientRequestId ??= APIManager.NewClientRequestId();
try
{
var response = await api.InitiateSendAsync(
clientRequestId: _clientRequestId,
senderName: api.GetPlayerName(),
senderEmail: api.GetPlayerEmail(),
senderPhone: senderPhone,
receiverPhone: receiverPhone,
receiverEmail: null,
receivingGameId: targetGameId,
amount: InvoFormat.Amount(amount));
_transactionId = response.transaction_id;
if (response.status == InvoStatus.PendingGuardianApproval)
{
ShowInfo("Waiting for a guardian. You will approve on your phone once they have.");
return; // poll GetSendStatusAsync / GetPendingActionsAsync, then come back here
}
if (response.status != InvoStatus.Success)
{
ShowError("This send needs another step. Try again shortly.");
return;
}
}
catch (InvoApiException ex) when (ex.ErrorCode == "PHONE_SHARE_APPROVAL_REQUIRED")
{
ShowError("That number is registered to another account and needs approval."); // never show ex.Body
return;
}
catch (InvoApiException ex)
{
Debug.LogError(ex);
ShowError("The send could not be started.");
return;
}
}
// The phone approval. Calling StartSend again after a decline re-shows the QR for the
// SAME transaction - it never initiates a second one.
try
{
var result = await InvoDeviceApproval.RunAsync(_transactionId, InvoApprovalFlow.Send, approvalView);
switch (result.Outcome)
{
case InvoDeviceApprovalOutcome.Settled:
case InvoDeviceApprovalOutcome.AlreadySettled:
ShowInfo("Sent. They collect it in the other game.");
_transactionId = null;
_clientRequestId = null;
break;
case InvoDeviceApprovalOutcome.Held:
ShowInfo("Approved - one more check is running.");
break;
case InvoDeviceApprovalOutcome.Denied:
case InvoDeviceApprovalOutcome.Expired:
ShowInfo("Not approved. Tap Send to show a new code.");
break;
}
}
catch (InvoApiException ex)
{
// AMBIGUOUS: the money may have moved. Read before you say "failed".
var status = await api.GetSendStatusAsync(_transactionId);
if (status.verification_state == "approved" || status.verification_state == "completed")
ShowInfo("Sent.");
else
ShowError("We could not confirm the approval. Check your history.");
Debug.LogError(ex);
}
}
private void ShowError(string message) { }
private void ShowInfo(string message) { }
}See CHANGELOG.md for the full list. In short: SMS verification is removed in favour of the QR / system-browser phone approval; production calls that need the game secret go through gameServerUrl; the panels gained new fields that you must wire (Updating your UI for 3.0.0).
Removed
- The
/auth/login+/auth/csrf-tokenhandshake and theAuthorization: Bearer/X-CSRF-Tokenheaders. They were never read by any game API endpoint, and/auth/loginauthenticates developer-console accounts rather than players. - The
playerPasswordconfig field, which existed only to feed that handshake. - The
console.invo.network/stripe-checkout/?game_secret=…checkout flow, which put the master secret in a browser URL and no longer functions. There is deliberately no fallback to it.
Fixed
VerifySmsAsyncsplit intoVerifySendSmsAsyncandVerifyTransferSmsAsync. The ambiguous name caused transfers to verify against the sends endpoint, which always returned404.GetAvailableDestinationsAsyncsplit intoGetSendDestinationsAsyncandGetTransferDestinationsAsync.- Phone numbers are no longer truncated at 12 digits — truncation turned valid international numbers into different, valid, wrong numbers.
- Amounts are formatted with
InvoFormat.Amount(invariant culture), so comma-decimal locales no longer produce400 "Invalid price format". - Money is
decimalrather thanfloatthroughout the purchase path. - Balance polling is single-flight with exponential backoff, and displays
available_balancerather thantotal_balance. - Panels log the specific unassigned Inspector field instead of throwing a swallowed
NullReferenceException.
Added
InvoApiExceptionwithErrorCode,ErrorId,RetryAfterSeconds,IsDuplicate,IsRateLimited,IsNetworkError, and a log-safeToString().InvoStatusconstants for the non-completion 2xx states.InvoFormatandInvoPhone.ClaimTransferAsync,ResendSendPinAsync,ResendTransferPinAsync,GetSendStatusAsync,GetTransferStatusAsync.NewClientRequestId(), and a caller-suppliedclientRequestIdon every write.StartBalancePolling()/StopBalancePolling().checkoutSessionEndpointand the hosted-checkout flow.verification_methodand guardian-approval handling in the bundled panels.InvoHostedApproval: system-browser passkey approval on iOS/Android with theinvo-sdk-<gameId>://donereturn, the match-code prompt (IInvoEnrollmentPromptView+ IMGUI default), and a build postprocessor that registers the scheme. See Hosted approval on mobile.
- Initial release
| Resource | Link |
|---|---|
| Plugin specification and limitations | InvoSDK-README.md |
| Documentation | docs.invo.network |
| Developer Console | console.invo.network |
| Sandbox Console | dev.console.invo.network |
| API Reference | docs.invo.network/api |
- Technical Issues: support@invo.network — quote
InvoApiException.ErrorId, which maps to a server log entry - Account/Billing: billing@invo.network
- Partnership Inquiries: partners@invo.network
Released under the MIT License.
Built by the Invo Network Team