Transfer Overview
A cross-game transfer moves a player’s own currency out of your title and into another title in the Invo network. It is a self-transfer: the same person is on both ends. They authorise it in your title with a passkey, or by approving on their phone from a QR code where the client cannot run a passkey, and then collect it at the destination.
The shape of it
SOURCE GAME DESTINATION GAME
1 initiate-transfer [game secret]
amount reserved, approval window opens
2 the player proves it is them — ONE factor:
passkey in-client, or a QR device approval
3 approve [player token] <-- the call that moves money
-> pending_claim, and the claim code
4 the player collects there:
pending list -> confirm-receipt [their token]
(claim code is the fallback)
5 transfer.sent transfer.receivedWeb, mobile web, mobile app
The passkey runs in the client. Face ID, Touch ID, Windows Hello. Strongest option, nothing to deliver, nothing to type.
Consoles, TVs, native Steam & desktop
The client cannot invoke the platform authenticator, so the ceremony moves to the player’s phone via a QR code and your server polls for the result.
A factor is not a settlement
Whatever the player used to prove it was them is a factor. It settles nothing. The transfer only advances when your side calls, with the player’s session token:
POST /api/sdk/transfers/{transaction_id}/approve
Authorization: Bearer <the player's session token>
{ "device_code": "..." } // or { "webauthn_assertion": { ... } }A device-approval poll that answers approved is the cue to make that call, not the end of the flow, and on a transfer it is also the only place the claim code is ever returned.
Where the detail lives
Cross-Game Transfer, stage by stage
Every call from initiate to collect, in curl, JavaScript and Python, with the errors and what to do about each.
Collecting, stage by stage
The receiving half: the pending list, the flow field, confirm-receipt.
What your server must call, in order
One row per call, with auth, body, success and error handling.
Lifecycle & webhooks
Statuses, windows, refunds and every event either flow emits.
Statuses
| transaction_status | verification_state | Meaning |
|---|---|---|
| pending_pin_verification | awaiting | Reserved on the sender’s balance, waiting for them to approve. (The name is historical; see the note below.) |
| pending_claim | approved | Approved. Waiting to be collected at the destination. |
| completed | completed | Credited at the destination. Terminal. |
| expired_pin_verification | expired | Never approved inside the approval window; refunded to the sender. |
| expired_claim | expired | Approved but never collected; refunded to the sender. |
Why the status says “pin”. The name is historical. It is the sender approval stage, and today it is cleared by a passkey or a device approval grant. A legacy SMS PIN fallback also clears it; that fallback is being retired and new integrations should not build on it. Treat the string as an opaque value meaning “waiting for the sender to approve”.
Reading a transfer’s state
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", "fee_amount": "5.00",
"created_at": "...",
"source_game_id": "...", "source_game_name": "...",
"target_game_id": "...", "target_game_name": "...",
"currency_id": 12, "currency_name": "Gold",
"order_id": "TFRO_...",
"claim_info": { "claim_code_expires_at": "..." } // while collectable
}Drive a waiting panel from verification_state: it flips from awaiting to approved the moment the sender clears the approval stage, by whichever factor. The destination title also receives to_phone and to_identity_id on this response, for attributing the inbound transfer to one of its players.
Prefer webhooks for your ledger and this endpoint for reconciliation. See Lifecycle & webhooks.
Where a player may transfer to
A transfer’s destination must be a title your transfer policy permits and that is live. Ask before you offer, rather than discovering it as a 403 TRANSFER_POLICY_VIOLATION at initiate:
POST $BASE/api/transfers/available-destinations
X-Game-Secret-Key: <game secret>
{ "source_game_id": 155963559928 }The full response contract, including per-destination limits and the transfer-policy fields, is on Available Player Destinations. For a list narrowed to where a specific player can actually receive, see Player-Aware Destinations.
Fees
Fees are calculated and stamped on the transaction at initiate, and nothing recomputes them later. fees_preview on the initiate response is what will be charged, and the collect side reports the same numbers in fee_breakdown on transfer.received.
Always read the numbers off the response. Never re-derive a fee from a rate in your own code: rates can be configured per partner, and a locally computed total will disagree with the ledger the moment they are. net_amount is what lands.
Guardrails you will meet
| Guardrail | How it appears | What to do |
|---|---|---|
| Guardian approval (minor accounts) | 202 at initiate with a guardian_approval block; 202 GUARDIAN_APPROVAL_PENDING at approve | Poll the approval-status endpoint, then approve as normal. The request goes to the guardian by email where one is verified. See Guardian Approval. |
| Phone-share consent | 409 PHONE_SHARE_APPROVAL_REQUIRED | The number is already tied to a different Invo identity. Follow the consent flow in the body, then retry. Consent is requested by email where a verified address exists. |
| Post-recovery hold | 403 PASSKEY_RECOVERY_COOLDOWN at initiate and approve | Money out is paused for 24 hours after a self-serve passkey recovery. Show retry_after and stop; collecting is unaffected. |
| Platform-value containment | 409 STEAM_VALUE_NON_TRANSFERABLE / NON_STEAM_VALUE_INTO_STEAM_BLOCKED | Value that originated on a platform rail stays there. The body says how much is movable, offer that. |
| Velocity and rate limits | 429, usually with retry_after | Back off. Do not loop. |
| Maintenance pause | 503 flow_paused | Transfers are paused platform-wide. Show a temporary state and retry later. |
Every code, with its meaning and required action, is on the error reference.
What you are told, and when
| Event | Fires when | Delivered to |
|---|---|---|
| device_approval.approved | A QR approval settled on the player’s phone, the cue to call approve | The title that began the grant |
| transfer.claim_pending | The sender approved; there is something to collect | Both titles (direction outbound / inbound) |
| transfer.sent / transfer.received | Collected and credited | Source title / destination title |
| transfer.claim_expired | The claim window lapsed without a collect | Both titles |
| transfer.refunded | Reserved funds returned to the sender, on either expiry path | Source title |
Peer sends use the same transfer.* names, they are not a separate event family. Setup and signature verification are on Receiving Webhooks.
Legacy fallbacks
Two older routes still work and are documented, and neither should appear in a new integration: the SMS PIN for the approval stage, and the claim code for collecting. Both are being retired.
A texted code is the most expensive channel we have and it authorises whoever reads the message. The passkey and QR paths cost nothing to deliver and authorise the person. That is the whole reason for the change.