Cross-Game Transfer, Stage by Stage

One player moves their own currency out of your title and into another. It is a self-transfer: the same human is on both ends, so they approve it in the source title and collect it in the destination title. Every call is shown as curl, JavaScript and Python. Base URLs, the status ladder and the actors are on the overview; the peer-to-peer variant is on Peer Send.

How a transfer differs from a send

  • • Same person, both ends. A send is addressed to someone else’s phone; a transfer is addressed to the player’s own number in another title.
  • • The claim code comes back to the caller. The approve response hands it straight to your server, because the person who will collect it is the person who started it. The source title can read it back from GET /api/transfers/{id}/status once approved, and approve also emails it to the sender’s own bound address (claim_delivery: "email"). The hosted claim page serves peer sends only.
  • • Different paths. /api/transfers/… and /api/sdk/transfers/…, plural, and the device-grant flow values are transfer and transfer_receipt.
  • • Collecting names a currency. The claim-code route takes a target_currency_id, because the destination title may run several.

The whole transfer, 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 below explain each step in full; the checklist is the same list as tick boxes.

SOURCE GAME

 1  SERVER  POST /api/sdk/player-token                        X-Game-Secret-Key: <source 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/transfers/initiate-transfer             X-Game-Secret-Key: <source game secret>
              { client_request_id, source_player_name, source_player_email, source_player_phone,
                target_player_phone, target_player_email, target_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 player's token again, right before approval (or use step 2's player_token)
            and hand it to the client

 4  the player approves, ONE of:
    a) CLIENT, web            client.approveHosted({ transactionId, flow: "transfer" })   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: "transfer", 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/transfers/{transaction_id}/approve   Authorization: Bearer <player's token>
    or SDK    { "device_code": "..." }
            -> 200 { status: "approved", next: "pending_claim",      <-- THE CALL THAT MOVES MONEY
                     claim_code, claim_code_expires_at,              store the claim code
                     claim_delivery }
            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/transfers/{transaction_id}/status   ->  verification_state "approved"
            (with the source game's secret this also returns claim_code and claim_code_expires_at)
            Reading the status moves nothing; it only shows step 5 ran.

DESTINATION GAME (the same player, over there)

 7  SERVER  POST /api/sdk/player-token with the DESTINATION game's secret
              { "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 "transfer"
 9  CLIENT  the player proves it is them (approveHosted or a QR grant, flow "transfer_receipt"), then
    SERVER  POST /api/sdk/transfers/{transaction_id}/confirm-receipt   <-- THE CALL THAT CREDITS THE PLAYER
    or SDK  Authorization: Bearer <the player's destination token>
            -> 200 { status: "completed", amount_received }
    fallback: POST /api/transfers/claim-transfer with the claim code (destination game secret)
10  SERVER  webhooks transfer.sent (source) and transfer.received (destination);
            the status endpoint now reports "completed"

    Never approved in the approval window, or never collected in the claim window:
    refunded to the player in the source game 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 transfer it created expired and was refunded. Call /api/sdk/transfers/{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

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 player’s token in the source title (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. You will mint a second token later, in the destination title, for Stage 6: they are different tokens for the same human.

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 playerToken = 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
player_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 transfer (your server)

Game secret. Reserves the amount from the player’s available balance in your title, stamps the fees, and opens the approval window. The destination is a game id; the player is identified there by their phone number.

POST $BASE/api/transfers/initiate-transfer
X-Game-Secret-Key: <source game secret>
Content-Type: application/json

{
  "client_request_id": "tfr-7f3a...",         // your idempotency key, unique per game
  "source_player_name": "Ada",
  "source_player_email": "ada@example.com",
  "source_player_phone": "+15555550100",      // E.164
  "target_player_phone": "+15555550100",      // the same human, usually the same number
  "target_game_id": "123456789012",
  "amount": "50.00",
  "target_player_email": "ada@example.com"    // recommended: lets the phone-share consent gate resolve
}
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": "TFRO_...",
  "new_balance": "950.00",                                  // available balance after the reservation
  "currency_name": "Gold",
  "transfer_details": {
    "source_game": "...", "target_game": "...", "target_game_id": "...",
    "currency": "Gold", "currency_id": 12, "amount_initiated": "50.00",
    "fees_preview": { "...": "..." },
    "transfer_policy": { "...": "..." }
  },
}

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.

Check for identity_rekeyed: true. When this call is the first to put a phone on the player (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 in the source title. The fields are absent when nothing changed, on both the 201 and the 202.

Refusals to expect here

  • • 400, missing or invalid field, insufficient balance, or a transfer to the title it is already in
  • • 403 SOURCE_GAME_NOT_LIVE / TARGET_GAME_NOT_LIVE, both titles must be live
  • • 403 TRANSFER_POLICY_VIOLATION, the destination is not permitted by your transfer policy
  • • 403 PASSKEY_RECOVERY_COOLDOWN, the player 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; 503 flow_paused during a maintenance pause

A 202 with a guardian_approval block is the minor-account branch, exactly as on a send, see Stage 3c.

curl
curl -sS -X POST "$BASE/api/transfers/initiate-transfer" \
  -H "X-Game-Secret-Key: $GAME_SECRET" -H "Content-Type: application/json" \
  -d '{
    "client_request_id": "tfr-'"$(uuidgen)"'",
    "source_player_name": "Ada",
    "source_player_email": "ada@example.com",
    "source_player_phone": "+15555550100",
    "target_player_phone": "+15555550100",
    "target_player_email": "ada@example.com",
    "target_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 t = await invoSdk.initiateTransfer({
  clientRequestId: randomUUID(),
  sourcePlayerName: "Ada", sourcePlayerEmail: "ada@example.com", sourcePlayerPhone: "+15555550100",
  targetPlayerPhone: "+15555550100", targetPlayerEmail: "ada@example.com",
  targetGameId: 123456789012,
  amount: "50.00",
});
// t.transactionId · t.verificationExpiresAt
if (t.identityRekeyed && t.playerToken) playerToken = t.playerToken;   // see the box above
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"])

t = invo_sdk.initiate_transfer(
    client_request_id=str(uuid.uuid4()),
    source_player_name="Ada", source_player_email="ada@example.com", source_player_phone="+15555550100",
    target_player_phone="+15555550100", target_player_email="ada@example.com",
    target_game_id=123456789012,
    amount="50.00",
)
# t.transaction_id · t.verification_expires_at
if t.identity_rekeyed and t.player_token:
    player_token = t.player_token   # see the box above

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

Identical to a send’s Stage 3 except for the flow value and the endpoint paths. Choose by what the client can do: on the web, the INVO-hosted approval in 3b is the primary path; on consoles, desktop launchers and native desktop builds, or in a browser that blocks the popup, the QR grant in 3a; in an iOS / Android app, the same hosted page in the system browser through the Unity / Unreal plugin (see Device Approval). Every path ends with a device code for Stage 4. Mint the player’s token again just before this stage (or use the player_token from a re-keyed initiate).

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

POST $BASE/api/sdk/approvals/device/begin
Authorization: Bearer <the player's token in the SOURCE game>

{ "transaction_id": "TXN_1757000000_AB12CD", "flow": "transfer", "channel": "qr" }
   // flow MUST be "transfer" here. "transfer_receipt" is the DESTINATION side, Stage 6.

200 OK
{
  "device_code": "b1s4...",                    // secret; your server keeps it
  "user_code": "K7QP-3MRD",                    // short code shown under the QR
  "verification_uri": "https://invo.network/device",
  "verification_uri_complete": "https://invo.network/device?user_code=K7QP-3MRD",
  "expires_in": 600, "interval": 5, "channel": "qr"
}

# then poll until approved:
POST $BASE/api/sdk/approvals/device/poll
Authorization: Bearer <the same player token>
{ "device_code": "b1s4..." }

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

Beginning again for the same transaction. A second begin while a grant for this transaction and this flow is still pending supersedes it and hands you a fresh code, the right answer after a crash or an abandoned scan. If the grant is already approved and inside its window, begin answers 409 DEVICE_APPROVAL_ALREADY_PENDING with expires_at: the approval exists, so settle with the code you hold. Past that window you may begin again.

First-time phones. If the scanning phone has no Invo passkey, the authorization_pending body carries an enrollment object (state, device_label, match_code). Show “Set up Invo on this phone?” with the match code and answer at POST /api/sdk/approvals/device/confirm-enrollment with { device_code, decision }. The screen is the proof, nothing is messaged to anyone.

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\":\"transfer\",\"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 and issues the claim code.
curl -sS -X POST "$BASE/api/sdk/transfers/$TXN/approve" \
  -H "Authorization: Bearer $PLAYER_TOKEN" -H "Content-Type: application/json" \
  -d "{\"device_code\":\"$DEVICE_CODE\"}" | jq '{status, next, claim_code, claim_code_expires_at}'
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: playerToken,
  transactionId: txn,
  flow: "transfer",
  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 getTransferStatus is the only way to
//        learn whether the money moved.
await recordAttempt(txn, grant.deviceCode);

const outcome = await invoSdk.completeDeviceApproval({
  playerToken: playerToken,
  transactionId: txn,
  flow: "transfer",
  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, and
//   settlement.claimCode / settlement.claimCodeExpiresAt: STORE BOTH.
// settlement.status "not_pending": the backend answered TRANSACTION_NOT_PENDING, and no
//   claim code is re-issued. settlement.alreadySettled true means an earlier attempt
//   landed; false does NOT mean "start again": read settlement.currentStatus first.
// settlement.holdReason: a 202 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: playerToken, 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: playerToken, 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: playerToken, transactionId: txn, flow: "transfer", deviceCode,
  });
}
Node, raw HTTP (works today, any SDK version)
const sleep = (s) => new Promise((r) => setTimeout(r, s * 1000));

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

let interval = grant.interval;
for (;;) {
  const { json: p } = await invo("POST", "/api/sdk/approvals/device/poll", asPlayer(playerToken), {
    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(playerToken), {
        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}`);
}

// APPROVE, the poll proved WHO. This moves the money and returns the claim code.
const { status, json: approved } = await invo("POST", `/api/sdk/transfers/${txn}/approve`,
  asPlayer(playerToken), { device_code: grant.device_code });
// 200 -> { status: "approved", next: "pending_claim", claim_code, claim_code_expires_at }
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=player_token,
    transaction_id=txn,
    flow="transfer",
    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_transfer_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=player_token,
    transaction_id=txn,
    flow="transfer",
    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, and
#   settlement.claim_code / settlement.claim_code_expires_at: STORE BOTH.
# settlement.status "not_pending": the backend answered TRANSACTION_NOT_PENDING, and no
#   claim code is re-issued. settlement.already_settled true means an earlier attempt
#   landed; false does NOT mean "start again": read settlement.current_status first.
# settlement.hold_reason: a 202 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=player_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=player_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=player_token, transaction_id=txn, flow="transfer",
        device_code=device_code,
    )
Python, raw HTTP (works today, any SDK version)
_, grant = invo("POST", "/api/sdk/approvals/device/begin", as_player(player_token),
                {"transaction_id": txn, "flow": "transfer", "channel": "qr"})
show_qr(grant["verification_uri_complete"], grant["user_code"])

interval = grant["interval"]
while True:
    _, p = invo("POST", "/api/sdk/approvals/device/poll", as_player(player_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(player_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}")

# APPROVE, the poll proved WHO. This moves the money and returns the claim code.
status, approved = invo("POST", f"/api/sdk/transfers/{txn}/approve", as_player(player_token),
                        {"device_code": grant["device_code"]})
# 200 -> {"status": "approved", "next": "pending_claim", "claim_code": ..., "claim_code_expires_at": ...}

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/transfers/{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 player’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: "transfer" }) 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: "transfer",
  // 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 carries the claim code: store it.
// "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: "transfer" });
} 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.approveTransfer() and POST /api/sdk/transfers/{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. The request goes to the guardian by email where one is verified; resend_endpoint sends the request again.

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

# approve's answers while the gate is closed:
202 GUARDIAN_APPROVAL_PENDING              poll; do not retry in a tight 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

Full state machine on Guardian Approval.

Stage 4. Approve. The call that moves money.

A factor is not a settlement. An approved device grant or a verified passkey assertion proves who. It does not advance the transaction. This call does, and it is the call that hands back the claim code, so a client that stops at the poll has no way to complete the transfer at all.

POST $BASE/api/sdk/transfers/{transaction_id}/approve
Authorization: Bearer <the PLAYER's token in the SOURCE game>    <-- 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_code": "KJMRS-47281",                          // the player collects with this
  "claim_code_expires_at": "2026-09-05T18:10:00+00:00",
  "claim_delivery": "email"                             // email | none | sms (legacy value)
}

Store the claim code. It is returned to the caller, because on a self-transfer the caller is the person who will collect. The source title can read it again from GET /api/transfers/{id}/status once approved, and when claim_delivery is email it was also mailed to the sender’s own bound address. If your destination-side integration uses the passkey or device-grant collect in Stage 6a you will not need it, but keep it anyway, because it is the fallback that always works.

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_transfer_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/transfers/${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/transfers/{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. Already approved, usually your own retry. Carries current_status.Read current_status and continue. The claim code is not repeated on this answer: read it back from GET /api/transfers/{id}/status with the source title’s secret, or collect through 6b.
400 DEVICE_APPROVAL_NOT_APPROVEDThe device code is unknown, another identity’s, for another transaction, for another flow, or not approved. One answer covers all of them deliberately.Check flow was "transfer" 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_MALFORMEDNot a player token, or it carries no identity.Re-mint. 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_transfer_sender)The token was minted before the player’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_transfer_senderThe token’s player did not start this transfer.Mint the token for the source player’s email, in the source title.
403 PASSKEY_RECOVERY_COOLDOWNMoney out is held after a passkey recovery. retry_after is absolute; retry_after_seconds is the countdown.Show the time and stop. See Stage 8.
202 / 410 guardian codesThe guardian gate is open, closed or lapsed. See 3c.202: poll then retry. 410: stop.
400 TRANSFER_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/transfers/${txn}/approve`,
  asPlayer(playerToken), { 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) {
  store(txn, json.claim_code, json.claim_code_expires_at);   // json.next === "pending_claim"
} else if (code === "TRANSACTION_NOT_PENDING") {
  // NOT an error. Already approved, read the status and reconcile.
  reconcile(txn, json.current_status);
} else if (code === "TRANSFER_APPROVE_FAILED") {
  // AMBIGUOUS: the money may already have moved. Never retry, never refund.
  reconcileThenReport(txn, json.error_ref);
} else {
  handleRefusal(status, json);
}
Python, handling the repeat call properly
status, body = invo("POST", f"/api/sdk/transfers/{txn}/approve",
                    as_player(player_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:
    store(txn, body["claim_code"], body["claim_code_expires_at"])
elif code == "TRANSACTION_NOT_PENDING":
    reconcile(txn, body["current_status"])          # already approved; not an error
elif code == "TRANSFER_APPROVE_FAILED":
    # AMBIGUOUS: the money may already have moved. Never retry, never refund.
    reconcile_then_report(txn, error_ref=body.get("error_ref"))
else:
    handle_refusal(status, body)

Stage 5. Knowing it moved

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

200 OK
{
  "status": "success",
  "transaction_id": "TXN_...",
  "transaction_type": "transfer",
  "transaction_status": "pending_claim",
  "verification_state": "approved",          // awaiting | approved | completed | expired | failed | unknown
  "amount": "50.00", "net_amount": "45.00",
  "created_at": "2026-09-04T18:00:00+00:00",
  "source_game_id": "...", "target_game_id": "...",
  "currency_id": 12,
  "order_id": "TFRO_...",
  "financial_tracking_status": "...",
  "claim_code": "KJMRS-47281",                 // source title only, and only once approved; otherwise null
  "claim_code_expires_at": "2026-09-05T18:10:00+00:00"   // same rule as claim_code
  // additive: "expiry_reason" ("approval_expired" | "pin_expired" | "claim_expired"), present only when it expired
}

This is not the send status. The transfer status has no claim_info object, no fee_amount, no game names, no currency_name, and no completed_at or claimed_by_* fields once collected: a completed transfer shows only transaction_status and verification_state of "completed". Take amounts and fees from the transfer.received webhook. Polling this moves nothing: if it still says "awaiting" after the player approved, the approve call was never made.

The destination title gets to_phone and to_identity_id on this response for attribution; to_identity_id is null when that number matches more than one of its players.

EventYou receive it asMeans
device_approval.approvedThe title that began the grantA device grant settled. Call approve now. It is a faster cue than the next poll, never a replacement for the approve call.
transfer.claim_pendingSource (direction: outbound) and destination (inbound)Stage 4 succeeded. The destination should surface “you have something to collect”.
transfer.sentSource titleCollected, the source side of completion.
transfer.receivedDestination titleCredited. On the transfer rail: amount_received, gross_amount, fee_breakdown, currency_name, new_balance, and confirmed_via when collected through an Invo surface. (A peer send's copy of this event carries neither gross_amount nor fee_breakdown.)
curl
curl -sS "$BASE/api/transfers/$TXN/status" \
  -H "X-Game-Secret-Key: $GAME_SECRET" | jq '{transaction_status, verification_state, claim_code_expires_at}'
Node, raw HTTP
const { json: s } = await invo("GET", `/api/transfers/${txn}/status`, asGame);
// s.verification_state: "awaiting" | "approved" | "completed" | "expired" | "failed" | "unknown"
Python, raw HTTP
_, s = invo("GET", f"/api/transfers/{txn}/status", as_game)
# s["verification_state"]: "awaiting" | "approved" | "completed" | "expired" | "failed" | "unknown"

Stage 6. Collecting, in the destination title

Receiving is exactly as strong as sending

The player proves it is her, on a device that is hers, with a passkey, the same class of proof she gave to send. A code is bearer value: it belongs to whoever has it. That is why the primary collect below is her own factor, and the claim code is the fallback underneath it.

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

6a. Show the player what is waiting for them

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

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

200 OK
{
  "pending": [
    {
      "transfer_id": "TXN_1757000000_AB12CD",
      "kind": "receiving_confirm",       // something to COLLECT  ("identity_gate" = something to APPROVE)
      "flow": "transfer",                // "transfer" -> /api/sdk/transfers/{id}/confirm-receipt
                                         // "send"     -> /api/sdk/send/{id}/confirm-receipt
      "amount": "45.00",                 // net, what actually lands
      "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 cross-game transfer with flow: "transfer" and a peer send with flow: "send". Reading kind as if it meant one rail is how a collect screen ends up with no button for half the items it is showing.

Two server-side signals complement it, for the destination title rather than the player:

# the webhook
transfer.claim_pending   direction: "inbound"   with to_phone and to_identity_id

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

6b. Self-claim with a factor: confirm-receipt

The primary route. Mint the player’s token in the destination title, produce a factor there, and settle. No claim code, nothing typed. A device grant on this side uses flow: "transfer_receipt".

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

{ "device_code": "..." }                       // from a grant begun with flow "transfer_receipt"
// or { "webauthn_assertion": { ... } }        // challenge first from
//    POST /api/sdk/transfers/{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 player has no account in the destination title yet, so there is nobody to credit. Minting their destination token with player_phone registers them and avoids this answer.Use the claim-code fallback below, it creates the account as part of collecting.
403 not_intended_receiverThe token’s player is not the addressed recipient, or their phone in the destination title does not match the number the transfer was addressed to.Check the token was minted by the destination title and the phone on file matches.
404 receiving_tenant_unavailableThe destination title could not be resolved.Check the transfer’s target title is still live.
409 PHONE_SHARE_APPROVAL_REQUIREDThe number is tied to a different Invo identity network-wide; that person must consent first.Follow the consent flow in the body, 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. completed means 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, destination side, device grant
# the DESTINATION game mints the same player's token, over there
DEST_TOKEN=$(curl -sS -X POST "$BASE/api/sdk/player-token" \
  -H "X-Game-Secret-Key: $DEST_GAME_SECRET" -H "Content-Type: application/json" \
  -d '{"player_email":"ada@example.com","player_phone":"+15555550100","player_name":"Ada"}' | jq -r .token)

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

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

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

const tok = await destSdk.mintPlayerToken({
  playerEmail: "ada@example.com", playerPhone: "+15555550100", playerName: "Ada",
});

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

const { status, json } = await invo("POST", `/api/sdk/transfers/${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 -> claim-transfer (the fallback below). The hosted
//     claim page does NOT serve transfers.
Python, the destination game's server (invonetwork 3.19.0+ mint, raw HTTP grant)
dest_sdk = InvoServer(game_secret=os.environ["DEST_GAME_SECRET"], base_url=BASE)

tok = dest_sdk.mint_player_token(player_email="ada@example.com", player_phone="+15555550100", player_name="Ada")

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

status, body = invo("POST", f"/api/sdk/transfers/{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 -> claim-transfer (the fallback below). The hosted
#     claim page does NOT serve transfers.
Browser, @invonetwork/web-sdk (player signed in at the destination)
const client = new InvoClient({ token: destinationToken, baseUrl });
await client.confirmReceiptTransfer(transactionId);   // challenge + passkey + confirm-receipt in one

Fallback: collect with the claim code

Use this only where 6b cannot run, most often because the player has no account in the destination title yet, which is exactly what 409 receiver_not_enrolled_use_claim_code is telling you. This route creates the player as part of collecting, and it names the currency to credit because a destination title may run more than one.

A code is weaker than a passkey by construction: it authorises whoever holds it. On a transfer that is less exposed than on a send, the code came back privately to your own server rather than to anyone else, but it is still the weaker proof, so do not make it the button players reach for first.

POST $BASE/api/transfers/claim-transfer
X-Game-Secret-Key: <DESTINATION game secret>

{
  "claim_code": "KJMRS-47281",                 // from the Stage 4 approve response
  "target_player_name": "Ada",
  "target_player_email": "ada@example.com",
  "target_player_phone": "+15555550100",       // must match the number the transfer was addressed to
  "target_currency_id": 34                     // which of your currencies to credit
}

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

400 WRONG_CLAIM_ENDPOINT   this code belongs to a peer send, use /api/currency-sends/claim-currency
Node, @invonetwork/web-sdk/server
const destSdk = new InvoServer({ gameSecret: process.env.DEST_GAME_SECRET!, baseUrl });
await destSdk.claimTransfer({
  claimCode: "KJMRS-47281",
  targetPlayerName: "Ada", targetPlayerEmail: "ada@example.com", targetPlayerPhone: "+15555550100",
  targetCurrencyId: 34,
});
Python, invonetwork
dest_sdk = InvoServer(game_secret=os.environ["DEST_GAME_SECRET"], base_url=BASE)
dest_sdk.claim_transfer(
    claim_code="KJMRS-47281",
    target_player_name="Ada", target_player_email="ada@example.com",
    target_player_phone="+15555550100",
    target_currency_id=34,
)

There is no resend for a transfer’s claim code. There is nothing to resend: it came back to the caller at Stage 4, in-band, the source title can read it back from GET /api/transfers/{id}/status, and approve emailed it to the sender’s own bound address when claim_delivery was email. That is a deliberate difference from a peer send, where the recipient is a different person who has to be told.

Stage 7. Expiry and refunds

Which window lapsedEnds up asEvents you receive
The approval window (verification_expires_at), nobody approvedexpired_pin_verification, reservation releasedtransfer.refunded with reason: "pin_expired", to the source title 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 source playertransfer.claim_expired to both titles, plus transfer.refunded with reason: "claim_expired" to the source title

Invo sweeps this on a short cycle rather than to the second, so the refund lands shortly after the deadline. Do not compute expiry yourself and write the transaction off. Wait for transfer.refunded or a status read. An expired transfer is finished; start a new one from Stage 1.

Stage 8. The recovery hold

After a player self-recovers a lost passkey, money leaving their account is paused for a period; retry_after says until when. It refuses every factor, because the threat it exists for is someone who has taken over the phone number.

403 Forbidden        (from BOTH /initiate-transfer and /api/sdk/transfers/{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
}

At initiate nothing is reserved. At approve the reservation stands and is swept back when the approval window lapses. Collecting is unaffected, a held player can still complete an inbound transfer.

Common mistakes on a transfer

  1. Polling to “approved” and stopping. On a transfer this fails twice over: the transaction never advances and no claim code is ever issued, because only the approve call creates one.
  2. Using the game secret on an /api/sdk/* call. Only player-token takes it.
  3. Reusing the source title’s token at the destination. A player token is scoped to one title. Stage 6 needs a second token, minted with the destination title’s secret.
  4. Beginning the grant with the wrong flow. transfer in the source title, transfer_receipt at the destination. A mismatch surfaces at settle time as DEVICE_APPROVAL_NOT_APPROVED.
  5. Sending a send’s claim code to claim-transfer, or the reverse. You get 400 WRONG_CLAIM_ENDPOINT, which names the endpoint you wanted.
  6. Treating TRANSACTION_NOT_PENDING as a failure. It means already done. Read current_status.
  7. Expiring the grant on a local timer. Honour expires_in and interval, and the windows from the responses that carry them.
  8. 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.
  9. Retrying TRANSFER_APPROVE_FAILED. It never gets better on a retry. Capture error_ref and send it to us.