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.
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}` });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_disabledphone_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.
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)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;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.tokenThe 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, duplicateclient_request_id;PHONE_SHARE_APPROVAL_REQUIRED; the two storefront-origin containment codes (see the error reference) - •
429, velocity or lockout, withretry_afterwhere applicable;503 flow_pausedduring a maintenance pause
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"
}'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 recipientimport 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 recipientStage 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).
| Client | Factor | What 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 popup | Device approval grant, QR (3a) | device_code |
| iOS / Android app | The same hosted page in the system browser (channel: "app_browser"), driven by the Unity / Unreal plugin. See Device Approval | device_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'sThe 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.
# 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\"}"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.
// 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,
});
}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 }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.
# 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,
)# 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.
- SERVER: mint the sender’s token (Stage 1) immediately before the player clicks, and hand it to the page.
- CLIENT: build an
InvoClientwith that token and arefreshTokenhook that asks your server for a new one. The SDK uses the hook once if a call answersSDK_TOKEN_EXPIRED. - CLIENT: call
approveHosted({ transactionId, flow: "send" })from a click handler: the popup is opened synchronously, before any network round trip. - CLIENT: on
status: "approved"the money call has already run andresult.approvalis its response. On"blocked", offer a button that calls it again from a fresh click, or fall back to the QR grant in 3a. - SERVER: confirm with the webhook or the status read (Stage 5).
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.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 shortlySee 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 itsplayer_token. Adding the player’s phone changed their Invo identity, so any token minted before initiate no longer matches them. - •
403withcode: "PLAYER_TOKEN_STALE"(theerrorfield still readsnot_send_sender): mint a new token and retry once. Nothing moved. - •
401withcode: "SDK_TOKEN_EXPIRED": the token ran out. Mint a new one and retry. Nothing moved.
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 };
}
}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, bodyWhat each refusal means
| Status & code | Meaning | Do this |
|---|---|---|
400 TRANSACTION_NOT_PENDING | Not 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_APPROVED | The 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_INPUT | No factor in the body, or a malformed one. | Send exactly one of the three factor shapes. |
401 SDK_TOKEN_MALFORMED | The token carries no identity, or is not a player token. | Re-mint the sender’s token. Tokens do not refresh. |
401 SDK_TOKEN_EXPIRED | The 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_sender | The token’s player is not this send’s sender. | Mint the token for the sender’s email, in the sending game. |
403 PASSKEY_RECOVERY_COOLDOWN | The 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 codes | The guardian gate is open, closed or lapsed. See 3c. | 202: poll and retry after approval. 410: stop. |
400 SEND_APPROVE_FAILED | Our 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_disabled | In-app verification is not switched on for the environment. | Contact Invo. |
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);
}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 expiredPolling 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.
| Event | You receive it as | Means |
|---|---|---|
| device_approval.approved | The title that began the grant | A device grant settled. Call approve now, this is a faster cue than the next poll, not a substitute for the approve call. |
| transfer.claim_pending | Sending game (direction: outbound) and receiving game (inbound) | Stage 4 succeeded. The receiving game should now surface “you have something to collect”. |
| transfer.sent | Sending game | Collected. The sending side of completion. |
| transfer.received | Receiving game | Credited. 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 -sS "$BASE/api/currency-sends/$TXN/status" \
-H "X-Game-Secret-Key: $GAME_SECRET" | jq '{transaction_status, verification_state, claim_info}'const { json: s } = await invo("GET", `/api/currency-sends/${txn}/status`, asGame);
// s.verification_state: "awaiting" | "approved" | "completed" | "expired" | "failed" | "unknown"_, 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" }| Refusal | Meaning | Do this |
|---|---|---|
409 receiver_not_enrolled_use_claim_code | This 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_receiver | The 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_REQUIRED | This 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_PENDING | Already collected, or the claim window closed. current_status says which. | Read the status. If completed, you are done. |
400 CONFIRM_RECEIPT_FAILED | Our 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. |
# 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\"}"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 fallbackreceiving_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 fallbackconst client = new InvoClient({ token: recipientToken, baseUrl });
await client.confirmReceiptSend(transactionId); // challenge + passkey + confirm-receipt in one6c. 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-transferconst receivingSdk = new InvoServer({ gameSecret: process.env.RECEIVING_GAME_SECRET!, baseUrl });
await receivingSdk.claimCurrency({
claimCode: "KJMRS-47281",
receiverPlayerName: "Bo", receiverPlayerEmail: "bo@example.com", receiverPlayerPhone: "+15555550111",
});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 lapsed | Ends up as | Events you receive |
|---|---|---|
The approval window (verification_expires_at), nobody approved | expired_pin_verification, reservation released to the sender | transfer.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 collected | expired_claim, funds returned to the sender | transfer.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
- 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.
- Using the game secret on an
/api/sdk/*call. Onlyplayer-tokentakes it. Everything else takes that player’s bearer token. - 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.
- Beginning the grant with the wrong flow.
sendfor the sender,send_receiptfor the recipient. A mismatch surfaces at approve asDEVICE_APPROVAL_NOT_APPROVED, which reads like the player never approved at all. - Treating
TRANSACTION_NOT_PENDINGas a failure. It means already done. Readcurrent_status. - Expiring the grant on a local timer. Honour
expires_inandintervalfrombegin, and the transaction windows from the responses that carry them. - Minting the token once, at login. Mint it with the phone, mint it again right before approval, and switch to the
player_tokeninitiate returns when it saysidentity_rekeyed: true.PLAYER_TOKEN_STALEandSDK_TOKEN_EXPIREDboth mean: mint and retry once. - Retrying
SEND_APPROVE_FAILED. It is never the caller’s fault and never gets better on a retry. Captureerror_refand send it to us.