Sends & Transfers. Error Reference
Every code either money flow can return, on either side, with what it actually means and the action it demands. Read the Action column as instructions, not advice: several of these are not failures at all, and treating them as failures is how integrations end up refunding money that had already arrived.
Three classes of answer
Not a failure
TRANSACTION_NOT_PENDING, receiver_not_enrolled_use_claim_code, authorization_pending, 202 guardian and identity holds. Something is already done, or is waiting on a human. Read the body and route accordingly.
Your call was wrong
Wrong credential, wrong player, wrong rail, wrong flow, wrong field. Fix the call, a retry of the same request will fail identically.
Our fault
The three *_FAILED catch-alls. They carry an error_ref, and they are ambiguous: the money may already have moved. Never retry, never refund, never re-initiate. Read the status first, then quote the reference to Invo support.
Read code AND error_code
The machine-readable identifier does not always arrive under the same key. Most refusals use code. The guardian gate, the risk holds (RISK_HOLD, STEP_UP_REQUIRED), the phone-share gate, HIGH_VALUE_REQUIRES_APP and WRONG_CLAIM_ENDPOINT use error_code. A few of the oldest answers carry only error. The Key column on every table below is authoritative; this list is only the shape of it.
// JavaScript
const code = body.code ?? body.error_code ?? body.error;
# Python
code = body.get("code") or body.get("error_code") or body.get("error")A dispatcher that reads only code falls through to its default branch on every guardian hold, every risk hold and every phone-share gate. That failure is quiet and looks partial: RECIPIENT_IDENTITY_PENDING does arrive under code and matches, so a named PHONE_SHARE_APPROVAL_REQUIRED case sitting beside it never fires and nothing looks obviously broken.
Every table on this page carries a Key column for exactly this reason.
error_ref, the fastest support ticket you will ever file
SEND_APPROVE_FAILED, TRANSFER_APPROVE_FAILED and CONFIRM_RECEIPT_FAILED are catch-alls. They are returned as 400 rather than 500 so that nothing internal leaks, but they always mean a fault on our side and the same request will fail the same way.
Do not read this as "nothing moved". The success body is built by reading the transaction back after it has been committed, so a fault raised at that point answers 400 with the money already moved. A network timeout on the settle call is ambiguous in exactly the same way, and the call is deliberately never auto-retried.
So: never retry, never refund, never re-initiate on one of these. Find out what actually happened first, by reading the transaction status or waiting for the transfer.claim_pending / transfer.received webhook. Only then raise the error_ref.
400 Bad Request
{
"error": "could not approve send",
"code": "SEND_APPROVE_FAILED",
"error_ref": "9c14ab7e" // <- log this, and send it to us
}Log error_ref with the transaction id every time you see one, and quote it when you raise the ticket. It identifies the exact log line for that request, which turns a morning of guessing into one message.
Approve and confirm-receipt
| HTTP | Code | Key | Means | Action |
|---|---|---|---|---|
| 400 | TRANSACTION_NOT_PENDING | code | The transaction has already moved past this step. Almost always your own successful call, retried. The body carries current_status. | Treat as success-already-happened. Read current_status and reconcile. Never refund, never alert, never re-initiate. |
| 400 | DEVICE_APPROVAL_NOT_APPROVED | code | The device code is unknown, belongs to another identity, is bound to another transaction, is for another flow, or was never approved. Deliberately one answer for all five, so the endpoint cannot be used to probe for live approvals. | Check flow first, send/transfer for approving, send_receipt/transfer_receipt for collecting. Then check the transaction id and the token’s player. Otherwise begin a new grant. |
| 400 | INVALID_INPUT | code | No factor in the body, or a malformed one (device_code not a string, webauthn_assertion not an object, device_signal missing). | Send exactly one factor, in the right shape. |
| 400 | no_registered_device_key | error | The device-signal factor was used, but no enrolled attestation key matches that fingerprint for this identity. | Enrol the device, or use a passkey or device grant instead. |
| 403 | WEBAUTHN_NOT_ENABLED_FOR_TENANT | code | The in-client passkey ceremony (webauthn/begin, webauthn_assertion) does not run for your title. Expected, and not a setup gap: there is nothing to configure. The body points at the hosted flow. (reason: "unavailable_on_this_environment" means the whole capability is off.) | Use the INVO-hosted approval: approveHosted() on the web, a QR device grant elsewhere. Both settle with device_code. |
| 400 | WEBAUTHN_NO_CREDENTIAL | code | This player has no passkey for the in-client ceremony yet. Not a malfunction. | Send them through the hosted approval (approveHosted(), or the QR grant): the hosted page sets up their passkey on the first visit and approves in the same step. |
| 401 | SDK_TOKEN_MALFORMED | code | The bearer token is not a player token, or carries no identity. | Re-mint from /api/sdk/player-token and send it as Authorization: Bearer. |
| 401 | SDK_TOKEN_EXPIRED | code | The player token has run out. Tokens are short-lived and do not refresh. Nothing moved. | Mint a new token from /api/sdk/player-token and retry. The browser SDK does this once on its own when you give InvoClient a refreshToken hook. |
| 401 | WEBAUTHN_UV_REQUIRED | code | The assertion verified but carried no user verification, so it is not a step-up. | Request user verification in the ceremony and try again. |
| 403 | PLAYER_TOKEN_STALE | code (with error not_send_sender / not_transfer_sender) | The token was minted before the player’s phone was added, so it carries their old identity. Returned by approve, device-approval begin and the passkey begin calls. Nothing moved. | Mint a new token and retry once. To avoid it: when initiate answers identity_rekeyed: true, use the player_token it returns, and mint again right before approval. |
| 403 | not_send_sender / not_transfer_sender | error | The token’s player did not start this transaction. | Mint the token for the sender’s email, in the sending title. If code is PLAYER_TOKEN_STALE, see the row above. |
| 403 | not_intended_receiver | error | On collect: the token’s identity is not the addressed recipient, or the phone on their player row does not match the number the value was addressed to. | Mint the token in the receiving title for the right player, and check the phone on file. |
| 403 | WEBAUTHN_TENANT_MISMATCH | code | The token’s title is not the sending title, so the passkey would be checked against the wrong domain. Sender side only. The two receipt flows answer not_intended_receiver for the same mistake. | Approve with a token minted by the sending title. (For collecting, mint it in the receiving title and expect not_intended_receiver if you get it wrong.) |
| 403 | PASSKEY_RECOVERY_COOLDOWN | code | This identity recovered a passkey recently, so money leaving the account is paused. Refuses every factor, at both initiate and approve. Collecting is unaffected. | Show retry_after (absolute) and stop. Do not retry inside the hold; retry_after_seconds is there for a backoff. |
| 403 | RECIPIENT_IDENTITY_DECLINED | code | A recipient-identity confirmation was declined. Terminal for this collect. Flag-gated and off by default. | Stop. The value stays with the sender and refunds at expiry. |
| 403 | DEVICE_REQUIRES_ELEVATION | code | This device is not cleared for money actions. Matches hold_reason: "device_requires_elevation" on the pending list. | Elevate the device, or act from one that is already cleared. |
| 409 | receiver_not_enrolled_use_claim_code | error | A routing instruction, not an error. There is no player row for this identity in the receiving title, so there is nobody to credit. | Split by rail. flow: "send" → the hosted claim page, or claim-currency. flow: "transfer" → claim-transfer only: the hosted claim page does not serve transfers and answers an opaque 404 for a transfer's claim code. |
| 409 | PHONE_SHARE_APPROVAL_REQUIRED | error_codehosted page: code | This phone number belongs network-wide to a different Invo identity. That person must consent before value lands on it here. | Follow the consent flow the body describes, then retry. Consent is requested by email where a verified address exists. Two shapes: the API surfaces answer error_code: "PHONE_SHARE_APPROVAL_REQUIRED" with the full body; the hosted claim page deliberately answers a reduced code: "PHONE_SHARE_REQUIRED" carrying only message, code_sent and expires_at (plus rate_limited / retry_after_seconds when throttled). Match on both names. |
| 400 | WALLET_WEBAUTHN_UNSUPPORTED | code | A cross-game wallet token was used with a passkey assertion, which is keyed to a single title’s domain. | Use the device-signal factor with that token. |
| 202 | GUARDIAN_APPROVAL_PENDING | error_code | A minor account whose guardian has not answered. Not a failure. | Poll /api/transactions/{id}/approval-status, then call approve again once the state is approved. |
| 202 | RECIPIENT_IDENTITY_PENDING | code | A recipient-identity confirmation is in flight. Not a failure and not final. Flag-gated and off by default, so most integrations never see it; handle it anyway, as you would any 202. | Tell the player a confirmation was sent; retry once they have answered. |
| 202 | STEP_UP_REQUIRED | error_code | The value crossed a threshold that wants a stronger proof than the one presented. Returned only while the risk engine is enabled; a no-op otherwise. | Run a user-verified factor (passkey or device grant) and call again with biometric_verified: true. |
| 202 | RISK_HOLD | error_code | The action is held for review. status is "held_for_review" and risk carries the assessment. Held, not refused, and not a failure. Returned only while the risk engine is enabled. | Stop and tell the player it is under review. Do not retry, and do not re-initiate. |
| 403 | HIGH_VALUE_REQUIRES_APP | error_code | Returned only by the deprecated legacy verify endpoints, not by initiate and not by these approve endpoints. | Approve with the hosted page or a QR grant, which is the only path to build. |
| 410 | GUARDIAN_APPROVAL_REJECTED | error_code | The guardian said no. Terminal. | Stop. The reservation is swept back when the approval window lapses. |
| 410 | GUARDIAN_APPROVAL_EXPIRED | error_code | The guardian never answered in time. Terminal. | Stop. Start a new transaction if the player still wants it. |
| 400 | SEND_APPROVE_FAILED | code | Our fault, and ambiguous. It does not mean nothing moved: the money can already have moved before the error was built. Carries an error_ref. | Never retry, never refund. Read the transaction status (or wait for the webhook) to learn what happened, then log error_ref and raise it with Invo. |
| 400 | TRANSFER_APPROVE_FAILED | code | Our fault, and ambiguous. As above, on the transfer rail. | Never retry, never refund. Read the status first, then quote error_ref. |
| 400 | CONFIRM_RECEIPT_FAILED | code | Our fault, and ambiguous. As above, on either collect endpoint. | Never retry, never refund. Read the status first, then quote error_ref. |
| 503 | GUARDIAN_APPROVAL_CHECK_UNAVAILABLE | error_code | The guardian gate could not be evaluated, and the endpoint fails closed rather than guessing. | Transient. Retry shortly. |
| 503 | IDENTITY_CHECK_UNAVAILABLE | code (confirm-receipt, hosted claim) or error_code (claim by code) | The recipient-identity check on a claim or confirm-receipt could not be evaluated. Nothing was moved. Carries retryable: true. Distinct from CONFIRMATION_SEND_FAILED, which means a confirmation could not be sent. | Transient. Retry the same call after a few seconds. |
| 503 | sdk_verification_disabled | error | In-app verification is not switched on for this environment. On the transfer collect route this is also what you see when transfer self-claim is off. | Use the fallback route and contact Invo. |
| 404 | none | none | No transaction with that id on that rail. A send id sent to a /transfers/ path lands here. | Check the rail. If in doubt read flow from the pending item. |
| 404 | receiving_tenant_unavailable | error | The receiving title could not be resolved. | Check the destination title is still live; otherwise raise it. |
The device approval grant
Begin, poll and confirm-enrollment. The poll answers are the RFC 8628 vocabulary, delivered as 400 with an error field, a standard device-grant client understands them without translation.
| HTTP | Code | Key | Means | Action |
|---|---|---|---|---|
| 400 | authorization_pending | error | Nobody has finished on the phone yet. Not a failure. | Wait interval seconds and poll again. If the body carries enrollment.state: "awaiting_screen", show the match-code prompt and answer at confirm-enrollment. |
| 400 | slow_down | error | You polled faster than interval. | Increase your interval and resume. Do not treat it as a failure. |
| 400 | expired_token | error | The grant passed its expires_in. | Begin a new grant, if the transaction’s own window still allows it. |
| 400 | access_denied | error | The player declined, either on the page, or by denying an enrolment from your screen. | Stop. Do not silently begin another grant; ask them first. |
| 400 | invalid_grant | error | Unknown device code, or one belonging to another identity. One answer for both, so the endpoint is not an oracle. | Check the token and the code go together; otherwise begin again. |
| 409 | DEVICE_APPROVAL_ALREADY_PENDING | code | An approved grant already exists for this transaction and flow, inside its window. Carries expires_at. (A merely pending grant does not produce this, a second begin supersedes it and returns a fresh code.) | Settle with the device code you already hold. If you lost it, wait out expires_at and begin again. |
| 409 | DEVICE_APPROVAL_NO_ENROLLMENT_PENDING | code | You answered an enrolment prompt that does not exist. | Only call confirm-enrollment when a poll carried an enrollment object. |
| 409 | DEVICE_APPROVAL_ENROLLMENT_ALREADY_DECIDED | code | That enrolment was already answered. The body says decided and via. | Resume polling; the outcome already applies. |
| 400 | INVALID_INPUT | code | Missing transaction_id, an unknown flow or channel, an invalid decision, or channel: "popup" from a caller with no https browser origin. | The body lists the valid values. Server-to-server callers use qr or app_browser. |
| 403 | not_transfer_sender / not_intended_receiver / tenant_mismatch | error | The token’s identity is not a party to that transaction on that side, so no grant is minted. | Check the flow matches the side you are on, and the token the player. |
| 503 | DEVICE_APPROVAL_NOT_CONFIGURED | code | The device-approval pages are not available in this environment. | Contact Invo. Use the in-client passkey path meanwhile if the client supports it. |
| 500 | DEVICE_APPROVAL_BEGIN_FAILED | code | The grant could not be minted. | One retry is reasonable here. If it persists, raise it. |
Initiating, and minting a player token
| HTTP | Code | Key | Means | Action |
|---|---|---|---|---|
| 400 | AMOUNT_BELOW_MINIMUM | error_code | From initiate-send or initiate-transfer: the amount is below the sending title’s minimum, in its own currency. The body also carries minimum_amount and currency_name, beside the existing message. On the wallet transfer route code stays INVALID_AMOUNT and these fields ride alongside. | Show the minimum from minimum_amount. To show it before the player types, read minimum_transfer from available-destinations. |
| 400 | AMOUNT_ABOVE_MAXIMUM | error_code | The amount is above the sending title’s maximum, or above the network ceiling for one send or transfer. Carries maximum_amount (and currency_name). | Show the maximum, or split the amount. |
| 400 | PHONE_MISMATCH | error_code | From initiate-send or initiate-transfer: the sender phone you passed is not the phone on file for that player. The message is “The phone number provided does not match the one on record for this account.” The number on file is never returned. (POST /api/sdk/players has its own 409 PHONE_MISMATCH; this is the initiate one.) | Send the number the player has on file. If the player has a new number, change it first: POST /api/sdk/players/phone/change-requests (see Player identity). |
| 401 | INVALID_GAME_SECRET | code | Missing, malformed or unknown X-Game-Secret-Key. | Check the header and the environment, a production secret does not work against sandbox. |
| 403 | TENANT_NOT_MIGRATED | code | In-app verification is not switched on for your title, so no player token can be minted and none of /api/sdk/* is reachable. | Contact Invo to enable it. |
| 404 | player_not_found | error | From /api/sdk/player-token: no such player in this title, and the call carried no phone. The body says so: hint: "pass player_phone to create the player". | Call again with player_phone (and player_name): the player is registered and the answer carries created: true. Always send the phone. |
| 200 | phone_mismatch: true | body flag | Not an error. /api/sdk/player-token found the player, but the phone you sent differs from the one on file. The token is for the player as Invo knows them; the phone was not changed. | Update the number with POST /api/sdk/players/phone (Player Identity), then mint again. |
| 400 | INVALID_INPUT / INVALID_PHONE | code | player_email missing, or player_phone not a valid number. | Send the phone in E.164 form, for example +15555550100. |
| 409 | PHONE_SHARE_APPROVAL_REQUIRED | error_code | Creating the player needs the phone, and it belongs to a different Invo identity. Also returned by initiate and by the collect calls. | The current holder of the number is asked to consent, by email where possible. Retry once they have. |
| 422 | identity_unavailable | error | The player row has no usable identity hash. | Raise it with Invo; it is a data condition, not a call you can fix. |
| 403 | SOURCE_GAME_NOT_LIVE / TARGET_GAME_NOT_LIVE | error_code | One end of the transaction is not in a live state. | Both titles must be live. Check the destination in the destinations endpoint before offering it. |
| 403 | TRANSFER_POLICY_VIOLATION / CURRENCY_SEND_POLICY_VIOLATION | error_code | The destination is not permitted by your transfer policy. | Only offer destinations the destinations endpoint returned. |
| 403 | GUARDIAN_REQUIRED | error_code | A minor account with no usable guardian on file. | Nothing is reserved. The account needs a guardian before it can send. |
| 409 | duplicate client_request_id | none | You already created this transaction. The idempotency key did its job. | Look up the existing transaction rather than creating another. |
| 409 | NON_STEAM_VALUE_INTO_STEAM_BLOCKED | error_code | The destination is distributed through a platform store and the source title is outside it: currency from outside cannot enter. Currency bought through a platform store is subject to that store's rules. | Do not offer that destination. Destination lists already leave it out. |
| 409 | STEAM_VALUE_NON_TRANSFERABLE | error_code | Currency bought through a platform store can only move between titles on that store; this move would take it outside. | Tell the player the currency can only move to titles on that store. Do not retry the same destination. |
| 409 | SMS_UNAVAILABLE_RECENT_PHONE_CHANGE | error_code | The player’s phone was changed by a game in the last 7 days, and your title has not started any hosted or QR approval yet, so this transaction could only go down the deprecated legacy path, which is closed to that player for now. Nothing is reserved. The same code can come from passkey setup when there is no email on file. | Build the hosted approval or the QR grant (Stage 3 of the send and transfer pages), then initiate again. Codes for that player go by email. |
| 429 | rate and velocity limits | error | Per player, per network, or a repeated-transaction guard. | Honour retry_after where present and back off. Do not loop. |
| 503 | flow_paused | error | Sends or transfers are paused platform-wide for maintenance. | Show a “temporarily unavailable” state and retry later. |
Registering a player and changing their phone
POST /api/sdk/players and POST /api/sdk/players/phone, both with the game secret. The full contract is on Player Identity.
| HTTP | Code | Key | Means | Action |
|---|---|---|---|---|
| 409 | PHONE_CHANGE_REQUIRES_VERIFICATION | code | The player has verified or secured this identity (a passkey, a device, a number they confirmed), so a game cannot change the number on its own. Nothing changed. The body carries change_request_endpoint. | Keep using the current number in your calls. Create a change request; Invo emails the player, who confirms on Invo’s page. |
| 409 | PHONE_SHARE_APPROVAL_REQUIRED | error_code | The new number belongs to a different Invo identity. | The holder of the number is asked to consent, by email where possible. Retry once they have. |
| 409 | PENDING_TRANSFERS_TO_OLD_PHONE / PENDING_TRANSFERS_TO_NEW_PHONE | code | From POST /api/sdk/players/phone or a change request: sends or transfers to the player’s current number (OLD) or to the new number (NEW) are still waiting to be collected. Nothing was written. | OLD: ask the player to collect them first (or wait until they expire), then retry. NEW: retry after they are collected or expire. |
| 409 | PHONE_CHANGE_RECOVERY_COOLDOWN | code | From a change request or its device approval: the player’s account recently went through a sign-in recovery, so the number cannot be changed yet. | Try later. |
| 409 | DEVICE_ENROLLED_AFTER_REQUEST | code | From the device approval of a phone change: the device (or its key) was set up after the change was requested. | The player approves on their earlier device, or start a new change request. |
| 409 | CONFIRM_ON_INVO_PAGE | code | From POST /api/sdk/players/phone/change-requests/{id}/confirm, always: a player-confirmed phone change is confirmed only on Invo’s page. | Do not collect the code in your game. The player follows the link in Invo’s email. |
| 409 | PHONE_MISMATCH | code | From POST /api/sdk/players: the player is already in your title with a different phone. | Change it with POST /api/sdk/players/phone. |
| 404 | player_not_found | error | From the phone change: no player with that email in your title. | Register them first, or mint a token with player_phone, which registers them. |
| 400 | INVALID_INPUT / INVALID_PHONE | code | A required field is missing, or the number is not valid. | Send E.164, for example +15555550100. |
| 503 | PHONE_UPDATE_UNAVAILABLE | code | The change could not be checked right now. Nothing changed. | Transient. Retry shortly. |
| 200 | identity_rekeyed: true | body flag | Not an error. The change, or an initiate that added the first phone, gave the player a new Invo identity. The body carries a fresh player_token. | Use that player_token for every later call. Earlier tokens answer PLAYER_TOKEN_STALE. |
After a game changes a player’s phone, any code for that player goes by email for 7 days, and the player is told about the change.
Claim delivery, resend-claim, the legacy PIN resend, and the claim-code route
POST /api/currency-sends/resend-pin and POST /api/transfers/resend-pin are deprecated legacy endpoints. Do not build on them. Approval is the hosted page or a QR grant. POST /api/sdk/send/{id}/resend-claim is live: it re-delivers the receiver’s claim link on the original channel and answers 200 { status: "resent", transaction_id, retry_after, claim_delivery }. Only its legacy sms channel is deprecated.
Not a failure: claim_delivery. The send approve 200 and the resend-claim 200 carry it, and initiate-send carries the prediction claim_delivery_planned. INVO emails the claim link when the receiver’s email is already on file for that phone; otherwise the receiver collects in the game. "email" = emailed to that address; "none" = nothing sent, because the receiver email supplied is not on file for the phone; "sms" = legacy, only when no receiver email was supplied and none is on file. Show the sender which one applies; never retry an approve over it.
| HTTP | Code | Key | Means | Action |
|---|---|---|---|---|
| 400 | No original PIN message found to resend. | message | resend-pin (deprecated): there is nothing to resend, which is always the case when initiate answered verification_method: "in_app". Other answers: 400 pin_expired, 429 resend_cooldown, 503 resend_unavailable. | Do not retry. Approve with the hosted page or a QR grant. |
| 429 | RESEND_COOLDOWN | code | A resend went out too recently. Carries retry_after in seconds. | Disable the button for that many seconds. |
| 409 | CLAIM_SMS_SUPPRESSED | code | resend-claim: this recipient collects in-app, so there is nothing to resend. | Point the sender at the fact that the recipient has already been notified in-app. |
| 409 | CLAIM_DELIVERY_WITHHELD | code | resend-claim: the receiver email given for this send is not on file for the receiver phone, so no claim link can be sent. The body carries claim_delivery: "none". | Do not retry. Tell the sender the receiver collects in the game. |
| 400 | NOT_CLAIMABLE | code | The transaction is not awaiting a claim, usually not yet approved, or already collected. | Read the status endpoint and act on the real state. |
| 400 | CLAIM_EXPIRED | code | The claim window has closed. The sender is refunded by the sweep. | Start a new transaction. |
| 400 | NO_RECEIVER_PHONE | code | Nothing to deliver a notification to. | Collect through the in-client route instead. |
| 400 | SEND_INCOMPLETE | code | resend-claim: the send is missing data needed to rebuild the message. | Do not retry. Read the status endpoint; contact support if it persists. |
| 400 | RESEND_CLAIM_FAILED | code | resend-claim failed server-side. Nothing was re-sent. | Do not loop. Try once later from a button, and read the status endpoint. |
| 400 | WRONG_CLAIM_ENDPOINT | error_code | A send’s claim code was sent to the transfer endpoint, or the reverse. | The body names expected_endpoint and the fields it wants. Better: collect with confirm-receipt and avoid codes. |
| 429 | CLAIM_LOCKED | error_code | Too many failed claim attempts on this phone, email or network. Carries locked_dimension, retry_after_seconds and a Retry-After header. | Stop and show the wait. Repeated wrong codes is the exact pattern this exists to stop. |
| 200 | needs_account_selection | status | Not an error, despite the 200. The phone matches more than one of your players and the claim cannot pick one. | Show the masked candidates from the body and re-submit with the chosen player id. |
The two answers most often mishandled
TRANSACTION_NOT_PENDING: read it as “already done”. Refunding, alerting or re-initiating on this is how a completed transaction becomes a support ticket.
DEVICE_APPROVAL_NOT_APPROVED: read it as “check your flow” before you read it as “the player declined”. The single most common cause is a grant begun with the sending flow being used to collect, or the reverse.