Peer Send, Stage by Stage

One player sends currency to another, addressed by phone number, inside your title or into a different one. Two humans, two sides: the sender initiates and approves in the sending game; the recipient collects in the receiving game, in the Invo app, or on a hosted page with nothing installed. Every call is shown as curl, JavaScript and Python. Base URLs, the status ladder and the actors are on the overview.

The whole send, numbered

Follow these steps in order and do not skip one. SERVER means your backend, holding the game secret. CLIENT means the player’s browser or app, holding only a player token. The stages further down this page explain each step in full; the checklist is the same list as tick boxes.

SENDER SIDE (the sending game)

 1  SERVER  POST /api/sdk/player-token                        X-Game-Secret-Key: <game secret>
              { "player_email", "player_phone", "player_name" }      always send the phone
            -> 200 { token, expires_at, identity_id }
               created: true          the player was new and is now registered in your game
               phone_mismatch: true   your phone differs from the one on file: change it with
                                      POST /api/sdk/players/phone (Player Identity), then mint again
            404 player_not_found      you left out player_phone: send it and the player is created

 2  SERVER  POST /api/currency-sends/initiate-send             X-Game-Secret-Key: <game secret>
              { client_request_id, sender_player_name, sender_player_email, sender_player_phone,
                receiver_player_phone, receiver_player_email, receiving_game_id, amount }
            -> 201 { transaction_id, verification_expires_at, ... }
            -> 202 the same, plus guardian_approval              minor account, Stage 3c
            identity_rekeyed: true  ->  use the player_token in this response from now on

 3  SERVER  mint the sender's token again, right before approval (or use step 2's player_token)
            and hand it to the client

 4  the sender approves, ONE of:
    a) CLIENT, web            client.approveHosted({ transactionId, flow: "send" })   from a click
                              INVO's approval page opens in a popup on INVO's domain, sets up a
                              passkey there if the player has none, and the SDK runs step 5 for you
    b) SERVER, consoles,      POST /api/sdk/approvals/device/begin
       desktop launchers,       { transaction_id, flow: "send", channel: "qr" }
       popup blocked          show the QR, then POST /api/sdk/approvals/device/poll until "approved"
    c) CLIENT, mobile app     the Unity or Unreal plugin's hosted approval (channel "app_browser")

 5  SERVER  POST /api/sdk/send/{transaction_id}/approve        Authorization: Bearer <sender's token>
    or SDK    { "device_code": "..." }
            -> 200 { status: "approved", next: "pending_claim", claim_delivery }   <-- MOVES MONEY
            403 code PLAYER_TOKEN_STALE   mint a new token and retry once
            401 code SDK_TOKEN_EXPIRED    mint a new token and retry

 6  SERVER  confirm it: webhook transfer.claim_pending, or
            GET /api/currency-sends/{transaction_id}/status   ->  verification_state "approved"
            The sender's part is now done. Reading the status moves nothing; it only shows step 5 ran.

RECIPIENT SIDE (the receiving game, which may be the same game)

 7  SERVER  POST /api/sdk/player-token with the RECEIVING game's secret, for the recipient
              { "player_email", "player_phone", "player_name" }     creates them there if new
 8  CLIENT  GET /api/sdk/transfers/pending  ->  the item with kind "receiving_confirm", flow "send"
 9  CLIENT  the recipient proves it is them (approveHosted or a QR grant, flow "send_receipt"), then
    SERVER  POST /api/sdk/send/{transaction_id}/confirm-receipt   <-- THE CALL THAT CREDITS THE RECIPIENT
    or SDK    -> 200 { status: "completed", amount_received }
    not in any game of yours? INVO emails the claim link when the receiver's email is already
    on file for that phone (a hosted claim page; nothing to build); otherwise the receiver
    collects in the game
10  SERVER  webhooks transfer.sent (sending game) and transfer.received (receiving game);
            the status endpoint now reports "completed"

    Never approved in the approval window, or never collected in the claim window:
    refunded to the sender automatically (Stage 7).

Step 5 is the one that moves money. A device grant that polls to “approved”, and a status read that says “awaiting”, have both done nothing to the transaction. One integration showed the QR, polled until it said approved, and stopped there: every send it created expired and was refunded, and nothing in its logs looked like an error. Call /api/sdk/send/{id}/approve with the device code. (approveHosted() and the SDK’s completeDeviceApproval() make that call for you.)

There is nothing to configure for passkeys. The approval ceremony always runs on INVO’s own domain, for every platform, through the hosted page in step 4. No domain to verify, no setting to switch on.

Helpers used by the raw examples

The SDK examples need only the client. The raw HTTP examples share these two helpers; nothing else is assumed.

Node 18+ (raw HTTP helper)
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

async function invo(method, path, headers, body) {
  const res = await fetch(`${BASE}${path}`, {
    method,
    headers: { "Content-Type": "application/json", ...headers },
    body: body === undefined ? undefined : JSON.stringify(body),
  });
  const json = await res.json().catch(() => ({}));
  return { status: res.status, json };
}
const asGame   = { "X-Game-Secret-Key": GAME_SECRET };
const asPlayer = (token) => ({ Authorization: `Bearer ${token}` });
Python 3.9+ (raw HTTP helper)
import os, time, uuid, requests

BASE = os.environ["INVO_BASE_URL"]            # https://invo.network  |  https://sandbox.invo.network/sandbox
GAME_SECRET = os.environ["INVO_GAME_SECRET"]  # server-side only

def invo(method, path, headers=None, body=None):
    r = requests.request(method, BASE + path,
                         headers={"Content-Type": "application/json", **(headers or {})},
                         json=body, timeout=15)
    try:
        return r.status_code, r.json()
    except ValueError:
        return r.status_code, {}

as_game = {"X-Game-Secret-Key": GAME_SECRET}
def as_player(token): return {"Authorization": f"Bearer {token}"}

Stage 1. Mint the sender’s player token (your server)

