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. Print it, tick it off, and you have a working integration. Full explanations live on Peer Send, Cross-Game Transfer and Collecting.
The two rows people skip
In both tables below, the approve row and the confirm-receipt row are the ones that move money. Everything above them produces a factor: a proof of who. A factor settles nothing. If your integration ends at “the poll said approved”, every transaction you create will expire and refund, and nothing in your logs will look like an error.
Reading the tables
- •
BASEishttps://invo.network, orhttps://sandbox.invo.network/sandboxin sandbox. - • game secret means the header
X-Game-Secret-Key, server-side only. - • player token means
Authorization: Bearer <token>, scoped to one player in one game, 15 minutes, no refresh. - • Rows marked money are the settling calls.
- • Rows marked optional are not required to complete a transaction.
Peer send, the sending side
| # | Who | Endpoint | Auth | Body | Success | On error |
|---|---|---|---|---|---|---|
| 1 | Your server | POST /api/currency-sends/initiate-send | game secret | client_request_id, sender_player_name/_email/_phone, receiver_player_phone, receiving_game_id, amount; receiver_player_email recommended | 201 with transaction_id, verification_expires_at, new_balance | 202 = guardian gate, poll and continue. 403 PASSKEY_RECOVERY_COOLDOWN = stop, show retry_after. 409 duplicate = you already have this one. 429 = back off. |
| 2 | Your server | POST /api/sdk/player-token | game secret | player_email (the sender) | 200 with token, expires_at | 403 TENANT_NOT_MIGRATED = not switched on for your game, contact Invo. 404 = the player does not exist in your game. |
| 3a | Server (console / Steam) | POST /api/sdk/approvals/device/begin | sender’s player token | transaction_id, flow: "send", channel (optional) | 200 with device_code, user_code, verification_uri_complete, expires_in, interval | 409 DEVICE_APPROVAL_ALREADY_PENDING = an approved grant exists, settle with the code you hold. 503 DEVICE_APPROVAL_NOT_CONFIGURED = contact Invo. |
| 3b | Server (console / Steam) | POST /api/sdk/approvals/device/poll | sender’s player token | device_code | 200 status: "approved". Now go to row 4 | authorization_pending = wait interval. slow_down = increase it. expired_token/invalid_grant = begin again. access_denied = stop. |
| 3c optional | Server | POST /api/sdk/approvals/device/confirm-enrollment | sender’s player token | device_code, decision | 200 with the outcome; polling resumes | Only needed when a poll carried enrollment.state: "awaiting_screen". 409 = already decided elsewhere. |
| 3d | Client (web / mobile) | POST /api/sdk/send/{id}/approve/webauthn/begin | sender’s player token | none | 200 challenge; run the ceremony | 400 WEBAUTHN_NO_CREDENTIAL = no passkey enrolled yet; enrol one or use the device grant. |
| 4 | Server or client | POST /api/sdk/send/{id}/approve money | sender’s player token | { device_code } or { webauthn_assertion } or { device_fingerprint, device_signal } | 200 { status: "approved", next: "pending_claim" } | 400 TRANSACTION_NOT_PENDING = already done, read current_status. 400 DEVICE_APPROVAL_NOT_APPROVED = wrong flow / wrong transaction / not approved. 400 SEND_APPROVE_FAILED = never retry, quote error_ref. 202/410 guardian. |
| 5 optional | Your server | GET /api/currency-sends/{id}/status | game secret | none | 200 with transaction_status, verification_state | 404 = not yours, or wrong rail. Prefer webhooks; use this to reconcile. |
| 6 optional | Your server | POST /api/sdk/send/{id}/resend-claim | sender’s player token | none | 200 { status: "resent", retry_after } | 429 RESEND_COOLDOWN = wait retry_after. 409 CLAIM_SMS_SUPPRESSED = they collect in-app; nothing to resend. 400 CLAIM_EXPIRED = start a new send. |
Cross-game transfer, the source game
| # | Who | Endpoint | Auth | Body | Success | On error |
|---|---|---|---|---|---|---|
| 1 | Your server | POST /api/transfers/initiate-transfer | game secret | client_request_id, source_player_name/_email/_phone, target_player_phone, target_game_id, amount; target_player_email recommended | 201 with transaction_id, verification_expires_at, new_balance | 202 = guardian gate. 403 TRANSFER_POLICY_VIOLATION = destination not permitted. 403 PASSKEY_RECOVERY_COOLDOWN = stop. 409 duplicate / consent / Steam containment. |
| 2 | Your server | POST /api/sdk/player-token | game secret | player_email (the source player) | 200 with token | As the send table, row 2. |
| 3a | Server (console / Steam) | POST /api/sdk/approvals/device/begin | player token | transaction_id, flow: "transfer" | 200 with device_code, user_code, verification_uri_complete | As the send table, row 3a. |
| 3b | Server (console / Steam) | POST /api/sdk/approvals/device/poll | player token | device_code | 200 status: "approved". Now go to row 4 | As the send table, row 3b. |
| 3c | Client (web / mobile) | POST /api/sdk/transfers/{id}/approve/webauthn/begin | player token | none | 200 challenge; run the ceremony | 400 WEBAUTHN_NO_CREDENTIAL = no passkey yet. |
| 4 | Server or client | POST /api/sdk/transfers/{id}/approve money | player token | { device_code } or { webauthn_assertion } or { device_fingerprint, device_signal } | 200 { status, next, claim_code, claim_code_expires_at }. Store the claim code | 400 TRANSACTION_NOT_PENDING = already done (and the claim code is not re-issued). 400 TRANSFER_APPROVE_FAILED = never retry, quote error_ref. 202/410 guardian. |
| 5 optional | Your server | GET /api/transfers/{id}/status | game secret | none | 200 with transaction_status, verification_state | Prefer webhooks; use this to reconcile. |
There is no resend row for a transfer: the claim code came back to you in row 4.
Both flows, the receiving side
Identical for a peer send and a cross-game transfer except for the rail in the path, which is decided by the flow field on the pending item, never by assumption.
| # | Who | Endpoint | Auth | Body / query | Success | On error |
|---|---|---|---|---|---|---|
| 1 | Your server | Webhook transfer.claim_pending (direction: inbound) | signature | none | You know what arrived and for whom (to_phone, to_identity_id) | Deduplicate on idempotency_key. Deliveries retry six times over ~31 hours. |
| 1b optional | Your server | GET /api/transfers/inbound-pending | game secret | player_email or player_phone | 200 { inbound_pending: [...] } | 400 = neither query parameter given. Claim codes are never returned here. |
| 2 | Your server | POST /api/sdk/player-token | game secret (receiving game) | player_email (the recipient) | 200 with token | 404 = no player row yet. On a send use the hosted claim page; on a transfer use claim-transfer, because the hosted page serves sends only. |
| 3 | Client | GET /api/sdk/transfers/pending | recipient’s player token | none | 200 { pending: [...] }. Take kind: "receiving_confirm" and read flow | Empty list = nothing awaiting this identity in this game. Do not infer the rail from kind. |
| 4a | Client (web / mobile) | POST /api/sdk/{send|transfers}/{id}/confirm-receipt/webauthn/begin | recipient’s player token | none | 200 challenge; run the ceremony | 400 WEBAUTHN_NO_CREDENTIAL = no passkey yet. 403 not_intended_receiver = wrong identity or phone mismatch. |
| 4b | Server (console / Steam) | POST /api/sdk/approvals/device/begin then /poll | recipient’s player token | transaction_id, flow: "send_receipt" or "transfer_receipt" | device_code, then status: "approved" | Same RFC 8628 codes as the sending side. Using the sender’s flow here fails at row 5. |
| 5 | Server or client | POST /api/sdk/{send|transfers}/{id}/confirm-receipt money | recipient’s player token | { device_code } or { webauthn_assertion } or { device_fingerprint, device_signal } | 200 { status: "completed", amount_received } | 409 receiver_not_enrolled_use_claim_code = send → hosted claim page or claim-currency; transfer → claim-transfer only. 409 PHONE_SHARE_APPROVAL_REQUIRED (under error_code) = consent, then retry. 202 RECIPIENT_IDENTITY_PENDING = wait, then retry. 400 TRANSACTION_NOT_PENDING = already collected. 400 CONFIRM_RECEIPT_FAILED = never retry, never refund; read the status, then quote error_ref. |
| 6 optional | Your server | Webhook transfer.received, or GET /api/{currency-sends|transfers}/{id}/status | signature / game secret | none | Credited, with amount_received and new_balance | Also fires for collects made outside your game, such as the hosted page or the Invo app. Handle it even if your server made no call. |
The legacy rows, kept out of the tables on purpose
These endpoints still work and are documented, but they are the routes being retired. They are not steps in a new integration, which is why they are not rows above.
| Legacy route | Replaced by | Why |
|---|---|---|
POST /api/currency-sends/verify-smsPOST /api/transfers/verify-sms | Row 4, approve with a passkey or device grant | A texted PIN is the most expensive and least secure proof available. Being retired. |
POST /api/currency-sends/resend-pinPOST /api/transfers/resend-pin | Nothing, there is no PIN to resend on the passkey path | Exists only to support the legacy PIN route. |
POST /api/currency-sends/claim-currencyPOST /api/transfers/claim-transfer | Receiving row 5, confirm-receipt; or the hosted claim page | A code authorises whoever holds it. Kept for a recipient with no Invo passkey, and because it creates the player row. |
Next
Every code in these tables, with its meaning and required action, is on the error reference.