Player Identity
How Invo knows who a player is, when to tell Invo about a new player, and how to change a player’s phone number without breaking their sends, transfers and collects. Every call on this page is made from your server with your game secret.
What a player identity is
A player’s identity on Invo is their email address plus their phone number. The same email and phone in two games is the same person, which is what lets currency move between games and lets one passkey approve in all of them.
- • Email is how you look a player up in your game. It is the key you pass on every call.
- • Phone is how money finds them. A peer 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.
- • Invo returns an opaque
identity_idthat is safe to store. When the email or phone changes, theidentity_idchanges too.
The three moments you must tell Invo
| When | Call (your server) | Why |
|---|---|---|
| You first have the player’s email and phone (sign-up, profile completion) | POST /api/sdk/players | The player exists on Invo before any money moves, so currency sent to them can be collected in your game straight away. |
| Every session that needs Invo on the client | POST /api/sdk/player-token with player_email and player_phone | Mints the player token. A player who is not on Invo yet is created here too, so a missed registration never blocks a collect. |
| The player changes their phone in your game | POST /api/sdk/players/phone, before you save the new number | Keeps the phone Invo holds in step with yours. Without it, sends from that player are refused and money sent to the new number never reaches them. |
A player does not need to have bought currency to be on Invo. Registering, or minting a token with a phone, is all it takes for a first-time receiver to collect in your game.
Setup used by the examples
import { InvoServer, InvoError } from "@invonetwork/web-sdk/server";
const BASE = process.env.INVO_BASE_URL!; // https://invo.network | https://sandbox.invo.network/sandbox
const GAME_SECRET = process.env.INVO_GAME_SECRET!; // server-side only, never shipped to a client
const invo = new InvoServer({ gameSecret: GAME_SECRET, baseUrl: BASE });import os
from invonetwork import InvoServer, InvoError
BASE = os.environ["INVO_BASE_URL"] # https://invo.network | https://sandbox.invo.network/sandbox
GAME_SECRET = os.environ["INVO_GAME_SECRET"]
invo = InvoServer(game_secret=GAME_SECRET, base_url=BASE)Register a player
Call this as soon as you hold both the player’s email and phone. It moves no money and sends the player nothing. Calling it again for the same player is safe: it answers exists. It never changes the phone of a player who is already registered; use Change a player’s phone for that.
POST $BASE/api/sdk/players
X-Game-Secret-Key: <your game secret>
Content-Type: application/json
{ "player_email": "bo@example.com", "player_phone": "+15555550111", "player_name": "Bo" } // player_name optional
201 Created { "status": "created", "identity_id": "...", "player_token": "...", "player_token_expires_at": "..." }
200 OK { "status": "exists", "identity_id": "...", "player_token": "...", "player_token_expires_at": "..." }
already registered in your game with this phone
409 PHONE_MISMATCH already registered in your game with a DIFFERENT phone.
Nothing changed. Use POST /api/sdk/players/phone if the player
really changed their number.
409 PHONE_SHARE_APPROVAL_REQUIRED the phone already belongs to a player with another email.
The owner of that phone is asked to consent; retry once they have.
(This one is reported in "error_code", not "code".)
401 INVALID_GAME_SECRET the game secret is missing or wrong.
403 TENANT_NOT_MIGRATED the SDK endpoints are not enabled for your game yet.
422 identity_unavailable the identity could not be computed; retry shortly.
(Reported in "error"; there is no "code".)
500 PLAYER_REGISTRATION_FAILED nothing was saved; retry.
400 INVALID_INPUT | INVALID_PHONE missing field, or a number that is not a valid phoneSend phones in international format (+15555550111). The player_token in the response is a normal player token you can hand to the client, exactly like one from /api/sdk/player-token.
curl -sS -X POST "$BASE/api/sdk/players" \
-H "X-Game-Secret-Key: $GAME_SECRET" -H "Content-Type: application/json" \
-d '{"player_email":"bo@example.com","player_phone":"+15555550111","player_name":"Bo"}'try {
const reg = await invo.registerPlayer({
playerEmail: "bo@example.com",
playerPhone: "+15555550111",
playerName: "Bo", // optional
});
// reg.status is "created" or "exists". Either way the player is on Invo.
// reg.playerToken, when present, is a normal player token for the client.
} catch (err) {
if (!(err instanceof InvoError)) throw err;
if (err.isPhoneMismatch) {
// Your record and Invo's disagree. Find out which number is current;
// if the player changed it, call invo.updatePlayerPhone.
} else if (err.isPhoneShareRefused) {
// Show err.message. Retry after the phone's owner consents.
} else {
throw err;
}
}try:
reg = invo.register_player(
player_email="bo@example.com",
player_phone="+15555550111",
player_name="Bo", # optional
)
# reg.status is "created" or "exists"
# reg.player_token, when present, is a normal player token for the client
except InvoError as err:
if err.is_phone_mismatch:
pass # reconcile the number; call invo.update_player_phone if it really changed
elif err.is_phone_share_refused:
pass # show err.message; retry after the phone's owner consents
else:
raiseMint a player token, creating the player if needed
POST /api/sdk/player-token accepts an optional player_phone and player_name. Always pass the phone. With it, a player who is not on Invo yet is created and gets a token in the same call. Without it, an unknown player answers 404 player_not_found exactly as before.
POST $BASE/api/sdk/player-token
X-Game-Secret-Key: <your game secret>
{ "player_email": "bo@example.com", "player_phone": "+15555550111", "player_name": "Bo" }
200 OK { "token": "...", "expires_at": "...", "identity_id": "..." }
+ "created": true the player was not on Invo; they are now
+ "phone_mismatch": true the player exists with a DIFFERENT phone. The token is for the
phone Invo holds; the phone you passed was ignored.
404 player_not_found unknown player and no player_phone given. The body carries a "hint".
409 PHONE_SHARE_APPROVAL_REQUIRED only when creating, as on POST /api/sdk/playersTreat phone_mismatch: true as a to-do, and first decide where the difference came from. If the player changed their number in your game, call POST /api/sdk/players/phone (or create a change request when that answers 409). If they did not, the number was changed through Invo (by the player in another game, or by Invo support) and Invo’s number is the current one: never push your stored number back. Re-mint the token, store the new identity_id, and if you need the full new number, ask the player.
const tok = await invo.mintPlayerToken({
playerEmail: "bo@example.com",
playerPhone: "+15555550111",
playerName: "Bo",
});
if (tok.created) {
// the player was not on Invo; they are now
}
if (tok.phoneMismatch) {
// reconcile; see "Change a player's phone"
}
// hand tok.token to the clienttok = invo.mint_player_token(
player_email="bo@example.com",
player_phone="+15555550111",
player_name="Bo",
)
if tok.created:
pass # the player was not on Invo; they are now
if tok.phone_mismatch:
pass # reconcile; see "Change a player's phone"
# hand tok.token to the clientChange a player’s phone
Call Invo first, save locally second
When a player edits their phone in your game, call this endpoint before you write the new number to your own database, and save it only on a 200. If you save first and Invo refuses, your record and Invo’s disagree: that player’s sends are refused with “the phone number provided does not match the one on record”, and currency sent to the new number never shows up for them.
POST $BASE/api/sdk/players/phone
X-Game-Secret-Key: <your game secret>
{ "player_email": "bo@example.com", "new_phone": "+15555550199" }
200 OK { "status": "updated", "identity_rekeyed": true,
"identity_id": "...", "player_token": "...", "player_token_expires_at": "..." }
Changed. Save the number now. Use the NEW player_token from here on: tokens minted
before the change are refused.
200 OK { "status": "unchanged", "identity_rekeyed": false, "identity_id": "...", ... }
That is already the number on file. Nothing was written.
409 PHONE_CHANGE_REQUIRES_VERIFICATION the player has secured this account (a passkey, an
enrolled device, or a number they confirmed). Your game
cannot change it on their behalf. Nothing was written.
The body carries "change_request_endpoint" (the path to
call next, with the /sandbox prefix in sandbox). See below.
409 PHONE_SHARE_APPROVAL_REQUIRED the new number belongs to a player with another email
("error_code"). Retry after its owner consents.
409 PENDING_TRANSFERS_TO_OLD_PHONE sends or transfers to the player's current number are
still waiting to be collected; changing the number now
would strand them. Nothing was written. Ask the player
to collect them first (or wait until they expire), retry.
409 PENDING_TRANSFERS_TO_NEW_PHONE sends or transfers to the new number are still waiting
to be collected. Nothing was written. Retry after they
are collected or expire.
404 player_not_found no player with that email in your game
400 INVALID_INPUT | INVALID_PHONE
503 PHONE_UPDATE_UNAVAILABLE could not be checked right now; nothing was written. Retry.
500 PHONE_UPDATE_FAILED the change could not be saved; nothing was written. Retry.When the answer is PHONE_CHANGE_REQUIRES_VERIFICATION
Once a player has secured their account, a phone change has to come from them, not from a game. Do not retry and do not save the new number locally. Create a change request instead: Invo emails the player, and the player confirms the change on Invo’s page.
What the player sees after a change your game makes
- • Invo emails the player a notice that their phone was changed by your game.
- • For 7 days, codes are sent by email only. Approving with a passkey or the device approval page is unaffected.
- • Sends and transfers the player had already started keep working with the new token. Currency already on its way to the old number is not collectable in-game: the pending list and collect match the player’s current phone. If nobody collects it within the claim window, it returns to the sender automatically.
curl -sS -X POST "$BASE/api/sdk/players/phone" \
-H "X-Game-Secret-Key: $GAME_SECRET" -H "Content-Type: application/json" \
-d '{"player_email":"bo@example.com","new_phone":"+15555550199"}'async function changePhone(email: string, newPhone: string) {
let res;
try {
res = await invo.updatePlayerPhone({ playerEmail: email, newPhone });
} catch (err) {
if (err instanceof InvoError && err.isPhoneChangeRequiresVerification) {
// the player confirms it themselves; see "Player-confirmed change" below
await invo.requestPhoneChange({ playerEmail: email, newPhone });
return { ok: false, message: "We emailed you a link to confirm your new number on Invo." };
}
if (err instanceof InvoError && err.isPhoneShareRefused) {
return { ok: false, message: err.message };
}
return { ok: false, message: "Could not change the number. Please try again." };
}
// res.status is "updated" or "unchanged"
await db.players.update(email, { phone: newPhone }); // save ONLY now
if (res.identityRekeyed && res.playerToken) {
session.setPlayerToken(res.playerToken); // the old token is stale
}
return { ok: true };
}def change_phone(email, new_phone):
try:
res = invo.update_player_phone(player_email=email, new_phone=new_phone)
except InvoError as err:
if err.is_phone_change_requires_verification:
# the player confirms it themselves; see "Player-confirmed change" below
invo.request_phone_change(player_email=email, new_phone=new_phone)
return False, "We emailed you a link to confirm your new number on Invo."
if err.is_phone_share_refused:
return False, err.message
return False, "Could not change the number. Please try again."
# res.status is "updated" or "unchanged"
db.players.update(email, phone=new_phone) # save ONLY now
if res.identity_rekeyed and res.player_token:
session.set_player_token(res.player_token) # the old token is stale
return True, NonePlayer-confirmed change: change requests
When POST /api/sdk/players/phone answers 409 PHONE_CHANGE_REQUIRES_VERIFICATION, the player confirms the change themselves:
- Your server requests the change with the player’s email and the new number.
- Invo emails the player a confirmation code and a link to a confirmation page on Invo’s site.
- The player confirms on Invo’s page. If they have a passkey with Invo, they also use it there; if they have an enrolled device, they also approve on that device.
Nothing changes until the player confirms, and the confirmation never happens in your game. Your game never receives the code or the link. Do not build a code field for this and never ask the player for the code; the email tells them to enter it only on Invo’s page. No text message is sent.
1. Create the request (server)
POST $BASE/api/sdk/players/phone/change-requests
X-Game-Secret-Key: <your game secret>
{ "player_email": "bo@example.com", "new_phone": "+15555550199" }
202 Accepted
{
"change_request_id": "3f0c...",
"status": "pending_confirmation",
"expires_at": "2026-10-09T15:30:00+00:00",
"delivery": "email",
"confirmation": "email_code",
"message": "INVO emailed the player a confirmation code and a link to a page on INVO's site. ..."
}Tell the player to check their email and confirm on Invo’s page. confirmation says what they will be asked for: email_code (the code alone), email_code_and_passkey (the code and their passkey, on Invo’s page) or email_code_and_device (the code on Invo’s page, then an approval on their enrolled device). A new request from your game for the same player replaces your earlier pending one. Nothing about the player changes at this step.
| Status | Code | Meaning and what to do |
|---|---|---|
| 400 | INVALID_INPUT, INVALID_PHONE | Fix the input. |
| 401 / 403 | INVALID_GAME_SECRET, TENANT_NOT_MIGRATED | Check the key and environment; contact Invo if your game is not enabled. |
| 404 | player_not_found (in error) | Create the player first. |
| 409 | PHONE_SAME | That number is already on file. Nothing to do. |
| 409 | PHONE_IN_USE_BY_ANOTHER_PLAYER | Another player uses that number; Invo does not contact them. Ask your player for a different number. |
| 409 | PENDING_TRANSFERS_TO_NEW_PHONE | Transfers to the new number are still waiting to be collected. Retry after they are collected or expire. |
| 409 | PENDING_TRANSFERS_TO_OLD_PHONE | Transfers to the current number are still waiting. Ask the player to collect them first (or wait until they expire), then retry. |
| 409 | CHANGE_REQUEST_IN_PROGRESS | Another game’s request for this player is waiting for the player. Retry after it is confirmed or expires. |
| 409 | PHONE_CHANGE_LOCKED | Phone changes for this player are paused for a while after failed confirmations; the player has been emailed. Retry later. |
| 409 | PHONE_CHANGE_RECOVERY_COOLDOWN | The player’s account recently went through a sign-in recovery, so the number cannot be changed yet. Retry later. |
| 409 | STEP_UP_UNAVAILABLE, IDENTITY_COLLISION, AMBIGUOUS_IDENTITY | This account cannot be changed this way. The player contacts Invo support. |
| 409 | WALLET_SECURED | The player manages this number in the Invo Wallet app; they change it there or through Invo support. |
| 409 | PHONE_NOT_SET | No phone on file yet. Set it with POST /api/sdk/players/phone. |
| 429 | TOO_MANY_CHANGE_REQUESTS (in code, no retry_after) | Too many requests for this player. Try again later; never in a loop. |
| 429 | RATE_LIMITED (in error_code) | A rate limit. Wait Retry-After seconds. |
| 503 | PHONE_CHANGE_UNAVAILABLE, CONFIRMATION_EMAIL_UNAVAILABLE | Temporarily unavailable, or the email could not be sent (that request is closed). Retry shortly. |
2. Read the request (server, optional)
GET $BASE/api/sdk/players/phone/change-requests/{change_request_id}
X-Game-Secret-Key: <your game secret>
200 OK
{
"change_request_id": "3f0c...",
"status": "pending",
"confirmation": "email_code",
"delivery": "email",
"new_phone_masked": "***-***-0199",
"created_at": "...", "expires_at": "...",
"confirmed_at": null, "confirmed_via": null
}
404 CHANGE_REQUEST_NOT_FOUND unknown id, or a request another game createdstatus | Meaning |
|---|---|
pending | Waiting for the player (the create response calls this state pending_confirmation). |
confirmed | Applied. confirmed_via is hosted_page (on Invo’s page) or hosted_page_device (on Invo’s page, then approved on the player’s device). |
expired | Not confirmed before expires_at. Create a new request if the player still wants the change. |
cancelled | Replaced by a newer request from your game, or overtaken by another change of the player’s number. Create a new request if needed. |
refused | The confirmation failed. Create a new request (or retry later if creation answers PHONE_CHANGE_LOCKED). |
denied | The player declined on their device. |
Poll sparingly, or wait for the player.phone_changed webhook. A request that cannot be applied at the moment the player confirms (for example because transfers are still waiting to be collected) stays pending until it expires, so the player can try again on the same page once they clear. The same applies on Invo’s page to PHONE_CHANGE_RECOVERY_COOLDOWN: after a recent sign-in recovery the number cannot be changed yet, so the player tries later.
Confirming in your game is not possible
POST /api/sdk/players/phone/change-requests/{id}/confirm always answers 409 CONFIRM_ON_INVO_PAGE. The player confirms only on Invo’s page, from the email. The SDKs have no method that sends a code.
3. Device approval (only for email_code_and_device)
After the player enters the code on Invo’s page, Invo asks their enrolled device to approve. These two calls are made on the player’s device, with the player token, by the Invo SDK (a token minted by any of your games for this player works). A push may wake the app; the app should also check on its own, for example when it comes to the foreground.
GET $BASE/api/sdk/players/phone/change-approvals
Authorization: Bearer <player token>
200 OK
{ "approvals": [ { "change_request_id": "3f0c...", "game_name": "Your Game",
"old_phone_masked": "***-***-0111", "new_phone_masked": "***-***-0199",
"expires_at": "..." } ] }
POST $BASE/api/sdk/players/phone/change-requests/{change_request_id}/approve
Authorization: Bearer <player token>
{ "device_fingerprint": "...",
"device_signal": { "transfer_id": "<change_request_id>", "nonce": "...",
"timestamp": 1760000000, "signature": "..." },
"decision": "approve" } // or "deny"; default "approve"
The device signs, with its enrolled key, exactly as for a transfer approval:
approve: phone-change|<change_request_id>|<nonce>|<timestamp>
decline: phone-change-deny|<change_request_id>|<nonce>|<timestamp>Show each approval to the player (“Approve changing your number to ***-***-0199?”) before signing.
| Status | Body / code | Meaning |
|---|---|---|
| 200 | {"status": "confirmed", "identity_id", "previous_identity_id", "players_updated", "verified_phone": false, "player_token", "player_token_expires_at"} | Applied. Use the returned player_token; older tokens are stale. |
| 200 | {"status": "denied"} | Declined; nothing changed. |
| 400 | INVALID_INPUT, no_registered_device_key | Missing fields, or this device has no key enrolled for the player. |
| 401 | (signal code, in error) | The signature did not verify. Nothing changed. |
| 403 | DEVICE_REQUIRES_ELEVATION | This device cannot approve sensitive changes yet. |
| 404 | CHANGE_REQUEST_NOT_FOUND | Unknown id, or a token for another player. |
| 409 | CHANGE_REQUEST_NOT_READY | The player has not entered the code on Invo’s page yet. |
| 409 | CHANGE_REQUEST_NOT_PENDING (with status) | Already confirmed, expired, cancelled, refused or declined. |
| 409 | DEVICE_ENROLLED_AFTER_REQUEST | This device (or its key) was set up after the change was requested, so it cannot approve it. The player approves on their earlier device, or your game creates a new request. |
| 409 | PHONE_CHANGE_RECOVERY_COOLDOWN | The player’s account recently went through a sign-in recovery, so approving is paused for now (declining still works). Try later. |
| 409 | a creation code above | The change cannot be applied now (for example PENDING_TRANSFERS_TO_OLD_PHONE). |
4. After the change: player.phone_changed
After any change of a player’s number through Invo (confirmed by the player, or made by Invo support), every game the player is in receives:
{
"event_type": "player.phone_changed",
"data": {
"player_email": "bo@example.com",
"previous_phone_masked": "***-***-0111",
"new_phone_masked": "***-***-0199",
"new_phone_included": true,
"new_phone": "+15555550199", // only for the game that requested the change
"changed_at": "2026-10-09T15:12:00+00:00",
"source": "player", // "player" or "support"
"identity_id": "<new identity_id>",
"previous_identity_id": "<old identity_id>"
}
}- •
new_phoneis present only for the game that requested the change (new_phone_included: true): you sent that number. Every other game, and every game whensourceissupport, getsnew_phone_maskedandnew_phone_included: false. The previous number is always masked. - • In every case, store the new
identity_idand re-mint the player’s token (older tokens are stale). - • Never push the number you have stored back to Invo with
POST /api/sdk/players/phone. Invo already holds the right number; yours is the old one, and pushing it would try to undo the player’s own change. If your game needs the full new number and did not receive it, ask the player for it in your game. - • The player is emailed that the number changed. The new number is not yet verified (
verified_phone: false). After a player-confirmed change,POST /api/sdk/players/phoneanswers409 PHONE_CHANGE_REQUIRES_VERIFICATIONfor that player: later changes go through a change request.
// server: the player asked to change their number and updatePlayerPhone answered 409
const req = await invo.requestPhoneChange({ playerEmail: "bo@example.com", newPhone: "+15555550199" });
// req.status === "pending_confirmation"; tell the player to check their email
// later, optionally:
const state = await invo.getPhoneChange(req.changeRequestId);
// state.status: "pending" | "confirmed" | "expired" | "cancelled" | "refused" | "denied"req = invo.request_phone_change(player_email="bo@example.com", new_phone="+15555550199")
# req.status == "pending_confirmation"; tell the player to check their email
state = invo.get_phone_change(req.change_request_id)
# state.status: "pending" | "confirmed" | "expired" | "cancelled" | "refused" | "denied"When the identity changes, so does the token
A player token belongs to one identity, and the identity is the email plus the phone. When the phone changes, every token minted before the change stops working for that player. That happens in two places, and they fail differently:
- • Your phone change (replacing a number).
POST /api/sdk/players/phonereturns a freshplayer_token. Switch to it straight away. An old token is refused with a plain403such asnot_send_senderornot_transfer_sender, with noPLAYER_TOKEN_STALEcode to tell you why. - • A first send or transfer that adds a phone. A player created without a phone (for example by a card checkout) gets one the first time they send. The initiate response then carries
identity_rekeyed: trueand a freshplayer_tokenwithplayer_token_expires_at.
Whenever a response carries identity_rekeyed: true, hand its player_token to the client and drop the old one. If you miss it after a phone was added to a phone-less player, the next approval is refused with 403 and code: "PLAYER_TOKEN_STALE": mint a new token with POST /api/sdk/player-token and retry. Nothing was moved by the refused call. PLAYER_TOKEN_STALE is only ever returned for that case; after a number is replaced, the refusal is the plain not_*_sender one above.
try {
return await approve(playerToken);
} catch (err) {
if (err instanceof InvoError && err.isPlayerTokenStale) {
const fresh = await invo.mintPlayerToken({ playerEmail, playerPhone });
return await approve(fresh.token); // safe: the refused call moved nothing
}
throw err;
}try:
result = approve(player_token)
except InvoError as err:
if not err.is_player_token_stale:
raise
fresh = invo.mint_player_token(player_email=player_email, player_phone=player_phone)
result = approve(fresh.token) # safe: the refused call moved nothingCommon mistakes
- Minting a token with only the email. A first-time receiver gets a 404 and cannot collect. Always pass
player_phone. - Saving a new phone locally and never telling Invo. The player’s next send is refused for a phone mismatch, and support has to fix it by hand.
- Saving the new phone before Invo says 200. On a 409 your record is now wrong. Save after the 200.
- Keeping the old player token after a change. Use the
player_tokenthe change returned, or mint a new one. - Calling the phone change from the client. It takes your game secret. Server only.
- Collecting the emailed code in your game. A player-confirmed change is confirmed only on Invo’s page. Never ask for the code.
- Pushing a stored number back after
phone_mismatchorplayer.phone_changed. If your player did not change it in your game, Invo’s number is current. Ask the player instead.