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 — 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, 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 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, otherwise a link to the hosted claim page | Nobody needs telling: the claim code returns in-band to the caller at approve |
| Can the sender re-notify? | Yes, /api/sdk/send/{id}/resend-claim | 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. 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 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
"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
}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. Nothing new is minted, the identical message goes out again, 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 }
429 RESEND_COOLDOWN too soon; wait retry_after seconds
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 sendNote this is the recipient’s notification. It is a different thing from the legacy /api/currency-sends/resend-pin, which belongs to the retiring SMS PIN path.
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 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. |
| 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) |
| 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 | Both games |
| transfer.refunded | Reserved funds returned to the sender, on either expiry path | Sending game |
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.
The one message still sent as a matter of course is the collect link to a recipient we can reach no other way, a phone number is all the sender gave us, so there is no address to email and no app to notify. Where the recipient is reachable in-app, even that is suppressed.