Sends & Transfers, Stage by Stage
Invo moves a player’s currency in exactly two ways: a peer send (one player to another, in your title or into a different title) and a cross-game transfer (the same player moving their own currency between two titles). Both run one lifecycle: your server initiates, the sender approves, and the recipient collects. This section walks every stage of both flows, on both sides, with the exact call at each step in curl, JavaScript and Python.
The one rule: nothing moves until you call approve
Whatever the sender used to prove it was them, a passkey in the browser, a phone scanning a QR shown on a console, a guardian saying yes, that proof is a factor. It settles nothing by itself. The reservation on the sender’s balance only advances when your side makes a second, separate call:
POST /api/sdk/send/{transaction_id}/approve (peer send, sender)
POST /api/sdk/transfers/{transaction_id}/approve (cross-game transfer, sender)
POST /api/sdk/send/{transaction_id}/confirm-receipt (peer send, recipient)
POST /api/sdk/transfers/{transaction_id}/confirm-receipt (transfer, the same player, at the destination)
Authorization: Bearer <that player's session token> <-- NOT the game secret
Content-Type: application/json
{ "device_code": "..." } // the device grant you just polled to "approved"
// or { "webauthn_assertion": { ... } } // an in-app / in-browser passkey
// or { "device_fingerprint": "...", "device_signal": { ... } } // an enrolled mobile device keyA device-approval poll that answers status: "approved" is the cue to make that call, not the end of the flow. A team that showed the QR, polled to “approved” and stopped there had a perfectly valid approval and a transaction still sitting in pending_pin_verification, refunded to the sender when the approval window lapsed. Nothing was broken; the second call was never made. Polling the status endpoint does not make it either: a status read only reports whether approve has happened.
Every send and transfer, in ten steps
Both flows follow the same numbered sequence. Only the paths and the flow values differ, and each stage page spells its version out in full. SERVER is your backend with the game secret; CLIENT is the player’s browser or app with a player token.
- SERVER:
POST /api/sdk/player-tokenwithplayer_email,player_phoneandplayer_name. A new player is registered (created: true);phone_mismatch: truemeans update the number first (Player Identity). - SERVER: initiate (
/api/currency-sends/initiate-sendor/api/transfers/initiate-transfer). If the answer hasidentity_rekeyed: true, use itsplayer_tokenfrom now on. - SERVER: mint the token again right before approval and hand it to the client.
- Approval: on the web,
approveHosted(), INVO’s hosted page in a popup; on consoles, desktop launchers or a popup-blocked browser, the QR device grant; in a mobile app, the hosted page in the system browser. - SERVER or SDK:
POST /api/sdk/send/{id}/approveor/api/sdk/transfers/{id}/approvewith the device code. This moves the money. OnPLAYER_TOKEN_STALEorSDK_TOKEN_EXPIRED, mint and retry once. - SERVER: confirm with
transfer.claim_pendingor the status read (verification_state: "approved"). - Receiving SERVER: mint the recipient’s token with the receiving title’s secret, phone included.
- Receiving CLIENT:
GET /api/sdk/transfers/pending, pick the item byflow. - Receiving side: approval with the receipt flow, then
confirm-receipt. This credits the recipient. - SERVER:
transfer.sentandtransfer.received; status"completed".
Full versions: Peer Send, Cross-Game Transfer, Collecting, and the tick-list.
The pages in this section
Peer send, stage by stage
Sender side and recipient side. Initiate, mint the token, choose a factor, approve, watch for completion, collect (in your title, in-app, or on the hosted claim page), resend, expiry and refunds.
Cross-game transfer, stage by stage
The same player on both ends. Initiate and approve in the source title, then collect in the destination title with a passkey, a device grant, or the claim code.
Collecting, stage by stage
The receiving side of both flows: the pending list, the flow field that picks the endpoint, confirm-receipt with a passkey or a QR device approval, and the hosted claim page.
What your server must call, in order
One row per call, per flow, per side: stage, who acts, endpoint, auth, body, what success looks like, and what to do on each error.
Error reference
Every code either flow can return, what it means, and the action it demands, including the ones that are not failures.
Base URLs, said once
Sandbox carries a /sandbox path prefix on every API route. Production does not. A missing prefix looks exactly like an endpoint that does not exist, so set it once:
Production BASE = https://invo.network Sandbox BASE = https://sandbox.invo.network/sandbox # every path in this section is written against BASE: POST $BASE/api/currency-sends/initiate-send POST $BASE/api/sdk/approvals/device/begin
The player-facing hosted pages are the exception: they are served at the site root, with no /sandbox prefix and no API path, because a human reads them off a QR code, https://invo.network/device and https://invo.network/claim in production, https://sandbox.invo.network/device and /claim in sandbox. You never construct these yourself. Invo hands you the exact URL in the begin response and puts the claim link in the email it sends the recipient. INVO emails the claim link when the receiver’s email is already on file for that phone; otherwise the receiver collects in the game.
Three actors, three credentials
| Who | Credential | Used for |
|---|---|---|
| Your server | X-Game-Secret-Key: <game secret> | Initiating, reading transaction status, listing inbound items, claiming by claim code, and minting a player token. Server-side only, never in a client build. |
| The player (through your server or the browser SDK) | Authorization: Bearer <player session token> | Everything under /api/sdk/*: starting and polling a device grant, and the approve and confirm-receipt calls. Scoped to one player in one title; short-lived (read expires_at); there is no refresh, so mint it again right before approval. |
| Invo | - | Emails the claim link when the receiver’s email is already on file for that phone (otherwise the receiver collects in the game), serves the hosted /device and /claim pages, emits webhooks, and sweeps expired transactions back to the sender. |
The game secret cannot approve anything. There is exactly one endpoint under /api/sdk/ that accepts it: POST /api/sdk/player-token, which exchanges it for a player token. Sending the game secret to approve or confirm-receipt is rejected as an unauthenticated call.
The lifecycle, in order
Both flows share one status ladder. The values below are what transaction_status reports on the status endpoints; verification_state is the coarser value to drive a waiting panel from.
your server initiates
│ funds move from the sender's available_balance to reserved
▼
pending_pin_verification verification_state: "awaiting"
│ window: verification_expires_at
│
├── sender approves ────────────────────────────► pending_claim
│ POST .../approve with a factor verification_state: "approved"
│ window: claim_code_expires_at
│ │
│ ├── recipient collects ──► completed
│ │ confirm-receipt, the hosted
│ │ /claim page, or the claim code
│ │
│ └── claim window passes ─► expired_claim
│ reserved funds returned to the sender
│ transfer.claim_expired + transfer.refunded
│
├── guardian says no ───────────────────────────► approve answers 410; the sweep refunds at the window
│
└── approval window passes, nothing approved ───► expired_pin_verification
reserved funds returned to the sender
transfer.refunded| transaction_status | verification_state | What it means | Whose move |
|---|---|---|---|
| pending_pin_verification | awaiting | Initiated; the amount is reserved on the sender’s balance; nobody has approved. | The sender. Produce a factor, then call approve. |
| pending_claim | approved | The sender approved. The value is waiting to be collected. | The recipient. Collect within the claim window. |
| completed | completed | Credited to the recipient. Terminal. | Nobody. Done. |
| expired_pin_verification | expired | Nobody approved inside the approval window. The reservation was released back to the sender. | The sender may start again from initiate. |
| expired_claim | expired | Approved, but never collected inside the claim window. Refunded to the sender. | The sender may start again from initiate. |
| failed… | failed | Terminal failure; failure_reason carries the detail. | Read failure_reason; do not retry blindly. |
Why the status is called pending_pin_verification. The name is historical. It is the sender approval stage, and today it is cleared by a passkey or a device approval grant through the hosted page or a QR. The status string itself will not change out from under you. Treat it as an opaque value meaning “waiting for the sender to approve”.
Read the windows off the response, never off this page. Initiate returns verification_expires_at; the status endpoint returns claim_info.claim_code_expires_at on a send and claim_code_expires_at (top level) on a transfer; a device grant returns expires_in. Starting a device grant for a send or transfer pushes the transaction’s own approval window out past the grant’s expiry so a late approval cannot land on an already-refunded transaction.
The failure branches, and who refunds what
Approval window lapses
Nobody approved. Invo’s sweep releases the reservation back to the sender’s available balance and fires transfer.refunded to the sending title with reason: "pin_expired" and expiry_reason: "approval_expired" (or "pin_expired" on the legacy code flow). No claim ever existed, so no transfer.claim_expired is sent.
Claim window lapses
Approved but never collected. The sweep refunds the sender and fires transfer.claim_expired to both titles (with direction: "outbound" / "inbound") plus transfer.refunded to the sending title with reason: "claim_expired".
Guardian declines
A minor account whose guardian answers no. approve returns 410 GUARDIAN_APPROVAL_REJECTED (or GUARDIAN_APPROVAL_EXPIRED if they never answered). The transaction stays unapproved and the approval-window sweep refunds it.
The sweep runs on a short cycle, so a refund lands within a few minutes of the deadline rather than at the exact second. Until it does, the status endpoint still reports the pre-expiry status, trust transfer.refunded or a later status read, not the clock.
The three factors, and one hold
The approve and confirm-receipt endpoints accept exactly one proof per call. Which one you use is decided by the client, not the tenant: the same title can be a passkey build on the web and a QR build on console.
| Factor | Body field | Use it when |
|---|---|---|
| Device approval grant (QR) | device_code | Every platform. On the web, approveHosted() runs it in a popup and sends the code for you (the primary web path); on consoles, TVs, desktop launchers and native desktop builds, or a browser that blocks the popup, the player scans a QR; in a mobile app, the hosted page opens in the system browser. The ceremony always happens on an Invo page, on Invo’s own domain. Nothing to configure. |
| Passkey (WebAuthn) | webauthn_assertion | The in-client ceremony on your own page. For your title it answers 403 WEBAUTHN_NOT_ENABLED_FOR_TENANT, and there is nothing to configure: build the hosted approval above instead. |
| Enrolled device key | device_fingerprint + device_signal | A mobile app that registered a hardware-backed key with Invo and signs the transaction id. |
Guardian approval is not a fourth factor, it is a gate in front of all three. For a minor account, initiate answers 202 with a guardian_approval block, and approve keeps answering 202 GUARDIAN_APPROVAL_PENDING until the guardian says yes. Once they do, you still need one of the three factors above and you still call approve.
The recovery hold is the one thing that refuses every factor. For a period after a player self-recovers a lost passkey, money leaving their account is paused: both initiate and approve answer 403 PASSKEY_RECOVERY_COOLDOWN with retry_after (an absolute time) and retry_after_seconds. Collecting is unaffected.
How you learn what happened
Two independent signals. Use webhooks as your ledger and status reads as the answer to “what is this one doing right now”.
| Event | Fires when | Delivered to |
|---|---|---|
| device_approval.approved | A device grant is settled on the player’s phone. Carries transaction_id, flow, identity_id, method. This is the cue to call approve or confirm-receipt, it does not carry the device code, which you already hold. | The title whose token started the grant. |
| transfer.claim_pending | The sender’s approve succeeded. There is now something to collect. | Both titles, direction: "outbound" to the sending title, "inbound" to the receiving title (with to_phone and to_identity_id for attribution). On a same-title move that title receives both copies; act on direction and dedupe per event, never on transaction_id (details). |
| transfer.sent | The value was collected, the sending side of completion. | The sending title. |
| transfer.received | The value was collected and credited. Both rails carry amount_received (net), currency_name and new_balance. The rails differ: a transfer adds gross_amount and fee_breakdown; a peer send has neither, and instead carries flow: "currency_send". confirmed_via is "sdk_receipt" or "hosted_claim", and is absent on the legacy claim-code path. | The receiving title. |
| transfer.claim_expired | The claim window lapsed without a collect. Carries expiry_reason: "claim_expired". | Both titles, like transfer.claim_pending (both copies on a same-title move). |
| transfer.refunded | Reserved funds were returned to the sender, on either expiry path. reason is pin_expired or claim_expired; expiry_reason (approval_expired, pin_expired or claim_expired) is the precise cause, and refunded_amount is the gross the sender committed: fees are only taken at collect, so an expiry returns the whole reservation. | The sending title. |
Both flows use the same transfer.* names, a peer send is not a separate event family. Every delivery is a durable, retried row: by default six delivery attempts in all, the first and then retries after roughly 30 seconds, 2 minutes, 10 minutes, 1 hour and 6 hours, after which it is marked dead and left for you to replay. Deduplicate on idempotency_key, not on event_id. A subscription may list "*" to receive every event type. See Receiving Webhooks.
# The two status reads, both with the game secret:
GET $BASE/api/currency-sends/{transaction_id}/status # peer send
GET $BASE/api/transfers/{transaction_id}/status # cross-game transfer
X-Game-Secret-Key: <game secret>
# and, for a receiving game that wants to enumerate what is waiting for a player:
GET $BASE/api/transfers/inbound-pending?player_phone=%2B15555550111
X-Game-Secret-Key: <receiving game secret>Common mistakes
- Polling to “approved” and stopping. This is the single most expensive mistake there is. The grant is a factor. It authorises the approve call; it is not the approve call. If your integration ends at the poll, every transaction you create will expire and refund, and nothing in your logs will look like an error. If you take one thing from this section: after
status: "approved": call/approve(or/confirm-receipt) with thatdevice_code. - Sending the game secret where the player token is required. Only
/api/sdk/player-tokentakes the game secret. Every other/api/sdk/*call takesAuthorization: Bearer <player token>. And it must be that player’s token: the sender’s for approve, the recipient’s (minted by the receiving title) for confirm-receipt. - Using the wrong flow. A peer send is
/api/sdk/send/{id}/…(singular) and a transfer is/api/sdk/transfers/{id}/…(plural). The device grant’sflowmust match the endpoint you will settle with:send,transfer,send_receiptortransfer_receipt. A grant begun for the wrong flow is refused at approve asDEVICE_APPROVAL_NOT_APPROVED, which reads like the player never approved. - Treating
TRANSACTION_NOT_PENDINGas a failure. It means the transaction has already moved on, usually because your first approve worked and this is a retry. The body carriescurrent_status. Read it and continue from there; do not refund, do not alert, do not start again. - Expiring a grant on your own timer. Honour
expires_at/expires_inand the poll’sinterval. A local countdown that is stricter than the server’s throws away approvals the player actually gave; one that is looser keeps polling a grant that is gone. Same for the transaction windows: readverification_expires_atandclaim_code_expires_at.