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. The token works, but sends to the player’s new number will not appear in their collect list, and their own sends from the new number are refused. If your record is the current one, call POST /api/sdk/players/phone.
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
Invo app, or a verified phone). Your game cannot change
it on their behalf. Nothing was written. See below.
409 PHONE_SHARE_APPROVAL_REQUIRED the new number belongs to a player with another email
("error_code"). Retry after its owner consents.
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. A self-serve confirmation for the player is coming. Until then, direct the player to Invo support to change the number, and keep the old number in your own record until support confirms. Do not save the new number locally on a 409.
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) {
return { ok: false, message: "Please contact Invo support to change this number." };
}
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:
return False, "Please contact Invo support to change this number."
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, NoneWhen 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.