Game secret in, player token out. The token identifies this player in this title and is what every /api/sdk/* call authenticates with. It is short-lived and there is no refresh: read expires_at, and mint again right before the approval in Stage 3.

Always send player_phone (and player_name). With a phone, a player who is not in your title yet is registered on the spot and the answer carries created: true. Without one, an unknown player is a 404. An existing player’s phone is never changed by this call. These fields are additive: a call that sends only player_email behaves exactly as it always has.

POST $BASE/api/sdk/player-token
X-Game-Secret-Key: <game secret>
Content-Type: application/json

{
  "player_email": "ada@example.com",
  "player_phone": "+15555550100",      // E.164. Always send it.
  "player_name": "Ada"                 // optional, used only when the player is created
}

200 OK
{
  "token": "eyJ...",
  "expires_at": "2026-09-04T18:15:00+00:00",
  "identity_id": "9f2c...",
  "created": true                      // only present when this call registered the player
}
// or, for an existing player whose phone on file is different:
{ "token": "eyJ...", "expires_at": "...", "identity_id": "...", "phone_mismatch": true }

400 INVALID_INPUT / INVALID_PHONE        fix the field named in "error"
401 INVALID_GAME_SECRET
403 TENANT_NOT_MIGRATED                  in-app verification is not switched on for your game yet; ask Invo
404 { "error": "player_not_found", "hint": "pass player_phone to create the player" }
409 PHONE_SHARE_APPROVAL_REQUIRED        (under error_code) the phone belongs to another Invo identity
422 identity_unavailable
503 sdk_verification_disabled

phone_mismatch: true means your player changed their number in your game and Invo still has the old one. The token you got is for the player as Invo knows them. Update the number with POST /api/sdk/players/phone, which returns a fresh token, before you initiate. The endpoint, and what to do when it answers 409 PHONE_CHANGE_REQUIRES_VERIFICATION, are on Player Identity.

Where the token lives depends on the platform. On a console or native desktop build your server holds it and runs Stages 3 and 4 on the player’s behalf. On the web the browser SDK holds it. It is not a secret in the way the game secret is, one player, short-lived, but it does let its holder approve that player’s transactions, so keep it out of logs and URLs.

curl
PLAYER_TOKEN=$(curl -sS -X POST "$BASE/api/sdk/player-token" \
  -H "X-Game-Secret-Key: $GAME_SECRET" -H "Content-Type: application/json" \
  -d '{"player_email":"ada@example.com","player_phone":"+15555550100","player_name":"Ada"}' | jq -r .token)
Node, @invonetwork/web-sdk 3.21.0+
import { InvoServer } from "@invonetwork/web-sdk/server";
const invoSdk = new InvoServer({ gameSecret: process.env.INVO_GAME_SECRET!, baseUrl: process.env.INVO_BASE_URL! });

const tok = await invoSdk.mintPlayerToken({
  playerEmail: "ada@example.com",
  playerPhone: "+15555550100",
  playerName: "Ada",
});
if (tok.phoneMismatch) await updatePhoneThenRemint();   // see Player Identity
let senderToken = tok.token;
Python, invonetwork 3.19.0+
from invonetwork import InvoServer
invo_sdk = InvoServer(game_secret=os.environ["INVO_GAME_SECRET"], base_url=os.environ["INVO_BASE_URL"])

tok = invo_sdk.mint_player_token(
    player_email="ada@example.com",
    player_phone="+15555550100",
    player_name="Ada",
)
if tok.phone_mismatch:
    update_phone_then_remint()   # see Player Identity
sender_token = tok.token

The SDK sends playerPhone and playerName as player_phone and player_name, and surfaces created and phoneMismatch (Python: phone_mismatch) on the result.

Stage 2. Initiate the send (your server)

Game secret. Reserves the amount from the sender’s available balance and opens the approval window. The recipient is addressed by phone number. This applies to a game or a platform alike: the API calls the tenant a game and names its key X-Game-Secret-Key, but a platform tenant uses the same field and the same flow.

POST $BASE/api/currency-sends/initiate-send
X-Game-Secret-Key: <game secret>
Content-Type: application/json

{
  "client_request_id": "send-7f3a...",        // your idempotency key, unique per game
  "sender_player_name": "Ada",
  "sender_player_email": "ada@example.com",
  "sender_player_phone": "+15555550100",      // E.164
  "receiver_player_phone": "+15555550111",    // E.164, the send is addressed to this number
  "receiving_game_id": "123456789012",        // your own game id for a same-game send
  "amount": "50.00",
  "receiver_player_email": "bo@example.com"   // optional, recommended: a hint, used only if already on file for the phone
}
201 Created
{
  "status": "success",
  "transaction_id": "TXN_1757000000_AB12CD",
  "verification_method": "in_app",                          // see the note below
  "verification_expires_at": "2026-09-04T18:10:00+00:00",   // the approval window, read it, never assume
  "order_id": "ORD_1757000000_A1B2C3D4",       // string, not a number
  "new_balance": "950.00",                                  // sender's available balance after the reservation
  "currency_name": "Gold",
  "send_details": {
    "sending_game": "...", "receiving_game": "...", "receiving_game_id": "...",
    "currency": "Gold", "currency_id": 12, "amount_initiated": "50.00",
    "fees_preview": { "...": "..." },
    "receiver_phone": "+15555550111",
    "send_type": "cross_game",                              // or "peer_to_peer" for a same-game send
    "transfer_policy": { "...": "..." }
  },
  "claim_delivery_planned": "email"            // predicted claim_delivery: "email" | "none" | "sms" (legacy)
}

Build the approval in Stage 3 whatever verification_method says. It is a legacy field, as is verification_required: the hosted approval and the QR grant work on every transaction. Read the approval window from verification_expires_at.

claim_delivery_planned predicts the claim_delivery that approve will report (Stage 5): "email" when the receiver’s email is already on file for that phone, "none" when the email you supplied is not on file for it (nothing will be sent; the receiver collects in the game), and the legacy "sms" only when no receiver email was supplied and none is on file. Show the sender which one applies before they approve.

202 instead of 201, the guardian gate. A sender on a minor account returns "status": "pending_guardian_approval" with a guardian_approval block (approval_id, state, expires_at, consent_channel, poll_endpoint, resend_endpoint). consent_channel is "email" when the guardian has a verified address, that is the preferred channel. The reservation is made; the send cannot be approved until the guardian answers. Stage 3c covers it.

Check for identity_rekeyed: true. When this call is the first to put a phone on the sender (a player created without one, for example at a hosted checkout), their Invo identity changes, and a token minted before it no longer matches them. The response then also carries identity_id, player_token and player_token_expires_at. Use that player_token for every later step. The fields are absent when nothing changed, on both the 201 and the 202.

Refusals to expect here

  • • 400, missing or invalid field, or insufficient balance
  • • 403 SOURCE_GAME_NOT_LIVE / TARGET_GAME_NOT_LIVE, both games must be live
  • • 403 CURRENCY_SEND_POLICY_VIOLATION, the destination is not permitted by your transfer policy
  • • 403 PASSKEY_RECOVERY_COOLDOWN, the sender recovered a passkey recently; nothing is reserved (Stage 8)
  • • 403 GUARDIAN_REQUIRED, a minor account with no usable guardian on file
  • • 409, duplicate client_request_id; PHONE_SHARE_APPROVAL_REQUIRED; the two storefront-origin containment codes (see the error reference)
  • • 429, velocity or lockout, with retry_after where applicable; 503 flow_paused during a maintenance pause
curl
curl -sS -X POST "$BASE/api/currency-sends/initiate-send" \
  -H "X-Game-Secret-Key: $GAME_SECRET" -H "Content-Type: application/json" \
  -d '{
    "client_request_id": "send-'"$(uuidgen)"'",
    "sender_player_name": "Ada",
    "sender_player_email": "ada@example.com",
    "sender_player_phone": "+15555550100",
    "receiver_player_phone": "+15555550111",
    "receiver_player_email": "bo@example.com",
    "receiving_game_id": "123456789012",
    "amount": "50.00"
  }'
Node, @invonetwork/web-sdk/server
import { InvoServer } from "@invonetwork/web-sdk/server";
import { randomUUID } from "node:crypto";

const invoSdk = new InvoServer({ gameSecret: process.env.INVO_GAME_SECRET!, baseUrl: process.env.INVO_BASE_URL! });

const send = await invoSdk.initiateSend({
  clientRequestId: randomUUID(),
  senderPlayerName: "Ada", senderPlayerEmail: "ada@example.com", senderPlayerPhone: "+15555550100",
  receiverPlayerPhone: "+15555550111", receiverPlayerEmail: "bo@example.com",
  receivingGameId: 123456789012,
  amount: "50.00",
});
// send.transactionId · send.verificationExpiresAt
// send.guardianApproval is present on the 202 branch, see Stage 3c
if (send.identityRekeyed && send.playerToken) senderToken = send.playerToken;   // see the box above
// send.claimDeliveryPlanned: how the claim link or code will reach the recipient
Python, invonetwork
import os, uuid
from invonetwork import InvoServer

invo_sdk = InvoServer(game_secret=os.environ["INVO_GAME_SECRET"], base_url=os.environ["INVO_BASE_URL"])

send = invo_sdk.initiate_send(
    client_request_id=str(uuid.uuid4()),
    sender_player_name="Ada", sender_player_email="ada@example.com", sender_player_phone="+15555550100",
    receiver_player_phone="+15555550111", receiver_player_email="bo@example.com",
    receiving_game_id=123456789012,
    amount="50.00",
)
# send.transaction_id · send.verification_expires_at
if send.identity_rekeyed and send.player_token:
    sender_token = send.player_token   # see the box above
# send.claim_delivery_planned: how the claim link or code will reach the recipient

Stage 3. The sender proves it is them (one factor)

Chosen by what the client can do, not by what the title is. Every path runs the passkey ceremony on INVO’s own page and ends with a device code for the approve call in Stage 4; none of them moves money on its own. Mint the sender’s token again just before this stage (or use the player_token from a re-keyed initiate).

ClientFactorWhat you send to approve
Web game or web platform (desktop or mobile browser)INVO-hosted approval, approveHosted() (3b). Primary for the web.device_code, sent by the SDK for you
Consoles, TVs, desktop launchers and native desktop builds; a browser that blocks the popupDevice approval grant, QR (3a)device_code
iOS / Android appThe same hosted page in the system browser (channel: "app_browser"), driven by the Unity / Unreal plugin. See Device Approvaldevice_code

3a. Device grant: consoles, TVs, desktop launchers, popup-blocked browsers

The client cannot invoke the platform authenticator, so the ceremony moves to the player’s phone on Invo’s own page. Four steps, all driven from your server with the sender’s token: begin → show → poll → approve. The behaviour of the hosted page itself (first-scan enrolment, the on-screen match code, recovery) is on Device Approval; this is the integrator’s sequence.

Step 1: begin

POST $BASE/api/sdk/approvals/device/begin
Authorization: Bearer <sender's player token>

{ "transaction_id": "TXN_1757000000_AB12CD", "flow": "send", "channel": "qr" }
   // flow MUST be "send" for a peer send's SENDER. channel is optional; "qr" is the default.

200 OK
{
  "device_code": "b1s4...",                    // secret; your server keeps it, never shows it
  "user_code": "K7QP-3MRD",                    // short code the player can read off the screen
  "verification_uri": "https://invo.network/device",
  "verification_uri_complete": "https://invo.network/device?user_code=K7QP-3MRD",   // render as the QR
  "expires_in": 600,                           // seconds, honour this, not a local timer
  "interval": 5,                               // minimum seconds between polls
  "channel": "qr"
}

Beginning again for the same transaction. If a grant for this transaction and this flow is still pending, a second begin supersedes it: the old code is closed and you get a fresh one. That is the right answer when your screen was lost, the player never finished on their phone, or the client crashed. If the grant is already approved and still inside its window, begin answers 409 DEVICE_APPROVAL_ALREADY_PENDING with expires_at, the approval already exists, so settle with the device code you hold rather than minting a second one. Once that window passes you may begin again.

The sender’s grant (flow: "send") and the recipient’s grant (flow: "send_receipt") share a transaction id and do not collide, they are tracked separately.

Step 2: show it

Render verification_uri_complete as a QR code and print user_code underneath for a player who would rather type it at verification_uri. Never display device_code, it is your server’s secret.

Step 3: poll

POST $BASE/api/sdk/approvals/device/poll
Authorization: Bearer <sender's player token>

{ "device_code": "b1s4..." }

200 OK   { "status": "approved", "transaction_id": "TXN_...", "flow": "send", "approved_at": "..." }
400      { "error": "authorization_pending", "interval": 5 }     keep polling
400      { "error": "slow_down", "interval": 5 }                 you polled too fast; back off
400      { "error": "expired_token" }                            the grant timed out; begin again
400      { "error": "access_denied" }                            the player declined; stop
400      { "error": "invalid_grant" }                            unknown code, or not this player's

The first time a player scans with a phone that has no Invo passkey, the phone asks your screen to vouch for it. While that is pending, the authorization_pending body carries an enrollment object with state: "awaiting_screen", a device_label and a match_code. Show “Set up Invo on this phone: <label>, code <match_code>?”, and send the answer to POST /api/sdk/approvals/device/confirm-enrollment with { "device_code", "decision": "approve" | "deny" } and the same player token. The console screen is the proof, so nothing is messaged to the player at all. The key is absent entirely for an already-enrolled phone.

Step 4: approve. Stage 4 below, and it is not optional.

status: "approved" means the player finished the ceremony. It does not mean the send has progressed. Go straight to Stage 4 with the same device_code.

curl: begin, poll, approve
# begin
BEGIN=$(curl -sS -X POST "$BASE/api/sdk/approvals/device/begin" \
  -H "Authorization: Bearer $PLAYER_TOKEN" -H "Content-Type: application/json" \
  -d "{\"transaction_id\":\"$TXN\",\"flow\":\"send\",\"channel\":\"qr\"}")
DEVICE_CODE=$(echo "$BEGIN" | jq -r .device_code)
INTERVAL=$(echo "$BEGIN" | jq -r .interval)
echo "$BEGIN" | jq -r '.verification_uri_complete, .user_code'   # render the first as a QR

# poll
while :; do
  P=$(curl -sS -X POST "$BASE/api/sdk/approvals/device/poll" \
    -H "Authorization: Bearer $PLAYER_TOKEN" -H "Content-Type: application/json" \
    -d "{\"device_code\":\"$DEVICE_CODE\"}")
  case "$(echo "$P" | jq -r '.status // .error')" in
    approved)              break ;;
    authorization_pending) sleep "$INTERVAL" ;;
    slow_down)             INTERVAL=$((INTERVAL + 5)); sleep "$INTERVAL" ;;
    *)                     echo "stopped: $P"; exit 1 ;;
  esac
done

# APPROVE, the step that moves money. Do not stop at "approved" above.
curl -sS -X POST "$BASE/api/sdk/send/$TXN/approve" \
  -H "Authorization: Bearer $PLAYER_TOKEN" -H "Content-Type: application/json" \
  -d "{\"device_code\":\"$DEVICE_CODE\"}"
Node, @invonetwork/web-sdk/server 3.7.0
import { InvoServer } from "@invonetwork/web-sdk/server";

const invoSdk = new InvoServer({ gameSecret: process.env.INVO_GAME_SECRET!, baseUrl: process.env.INVO_BASE_URL! });

// 1. BEGIN is always yours: your game draws the QR between begin and the first poll.
const grant = await invoSdk.beginDeviceApproval({
  playerToken: senderToken,
  transactionId: txn,
  flow: "send",
  channel: "qr",
});
showQr(grant.verificationUriComplete, grant.userCode);

// 2 + 3. completeDeviceApproval polls AND calls approveWithDeviceCode when the poll
//        says approved. One call, so stopping at the poll is not possible. It blocks
//        for as long as the player takes: run it from a worker or a job, not from a
//        request handler with a short timeout.
//        RECORD THE ATTEMPT FIRST. If the process dies between the settle call and its
//        response, that record plus getSendStatus is the only way to
//        learn whether the money moved.
await recordAttempt(txn, grant.deviceCode);

const outcome = await invoSdk.completeDeviceApproval({
  playerToken: senderToken,
  transactionId: txn,
  flow: "send",
  deviceCode: grant.deviceCode,
  interval: grant.interval,
  onEnrollment: (enrollment, respond) => {
    if (enrollment?.state === "awaiting_screen") {
      showPrompt(
        "Set up Invo on " + enrollment.deviceLabel + "? Code " + enrollment.matchCode,
        (ok) => respond(ok ? "approve" : "deny"),
      );
    } else {
      hidePrompt();
    }
  },
});

// outcome.status: "approved" | "denied" | "expired" | "aborted"
if (outcome.status !== "approved") return handleDeclineOrExpiry(outcome);

const settlement = outcome.settlement!;   // the money step has already run
// settlement.status "approved" + settlement.next "pending_claim" on success.
// settlement.status "not_pending": the backend answered TRANSACTION_NOT_PENDING.
//   settlement.alreadySettled true  -> an earlier attempt already landed. Safe.
//   settlement.alreadySettled false -> DOES NOT mean "start again". Read
//   settlement.currentStatus, or fetch the transaction, before telling the player anything.
// settlement.holdReason: a 202 hold (GUARDIAN_APPROVAL_PENDING, RISK_HOLD, ...). The
//   money is held, not refused.
Node, driving the poll yourself (3.7.0)
// Use the three calls directly when your server cannot block. Keep deviceCode in your
// own store and poll from a scheduler.
const poll = await invoSdk.pollDeviceApproval({ playerToken: senderToken, deviceCode });
// poll.status: "approved" | "authorization_pending" | "slow_down" | "expired_token" | "access_denied"
//   invalid_grant is THROWN as an InvoError, not returned.

if (poll.status === "authorization_pending" && poll.enrollment?.state === "awaiting_screen") {
  const ok = await askOnScreen(
    "Set up Invo on " + poll.enrollment.deviceLabel + "? Code " + poll.enrollment.matchCode);
  await invoSdk.confirmDeviceEnrollment({
    playerToken: senderToken, deviceCode, decision: ok ? "approve" : "deny",
  });
}

if (poll.status === "approved") {
  // THE MONEY STEP. Polling to "approved" and stopping here moves nothing.
  const settlement = await invoSdk.approveWithDeviceCode({
    playerToken: senderToken, transactionId: txn, flow: "send", deviceCode,
  });
}
Node, raw HTTP (works today, any SDK version)
const sleep = (s) => new Promise((r) => setTimeout(r, s * 1000));

// begin
const { json: grant } = await invo("POST", "/api/sdk/approvals/device/begin", asPlayer(senderToken), {
  transaction_id: txn, flow: "send", channel: "qr",
});
showQr(grant.verification_uri_complete, grant.user_code);

// poll
let interval = grant.interval;
for (;;) {
  const { json: p } = await invo("POST", "/api/sdk/approvals/device/poll", asPlayer(senderToken), {
    device_code: grant.device_code,
  });
  if (p.status === "approved") break;
  if (p.error === "authorization_pending") {
    if (p.enrollment?.state === "awaiting_screen") {
      const ok = await askOnScreen(`Set up Invo on ${p.enrollment.device_label}? Code ${p.enrollment.match_code}`);
      await invo("POST", "/api/sdk/approvals/device/confirm-enrollment", asPlayer(senderToken), {
        device_code: grant.device_code, decision: ok ? "approve" : "deny",
      });
    }
    await sleep(interval);
    continue;
  }
  if (p.error === "slow_down") { interval += 5; await sleep(interval); continue; }
  throw new Error(`device approval stopped: ${p.error}`);   // expired_token | access_denied | invalid_grant
}

// APPROVE, the poll proved WHO. This is what moves the money.
const { status, json: approved } = await invo("POST", `/api/sdk/send/${txn}/approve`,
  asPlayer(senderToken), { device_code: grant.device_code });
// 200 -> { status: "approved", next: "pending_claim", transaction_id, claim_delivery }
Python, invonetwork 3.6.0
import os
from invonetwork import InvoServer

invo_sdk = InvoServer(game_secret=os.environ["INVO_GAME_SECRET"], base_url=os.environ["INVO_BASE_URL"])

# 1. BEGIN is always yours: your game draws the QR between begin and the first poll.
grant = invo_sdk.begin_device_approval(
    player_token=sender_token,
    transaction_id=txn,
    flow="send",
    channel="qr",
)
show_qr(grant.verification_uri_complete, grant.user_code)

# 2 + 3. complete_device_approval polls AND calls approve_with_device_code when the poll
#        says approved. One call, so stopping at the poll is not possible. It blocks for
#        as long as the player takes: run it from a worker, not a request handler.
#        RECORD THE ATTEMPT FIRST. If the process dies between the settle call and its
#        response, that record plus get_send_status is the only way to learn
#        whether the money moved.
record_attempt(txn, grant.device_code)

def on_enrollment(enrollment, respond):
    if enrollment and enrollment.state == "awaiting_screen":
        ok = ask_on_screen(
            f"Set up Invo on {enrollment.device_label}? Code {enrollment.match_code}")
        respond("approve" if ok else "deny")

outcome = invo_sdk.complete_device_approval(
    player_token=sender_token,
    transaction_id=txn,
    flow="send",
    device_code=grant.device_code,
    interval=grant.interval,
    on_enrollment=on_enrollment,
)

# outcome.status: "approved" | "denied" | "expired" | "aborted"
if outcome.status != "approved":
    return handle_decline_or_expiry(outcome)

settlement = outcome.settlement   # the money step has already run
# settlement.status "approved" + settlement.next "pending_claim" on success.
# settlement.status "not_pending": the backend answered TRANSACTION_NOT_PENDING.
#   settlement.already_settled true  -> an earlier attempt already landed. Safe.
#   settlement.already_settled false -> DOES NOT mean "start again". Read
#   settlement.current_status, or fetch the transaction, before telling the player anything.
# settlement.hold_reason: a 202 hold (GUARDIAN_APPROVAL_PENDING, RISK_HOLD, ...). The
#   money is held, not refused.
Python, driving the poll yourself (3.6.0)
# Use the three calls directly when your server cannot block.
poll = invo_sdk.poll_device_approval(player_token=sender_token, device_code=device_code)
# poll.status: "approved" | "authorization_pending" | "slow_down" | "expired_token" | "access_denied"
#   invalid_grant is RAISED as an InvoError, not returned.

if poll.status == "authorization_pending" and poll.enrollment and poll.enrollment.state == "awaiting_screen":
    ok = ask_on_screen(
        f"Set up Invo on {poll.enrollment.device_label}? Code {poll.enrollment.match_code}")
    invo_sdk.confirm_device_enrollment(
        player_token=sender_token, device_code=device_code,
        decision="approve" if ok else "deny",
    )

if poll.status == "approved":
    # THE MONEY STEP. Polling to "approved" and stopping here moves nothing.
    settlement = invo_sdk.approve_with_device_code(
        player_token=sender_token, transaction_id=txn, flow="send",
        device_code=device_code,
    )
Python, raw HTTP (works today, any SDK version)
# begin
_, grant = invo("POST", "/api/sdk/approvals/device/begin", as_player(sender_token),
                {"transaction_id": txn, "flow": "send", "channel": "qr"})
show_qr(grant["verification_uri_complete"], grant["user_code"])

# poll
interval = grant["interval"]
while True:
    _, p = invo("POST", "/api/sdk/approvals/device/poll", as_player(sender_token),
                {"device_code": grant["device_code"]})
    if p.get("status") == "approved":
        break
    err = p.get("error")
    if err == "authorization_pending":
        enrol = p.get("enrollment")
        if enrol and enrol.get("state") == "awaiting_screen":
            ok = ask_on_screen(f"Set up Invo on {enrol['device_label']}? Code {enrol['match_code']}")
            invo("POST", "/api/sdk/approvals/device/confirm-enrollment", as_player(sender_token),
                 {"device_code": grant["device_code"], "decision": "approve" if ok else "deny"})
        time.sleep(interval)
        continue
    if err == "slow_down":
        interval += 5
        time.sleep(interval)
        continue
    raise RuntimeError(f"device approval stopped: {err}")   # expired_token | access_denied | invalid_grant

# APPROVE, the poll proved WHO. This is what moves the money.
status, approved = invo("POST", f"/api/sdk/send/{txn}/approve", as_player(sender_token),
                        {"device_code": grant["device_code"]})
# 200 -> {"status": "approved", "next": "pending_claim", "transaction_id": ..., "claim_delivery": ...}

3b. Web: INVO-hosted approval, approveHosted()

The primary path for a browser. The web SDK runs the whole device grant from the page with the player token it holds: it begins the grant, opens INVO’s approval page in a popup on INVO’s own domain, polls, shows the match-code prompt when a new phone or browser is being set up, and then calls /api/sdk/send/{id}/approve with the device code for you. If the player has no passkey yet, the page sets one up during the same visit; if a code has to be sent to the player during setup, it goes by email by default. You configure nothing: there is no passkey domain of your own to set up.

  1. SERVER: mint the sender’s token (Stage 1) immediately before the player clicks, and hand it to the page.
  2. CLIENT: build an InvoClient with that token and a refreshToken hook that asks your server for a new one. The SDK uses the hook once if a call answers SDK_TOKEN_EXPIRED.
  3. CLIENT: call approveHosted({ transactionId, flow: "send" }) from a click handler: the popup is opened synchronously, before any network round trip.
  4. CLIENT: on status: "approved" the money call has already run and result.approval is its response. On "blocked", offer a button that calls it again from a fresh click, or fall back to the QR grant in 3a.
  5. SERVER: confirm with the webhook or the status read (Stage 5).
Browser, @invonetwork/web-sdk, approveHosted()
import { InvoClient } from "@invonetwork/web-sdk";

const client = new InvoClient({
  token: freshTokenFromYourServer,          // minted right before this click
  baseUrl,
  refreshToken: () => fetchNewTokenFromYourServer(),   // used once on SDK_TOKEN_EXPIRED
});

// FROM A CLICK HANDLER. The popup must open synchronously.
const result = await client.approveHosted({
  transactionId: txn,
  flow: "send",
  // renderPrompt: false + onEnrollment if you want to draw the match-code prompt yourself
  // popupBlocked: "resolve" (default) resolves "blocked" so you can retry from a button;
  //   "redirect" navigates THIS tab instead, and your page unloads.
});

// result.status: "approved" | "denied" | "expired" | "closed" | "blocked" | "redirected"
// "approved":   result.approval is the approve response. The money call already ran.
//               result.approval.status "approved", next "pending_claim".
// "closed":     the player closed the popup. Nothing moved; they can try again.
// "redirected": your page is gone; the device_approval.approved webhook and the status read are your signal.
Browser, a stale token (PLAYER_TOKEN_STALE)
try {
  await client.approveHosted({ transactionId: txn, flow: "send" });
} catch (err) {
  if (err.code === "PLAYER_TOKEN_STALE") {
    // The player's phone was added after this token was minted. Get a new token
    // from your server, build a new InvoClient with it, and let the player click again.
    showRetryButton(await fetchNewTokenFromYourServer());
  } else {
    throw err;
  }
}

The in-client passkey methods. client.approveSend() and POST /api/sdk/send/{id}/approve/webauthn/begin run the ceremony on your own page. For your title they answer 403 WEBAUTHN_NOT_ENABLED_FOR_TENANT, and that is not a setup gap: there is nothing to configure. Use approveHosted(), which reaches the same approve call. A 400 WEBAUTHN_NO_CREDENTIAL means the same thing for a player: send them through the hosted page, which sets up their passkey.

There is no Python example for this path: it runs in the browser. Your Python backend’s job is minting the token, initiating, and reading status.

3c. Guardian approval, the 202 branch

A minor account needs a guardian to agree before any factor counts. Initiate answers 202, and until the guardian answers, approve keeps answering 202 GUARDIAN_APPROVAL_PENDING. The approval goes to the guardian by email; resend_endpoint sends the request again.

# poll (game secret)
GET $BASE/api/transactions/{transaction_id}/approval-status
X-Game-Secret-Key: <game secret>
-> { "status": "ok", "approval": { "state": "pending" | "approved" | "rejected" | "expired", ... } }

# once state is "approved", produce a factor (3a or 3b) and call Stage 4 as normal.

# approve's answers while the gate is closed:
202 GUARDIAN_APPROVAL_PENDING              still waiting, poll, do not retry in a loop
410 GUARDIAN_APPROVAL_REJECTED             the guardian said no; stop
410 GUARDIAN_APPROVAL_EXPIRED              nobody answered in time; stop
503 GUARDIAN_APPROVAL_CHECK_UNAVAILABLE    transient; retry shortly

See Guardian Approval for the resend endpoint and the full state machine.

Stage 4. Approve. The call that moves money.

A factor is not a settlement. Whatever Stage 3 produced, an approved device grant, a verified passkey assertion, proves who. It does not advance the transaction. This call does, and only this call. It re-checks the guardian gate, the recovery hold and the transaction’s own state before it moves anything, which is exactly why the factor cannot be allowed to settle on its own.

POST $BASE/api/sdk/send/{transaction_id}/approve
Authorization: Bearer <the SENDER's player token>       <-- never the game secret
Content-Type: application/json

{ "device_code": "b1s4..." }                            // the grant you just polled to "approved"
// or { "webauthn_assertion": { ... } }                 // the in-client passkey
// or { "device_fingerprint": "...", "device_signal": { ... }, "biometric_verified": true }

200 OK
{ "status": "approved", "next": "pending_claim", "transaction_id": "TXN_...",
  "claim_delivery": "email" }                           // "email" | "none" | "sms" (legacy)

After a 200 the reservation is committed, the transaction is pending_claim, the claim window is open, and transfer.claim_pending has been queued to both games. Nothing more is required of the sender.

claim_delivery says how the receiver was told: "email" = the claim link was emailed to an address already on file for the claim phone; "none" = nothing was sent, because the receiver email you supplied is not on file for that phone (the receiver collects in the game); "sms" is a legacy value, used only when you supplied no receiver email and none is on file. Do not build on it.

Right before this call: a fresh token for the right identity

  • • Mint the token again immediately before you approve (Stage 1), unless initiate just handed you one.
  • • If initiate answered identity_rekeyed: true, use its player_token. Adding the player’s phone changed their Invo identity, so any token minted before initiate no longer matches them.
  • • 403 with code: "PLAYER_TOKEN_STALE" (the error field still reads not_send_sender): mint a new token and retry once. Nothing moved.
  • • 401 with code: "SDK_TOKEN_EXPIRED": the token ran out. Mint a new one and retry. Nothing moved.
Node, approve with one re-mint on a stale or expired token
async function approveWithFreshToken(txn, deviceCode, mint) {
  let token = await mint();                         // POST /api/sdk/player-token, phone included
  for (let attempt = 0; attempt < 2; attempt++) {
    const { status, json } = await invo("POST", `/api/sdk/send/${txn}/approve`,
      asPlayer(token), { device_code: deviceCode });
    const code = json.code ?? json.error_code ?? json.error;
    if (attempt === 0 && (code === "PLAYER_TOKEN_STALE" || code === "SDK_TOKEN_EXPIRED")) {
      token = await mint();                         // nothing moved; mint and retry once
      continue;
    }
    return { status, json };
  }
}
Python, approve with one re-mint on a stale or expired token
def approve_with_fresh_token(txn, device_code, mint):
    token = mint()                                  # POST /api/sdk/player-token, phone included
    for attempt in range(2):
        status, body = invo("POST", f"/api/sdk/send/{txn}/approve",
                            as_player(token), {"device_code": device_code})
        code = body.get("code") or body.get("error_code") or body.get("error")
        if attempt == 0 and code in ("PLAYER_TOKEN_STALE", "SDK_TOKEN_EXPIRED"):
            token = mint()                          # nothing moved; mint and retry once
            continue
        return status, body

What each refusal means

Status & codeMeaningDo this
400 TRANSACTION_NOT_PENDINGNot a failure. The transaction already moved on, almost always because your first approve succeeded and this is a retry. The body carries current_status.Read current_status and continue from there. Do not refund, alert, or start again.
400 DEVICE_APPROVAL_NOT_APPROVEDThe device code is unknown, belongs to another identity, is for another transaction, is for another flow, or was never approved. One answer covers all of them deliberately.Check flow was "send" and the transaction id matches. Otherwise begin a fresh grant.
400 INVALID_INPUTNo factor in the body, or a malformed one.Send exactly one of the three factor shapes.
401 SDK_TOKEN_MALFORMEDThe token carries no identity, or is not a player token.Re-mint the sender’s token. Tokens do not refresh.
401 SDK_TOKEN_EXPIREDThe player token ran out. Nothing moved.Mint a new token (Stage 1) and retry.
403 PLAYER_TOKEN_STALE (in code; error is not_send_sender)The token was minted before the sender’s phone was added, so it carries their old identity. Nothing moved.Mint a new token and retry once. Next time use the player_token from a re-keyed initiate.
403 not_send_senderThe token’s player is not this send’s sender.Mint the token for the sender’s email, in the sending game.
403 PASSKEY_RECOVERY_COOLDOWNThe sender recovered a passkey recently; money out is held. retry_after is an absolute time, retry_after_seconds the countdown.Show the time and stop. Do not retry inside the hold. See Stage 8.
202 / 410 guardian codesThe guardian gate is open, closed or lapsed. See 3c.202: poll and retry after approval. 410: stop.
400 SEND_APPROVE_FAILEDOur fault, and ambiguous. It does not mean nothing moved: the money can already have moved before the error was built. Carries an error_ref.Never retry, never refund, never re-initiate. Read the transaction status, or wait for the claim-pending webhook, to find out what actually happened. Then quote the error_ref to Invo support.
503 sdk_verification_disabledIn-app verification is not switched on for the environment.Contact Invo.
Node, handling the repeat call properly
const { status, json } = await invo("POST", `/api/sdk/send/${txn}/approve`,
  asPlayer(senderToken), { device_code: deviceCode });

// Some refusals carry "code", others "error_code", a few only "error".
// Read all three or you fall through on every guardian, risk and phone-share hold.
const code = json.code ?? json.error_code ?? json.error;

if (status === 200) {
  markApproved(txn);                       // json.next === "pending_claim"
} else if (code === "TRANSACTION_NOT_PENDING") {
  // NOT an error. Someone (probably you) already approved it.
  reconcile(txn, json.current_status);     // e.g. "pending_claim" or "completed"
} else if (code === "SEND_APPROVE_FAILED") {
  // AMBIGUOUS: the money may already have moved. Never retry, never refund,
  // never re-initiate. Read the status first, then raise the error_ref.
  reconcileThenReport(txn, json.error_ref);
} else {
  handleRefusal(status, json);
}
Python, handling the repeat call properly
status, body = invo("POST", f"/api/sdk/send/{txn}/approve",
                    as_player(sender_token), {"device_code": device_code})

# Some refusals carry "code", others "error_code", a few only "error".
# Read all three or you fall through on every guardian, risk and phone-share hold.
code = body.get("code") or body.get("error_code") or body.get("error")

if status == 200:
    mark_approved(txn)                          # body["next"] == "pending_claim"
elif code == "TRANSACTION_NOT_PENDING":
    # NOT an error. Already approved: read the status and move on.
    reconcile(txn, body["current_status"])
elif code == "SEND_APPROVE_FAILED":
    # AMBIGUOUS: the money may already have moved. Never retry, never refund,
    # never re-initiate. Read the status first, then raise the error_ref.
    reconcile_then_report(txn, error_ref=body.get("error_ref"))
else:
    handle_refusal(status, body)

Stage 5. Knowing it moved

Two signals, and you should wire both: webhooks for your ledger, a status read for “what is this one doing right now”.

GET $BASE/api/currency-sends/{transaction_id}/status
X-Game-Secret-Key: <game secret of either game>

200 OK
{
  "status": "success",
  "transaction_id": "TXN_...",
  "transaction_type": "currency_send",
  "transaction_status": "pending_claim",
  "verification_state": "approved",          // awaiting | approved | completed | expired | failed | unknown
  "amount": "50.00", "net_amount": "45.00", "fee_amount": "5.00",
  "sending_game_id": "...", "sending_game_name": "...",
  "receiving_game_id": "...", "receiving_game_name": "...",
  "currency_id": 12, "currency_name": "Gold",
  "order_id": "ORD_1757000000_A1B2C3D4",     // string, not a number
  "claim_info": { "claim_code_expires_at": "2026-09-05T18:10:00+00:00" }   // while pending_claim
}

// once collected, transaction_status and verification_state are "completed" and the body adds:
//   "completed_at", "claimed_by_player_name", "claimed_by_player_email"
// while awaiting approval it carries a legacy "verification_info" object instead of claim_info (ignore it),
// and "failure_reason" appears whenever one is recorded
// additive: "expiry_reason" ("approval_expired" | "pin_expired" | "claim_expired"), only when the send expired

Polling this does not move money. It tells you whether Stage 4 happened (verification_state: "approved") and whether the recipient has collected ("completed"). If it still says "awaiting" after the player approved, the approve call was never made.

The receiving game gets two extra fields on this response. to_phone and to_identity_id, so it can attribute the inbound send to one of its own players. to_identity_id is null when the phone matches more than one of your players.

EventYou receive it asMeans
device_approval.approvedThe title that began the grantA device grant settled. Call approve now, this is a faster cue than the next poll, not a substitute for the approve call.
transfer.claim_pendingSending game (direction: outbound) and receiving game (inbound)Stage 4 succeeded. The receiving game should now surface “you have something to collect”.
transfer.sentSending gameCollected. The sending side of completion.
transfer.receivedReceiving gameCredited. On the send rail: amount_received, currency_name, new_balance, flow: "currency_send", and confirmed_via when collected through an Invo surface. No gross_amount and no fee_breakdown on this rail, those are transfer-rail only.
curl
curl -sS "$BASE/api/currency-sends/$TXN/status" \
  -H "X-Game-Secret-Key: $GAME_SECRET" | jq '{transaction_status, verification_state, claim_info}'
Node, raw HTTP
const { json: s } = await invo("GET", `/api/currency-sends/${txn}/status`, asGame);
// s.verification_state: "awaiting" | "approved" | "completed" | "expired" | "failed" | "unknown"
Python, raw HTTP
_, s = invo("GET", f"/api/currency-sends/{txn}/status", as_game)
# s["verification_state"]: "awaiting" | "approved" | "completed" | "expired" | "failed" | "unknown"

Stage 6. The recipient collects

Receiving is exactly as strong as sending

The recipient proves it is her, on a device that is hers, with a passkey, the same class of proof the sender gave. A typed code is bearer value: it belongs to whoever has it. That is why typing a code is no longer how you collect in your title, and why the primary route below is the recipient’s own factor.

The recipient side in four steps (full detail, both rails, on Collecting, Stage by Stage): (1) the receiving game’s server mints the recipient’s token with player_email and player_phone, which registers them there if they are new; (2) the client lists GET /api/sdk/transfers/pending and picks the item with flow: "send"; (3) the recipient approves with approveHosted({ flow: "send_receipt" }) or a QR grant with that flow; (4) POST /api/sdk/send/{id}/confirm-receipt credits them. Step 4 is the recipient’s money call, and the SDK makes it for you on the hosted path.

6a. Show the recipient what is waiting for them

The recipient’s client asks, with the recipient’s player token, what needs their action. This is the list to build your “you have something to collect” screen from.

GET $BASE/api/sdk/transfers/pending
Authorization: Bearer <the RECIPIENT's player token, minted by the RECEIVING game>

200 OK
{
  "pending": [
    {
      "transfer_id": "TXN_1757000000_AB12CD",
      "kind": "receiving_confirm",       // something to COLLECT  ("identity_gate" = something to APPROVE)
      "flow": "send",                    // "send" -> /api/sdk/send/{id}/confirm-receipt
                                         // "transfer" -> /api/sdk/transfers/{id}/confirm-receipt
      "amount": "45.00",                 // what the recipient receives, net
      "currency": "Gold",
      "counterparty_game": "Example Game",
      "expires_at": "2026-09-05T18:10:00+00:00",
      "step_up_required": false,
      "held": false,
      "hold_reason": null
    }
  ]
}

Branch on flow, not on kind. receiving_confirm covers both rails: a peer send arrives with flow: "send" and a cross-game transfer with flow: "transfer". An integrator who read receiving_confirm as “transfers only” shipped a collect screen with no button for sends. Use flow to pick the endpoint and nothing else.

Two server-side signals complement it, for the receiving game rather than the player:

# the webhook, you are told, you do not ask
transfer.claim_pending   direction: "inbound"   with to_phone and to_identity_id

# enumerate what is waiting for one of your players, on demand (game secret)
GET $BASE/api/transfers/inbound-pending?player_phone=%2B15555550111
X-Game-Secret-Key: <RECEIVING game secret>
-> { "inbound_pending": [ { "transaction_id", "flow": "send", "amount", "net_amount",
                            "source_game_id", "source_game", "to_phone", "to_identity_id",
                            "created_at", "claim_code_expires_at" } ] }
   # player_email works too. The claim code is never returned here.

6b. Collect in your title: confirm-receipt

The primary route. The recipient is signed into the receiving game, so they prove who they are directly and the value lands. Mint their token with the receiving game’s secret, produce a factor, and settle. Same two-step shape as the sender’s side, one flow value along: a device grant for this side uses flow: "send_receipt".

POST $BASE/api/sdk/send/{transaction_id}/confirm-receipt
Authorization: Bearer <the RECIPIENT's player token, minted by the RECEIVING game>

{ "device_code": "..." }                       // from a grant begun with flow "send_receipt"
// or { "webauthn_assertion": { ... } }        // challenge first from
//    POST /api/sdk/send/{id}/confirm-receipt/webauthn/begin

200 OK
{ "status": "completed", "transaction_id": "TXN_...", "amount_received": "45.00" }
RefusalMeaningDo this
409 receiver_not_enrolled_use_claim_codeThis person has no account in your title yet, so there is nobody to credit. Minting their token with player_phone registers them and avoids this answer.Use the hosted claim page (6c), or the claim-code fallback below, both create the account as part of collecting.
403 not_intended_receiverThe token’s player is not the addressed recipient, or their phone does not match the number the send was addressed to.Check you minted the token in the receiving game, for the addressed player.
409 PHONE_SHARE_APPROVAL_REQUIREDThis phone number is already tied to a different Invo identity; that person must consent before value lands here.Follow the consent flow the body describes, then retry. Consent is requested by email where one is verified.
400 TRANSACTION_NOT_PENDINGAlready collected, or the claim window closed. current_status says which.Read the status. If completed, you are done.
400 CONFIRM_RECEIPT_FAILEDOur fault, and ambiguous. It does not mean nothing moved: the money can already have moved before the error was built. Carries an error_ref.Never retry, never refund. Read the status first, then quote the error_ref to Invo support.
curl, recipient side, device grant
# the RECEIVING game mints the RECIPIENT's token
RECIPIENT_TOKEN=$(curl -sS -X POST "$BASE/api/sdk/player-token" \
  -H "X-Game-Secret-Key: $RECEIVING_GAME_SECRET" -H "Content-Type: application/json" \
  -d '{"player_email":"bo@example.com","player_phone":"+15555550111","player_name":"Bo"}' | jq -r .token)

# begin a grant on the RECEIPT flow
curl -sS -X POST "$BASE/api/sdk/approvals/device/begin" \
  -H "Authorization: Bearer $RECIPIENT_TOKEN" -H "Content-Type: application/json" \
  -d "{\"transaction_id\":\"$TXN\",\"flow\":\"send_receipt\"}"

# ... show the QR, poll to "approved" exactly as in Stage 3a ...

# THE CALL THAT COLLECTS
curl -sS -X POST "$BASE/api/sdk/send/$TXN/confirm-receipt" \
  -H "Authorization: Bearer $RECIPIENT_TOKEN" -H "Content-Type: application/json" \
  -d "{\"device_code\":\"$DEVICE_CODE\"}"
Node, the receiving game's server (@invonetwork/web-sdk 3.21.0+ mint, raw HTTP grant)
const receivingSdk = new InvoServer({ gameSecret: process.env.RECEIVING_GAME_SECRET!, baseUrl });

const tok = await receivingSdk.mintPlayerToken({
  playerEmail: "bo@example.com", playerPhone: "+15555550111", playerName: "Bo",
});

const { json: grant } = await invo("POST", "/api/sdk/approvals/device/begin", asPlayer(tok.token), {
  transaction_id: txn, flow: "send_receipt",          // note the flow
});
// ... show QR, poll to approved (identical loop to Stage 3a) ...

const { status, json } = await invo("POST", `/api/sdk/send/${txn}/confirm-receipt`,
  asPlayer(tok.token), { device_code: grant.device_code });
// 200 -> { status: "completed", amount_received: "45.00" }
// 409 receiver_not_enrolled_use_claim_code -> hosted claim page (6c), or the claim-code fallback
Python, the receiving game's server (invonetwork 3.19.0+ mint, raw HTTP grant)
receiving_sdk = InvoServer(game_secret=os.environ["RECEIVING_GAME_SECRET"], base_url=BASE)

tok = receiving_sdk.mint_player_token(player_email="bo@example.com", player_phone="+15555550111", player_name="Bo")

_, grant = invo("POST", "/api/sdk/approvals/device/begin", as_player(tok.token),
                {"transaction_id": txn, "flow": "send_receipt"})   # note the flow
# ... show QR, poll to approved (identical loop to Stage 3a) ...

status, body = invo("POST", f"/api/sdk/send/{txn}/confirm-receipt",
                    as_player(tok.token), {"device_code": grant["device_code"]})
# 200 -> {"status": "completed", "amount_received": "45.00"}
# 409 receiver_not_enrolled_use_claim_code -> hosted claim page (6c), or the claim-code fallback
Browser, @invonetwork/web-sdk (recipient signed in on the web)
const client = new InvoClient({ token: recipientToken, baseUrl });
await client.confirmReceiptSend(transactionId);   // challenge + passkey + confirm-receipt in one

6c. For a recipient who is not in your title: the hosted claim page

A peer send is addressed to a phone number, and the person behind that number may not have your title at all. For them, INVO emails the claim link when the receiver’s email is already on file for that phone; otherwise the receiver collects in the game. The link opens a hosted page at https://invo.network/claim. The page takes their email and mints them a passkey, then collects, it is the same class of proof as 6b, not a code box. You build nothing for this.

Fallback only: collecting with the claim code

This is not how a recipient collects in your title. In-client claim-code entry was removed deliberately: a code is bearer value, and whoever holds it is not necessarily the person the money was meant for. Do not build a “enter your code” screen.

The endpoint remains for the one case the stronger routes cannot serve: a recipient who has no Invo passkey and cannot get one, which is also what 409 receiver_not_enrolled_use_claim_code is pointing you at, because this route creates the receiving player as part of collecting.

POST $BASE/api/currency-sends/claim-currency
X-Game-Secret-Key: <RECEIVING game secret>

{
  "claim_code": "KJMRS-47281",
  "receiver_player_name": "Bo",
  "receiver_player_email": "bo@example.com",
  "receiver_player_phone": "+15555550111"      // must match the number the send was addressed to
}

200 OK -> { "status": "success", "transaction_id", "new_balance", "currency_name",
            "send_details": { "amount_received", ... }, "completion_time", "order_id" }

400 WRONG_CLAIM_ENDPOINT   this code belongs to a transfer, use /api/transfers/claim-transfer
Node, @invonetwork/web-sdk/server
const receivingSdk = new InvoServer({ gameSecret: process.env.RECEIVING_GAME_SECRET!, baseUrl });
await receivingSdk.claimCurrency({
  claimCode: "KJMRS-47281",
  receiverPlayerName: "Bo", receiverPlayerEmail: "bo@example.com", receiverPlayerPhone: "+15555550111",
});
Python, invonetwork
receiving_sdk = InvoServer(game_secret=os.environ["RECEIVING_GAME_SECRET"], base_url=BASE)
receiving_sdk.claim_currency(
    claim_code="KJMRS-47281",
    receiver_player_name="Bo", receiver_player_email="bo@example.com",
    receiver_player_phone="+15555550111",
)

Stage 7. Expiry and refunds

Nothing strands. A send that is not approved, or is approved and not collected, is swept back to the sender by Invo. You do not call anything to make that happen, you listen for it.

Which window lapsedEnds up asEvents you receive
The approval window (verification_expires_at), nobody approvedexpired_pin_verification, reservation released to the sendertransfer.refunded with reason: "pin_expired", to the sending game only. refunded_amount is the gross the sender committed, which is the whole reservation: fees are only ever taken at collect, so an expiry returns the full amount and nothing is kept.
The claim window (claim_code_expires_at), approved but never collectedexpired_claim, funds returned to the sendertransfer.claim_expired to both games, plus transfer.refunded with reason: "claim_expired" to the sending game

The sweep runs on a short cycle rather than to the second, so expect the refund shortly after the deadline. Do not compute expiry yourself and mark a transaction dead: it is not dead until transfer.refunded arrives or the status endpoint says so. A send that expires is finished, start a new one from Stage 1 rather than trying to revive it.

Stage 8. The recovery hold

If a player self-recovers a lost passkey, money leaving their account is paused for a period; retry_after says until when. This is protection against someone who has taken over the phone number, so it refuses every factor, not just the one that was recovered.

403 Forbidden        (from BOTH /initiate-send and /api/sdk/send/{id}/approve)
{
  "status": "error",
  "error": "This account recently recovered a passkey; money transfers are paused for a while.",
  "code": "PASSKEY_RECOVERY_COOLDOWN",
  "retry_after": "2026-09-05T18:04:00+00:00",     // absolute, show this
  "retry_after_seconds": 3600                    // the same thing, for a backoff
}

At initiate, nothing is reserved. At approve, the reservation stands and will be swept back when the approval window lapses. Collecting is unaffected, a held player can still receive and claim. Show the time from retry_after and stop; retrying inside the hold just repeats the 403.

Common mistakes on a send

  1. Polling to “approved” and stopping. The most expensive mistake there is, because it looks like success. Stage 3 ends at a factor; Stage 4 is a separate call.
  2. Using the game secret on an /api/sdk/* call. Only player-token takes it. Everything else takes that player’s bearer token.
  3. Approving with the wrong player’s token. The sender’s token for approve; the recipient’s token, minted by the receiving game, for confirm-receipt.
  4. Beginning the grant with the wrong flow. send for the sender, send_receipt for the recipient. A mismatch surfaces at approve as DEVICE_APPROVAL_NOT_APPROVED, which reads like the player never approved at all.
  5. Treating TRANSACTION_NOT_PENDING as a failure. It means already done. Read current_status.
  6. Expiring the grant on a local timer. Honour expires_in and interval from begin, and the transaction windows from the responses that carry them.
  7. Minting the token once, at login. Mint it with the phone, mint it again right before approval, and switch to the player_token initiate returns when it says identity_rekeyed: true. PLAYER_TOKEN_STALE and SDK_TOKEN_EXPIRED both mean: mint and retry once.
  8. Retrying SEND_APPROVE_FAILED. It is never the caller’s fault and never gets better on a retry. Capture error_ref and send it to us.