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_id that is safe to store. When the email or phone changes, the identity_id changes too.

The three moments you must tell Invo

WhenCall (your server)Why
You first have the player’s email and phone (sign-up, profile completion)POST /api/sdk/playersThe 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 clientPOST /api/sdk/player-token with player_email and player_phoneMints 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 gamePOST /api/sdk/players/phone, before you save the new numberKeeps 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

Node 18+, @invonetwork/web-sdk 3.21.0+ (server)
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 });
Python 3.9+, invonetwork 3.19.0+ (server)
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 phone

Send 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
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"}'
Node, @invonetwork/web-sdk 3.21.0+
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;
  }
}
Python, invonetwork 3.19.0+
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:
        raise

Mint 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/players

Treat 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.

Node, @invonetwork/web-sdk 3.21.0+
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 client
Python, invonetwork 3.19.0+
tok = 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 client

Change 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
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"}'
Node, @invonetwork/web-sdk 3.21.0+
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 };
}
Python, invonetwork 3.19.0+
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, None

Player-confirmed change: change requests

When POST /api/sdk/players/phone answers 409 PHONE_CHANGE_REQUIRES_VERIFICATION, the player confirms the change themselves:

  1. Your server requests the change with the player’s email and the new number.
  2. Invo emails the player a confirmation code and a link to a confirmation page on Invo’s site.
  3. 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.

StatusCodeMeaning and what to do
400INVALID_INPUT, INVALID_PHONEFix the input.
401 / 403INVALID_GAME_SECRET, TENANT_NOT_MIGRATEDCheck the key and environment; contact Invo if your game is not enabled.
404player_not_found (in error)Create the player first.
409PHONE_SAMEThat number is already on file. Nothing to do.
409PHONE_IN_USE_BY_ANOTHER_PLAYERAnother player uses that number; Invo does not contact them. Ask your player for a different number.
409PENDING_TRANSFERS_TO_NEW_PHONETransfers to the new number are still waiting to be collected. Retry after they are collected or expire.
409PENDING_TRANSFERS_TO_OLD_PHONETransfers to the current number are still waiting. Ask the player to collect them first (or wait until they expire), then retry.
409CHANGE_REQUEST_IN_PROGRESSAnother game’s request for this player is waiting for the player. Retry after it is confirmed or expires.
409PHONE_CHANGE_LOCKEDPhone changes for this player are paused for a while after failed confirmations; the player has been emailed. Retry later.
409PHONE_CHANGE_RECOVERY_COOLDOWNThe player’s account recently went through a sign-in recovery, so the number cannot be changed yet. Retry later.
409STEP_UP_UNAVAILABLE, IDENTITY_COLLISION, AMBIGUOUS_IDENTITYThis account cannot be changed this way. The player contacts Invo support.
409WALLET_SECUREDThe player manages this number in the Invo Wallet app; they change it there or through Invo support.
409PHONE_NOT_SETNo phone on file yet. Set it with POST /api/sdk/players/phone.
429TOO_MANY_CHANGE_REQUESTS (in code, no retry_after)Too many requests for this player. Try again later; never in a loop.
429RATE_LIMITED (in error_code)A rate limit. Wait Retry-After seconds.
503PHONE_CHANGE_UNAVAILABLE, CONFIRMATION_EMAIL_UNAVAILABLETemporarily 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 created
statusMeaning
pendingWaiting for the player (the create response calls this state pending_confirmation).
confirmedApplied. confirmed_via is hosted_page (on Invo’s page) or hosted_page_device (on Invo’s page, then approved on the player’s device).
expiredNot confirmed before expires_at. Create a new request if the player still wants the change.
cancelledReplaced by a newer request from your game, or overtaken by another change of the player’s number. Create a new request if needed.
refusedThe confirmation failed. Create a new request (or retry later if creation answers PHONE_CHANGE_LOCKED).
deniedThe 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.

StatusBody / codeMeaning
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.
400INVALID_INPUT, no_registered_device_keyMissing fields, or this device has no key enrolled for the player.
401(signal code, in error)The signature did not verify. Nothing changed.
403DEVICE_REQUIRES_ELEVATIONThis device cannot approve sensitive changes yet.
404CHANGE_REQUEST_NOT_FOUNDUnknown id, or a token for another player.
409CHANGE_REQUEST_NOT_READYThe player has not entered the code on Invo’s page yet.
409CHANGE_REQUEST_NOT_PENDING (with status)Already confirmed, expired, cancelled, refused or declined.
409DEVICE_ENROLLED_AFTER_REQUESTThis 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.
409PHONE_CHANGE_RECOVERY_COOLDOWNThe player’s account recently went through a sign-in recovery, so approving is paused for now (declining still works). Try later.
409a creation code aboveThe 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_phone is present only for the game that requested the change (new_phone_included: true): you sent that number. Every other game, and every game when source is support, gets new_phone_masked and new_phone_included: false. The previous number is always masked.
  • • In every case, store the new identity_id and 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/phone answers 409 PHONE_CHANGE_REQUIRES_VERIFICATION for that player: later changes go through a change request.
Node, @invonetwork/web-sdk 3.22.0+
// 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"
Python, invonetwork 3.20.0+
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/phone returns a fresh player_token. Switch to it straight away. An old token is refused with a plain 403 such as not_send_sender or not_transfer_sender, with no PLAYER_TOKEN_STALE code 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: true and a fresh player_token with player_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.

Node, handling a stale token
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;
}
Python, handling a stale token
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 nothing

Common mistakes

  1. Minting a token with only the email. A first-time receiver gets a 404 and cannot collect. Always pass player_phone.
  2. 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.
  3. Saving the new phone before Invo says 200. On a 409 your record is now wrong. Save after the 200.
  4. Keeping the old player token after a change. Use the player_token the change returned, or mint a new one.
  5. Calling the phone change from the client. It takes your game secret. Server only.
  6. Collecting the emailed code in your game. A player-confirmed change is confirmed only on Invo’s page. Never ask for the code.
  7. Pushing a stored number back after phone_mismatch or player.phone_changed. If your player did not change it in your game, Invo’s number is current. Ask the player instead.

Related