Send Overview
A peer send is one player giving currency to another, addressed by phone number, inside your game or platform, or into a different one. Two people, two sides. The sender authorises it with a passkey, or by approving on their phone from a QR code where the client cannot run a passkey. The recipient collects with their own proof, the same way.
The shape of it
SENDING GAME RECEIVING GAME (may be the same game)
1 initiate-send [game secret]
amount reserved, approval window opens
2 the sender proves it is them, with ONE factor:
passkey in-client, or a QR device approval
3 approve [player token] <-- the call that moves money
-> pending_claim
4 the recipient collects:
pending list -> confirm-receipt [their token]
or the hosted claim page if they have no account
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 & desktop launchers
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 sender used to prove it was them is a factor. It settles nothing. The send only advances when your side calls, with the sender’s session token:
POST /api/sdk/send/{transaction_id}/approve
Authorization: Bearer <the sender's session token>
{ "device_code": "..." } // or { "webauthn_assertion": { ... } }The same is true on the receiving side: an approved receipt grant does nothing until you call confirm-receipt with it.
Where the detail lives
Peer Send, 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.
Send or transfer?
| Peer send | Cross-game transfer | |
|---|---|---|
| Who is on each end | Two different people | The same person, twice |
| Paths | /api/currency-sends/…, /api/sdk/send/… | /api/transfers/…, /api/sdk/transfers/… |
Device-grant flow | send / send_receipt | transfer / transfer_receipt |
| Who is notified there is something to collect | The recipient, in-app where possible; INVO emails the claim link when the receiver’s email is already on file for that phone; otherwise the receiver collects in the game | The sender holds the claim code: it is in the approve response, readable from GET /api/transfers/{id}/status (sending game only, after approval), and approve emails it to the sender’s own address on file |
| Can the sender re-notify? | Yes, /api/sdk/send/{id}/resend-claim (live; it re-delivers on the original channel) | Not applicable |
A same-game send is still a send: set receiving_game_id to your own game id, and the response reports send_type: "peer_to_peer".
Statuses
| transaction_status | verification_state | Meaning |
|---|---|---|
| pending_pin_verification | awaiting | Reserved on the sender’s balance, waiting for them to approve. |
| pending_claim | approved | Approved. Waiting for the recipient to collect. |
| completed | completed | Credited to the recipient. 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. Treat the string as an opaque value meaning “waiting for the sender to approve”.
Reading a send’s state
GET $BASE/api/currency-sends/{transaction_id}/status
X-Game-Secret-Key: <game secret of either game>
200 OK
{
"status": "success",
"transaction_id": "TXN_...",
"transaction_type": "currency_send",
"transaction_status": "pending_claim",
"verification_state": "approved", // awaiting | approved | completed | expired | failed | unknown
"amount": "50.00", "net_amount": "45.00", "fee_amount": "5.00",
"created_at": "...",
"sending_game_id": "...", "sending_game_name": "...",
"receiving_game_id": "...", "receiving_game_name": "...",
"currency_id": 12, "currency_name": "Gold",
"order_id": "ORD_1757000000_A1B2C3D4", // string, not a number
"claim_info": { "claim_code_expires_at": "..." } // while collectable
// additive: "expiry_reason" ("approval_expired" | "pin_expired" | "claim_expired"), present only when it expired
}The receiving game also gets to_phone and to_identity_id on this response, for attributing the inbound send to one of its players. To enumerate everything waiting for a player without knowing transaction ids, use GET /api/transfers/inbound-pending. See Collecting.
“They say they never got it”
Once a send is approved, the sender can re-deliver the recipient’s collect notification. The endpoint is live. Nothing new is minted: the same claim link goes out again on the original channel, so support can read the two side by side.
POST $BASE/api/sdk/send/{transaction_id}/resend-claim
Authorization: Bearer <the SENDER's player token> (no body)
200 { "status": "resent", "transaction_id": "TXN_...", "retry_after": 30, "claim_delivery": "email" }
429 RESEND_COOLDOWN too soon; wait retry_after seconds
409 CLAIM_DELIVERY_WITHHELD the receiver email given is not on file for the phone; nothing is sent,
the receiver collects in the game (body carries claim_delivery: "none")
409 CLAIM_SMS_SUPPRESSED this recipient collects in-app; nothing is sent for this send
400 NOT_CLAIMABLE not awaiting a claim
400 CLAIM_EXPIRED the window closed; start a new send
400 NO_RECEIVER_PHONE the send has no receiver phone to resolve a delivery from
400 SEND_INCOMPLETE the send is missing data needed to rebuild the message
400 RESEND_CLAIM_FAILED server-side failure; do not loop, read the status endpointNote this is the recipient’s notification: INVO emails the claim link when the receiver’s email is already on file for that phone; otherwise the receiver collects in the game. claim_delivery on the 200 is "email", or the legacy "sms". CLAIM_SMS_SUPPRESSED is a literal error code; match on it as written.
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, at initiate or at collect | 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 may be paused for a while after an account recovery. The response says until when (retry_after): show it and stop. Collecting is unaffected. |
| Platform-value containment | 409 NON_STEAM_VALUE_INTO_STEAM_BLOCKED, 409 STEAM_VALUE_NON_TRANSFERABLE | A title distributed through a platform store cannot receive currency from a title outside that store. Currency bought through a platform store is subject to that store's rules and can only move between titles on that store. Destination lists already leave out titles you cannot send into. |
| Velocity and rate limits | 429, usually with retry_after | Back off. Do not loop. |
| Maintenance pause | 503 flow_paused | Sends 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
A peer send emits the same transfer.* events as a cross-game transfer, there is no separate send event family.
| Event | Fires when | Delivered to |
|---|---|---|
| device_approval.approved | A QR approval settled on the player’s phone, the cue to call approve or confirm-receipt | The title that began the grant |
| transfer.claim_pending | The sender approved; there is something to collect | Both games (direction outbound / inbound); a same-title move delivers both copies to that title |
| transfer.sent / transfer.received | Collected and credited. The send rail's payload carries flow: "currency_send" and no gross_amount or fee_breakdown | Sending game / receiving game |
| transfer.claim_expired | The claim window lapsed without a collect; carries expiry_reason | Both games, as above |
| transfer.refunded | Reserved funds returned to the sender, on either expiry path | Sending game |
Setup and signature verification are on Receiving Webhooks.
Legacy fallbacks
An older collecting route still works and is documented, and should not appear in a new integration: the claim code. It is being retired.
A recipient in a game collects with their player token (minted with email + phone; created automatically), so nothing needs to be delivered. For a recipient outside the game, INVO emails the claim link when the receiver’s email is already on file for that phone; otherwise the receiver collects in the game. Where the recipient is reachable in-app, even that is suppressed.