Collecting, Stage by Stage
The receiving half of both money flows, a peer send addressed to your player, and a cross-game transfer arriving from another game. One page, both rails, both surfaces: in-app with a passkey, and by QR on consoles, TVs and native Steam builds. If you only build one screen from these docs, build this one: a player who cannot collect is a player whose money is stuck until it refunds.
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 to send it. That symmetry is the design, not a nicety. A code in a message is bearer value: it belongs to whoever reads the message first, and it cannot tell the difference between the person it was meant for and the person holding the phone.
So collecting in your game is confirm-receipt with a factor. The claim code still exists, for a recipient who has no Invo passkey and cannot get one, and it is documented at the bottom of this page as what it is: the fallback.
The map
Something reached pending_claim. Your game is the receiving side.
1 YOU ARE TOLD
webhook transfer.claim_pending direction "inbound" (server; carries to_phone, to_identity_id)
or, on demand: GET /api/transfers/inbound-pending [game secret]
2 THE PLAYER IS SHOWN
GET /api/sdk/transfers/pending [the RECIPIENT's player token]
-> items with kind "receiving_confirm" and a "flow" of "send" or "transfer"
THE "flow" FIELD PICKS THE ENDPOINT. Nothing else does.
3 THE PLAYER PROVES IT IS HER — ONE factor
in-app / web: POST /api/sdk/{send|transfers}/{id}/confirm-receipt/webauthn/begin
-> passkey ceremony -> assertion
console / Steam: POST /api/sdk/approvals/device/begin
flow "send_receipt" (a send) | "transfer_receipt" (a transfer)
-> show QR -> poll to "approved" -> device_code
4 THE CALL THAT CREDITS
flow "send" -> POST /api/sdk/send/{id}/confirm-receipt [her player token]
flow "transfer" -> POST /api/sdk/transfers/{id}/confirm-receipt [her player token]
-> { status: "completed", amount_received: "45.00" }
5 YOU CONFIRM
webhook transfer.received (you) + transfer.sent (the sending game)
GET /api/{currency-sends|transfers}/{id}/status -> transaction_status "completed"
Not collected before claim_code_expires_at -> refunded to the sender.
You receive transfer.claim_expired; the sender's game receives transfer.refunded.Step 4 is the one that credits the player. Steps 3 and 4 are different calls. A device grant that polls to “approved” has proved who and moved nothing. Until you call confirm-receipt with that device code, the value is still sitting in the claim window, counting down to a refund.
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_RECEIVING_GAME_SECRET; // YOUR game — the receiving one. 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, requests
BASE = os.environ["INVO_BASE_URL"] # https://invo.network | https://sandbox.invo.network/sandbox
GAME_SECRET = os.environ["INVO_RECEIVING_GAME_SECRET"] # YOUR game — the receiving one. 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}"}The lifecycle, from the recipient’s side
Nothing is collectable until the sender has approved. Before that, the value does not exist for you: it is reserved on the sender’s balance in another game and may never arrive.
| transaction_status | Visible to you? | What the recipient can do |
|---|---|---|
| pending_pin_verification | No. Nothing is addressed to you yet. | Nothing. The sender has not approved. |
| pending_claim | Yes, transfer.claim_pending arrives, the item appears in the pending list, and inbound-pending lists it. | Collect. This is the whole window, and it closes at claim_code_expires_at. |
| completed | Yes, transfer.received, with the credited amount and the new balance. | Nothing. Credited. Terminal. |
| expired_claim | Yes, transfer.claim_expired. | Nothing; the sender was refunded. The sender must start again. |
The claim window defaults to 24 hours today, but read expires_at off the pending item or claim_code_expires_at off the status response rather than hard-coding it. Invo sweeps expired items on a short cycle, so an uncollected item goes quiet shortly after its deadline rather than at the exact second.
Stage 1. Your server learns there is something inbound
Two server-side signals, both authenticated as your game. Wire the webhook; use the enumeration for reconciliation and for a player who opens your game before your webhook handler caught up.
The webhook
{
"event_id": "...",
"idempotency_key": "...", // DEDUPE ON THIS
"event_type": "transfer.claim_pending",
"schema_version": "1.0",
"created_at": "2026-09-04T18:00:00+00:00",
"tenant_id": "155963559928",
"data": {
"transaction_id": "TXN_1757000000_AB12CD",
"direction": "inbound", // "outbound" is the sending game's copy — ignore it if you see it
"amount": "50.00",
"net_transfer_amount": "45.00",
"claim_code_expires_at": "2026-09-05T18:10:00+00:00",
"from_game_id": "...", "to_game_id": "...", "from_player_id": "...",
"to_phone": "+15555550111", // attribution: match to your player's phone
"to_identity_id": "9f2c..." // null when that phone matches more than one of your players
}
}Attribution is on you, and it is phone-first. Both rails address the recipient by phone number. to_identity_id is a stable opaque id you can store against your player, but it is only populated when the number resolves to exactly one of your players, so match on to_phone and treat to_identity_id as a confirmation when present, never as the only key.
Enumerating on demand
GET $BASE/api/transfers/inbound-pending?player_phone=%2B15555550111
X-Game-Secret-Key: <YOUR (receiving) game secret>
# player_email works as the query too. One of the two is required.
200 OK
{
"inbound_pending": [
{
"transaction_id": "TXN_1757000000_AB12CD",
"flow": "send", // "send" | "transfer"
"amount": "50.00", // gross
"net_amount": "45.00", // what lands
"source_game_id": "...", "source_game": "Ship Busters",
"to_phone": "+15555550111", "to_identity_id": "9f2c...",
"created_at": "...", "claim_code_expires_at": "..."
}
]
}
400 player_email or player_phone query parameter is required
401 bad or missing game secretIt returns only items whose destination is your game, only while they are still collectable, and never the claim code, the claim code is the recipient’s, not yours.
curl -sS -G "$BASE/api/transfers/inbound-pending" \
--data-urlencode "player_phone=+15555550111" \
-H "X-Game-Secret-Key: $GAME_SECRET" | jq '.inbound_pending'const q = new URLSearchParams({ player_phone: "+15555550111" });
const { json } = await invo("GET", `/api/transfers/inbound-pending?${q}`, asGame);
for (const row of json.inbound_pending) {
// row.flow decides the endpoint later; row.claim_code_expires_at drives the countdown you show
surfaceCollectPrompt(row);
}from urllib.parse import urlencode
q = urlencode({"player_phone": "+15555550111"})
_, body = invo("GET", f"/api/transfers/inbound-pending?{q}", as_game)
for row in body["inbound_pending"]:
# row["flow"] decides the endpoint later; row["claim_code_expires_at"] drives the countdown
surface_collect_prompt(row)Stage 2. The player’s own list, and the field that decides everything
This is the call your collect screen is built from. It is authenticated as the recipient, not as your game, and returns only what that identity may act on, no PII, no other players, no claim codes. Mint her token first with your (the receiving game’s) secret:
POST $BASE/api/sdk/player-token
X-Game-Secret-Key: <YOUR (receiving) game secret>
{ "player_email": "bo@example.com" }
-> { "token": "eyJ...", "expires_at": "...", "identity_id": "..." } // 15 minutes, no refresh
GET $BASE/api/sdk/transfers/pending
Authorization: Bearer <the RECIPIENT's player token>
200 OK
{
"pending": [
{
"transfer_id": "TXN_1757000000_AB12CD",
"kind": "receiving_confirm",
"flow": "send",
"amount": "45.00",
"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. Never on kind.
kind: "receiving_confirm" means “something to collect”. It says nothing about which rail. Both rails appear under it:
flow === "send" -> POST /api/sdk/send/{transfer_id}/confirm-receipt
flow === "transfer" -> POST /api/sdk/transfers/{transfer_id}/confirm-receiptA team read receiving_confirm as “transfers only”, handled just those, and shipped a collect screen with no button on the send rows. The rows were there the whole time. Peer sends are first-class in this list; cross-game transfers appear alongside them once the transfer self-claim route is enabled for the environment.
Every field on a pending item
| Field | Type | Meaning |
|---|---|---|
| transfer_id | string | The transaction id. It goes in the confirm-receipt path. (The key is named transfer_id on both rails.) |
| kind | string | "receiving_confirm", something to collect. "identity_gate", something this same player started and must approve; that is the sender side, and it calls /approve, not confirm-receipt. |
| flow | string | "send" or "transfer", the only field that decides which endpoint to call. Also decides the device-grant flow value: send_receipt or transfer_receipt. |
| amount | string | On a receiving_confirm item this is the net, what will actually land, after fees. Show this number. (On an identity_gate item it is the gross the sender is spending.) |
| currency | string | Display name of the currency involved. May be null if the currency has been removed. |
| counterparty_game | string | On a receiving_confirm item, the source game’s name, “45 Gold from Ship Busters”. A name only; no ids, no player data. |
| expires_at | ISO 8601 | The end of the claim window. Drive your countdown from this, not from a local clock. |
| step_up_required | bool | A hint that this value is large enough to want a stronger prompt. Advisory, the endpoint is the authority and will refuse if it needs more. |
| held | bool | True when the item is visible but not actionable from this device right now. Show it greyed with the reason rather than hiding it. |
| hold_reason | string | null | Why. device_requires_elevation, this device is not cleared for money actions. guardian_pending, a guardian has yet to answer (only ever on identity_gate items; receiving is never guardian-gated). |
The list is deduplicated by transfer_id and capped, newest first. It is the authoritative list: a push notification or a webhook is only a wake-up, and the client should always re-read this before rendering a prompt. Poll it on a relaxed cadence, every 20 to 30 seconds while a collect screen is open is plenty.
# 1. your server mints the RECIPIENT's token, with YOUR game secret
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":"bo@example.com"}' | jq -r .token)
# 2. what is waiting for her
curl -sS "$BASE/api/sdk/transfers/pending" -H "Authorization: Bearer $PLAYER_TOKEN" \
| jq '[.pending[] | select(.kind=="receiving_confirm")]'const { json: tok } = await invo("POST", "/api/sdk/player-token", asGame, {
player_email: "bo@example.com",
});
const { json } = await invo("GET", "/api/sdk/transfers/pending", asPlayer(tok.token));
const toCollect = json.pending.filter((i) => i.kind === "receiving_confirm");
for (const item of toCollect) {
// THE ONE BRANCH THAT MATTERS. Do not assume the rail.
const path = item.flow === "transfer"
? `/api/sdk/transfers/${item.transfer_id}/confirm-receipt`
: `/api/sdk/send/${item.transfer_id}/confirm-receipt`;
render({
title: `${item.amount} ${item.currency} from ${item.counterparty_game}`,
expiresAt: item.expires_at,
disabled: item.held,
disabledReason: item.hold_reason,
onCollect: () => collect(path, item), // Stage 3 + 4
});
}import { InvoClient } from "@invonetwork/web-sdk";
const client = new InvoClient({ token: recipientToken, baseUrl });
const { pending } = await client.getPendingCollect();
// Same list as GET /api/sdk/transfers/pending, camelCased:
// item.transferId (the backend key is transfer_id)
// item.kind "receiving_confirm" | "identity_gate"
// item.flow "send" | "transfer" <- THE BRANCH
// item.amount, item.currency, item.counterpartyGame
// item.expiresAt, item.stepUpRequired, item.held, item.holdReason
for (const item of pending.filter((i) => i.kind === "receiving_confirm")) {
// Branch on flow. kind does NOT tell you the rail.
await (item.flow === "transfer"
? client.confirmReceiptTransfer(item.transferId)
: client.confirmReceiptSend(item.transferId));
}_, tok = invo("POST", "/api/sdk/player-token", as_game, {"player_email": "bo@example.com"})
_, body = invo("GET", "/api/sdk/transfers/pending", as_player(tok["token"]))
to_collect = [i for i in body["pending"] if i["kind"] == "receiving_confirm"]
for item in to_collect:
# THE ONE BRANCH THAT MATTERS. Do not assume the rail.
if item["flow"] == "transfer":
path = f"/api/sdk/transfers/{item['transfer_id']}/confirm-receipt"
else:
path = f"/api/sdk/send/{item['transfer_id']}/confirm-receipt"
render(
title=f"{item['amount']} {item['currency']} from {item['counterparty_game']}",
expires_at=item["expires_at"],
disabled=item["held"],
disabled_reason=item["hold_reason"],
on_collect=lambda p=path, it=item: collect(p, it), # Stage 3 + 4
)One list, two jobs. The same endpoint also returns kind: "identity_gate" items, money this player is sending and has yet to approve. If you render one combined “needs you” inbox, branch on kind to choose approve versus collect, and then on flow to choose the endpoint within either. See Peer Send and Cross-Game Transfer for the approve side.
Stage 3. The recipient proves it is her (one factor)
Exactly the same two factors as the sending side, chosen by what the client can do. Each ends with something you pass to Stage 4; neither credits anything on its own.
3a. Passkey in-client, desktop web, mobile web, mobile app
Fetch a challenge bound to this transaction, run the ceremony, send the assertion to confirm-receipt. The endpoint you fetch the challenge from is chosen by the item’s flow, exactly like the settle call.
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.
flow "send":
POST $BASE/api/sdk/send/{transaction_id}/confirm-receipt/webauthn/begin
flow "transfer":
POST $BASE/api/sdk/transfers/{transaction_id}/confirm-receipt/webauthn/begin
Authorization: Bearer <the RECIPIENT's player token>
200 OK -> PublicKeyCredentialRequestOptions, bound to this transaction
400 WEBAUTHN_NO_CREDENTIAL she has no passkey enrolled yet. Enrol one, or use 3b
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 3b.
403 not_intended_receiver the token's identity is not this item's recipient, OR the token
was minted by the wrong game. On the two RECEIPT flows a tenant
mismatch answers with this code, not WEBAUTHN_TENANT_MISMATCH.
404 no such transaction on that railimport { InvoClient } from "@invonetwork/web-sdk";
const client = new InvoClient({ token: recipientToken, baseUrl });
// challenge + passkey ceremony + confirm-receipt, in one call
await (item.flow === "transfer"
? client.confirmReceiptTransfer(item.transfer_id)
: client.confirmReceiptSend(item.transfer_id));In 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: "send_receipt",
// 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 rail = item.flow === "transfer" ? "transfers" : "send";
// 1. transaction-bound challenge
const optsRes = await fetch(`${BASE}/api/sdk/${rail}/${item.transfer_id}/confirm-receipt/webauthn/begin`, {
method: "POST", headers: { Authorization: `Bearer ${recipientToken}` },
});
const options = await optsRes.json();
// 2. the ceremony (decode the base64url fields per the WebAuthn spec first)
const credential = await navigator.credentials.get({ publicKey: decodeOptions(options) });
// 3. THE CALL THAT CREDITS
const res = await fetch(`${BASE}/api/sdk/${rail}/${item.transfer_id}/confirm-receipt`, {
method: "POST",
headers: { Authorization: `Bearer ${recipientToken}`, "Content-Type": "application/json" },
body: JSON.stringify({ webauthn_assertion: encodeCredential(credential) }),
});
const done = await res.json(); // { status: "completed", amount_received: "45.00" }// 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 for this factor: the ceremony runs in the browser or the mobile client, never on your server. Your Python backend mints the token (Stage 2) and reads status (Stage 5).
3b. Device grant, consoles, TVs, native Steam and desktop clients
Where the client cannot invoke the platform authenticator, the ceremony moves to her phone on Invo’s own page. Same begin/show/poll sequence as the sending side; the only difference is the flow value.
Pending item’s flow | Device-grant flow | Settle at |
|---|---|---|
"send" | "send_receipt" | /api/sdk/send/{id}/confirm-receipt |
"transfer" | "transfer_receipt" | /api/sdk/transfers/{id}/confirm-receipt |
POST $BASE/api/sdk/approvals/device/begin
Authorization: Bearer <the RECIPIENT's player token>
{ "transaction_id": "TXN_...", "flow": "send_receipt", "channel": "qr" }
200 OK
{
"device_code": "b1s4...", // secret; your server keeps it, never renders it
"user_code": "K7QP-3MRD", // print under the QR for a player who would rather type
"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
POST $BASE/api/sdk/approvals/device/poll
Authorization: Bearer <the same recipient token>
{ "device_code": "b1s4..." }
200 { "status": "approved", "transaction_id": "TXN_...", "flow": "send_receipt", "approved_at": "..." }
400 { "error": "authorization_pending", "interval": 5 } keep polling
400 { "error": "slow_down", "interval": 5 } back off, then resume
400 { "error": "expired_token" } begin again
400 { "error": "access_denied" } she declined; stop
400 { "error": "invalid_grant" } unknown code, or not hersThe receipt grant and the sender’s grant do not collide. They share a transaction id but are tracked per flow, so a recipient can begin send_receipt while the sender’s send grant is still on record.
Beginning again. 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 it is already approved and inside its window you get 409 DEVICE_APPROVAL_ALREADY_PENDING with expires_at: settle with the code you already hold.
First-time phones. If her phone has no Invo passkey, the authorization_pending body carries an enrollment object (state, device_label, match_code, recovery). Show “Set up Invo on this phone: <label>, code <match_code>?” and answer at POST /api/sdk/approvals/device/confirm-enrollment with { device_code, decision }. The screen she is already looking at is the proof, nothing is messaged to anyone.
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_receipt\",\"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
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
# THE CALL THAT CREDITS. Do not stop at "approved" above.
curl -sS -X POST "$BASE/api/sdk/send/$TXN/confirm-receipt" \
-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: recipientToken,
transactionId: item.transferId,
flow: "send_receipt",
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(item.transferId, grant.deviceCode);
const outcome = await invoSdk.completeDeviceApproval({
playerToken: recipientToken,
transactionId: item.transferId,
flow: "send_receipt",
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 "completed" + settlement.amountReceived on success.
// settlement.status "not_pending": already collected, or the claim window closed.
// settlement.alreadySettled true means it is already collected. false covers "not
// there yet" too (the SENDER may not have approved), so read settlement.currentStatus.
// settlement.holdReason: a 202 hold (RECIPIENT_IDENTITY_PENDING, ...).
// 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: recipientToken, 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: recipientToken, 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: recipientToken, transactionId: item.transferId, flow: "send_receipt", deviceCode,
});
}const sleep = (s) => new Promise((r) => setTimeout(r, s * 1000));
const rail = item.flow === "transfer" ? "transfers" : "send";
const grantFlow = item.flow === "transfer" ? "transfer_receipt" : "send_receipt";
const { json: grant } = await invo("POST", "/api/sdk/approvals/device/begin", asPlayer(recipientToken), {
transaction_id: item.transfer_id, flow: grantFlow, 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(recipientToken), {
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(recipientToken), {
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}`);
}
// THE CALL THAT CREDITS
const { status, json: done } = await invo("POST",
`/api/sdk/${rail}/${item.transfer_id}/confirm-receipt`,
asPlayer(recipientToken), { device_code: grant.device_code });
// 200 -> { status: "completed", amount_received: "45.00" }import os
from invonetwork import InvoServer
invo_sdk = InvoServer(game_secret=os.environ["INVO_RECEIVING_GAME_SECRET"], base_url=os.environ["INVO_BASE_URL"])
grant_flow = "transfer_receipt" if item["flow"] == "transfer" else "send_receipt"
# 1. BEGIN is always yours: your game draws the QR between begin and the first poll.
grant = invo_sdk.begin_device_approval(
player_token=recipient_token,
transaction_id=item["transfer_id"],
flow=grant_flow,
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, 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 the status endpoint is the only way to learn whether
# the credit landed.
record_attempt(item["transfer_id"], 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=recipient_token,
transaction_id=item["transfer_id"],
flow=grant_flow,
device_code=grant.device_code,
interval=grant.interval,
on_enrollment=on_enrollment,
)
if outcome.status != "approved":
return handle_decline_or_expiry(outcome)
settlement = outcome.settlement # the money step has already run
# settlement.status "completed" + settlement.amount_received on success.
# settlement.status "not_pending": already collected, or the claim window closed.
# already_settled True means it is already collected. False ALSO covers "not there
# yet" (the SENDER may not have approved), so read settlement.current_status.
# settlement.hold_reason: a 202 hold (RECIPIENT_IDENTITY_PENDING, ...).rail = "transfers" if item["flow"] == "transfer" else "send"
grant_flow = "transfer_receipt" if item["flow"] == "transfer" else "send_receipt"
_, grant = invo("POST", "/api/sdk/approvals/device/begin", as_player(recipient_token),
{"transaction_id": item["transfer_id"], "flow": grant_flow, "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(recipient_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(recipient_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}")
# THE CALL THAT CREDITS
status, done = invo("POST", f"/api/sdk/{rail}/{item['transfer_id']}/confirm-receipt",
as_player(recipient_token), {"device_code": grant["device_code"]})
# 200 -> {"status": "completed", "amount_received": "45.00"}Stage 4. Confirm receipt. The call that credits.
A factor is not a settlement. The passkey assertion or the approved device grant proves who. It does not credit anything. This call does, it re-checks that she is the addressed recipient, that the phone on file matches, that any consent gate is satisfied, and that the item is still inside its claim window, and only then moves the value into her balance.
flow "send": POST $BASE/api/sdk/send/{transaction_id}/confirm-receipt
flow "transfer": POST $BASE/api/sdk/transfers/{transaction_id}/confirm-receipt
Authorization: Bearer <the RECIPIENT's player token, minted by the RECEIVING game>
Content-Type: application/json
{ "device_code": "b1s4..." } // the receipt grant you polled to "approved"
// or { "webauthn_assertion": { ... } } // the in-client passkey
// or { "device_fingerprint": "...", "device_signal": { ... }, "biometric_verified": true }
200 OK
{ "status": "completed", "transaction_id": "TXN_...", "amount_received": "45.00" }After a 200 the balance has moved, the transaction is completed, and transfer.received has been queued to you and transfer.sent to the sending game. amount_received is the net, the same number the pending item showed.
What each refusal means
| Status & code | Meaning | Do this |
|---|---|---|
400 TRANSACTION_NOT_PENDING | Not a failure. Already collected, or the claim window closed. Carries current_status. | Read current_status. completed means you are done, refresh the balance and drop the prompt. Do not retry. |
409 receiver_not_enrolled_use_claim_code | There is no player row for this identity in your game, so there is nobody to credit. | Split by rail. flow: "send" → the hosted claim page, or claim-currency. flow: "transfer" → claim-transfer only: the hosted claim page does not serve transfers and answers an opaque 404 for a transfer's claim code. Both create the player as part of collecting. |
403 not_intended_receiver | The token’s identity is not this item’s recipient, or the phone on her player row does not match the number the value was addressed to. | Check you minted the token in your game for the right player, and that her phone on file is the addressed number. |
404 receiving_tenant_unavailable | The receiving game could not be resolved. | Check the game is still live. Otherwise raise it with Invo. |
409 PHONE_SHARE_APPROVAL_REQUIRED | This phone number is tied network-wide to a different Invo identity. That person must consent before value lands on it here. | Follow the consent flow the body describes, then retry. Consent is requested by email where a verified address exists. |
202 RECIPIENT_IDENTITY_PENDING | An extra recipient-identity confirmation is in flight. Not a failure and not final. | Tell her a confirmation was sent and to retry once she has answered. |
403 RECIPIENT_IDENTITY_DECLINED | That confirmation was declined. Terminal for this attempt. | Stop. The value stays with the sender and refunds at expiry. |
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 the flow first. A receipt grant must be send_receipt / transfer_receipt, a grant begun as send or transfer lands here and reads like she never approved. |
400 no_registered_device_key | The device-signal factor was used but no enrolled key matches. | Enrol the device, or use a passkey or device grant instead. |
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. |
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, 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 | The route is not switched on for this environment. On the transfer rail this is also what you see when transfer self-claim is off. | Use the claim-code fallback and contact Invo. |
async function collect(item, recipientToken, factorBody) {
const rail = item.flow === "transfer" ? "transfers" : "send";
const { status, json } = await invo("POST",
`/api/sdk/${rail}/${item.transfer_id}/confirm-receipt`,
asPlayer(recipientToken), factorBody);
if (status === 200) return credited(json.amount_received);
// Some refusals carry "code", others carry "error_code", a few only "error".
// Check all three or you will fall through to default on the ones that matter.
switch (json.code ?? json.error_code ?? json.error) {
case "TRANSACTION_NOT_PENDING":
// NOT an error. Already collected, or the window closed.
return reconcile(item.transfer_id, json.current_status);
case "receiver_not_enrolled_use_claim_code":
// RAIL-SPECIFIC. The hosted claim page serves peer sends only.
return item.flow === "transfer"
? offerClaimTransfer(item) // claim-transfer, game secret
: offerHostedClaimOrClaimCurrency(item); // hosted /claim page, or claim-currency
case "PHONE_SHARE_APPROVAL_REQUIRED":
return startConsentFlow(json); // retry after consent
case "RECIPIENT_IDENTITY_PENDING":
return tellPlayerToConfirmThenRetry();
case "CONFIRM_RECEIPT_FAILED":
return logForSupport({ txn: item.transfer_id, errorRef: json.error_ref }); // never retry
default:
return handleRefusal(status, json);
}
}def collect(item, recipient_token, factor_body):
rail = "transfers" if item["flow"] == "transfer" else "send"
status, body = invo("POST", f"/api/sdk/{rail}/{item['transfer_id']}/confirm-receipt",
as_player(recipient_token), factor_body)
if status == 200:
return credited(body["amount_received"])
# Some refusals carry "code", others "error_code", a few only "error".
# Check all three or you will fall through on the ones that matter.
code = body.get("code") or body.get("error_code") or body.get("error")
if code == "TRANSACTION_NOT_PENDING":
return reconcile(item["transfer_id"], body["current_status"]) # not an error
if code == "receiver_not_enrolled_use_claim_code":
# RAIL-SPECIFIC. The hosted claim page serves peer sends only.
if item["flow"] == "transfer":
return offer_claim_transfer(item) # claim-transfer, game secret
return offer_hosted_claim_or_claim_currency(item) # hosted /claim page, or claim-currency
if code == "PHONE_SHARE_APPROVAL_REQUIRED":
return start_consent_flow(body) # retry after consent
if code == "RECIPIENT_IDENTITY_PENDING":
return tell_player_to_confirm_then_retry()
if code == "CONFIRM_RECEIPT_FAILED":
return log_for_support(txn=item["transfer_id"], error_ref=body.get("error_ref")) # never retry
return handle_refusal(status, body)Stage 5. Confirming it landed
The 200 from Stage 4 is authoritative for the client. Your ledger should be driven by the webhook, because it also fires for value collected through routes your server never called, the hosted claim page, or the player’s Invo app.
A peer send, note there is no gross_amount and no fee_breakdown on this rail:
{
"event_type": "transfer.received",
"schema_version": "1.0",
"idempotency_key": "...", // DEDUPE ON THIS
"tenant_id": "<your game id>",
"data": {
"transaction_id": "TXN_...",
"order_id": "ORD_1757000000_A1B2C3D4",
"direction": "inbound",
"flow": "currency_send", // present on the SEND rail only
"player_email": "bo@example.com",
"identity_id": "9f2c...",
"amount_received": "45.00", // net. the only amount on this rail
"currency_name": "Gold",
"from_tenant_id": "...", "from_player_email": "...", "from_identity_id": "...",
"new_balance": "1045.00", // canonical post-write balance. display this
"confirmed_via": "sdk_receipt" // see the table below. ABSENT on the legacy claim-code path
}
}A cross-game transfer, this rail adds the gross and the fee split, and carries no flow key:
{
"event_type": "transfer.received",
"schema_version": "1.0",
"idempotency_key": "...",
"tenant_id": "<your game id>",
"data": {
"transaction_id": "TXN_...",
"order_id": "TFRO_1757000000_A1B2C3D4",
"direction": "inbound",
"player_email": "ada@example.com",
"identity_id": "9f2c...",
"amount_received": "45.00", // net
"gross_amount": "50.00", // TRANSFER RAIL ONLY
"fee_breakdown": { // TRANSFER RAIL ONLY
"total_fee": "5.00", "losing_game_fee": "1.75",
"gaining_game_fee": "1.75", "invo_fee": "1.50",
"net_amount_received": "45.00"
},
"currency_name": "Gold",
"from_tenant_id": "...", "from_player_email": "...", "from_identity_id": "...",
"new_balance": "1045.00",
"confirmed_via": "sdk_receipt"
}
}Write your ledger row off the fields that exist on the rail you received. A handler that reads gross_amount or fee_breakdown unconditionally gets undefined on every peer send. On the send rail, derive the gross from your own record of the transaction, or read it from the status endpoint.
confirmed_via says which surface collected it: "sdk_receipt" for confirm-receipt (either rail), "hosted_claim" for the hosted claim page, and the key is absent entirely when the value was collected through the legacy claim-code endpoints. Treat a missing key as "collected by claim code", not as an error.
And the direct read, with your game secret, the same status endpoints the sending side uses, chosen by rail:
GET $BASE/api/currency-sends/{transaction_id}/status # a peer send
GET $BASE/api/transfers/{transaction_id}/status # a cross-game transfer
X-Game-Secret-Key: <your game secret>
-> transaction_status: "completed", verification_state: "completed",
completed_at, claimed_by_player_name, claimed_by_player_emailcurl -sS "$BASE/api/currency-sends/$TXN/status" \
-H "X-Game-Secret-Key: $GAME_SECRET" | jq '{transaction_status, verification_state, completed_at}'const rail = item.flow === "transfer" ? "transfers" : "currency-sends";
const { json: s } = await invo("GET", `/api/${rail}/${item.transfer_id}/status`, asGame);
// s.transaction_status === "completed"rail = "transfers" if item["flow"] == "transfer" else "currency-sends"
_, s = invo("GET", f"/api/{rail}/{item['transfer_id']}/status", as_game)
# s["transaction_status"] == "completed"Stage 6. Recipients who are not in your game
Peer sends only. The hosted page resolves a send claim code; a transfer's claim code gets an opaque 404 there, so a transfer that answers 409 receiver_not_enrolled_use_claim_code goes to claim-transfer instead.
A peer send is addressed to a phone number, and the person behind it may have no account with you at all. Invo notifies them with a link to a hosted page at https://invo.network/claim (https://sandbox.invo.network/claim in sandbox).
The hosted page is not a code box. It takes her email and mints her a passkey, then collects, the same class of proof as Stage 3, run on Invo’s own domain because there is no client of yours to run it in. You build nothing for this. When it completes you receive transfer.received exactly as if she had collected in your game, and the player row is created on your side as part of it.
This is the one place a text message is still sent, and it is deliberate. A phone number is all the sender gave us: there is no address to email and no app to notify. Where the recipient is reachable in-app, the message is suppressed and she collects through Stage 4 instead. Everywhere else in these flows, notification is in-app or by email.
The sender can re-deliver that notification once the send is in its claim window: see Peer Send, “The sender’s resend”. There is no equivalent on the transfer rail, and nothing on that rail is addressed to a recipient: the claim code is returned in-band to the caller, and any fallback message goes to the sender and carries no collect link.
Fallback only: collecting with a claim code
This is not how a recipient collects in your game, and you should not build a “enter your code” screen. In-game code entry was removed from the recommended flow deliberately: a code in a message is bearer value, and the person who reads the message first is not necessarily the person the money was meant for. Everything above exists so that receiving is as strong as sending.
The endpoints remain for the one case the stronger routes cannot serve, a recipient with no Invo passkey who cannot get one, and because they create the receiving player as part of collecting.
They are not interchangeable, and neither is the hosted page. A peer send goes to claim-currency or the hosted page. A cross-game transfer goes to claim-transfer only. Branch on the item's flow, exactly as you do for confirm-receipt.
# a peer send
POST $BASE/api/currency-sends/claim-currency
X-Game-Secret-Key: <your game secret>
{ "claim_code", "receiver_player_name", "receiver_player_email", "receiver_player_phone" }
# a cross-game transfer (also names the currency to credit)
POST $BASE/api/transfers/claim-transfer
X-Game-Secret-Key: <your game secret>
{ "claim_code", "target_player_name", "target_player_email", "target_player_phone", "target_currency_id" }
400 WRONG_CLAIM_ENDPOINT you used the other rail's endpoint; the body names the right oneFull contracts, including the attempt limits and lockout behaviour, are on Claim Sent Currency and Claim a Transfer. Both are documented as the legacy route they are.
What your server and client must call, in order
| # | Who | Call | Auth | Success |
|---|---|---|---|---|
| 1 | Your server | Receive transfer.claim_pending (direction: inbound), or GET /api/transfers/inbound-pending | Game secret | You know a collect is available and who it is for |
| 2 | Your server | POST /api/sdk/player-token | Game secret | The recipient’s token, 15 minutes |
| 3 | Client | GET /api/sdk/transfers/pending | Her player token | receiving_confirm items; read flow |
| 4a | Client (web / mobile) | POST /api/sdk/{send|transfers}/{id}/confirm-receipt/webauthn/begin then the ceremony | Her player token | An assertion. 403 WEBAUTHN_NOT_ENABLED_FOR_TENANT here is the normal answer for a title with no verified domain: use 4b instead. |
| 4b | Server (console / Steam) | POST /api/sdk/approvals/device/begin (flow: send_receipt | transfer_receipt) → show QR → /poll to approved | Her player token | A device_code |
| 5 | Either | POST /api/sdk/{send|transfers}/{id}/confirm-receipt with the factor | Her player token | status: "completed", the money has moved |
| 6 | Your server | Receive transfer.received, or read the status endpoint | Game secret | Ledger updated, new_balance displayed |
Step 4 and step 5 are separate calls, on both surfaces. Step 4 proves who; step 5 moves the money.
Common mistakes on the collecting side
- Assuming
receiving_confirmmeans transfers only, and dropping the send rows. A real integration shipped this: the collect screen rendered the transfer items and silently ignored every peer send, so players saw a list with no button and their money expired.kindtells you approve-versus-collect;flowtells you the rail. Handle both values offlowfrom day one. - Polling a device grant to “approved” and stopping. Same mistake as on the sending side, same cost. The grant is a factor; confirm-receipt is the settlement.
- Beginning the grant with the sender’s flow. The receiving side is
send_receipt/transfer_receipt. A grant begun assendortransferis refused at confirm-receipt asDEVICE_APPROVAL_NOT_APPROVED, which reads like the player never approved. - Using the sending game’s token, or your game secret. confirm-receipt takes the recipient’s player token, minted by the receiving game. The game secret is only for minting that token, the webhook, the status read and the enumeration.
- Treating
TRANSACTION_NOT_PENDINGas a failure. It usually means she already collected, through the Invo app or the hosted page, while your screen was open. Readcurrent_status, refresh the balance, and drop the prompt. - Treating
409 receiver_not_enrolled_use_claim_codeas an error. It is a routing instruction: this person has no account with you, so send them to the hosted claim page. - Hiding
helditems. A held row is real money waiting. Show it, greyed, with the reason, hiding it means the player never learns why nothing is happening. - Building an “enter your claim code” screen. That is the retiring fallback, not the flow. A code authorises whoever holds it; a passkey authorises her.
- Running the countdown off a local timer. Use
expires_atfrom the pending item. Too strict and you hide collectable money; too loose and you show a prompt that cannot succeed. - Retrying
CONFIRM_RECEIPT_FAILED. Never the caller’s fault, never better on a retry. Captureerror_refand send it to us.