Sends & Transfers, Stage by Stage

Invo moves a player’s currency in exactly two ways: a peer send (one player to another, in your game or into a different game) and a cross-game transfer (the same player moving their own currency between two games). 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 key

A 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.

The pages in this section

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 recipient’s text.

Three actors, three credentials

WhoCredentialUsed for
Your game serverX-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 game; lives 15 minutes; there is no refresh.
Invo-Texts the recipient their claim code and link, serves the hosted /device and /claim pages, emits webhooks, and sweeps expired transactions back to the sender roughly every five minutes.

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 (10 minutes by default)
        │
        ├── sender approves ────────────────────────────► pending_claim
        │     POST .../approve with a factor                verification_state: "approved"
        │                                                   window: claim_code_expires_at (24 hours by default)
        │                                                         │
        │                                                         ├── recipient collects ──► completed
        │                                                         │     confirm-receipt, the hosted
        │                                                         │     /claim page, or the claim code
        │                                                         │
        │                                                         └── 24 h 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
        │
        └── 10 minutes pass, nothing approved ──────────► expired_pin_verification
                                                          reserved funds returned to the sender
                                                          transfer.refunded
transaction_statusverification_stateWhat it meansWhose move
pending_pin_verificationawaitingInitiated; the amount is reserved on the sender’s balance; nobody has approved.The sender. Produce a factor, then call approve.
pending_claimapprovedThe sender approved. The value is waiting to be collected.The recipient. Collect within the claim window.
completedcompletedCredited to the recipient. Terminal.Nobody. Done.
expired_pin_verificationexpiredNobody approved inside the approval window. The reservation was released back to the sender.The sender may start again from initiate.
expired_claimexpiredApproved, but never collected inside the claim window. Refunded to the sender.The sender may start again from initiate.
failed…failedTerminal 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. There is a legacy SMS PIN fallback that also clears it; it is being retired and new integrations should not build on it. 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; a device grant returns expires_in. The platform defaults today are ten minutes to approve, twenty-four hours to collect and ten minutes for a device grant, and 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. Those are defaults, not promises.

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 game with reason: "pin_expired". 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 games (with direction: "outbound" / "inbound") plus transfer.refunded to the sending game 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 game: the same title can be a passkey build on the web and a QR build on console.

FactorBody fieldUse it when
Device approval grant (QR)device_codeConsoles, TVs and native desktop / Steam builds, anywhere the client cannot invoke the platform authenticator. The ceremony happens on the player’s phone, on an Invo page.
Passkey (WebAuthn)webauthn_assertionDesktop and mobile web, where the browser can run the ceremony in-client. Requires a verified partner domain of your own, and new ones are no longer accepted, so for most titles this answers 403 WEBAUTHN_NOT_ENABLED_FOR_TENANT and the device grant above is the path to build.
Enrolled device keydevice_fingerprint + device_signalA 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 24 hours 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”.

EventFires whenDelivered to
device_approval.approvedA 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 game whose token started the grant.
transfer.claim_pendingThe sender’s approve succeeded. There is now something to collect.Both games, direction: "outbound" to the sending game, "inbound" to the receiving game (with to_phone and to_identity_id for attribution).
transfer.sentThe value was collected, the sending side of completion.The sending game.
transfer.receivedThe 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 game.
transfer.claim_expiredThe claim window lapsed without a collect.Both games.
transfer.refundedReserved funds were returned to the sender, on either expiry path. reason is pin_expired or claim_expired, and refunded_amount is the gross the sender committed: fees are only taken at collect, so an expiry returns the whole reservation.The sending game.

Both flows use the same transfer.* names, a peer send is not a separate event family. Every delivery is a durable, retried row: six attempts on a widening backoff of roughly 30 seconds, 2 minutes, 10 minutes, 1 hour, 6 hours and 24 hours, jittered, after which it is marked dead and left for you to redrive. 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

  1. 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 that device_code.
  2. Sending the game secret where the player token is required. Only /api/sdk/player-token takes the game secret. Every other /api/sdk/* call takes Authorization: Bearer <player token>. And it must be that player’s token: the sender’s for approve, the recipient’s (minted by the receiving game) for confirm-receipt.
  3. 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’s flow must match the endpoint you will settle with: send, transfer, send_receipt or transfer_receipt. A grant begun for the wrong flow is refused at approve as DEVICE_APPROVAL_NOT_APPROVED, which reads like the player never approved.
  4. Treating TRANSACTION_NOT_PENDING as a failure. It means the transaction has already moved on, usually because your first approve worked and this is a retry. The body carries current_status. Read it and continue from there; do not refund, do not alert, do not start again.
  5. Expiring a grant on your own timer. Honour expires_at / expires_in and the poll’s interval. 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: read verification_expires_at and claim_code_expires_at.

Start here