Cross-Game Transfer, Stage by Stage
One player moves their own currency out of your game and into another. It is a self-transfer: the same human is on both ends, so they approve it in the source game and collect it in the destination game. 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 game.
- • 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. If a fallback message does go out on this rail it goes to the sender, not to a recipient, and it carries no collect link, the hosted claim page serves peer sends only.
- • Different paths.
/api/transfers/…and/api/sdk/transfers/…, plural, and the device-grantflowvalues aretransferandtransfer_receipt. - • Collecting names a currency. The claim-code route takes a
target_currency_id, because the destination game may run several.
The map
SOURCE GAME DESTINATION GAME (the same player, over there)
────────────────────────────────────────────────── ─────────────────────────────────────────────────────────
1 POST /api/transfers/initiate-transfer [game secret]
-> transaction_id, verification_expires_at (nothing yet)
2 POST /api/sdk/player-token [game secret]
-> the player's token in THIS game (15 min)
3 the player proves it is them — ONE factor:
in-app passkey -> approve/webauthn/begin, then the assertion
device grant -> begin (flow "transfer"), show QR, poll to "approved"
(guardian gate) -> 202 until the guardian answers, then a factor
4 POST /api/sdk/transfers/{id}/approve [player token] <-- THE CALL THAT MOVES MONEY
{ "device_code": "..." } or { "webauthn_assertion": {...} }
-> { status: "approved", next: "pending_claim",
claim_code: "...", claim_code_expires_at: "..." } the code comes back HERE
5 webhook transfer.claim_pending (outbound) webhook transfer.claim_pending (inbound)
GET /api/transfers/{id}/status [game secret] GET /api/transfers/inbound-pending [game secret]
6 the player collects, in the destination game:
a) mint THEIR token there -> passkey, or a device grant
with flow "transfer_receipt" ->
POST /api/sdk/transfers/{id}/confirm-receipt
b) or type the claim code ->
POST /api/transfers/claim-transfer [game secret]
webhook transfer.sent webhook transfer.received
7 not collected in the claim window -> refunded to the sender; transfer.claim_expired + transfer.refunded
not approved in the approval window -> refunded to the sender; transfer.refundedStep 4 is the one that moves money. Steps 3 and 4 are different calls. A device grant that polls to “approved” has done nothing to the transaction until your side calls /api/sdk/transfers/{id}/approve with that device code. The claim code you need for step 6 only exists in that call’s response.
Helpers used by the raw examples
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. Initiate the transfer (your server)
Game secret. Reserves the amount from the player’s available balance in your game, 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": "155963559928",
"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": { "...": "..." }
},
"verification_required": { "phone_number_masked": "*********0100", "pin_expires_in_minutes": 10 }
}verification_method is a hint about the legacy fallback, not a choice you must obey. "in_app" means Invo saw an approval route for this player and sent no fallback message. "sms" means the legacy PIN fallback was also sent because none was detected yet. Either way, the passkey and device-grant paths below are live on that transaction and are what you should build. verification_required.pin_expires_in_minutes is the same window as verification_expires_at, named after the legacy path.
Refusals to expect here
- •
400, missing or invalid field, insufficient balance, or a transfer to the game it is already in - •
403 SOURCE_GAME_NOT_LIVE/TARGET_GAME_NOT_LIVE, both games 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, duplicateclient_request_id;PHONE_SHARE_APPROVAL_REQUIRED;STEAM_VALUE_NON_TRANSFERABLE;NON_STEAM_VALUE_INTO_STEAM_BLOCKED - •
429, velocity or lockout;503 flow_pausedduring a maintenance pause
A 202 with a guardian_approval block is the minor-account branch, exactly as on a send, see Stage 3c.
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": "155963559928",
"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 t = await invoSdk.initiateTransfer({
clientRequestId: randomUUID(),
sourcePlayerName: "Ada", sourcePlayerEmail: "ada@example.com", sourcePlayerPhone: "+15555550100",
targetPlayerPhone: "+15555550100", targetPlayerEmail: "ada@example.com",
targetGameId: 155963559928,
amount: "50.00",
});
// t.transactionId · t.verificationExpiresAtimport 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=155963559928,
amount="50.00",
)
# t.transaction_id · t.verification_expires_atStage 2. Mint the player’s token in the source game
Game secret in, player token out, scoped to this player in this game. It lives 15 minutes with no refresh, so mint it immediately before Stage 3. You will mint a second token later, in the destination game, for Stage 6, they are different tokens for the same human.
POST $BASE/api/sdk/player-token
X-Game-Secret-Key: <source game secret>
{ "player_email": "ada@example.com" }
200 OK
{ "token": "eyJ...", "expires_at": "2026-09-04T18:15:00+00:00", "identity_id": "9f2c..." }
401 INVALID_GAME_SECRET · 403 TENANT_NOT_MIGRATED · 404 player_not_found
422 identity_unavailable · 503 sdk_verification_disabledPLAYER_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"}' | jq -r .token)const { token: playerToken } = await invoSdk.mintPlayerToken({ playerEmail: "ada@example.com" });player_token = invo_sdk.mint_player_token(player_email="ada@example.com").tokenStage 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.
3a. Device grant, consoles, TVs, native Steam and desktop clients
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'sBeginning 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.
# 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 the only place the claim code appears.
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}'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.
// 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,
});
}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 }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.
# 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,
)_, 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. Passkey in-client, desktop web, mobile web, mobile app
Check this before you build the in-client path. The in-client ceremony runs on your own verified domain, and new partner domains are no longer accepted. For most titles the normal answer is 403 WEBAUTHN_NOT_ENABLED_FOR_TENANT with reason: "no_verified_domain" and hosted_flow.partner_rp_frozen: true. That is not a setup gap you can close, and it is not a malfunction: the body points you at the device approval grant, which runs the same passkey ceremony on Invo's own domain and settles with device_code. Unless you already hold a verified domain, build the device grant.
POST $BASE/api/sdk/transfers/{transaction_id}/approve/webauthn/begin
Authorization: Bearer <the player's token in the SOURCE game>
200 OK -> PublicKeyCredentialRequestOptions, bound to this transaction
400 WEBAUTHN_NO_CREDENTIAL this player has not enrolled a passkey yet. Enrol one, or use 3a
403 WEBAUTHN_NOT_ENABLED_FOR_TENANT your game has no verified passkey domain. This is the NORMAL answer
for a new title (partner domains are frozen). Use 3a instead.
403 WEBAUTHN_TENANT_MISMATCH the token's game is not the source game
# then, with the assertion the browser produced:
POST $BASE/api/sdk/transfers/{transaction_id}/approve
Authorization: Bearer <the same player token>
{ "webauthn_assertion": { "id": "...", "response": { ... } } }import { InvoClient } from "@invonetwork/web-sdk";
const client = new InvoClient({ token: playerToken, baseUrl });
// challenge + passkey ceremony + approve, in one call
const approved = await client.approveTransfer(transactionId);
// approved.claimCode — hold on to it for Stage 6In a browser with no passkey of its own? The web SDK can run the whole device grant from the page with the player token it already holds: approveHosted({ transactionId, flow }) opens the Invo approval page in a popup, shows the match-code prompt, and calls the settle endpoint for you. It is the browser equivalent of everything in this section, and it ends at the same money call.
Call it from a click handler, because the popup is opened synchronously before any network round trip. The page’s postMessage is a wake-up that triggers an immediate poll, never the result.
// 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" | "blocked" | "redirected" | "aborted"
// On "approved", result carries the settle response: the money call already ran.
// On "redirected", your page is gone: the device_approval.approved webhook is your signal.const optsRes = await fetch(`${BASE}/api/sdk/transfers/${txn}/approve/webauthn/begin`, {
method: "POST", headers: { Authorization: `Bearer ${playerToken}` },
});
const options = await optsRes.json();
const credential = await navigator.credentials.get({ publicKey: decodeOptions(options) });
// THE CALL THAT MOVES MONEY
const res = await fetch(`${BASE}/api/sdk/transfers/${txn}/approve`, {
method: "POST",
headers: { Authorization: `Bearer ${playerToken}`, "Content-Type": "application/json" },
body: JSON.stringify({ webauthn_assertion: encodeCredential(credential) }),
});
const approved = await res.json(); // approved.claim_code// The challenge and credential ids travel as base64url strings; the WebAuthn API wants
// ArrayBuffers, and hands them back as ArrayBuffers. These two are the whole conversion.
const b64uToBuf = (s) =>
Uint8Array.from(atob(s.replace(/-/g, "+").replace(/_/g, "/")), (c) => c.charCodeAt(0)).buffer;
const bufToB64u = (b) =>
btoa(String.fromCharCode(...new Uint8Array(b)))
.replace(/+/g, "-").replace(///g, "_").replace(/=+$/, "");
function decodeOptions(options) {
return {
...options,
challenge: b64uToBuf(options.challenge),
allowCredentials: (options.allowCredentials ?? []).map((c) => ({
...c, id: b64uToBuf(c.id),
})),
};
}
function encodeCredential(cred) {
return {
id: cred.id,
rawId: bufToB64u(cred.rawId),
type: cred.type,
response: {
clientDataJSON: bufToB64u(cred.response.clientDataJSON),
authenticatorData: bufToB64u(cred.response.authenticatorData),
signature: bufToB64u(cred.response.signature),
userHandle: cred.response.userHandle ? bufToB64u(cred.response.userHandle) : null,
},
};
}No Python example: the passkey ceremony happens in the browser or mobile client, never on your server.
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 when they have a verified address; a text is an explicit fallback requested through resend_endpoint.
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 shortlyFull 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 also the only place the claim code is ever revealed, 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"
}Store the claim code. It is returned once, to the caller, because on a self-transfer the caller is the person who will collect. 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.
What each refusal means
| Status & code | Meaning | Do this |
|---|---|---|
400 TRANSACTION_NOT_PENDING | Not a failure. Already approved, usually your own retry. Carries current_status. | Read current_status and continue. Note the claim code is not re-issued on this answer, so read it from your own record or fall through to 6a. |
400 DEVICE_APPROVAL_NOT_APPROVED | The 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_INPUT | No factor in the body, or a malformed one. | Send exactly one of the three factor shapes. |
401 SDK_TOKEN_MALFORMED | Not a player token, or it carries no identity. | Re-mint. A 401 after 15 minutes is expected; tokens do not refresh. |
403 not_transfer_sender | The token’s player did not start this transfer. | Mint the token for the source player’s email, in the source game. |
403 PASSKEY_RECOVERY_COOLDOWN | Money 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 codes | The guardian gate is open, closed or lapsed. See 3c. | 202: poll then retry. 410: stop. |
400 TRANSFER_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/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); this one
} else {
handleRefusal(status, json);
}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_status": "pending_claim",
"verification_state": "approved", // awaiting | approved | completed | expired | failed
"amount": "50.00", "net_amount": "45.00",
"claim_info": { "claim_code_expires_at": "2026-09-05T18:10:00+00:00" }
}The destination game 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.
| Event | You receive it as | Means |
|---|---|---|
| device_approval.approved | The game that began the grant | A device grant settled. Call approve now. It is a faster cue than the next poll, never a replacement for the approve call. |
| transfer.claim_pending | Source (direction: outbound) and destination (inbound) | Stage 4 succeeded. The destination should surface “you have something to collect”. |
| transfer.sent | Source game | Collected, the source side of completion. |
| transfer.received | Destination game | Credited. 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 -sS "$BASE/api/transfers/$TXN/status" \
-H "X-Game-Secret-Key: $GAME_SECRET" | jq '{transaction_status, verification_state, claim_info}'const { json: s } = await invo("GET", `/api/transfers/${txn}/status`, asGame);
// s.verification_state: "awaiting" | "approved" | "completed" | "expired" | "failed"_, s = invo("GET", f"/api/transfers/{txn}/status", as_game)
# s["verification_state"]: "awaiting" | "approved" | "completed" | "expired" | "failed"Stage 6. Collecting, in the destination game
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.
6a. Show the player what is waiting for them
The client asks, with the player’s destination-game 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": "Ship Busters",
"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 game 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 game, produce a factor there, and settle. No claim code, no message, 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" }| Refusal | Meaning | Do this |
|---|---|---|
409 receiver_not_enrolled_use_claim_code | This player has no account in the destination game yet, so there is nobody to credit. | Use the claim-code fallback below, it creates the account as part of collecting. |
403 not_intended_receiver | The token’s player is not the addressed recipient, or their phone in the destination game does not match the number the transfer was addressed to. | Check the token was minted by the destination game and the phone on file matches. |
404 receiving_tenant_unavailable | The destination game could not be resolved. | Check the transfer’s target game is still live. |
409 PHONE_SHARE_APPROVAL_REQUIRED | The 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_PENDING | Already collected, or the claim window closed. current_status says which. | Read the status. completed means 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 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"}' | 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\"}"const dest = { "X-Game-Secret-Key": process.env.DEST_GAME_SECRET };
const { json: tok } = await invo("POST", "/api/sdk/player-token", dest, { player_email: "ada@example.com" });
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.dest = {"X-Game-Secret-Key": os.environ["DEST_GAME_SECRET"]}
_, tok = invo("POST", "/api/sdk/player-token", dest, {"player_email": "ada@example.com"})
_, 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.const client = new InvoClient({ token: destinationToken, baseUrl });
await client.confirmReceiptTransfer(transactionId); // challenge + passkey + confirm-receipt in oneFallback: collect with the claim code
Use this only where 6b cannot run, most often because the player has no account in the destination game 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 game 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 into a message, 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-currencyconst 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,
});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, and it is in your own record. That is a deliberate difference from a peer send, where the recipient is a different person who has to be told. If you lost it and the player is enrolled in the destination game, collect through 6b instead.
Stage 7. Expiry and refunds
| Which window lapsed | Ends up as | Events you receive |
|---|---|---|
The approval window (verification_expires_at), nobody approved | expired_pin_verification, reservation released | transfer.refunded with reason: "pin_expired", to the source 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 source player | transfer.claim_expired to both games, plus transfer.refunded with reason: "claim_expired" to the source game |
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 24 hours. 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 24 hours.",
"code": "PASSKEY_RECOVERY_COOLDOWN",
"retry_after": "2026-09-05T18:04:00+00:00", // absolute — show this
"retry_after_seconds": 82800
}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.
Legacy fallback: the SMS PIN
Before passkeys and device grants, a transfer was approved by texting the player a numeric PIN and having them type it back. That path still exists for partners who have not moved yet: POST /api/transfers/verify-sms with the game secret, transaction_id and sms_pin. It reaches the same pending_claim state and returns the same claim code.
It is the legacy path and it is being retired. Do not build a new integration on it. Everything else in this flow has already moved off messaging: the claim code comes back in-band, the destination is told by webhook, guardian consent goes by email, and a new phone is vouched for by a code on your own screen. See Approve a Transfer for the legacy endpoint’s full contract.
Common mistakes on a transfer
- Polling to “approved” and stopping. On a transfer this fails twice over: the transaction never advances and you never receive the claim code, which is only ever returned by the approve call.
- Using the game secret on an
/api/sdk/*call. Onlyplayer-tokentakes it. - Reusing the source game’s token at the destination. A player token is scoped to one game. Stage 6 needs a second token, minted with the destination game’s secret.
- Beginning the grant with the wrong flow.
transferin the source game,transfer_receiptat the destination. A mismatch surfaces at settle time asDEVICE_APPROVAL_NOT_APPROVED. - Sending a send’s claim code to
claim-transfer, or the reverse. You get400 WRONG_CLAIM_ENDPOINT, which names the endpoint you wanted. - Treating
TRANSACTION_NOT_PENDINGas a failure. It means already done. Readcurrent_status. - Expiring the grant on a local timer. Honour
expires_inandinterval, and the windows from the responses that carry them. - Retrying
TRANSFER_APPROVE_FAILED. It never gets better on a retry. Captureerror_refand send it to us.