FAQ / Players and Balances
Players and Balances: Questions and Answers
Registering players, player identity and phone numbers, balance reads, new_balance on writes, and wallet identity lookup. 73 questions, grouped by the documentation page each one is about. Where a response or webhook carries a figure, read it there rather than recomputing it.
Player Management
Read the Player Management page
When should my server call POST /api/sdk/players instead of waiting for a first purchase?
As soon as you hold both the player's email and phone, for example at sign-up or profile completion. That puts the player on INVO before any money moves, so currency sent to them can be collected in your game right away; a player who has never bought anything can still receive.
What does the by-email balance lookup return, and why are amounts strings?
GET /api/player-balances/player/by-email/{email} returns player (player_id, player_name, player_email, date_joined), balances (currency_id, currency_name, available_balance, reserved_balance, total_balance), summary (total_value, currency_count, has_funds) and last_updated. Amounts are decimal strings so they keep exact precision; parse them with a decimal type, not a float.
Do I have to URL-encode the email in the path, including addresses with "+"?
Yes, encode it on your side with encodeURIComponent (or your language's equivalent); the API does not encode for you. An encoded + (%2B) is decoded back to +, and the API lower-cases and trims the address before looking it up.
Invo caches individual lookups for 5 minutes. How do I avoid showing a stale balance right after a purchase?
INVO clears that player's cached balance itself whenever its own flows change it (currency purchases, item purchases, sends, transfers and admin adjustments), so a read after the write returns the new value. For the fastest UI, use new_balance from the write response or the webhook, and re-read the by-email endpoint as the source of truth.
What is POST /api/player-balances/webhook/balance-updated, and when am I expected to call it?
It is a cache-invalidation call: send X-Game-Secret-Key and { "game_id", "player_id", "player_email" } for one of your players and INVO drops that player's cached balance. You normally never need it, because INVO invalidates the cache on every balance change it makes; use it only if you see a stale read. A game_id that is not yours returns 403, and missing fields return 400.
What is the maximum batch size for POST /api/player-balances/batch, and how is it chunked server side?
Up to 100,000 players per request (emails and ids combined); a larger request returns 400 with requested and maximum. The server queries in chunks of 5,000 players.
Can I mix player_emails and player_ids in one batch, and how is a missing player shown?
Yes, both arrays can be sent together and count toward the same limit; email results come first, then id results. A missing player appears in results as { "player": { "player_email": ... } or { "player_id": ... }, "error": "Player not found", "balances": [] }, and summary reports total_requested, total_found and total_not_found.
At what player count should I switch from individual lookups to batch?
Once you need balances for roughly 100 or more players at once, such as a leaderboard or a guild screen, use batch. Batch rows include available_balance and total_balance but not reserved_balance, and batch is limited to 50 requests per minute per title, so keep single lookups for per-player screens.
How do I resume GET /api/player-balances/stream-all with last_player_id after a dropped connection?
Players are streamed in ascending player_id order and the call returns players with an id greater than last_player_id. Record the highest player_id you fully received and call again with last_player_id set to it; a stream that completes ends with total_streamed and last_player_id, but a dropped one has no trailer, so rely on your own record.
What are the defaults and limits for chunk_size and include_zero_balances on stream-all?
chunk_size defaults to 10,000 and is capped at 50,000; it only controls how many rows the server reads per query, because one call streams every player. include_zero_balances defaults to false, which skips zero balances and players with nothing left.
Why does stream-all return available and total instead of available_balance and total_balance?
Stream-all uses a compact row format to keep very large exports small: each balance has currency_id, currency_name, available and total (no reserved value). The values mean the same as available_balance and total_balance on the other endpoints.
How should I schedule nightly reconciliation for a very large title with a limit of 5 stream-all calls per hour?
Run one stream-all call per night: a single call walks every player in your title, so you only need the extra calls to resume after a drop. Use a large chunk_size (up to 50,000), parse the response incrementally (it is one streamed JSON document, not one object per line), and checkpoint the last player_id you processed. The limit is 5 per hour per title and 2 per hour per calling IP.
What does GET /api/player-balances/game-summary return, and how stale can it be?
players (total_players, new_players_24h), economy per currency (currency_name, funded_players, total_balance, average_balance), activity.transactions_24h and generated_at. It is cached for 10 minutes, so figures can be up to 10 minutes old; check generated_at.
How does the circuit breaker behave, and how do I detect it from a 503?
After repeated database failures the balance service stops querying for about 30 seconds and answers 503 with "error": "service_unavailable" and a message, then tries again. Treat that 503 as temporary: wait at least 30 seconds before retrying and keep showing your last known balance meanwhile.
What retry policy does the page recommend?
Up to three attempts: on a 503 service_unavailable wait about 30 seconds, on a 429 back off exponentially and honor Retry-After, and do not retry 400, 401 or 404. Retry a 500 with backoff.
Why must I never call the balance API from client code, and how should a game client show a balance instead?
The balance API is authenticated by your game secret, and a key shipped in a client can be extracted and used to read and act on every player in your title. Have the client ask your own server, which calls INVO, or use the Web SDK's InvoClient.getBalance() with a short-lived player token.
Player Identity
What defines a player identity on Invo, and why is the same email and phone across games treated as one person?
A player identity is the email address plus the phone number. The same pair in two games is the same person, which is what lets currency move between games and lets one passkey approve in all of them.
Why is email the lookup key while phone is where money is addressed?
Your game knows its players by email, so every call takes player_email. A send is addressed to a phone number, and it appears in your player's collect list only when the phone INVO holds for that player is the number the sender typed, which is why the two must stay in step.
When does identity_id change, and what should I do with my stored copy?
It changes whenever the player's email or phone changes, for example after a phone change. Store the new value from the response or the player.phone_changed webhook (which also gives previous_identity_id) and re-mint the player's token.
What are the three moments my server must call Invo?
When you first hold the player's email and phone, call POST /api/sdk/players. For every session that needs INVO on the client, call POST /api/sdk/player-token with player_email and player_phone. When the player changes their phone in your game, call POST /api/sdk/players/phone before saving the new number.
Is POST /api/sdk/players safe to call on every login?
Yes. It answers 201 with status: "created" the first time and 200 with status: "exists" after that, moves no money, sends the player nothing and never changes the phone of an existing player. Both answers include identity_id and a usable player_token.
Why did POST /api/sdk/players return 409 PHONE_MISMATCH, and why doesn't it update the phone for me?
The player is already registered in your game with a different phone. Registration never changes a phone, because a silent change would re-key the identity and could redirect money; if the player really changed their number, call POST /api/sdk/players/phone.
What does 409 PHONE_SHARE_APPROVAL_REQUIRED mean, and how does the existing phone owner consent?
The phone already belongs to a player with a different email, so its owner must agree before another email shares it (the code is in error_code). The 409 starts the consent: the phone's owner receives a one-time code, your backend submits it with POST /api/wallet/phone-share/approve (SDK: phoneShareApprove), and you then retry the original request unchanged.
What do 403 TENANT_NOT_MIGRATED and 422 identity_unavailable mean?
403 TENANT_NOT_MIGRATED means the SDK endpoints are not enabled for your game yet; ask INVO to enable them (sandbox first). 422 identity_unavailable means INVO could not compute an identity for that player record; retry shortly, and contact support if it persists.
What phone format is required, and what error comes back for a bad number?
International E.164 format with a leading + and country code, for example +15555550111. A missing phone returns 400 INVALID_INPUT, and an unparseable or invalid number returns 400 INVALID_PHONE with the reason in error.
Why does POST /api/sdk/player-token return 404 player_not_found for a first-time receiver?
You minted with only player_email for a player not yet in your game. Pass player_phone (and optionally player_name) and the player is created in the same call; the 404 body carries a hint saying so.
What do created: true and phone_mismatch: true mean on the player-token response?
created: true means the player was not on INVO and has just been created. phone_mismatch: true means the player exists with a different phone than you passed; the token is for the phone INVO holds and your phone was ignored, so reconcile before doing anything else.
After a phone_mismatch or a player.phone_changed webhook, why must I never push my stored number back?
If your player did not change the number in your game, it was changed through INVO (by the player confirming a change, or by INVO support), so INVO's number is current and yours is the old one. Pushing yours back would try to undo the player's own change. Store the new identity_id, re-mint the token, and ask the player for the full number if you need it.
Why must I call POST /api/sdk/players/phone before saving the new number locally?
If you save first and INVO refuses (for example 409 PHONE_CHANGE_REQUIRES_VERIFICATION or pending transfers), your record and INVO's disagree: the player's sends are refused for a phone mismatch and currency sent to the new number never reaches them. Save only after a 200 with status: "updated" or "unchanged".
When does a phone change return 409 PHONE_CHANGE_REQUIRES_VERIFICATION, and what should my server do next?
When the player has secured their account (a passkey, an enrolled device, or a number they confirmed), so a game cannot change it on their behalf; nothing is written. Do not retry or save locally: create a change request with POST /api/sdk/players/phone/change-requests (the path is also in change_request_endpoint), and the player confirms on INVO's page.
Why would a phone change be blocked with PENDING_TRANSFERS_TO_OLD_PHONE or PENDING_TRANSFERS_TO_NEW_PHONE?
Sends or transfers addressed to the current number (old) or to the new number are still waiting to be collected, and changing the number now would strand them. Nothing is written; retry after they are collected or expire (and for the old phone, ask the player to collect first).
How do phone change requests work, and what do email_code, email_code_and_passkey and email_code_and_device mean?
Your server calls POST /api/sdk/players/phone/change-requests with player_email and new_phone and gets 202 with change_request_id, status: "pending_confirmation" and confirmation. INVO emails the player a code and a link to its own page, where the player confirms. email_code needs only the code, email_code_and_passkey needs the code plus their INVO passkey, and email_code_and_device needs the code and then an approval on their enrolled device.
Why does the confirm endpoint always return 409 CONFIRM_ON_INVO_PAGE?
A player-confirmed change is accepted only on INVO's hosted page, never in a game, so the code and link never pass through your title. Do not build a code field; tell the player to check their email.
What do change request statuses mean?
pending: waiting for the player (the create response calls it pending_confirmation); confirmed: applied, with confirmed_via hosted_page or hosted_page_device; expired: not confirmed by expires_at; cancelled: replaced by a newer request or overtaken by another change; refused: the confirmation failed; denied: the player declined on their device. For anything but confirmed, create a new request if the player still wants the change.
What does the player.phone_changed webhook contain, and why is new_phone sent only to the requesting game?
data carries player_email, previous_phone_masked, new_phone_masked, new_phone_included, changed_at, source (player or support), identity_id and previous_identity_id, plus new_phone for the game that requested the change. Only that game already knows the number because it sent it; every other game gets the masked value, so a phone number is never handed to a title that did not have it.
Why do old tokens get 403 not_send_sender after a phone replacement but PLAYER_TOKEN_STALE when a first phone is added?
Replacing a phone re-keys the identity, so an old token names an identity that is no longer the sender and gets the ordinary 403 not_send_sender or not_transfer_sender; use the fresh player_token returned by the phone change. PLAYER_TOKEN_STALE is returned only when a phone-less player gained a phone on their first send or transfer (that response carries identity_rekeyed: true and a new token); mint a new token and retry, since the refused call moved nothing.
Player Balance API
Read the Player Balance API page
What is the difference between available_balance, reserved_balance and total_balance, and which one gates a purchase?
available_balance can be spent now, reserved_balance is held for pending operations, and total_balance is the two added together. Spending and sending are checked against available_balance only.
Which pending operations put funds into reserved_balance?
Sends and transfers the player has initiated: the amount is reserved at initiate and stays reserved until it is approved and collected, refunded or expires.
How should I parse string amounts to avoid floating-point errors?
Parse them with a decimal type: Decimal in Python or C#, a decimal library such as decimal.js in JavaScript, never parseFloat. Compare and add in that type (or in integer minor units).
Should I look up players by player_id or by email?
By email. The balance API has no lookup by player_id: use GET /api/player-balances/player/by-email/{email}, or the batch endpoint, which also accepts INVO player_ids.
What is summary.total_value_usd, and should I show it to players?
There is no total_value_usd field. The by-email response has summary.total_value, which is available plus reserved in your game currency, not USD; show the player available_balance instead. To convert coins to USD, use 10 coins = $1 unless your game has its own rate.
What are POST /api/player-balances/credit and /debit, and who may call them?
Those endpoints do not exist. Balances are credited only through purchase and claim flows (hosted checkout, POST /api/currency-purchases/confirm-payment, claims of sends and transfers) and debited through POST /api/item-purchases/purchase-item and initiating a send or transfer.
When should I credit through confirm-payment instead of a generic credit endpoint?
There is no generic credit endpoint. Call POST /api/currency-purchases/confirm-payment with payment_intent_id (and order_id) only after a direct card purchase returned status: "requires_action" and the player completed 3-D Secure; it credits the order and returns new_balance. Hosted checkout does this for you.
Do new players receive starting currency automatically, and when?
No. A player's balance starts at zero and grows only through purchases or collected sends and transfers; if you want a sign-up bonus, sell or grant it through those flows from your own funds.
How do I subscribe to balance updates over the WebSocket, and should it be opened from my server only?
There is no balance WebSocket. Get changes from webhooks (purchase.completed, item.purchased and transfer.received carry new_balance) and re-read the by-email endpoint on your server.
Which webhook event carries balance changes?
There is no balance event. The post-change balance arrives as new_balance on purchase.completed, item.purchased and transfer.received, and on the response of each write.
How long should I cache balances?
30 to 60 seconds on your side is a good default, and refresh immediately after any write or webhook for that player. INVO clears its own cache whenever its flows change a balance, so a fresh read is current.
Get Player Balance
Read the Get Player Balance page
Which header authenticates the balance lookup, and why not Authorization: Bearer?
X-Game-Secret-Key: <your game secret>. It is a server-to-server game credential, not a user session; Authorization: Bearer is used only for short-lived player tokens on the /api/sdk/* player routes. A missing header returns 401.
Who encodes the email in the path, the API or my client?
Your client. URL-encode the email (for example encodeURIComponent) when you build the path; the API decodes it and lower-cases it.
What does the 404 body look like, and how do I tell a new player from a typo?
404 { "message": "Player not found", "player_email": "<the address you sent>" }. The API cannot tell a new player from a typo, since both mean no player with that email in your game. Register players with POST /api/sdk/players when they sign up, so a 404 for a known player points to a typo or a different address.
Is player_id in the by-id lookup Invo's ID or mine?
There is no by-id lookup on the balance API. The player_id in INVO's responses (and in the batch player_ids field) is INVO's own per-game id, not your user id.
Which value should I display in the HUD: available, reserved or total?
available_balance, because it is what the player can spend now. Show reserved_balance separately as pending if you want to explain where the rest is.
Is it safe to ship the X-Game-Secret-Key in a Unity client that polls the balance every 30 seconds?
No. Anyone can extract the key from a client build and use it to act as your title for every player. Poll your own server (the Unity SDK sends these calls to your gameServerUrl in production) or use a player-token read.
Which balance rate limit applies to this endpoint?
2,000 requests per minute per title and 100 per minute per calling IP. If all your lookups come from one server IP, the 100-per-minute limit is the one you reach first, so cache and use batch for many players.
How should I handle 400, 401, 404, 429 and 500?
400: fix the email (it must contain @). 401: check the key and the environment. 404: the player is not in your game (show zero or register them). 429: wait Retry-After seconds. 500 or 503: retry with backoff (at least 30 seconds for a 503 service_unavailable).
Write-Response Contract: new_balance
Read the Write-Response Contract: new_balance page
Where is the post-write balance on every credit or debit endpoint?
At the top level of the response as new_balance.
Which endpoints are covered by the new_balance contract?
/api/currency-purchases/purchase-currency, /api/item-purchases/purchase-item, /api/transfers/initiate-transfer, /api/transfers/claim-transfer, /api/currency-sends/initiate-send and /api/currency-sends/claim-currency, plus the deprecated verify-sms routes. confirm-payment, the game-platform rail finalize, and the hosted checkout status read also return it.
On initiate-transfer and initiate-send, does new_balance reflect the sender's available balance after the reservation?
Yes. It is the sender's available_balance after the amount was moved into reserved.
What unit and type is new_balance?
A decimal string in the game's own currency (not USD); currency_name in the same response says which currency.
When is new_balance null, and why null instead of missing?
When nothing has been credited yet: a card purchase answering requires_action (3-D Secure), a pending_payment answer that sends the player to a payment page, and a game-platform rail init. It is also null on a confirm-payment replay of an already-credited order (already_processed: true). The key is always present so you can branch on status without checking whether it exists.
Why should I still treat the by-email lookup as the source of truth, and how does the dual-read pattern work?
The write response is a fast hint, but a timeout, retry or replay can leave you without it or with null. Update your UI from new_balance right away, then re-read GET /api/player-balances/player/by-email/{email} in the background and take its available_balance as final.
Which legacy fields mirror new_balance, and will they be removed?
purchase_details.new_balance (currency purchase), balance_info.new_balance (item purchase), transfer_details.new_balance (transfers) and send_details.new_balance (sends) carry the same value. They are kept for existing integrations and are not being removed, but new code should read the top-level field.
Do confirm-payment, the game-platform finalize and checkout session status follow the same contract?
Yes, all three return a top-level new_balance. On confirm-payment it is null on an already-processed replay, and on the hosted checkout status read it is the player's current balance (null if the session has lapsed or the balance cannot be read), not a figure tied to that purchase.
Wallet Identity Lookup
Read the Wallet Identity Lookup page
What does GET /api/wallet/identities return, and when should I call it?
For one of your players it returns status, player_email, player_phone, wallet_user_id, primary_email, primary_phone, is_minor and emails (each email, primary, verified_at). Only emails that also exist as players in your own game are listed, and primary_email/primary_phone are your player's own values. Call it from your server after login to show "your other accounts in this game" as switchable accounts.
Why can one wallet hold several emails under one verified phone?
One person can legitimately use several emails under one phone (separate gaming handles, or a parent and child on a shared device), and extra emails are added through the phone owner's phone-share consent.
Can I query by player_email, player_phone or both, and what happens when both are given but don't match?
Either or both. With both, they must match the same player in your game; if they don't, you get the same 404 "Player not found in this game.". With neither you get 400.
Why must player_phone be E.164 with "+" encoded as %2B?
The phone is matched in canonical international form, and in a query string an unencoded + is decoded as a space, which turns the number into an invalid one. Send player_phone=%2B15551234567; an invalid phone returns 400.
Why must this endpoint never be called from a browser or mobile app?
It requires X-Game-Secret-Key, which is a server-only credential, and it returns personal data (emails and phone). Call it from your backend and pass only what the UI needs to the client.
What does a legacy player created before the 2026-05-04 wallet rollout return?
A 200 with wallet_user_id: null, is_minor: false, primary_email/primary_phone set to the player's own values, a single emails entry (primary: true, verified_at: null) and message: "Player has no wallet binding.".
How should I display an email with verified_at null?
As not yet verified (for example a "pending" label), distinct from verified emails. Show the primary: true email first.
Why does the endpoint return the same 404 for players in other titles and players who don't exist?
So it cannot be used to find out whether an email or phone exists elsewhere on the network. The lookup is limited to your game's players, and anything outside it returns the same 404.
Why is guardian_wallet_user_id withheld for minors, and what can I do with is_minor?
Guardian relationships are sensitive, so the endpoint only exposes is_minor. Use it to adjust your own experience, for example by expecting guardian holds on that player's sends and transfers; guardian approvals happen through INVO.
How does the 60 requests per minute per IP limit work when every request comes from my backend?
The limit is counted per calling IP, so all lookups from one backend IP share 60 per minute. Fetch once per player session and cache the result instead of calling per page view, and on a 429 back off before retrying.
Back to the FAQ hub. Something not answered here? Email partners@invo.network.