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 | This game has no verified passkey domain of its own, so the in-client ceremony cannot run for it. The normal state for a new title, not a setup gap: new partner domains are no longer accepted. The body carries reason: "no_verified_domain" and hosted_flow.partner_rp_frozen: true. (reason: "unavailable_on_this_environment" means the whole capability is off.) | Use the device approval grant, which runs the same ceremony on Invo's own domain and settles with device_code. Nothing to configure. |
| 400 | WEBAUTHN_NO_CREDENTIAL | code | This player has no passkey enrolled. Not a malfunction: verifying a domain turns passkeys on for the game, and each player still enrols their own credential once. Those are two separate steps. | Prompt them to enrol, or fall back to the device grant. |
| 401 | SDK_TOKEN_MALFORMED | code | The bearer token is not a player token, or carries no identity. | Re-mint from /api/sdk/player-token. A 401 fifteen minutes after minting is expected, tokens do not refresh. |
| 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 | 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 game. |
| 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 game for the right player, and check the phone on file. |
| 403 | WEBAUTHN_TENANT_MISMATCH | code | The token’s game is not the sending game, 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 game. (For collecting, mint it in the receiving game and expect not_intended_receiver if you get it wrong.) |
| 403 | PASSKEY_RECOVERY_COOLDOWN | code | This identity recovered a passkey within the last 24 hours, 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 game, 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 game’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 | This value is above the ceiling for the legacy message-based path. Returned by the legacy verify-sms endpoints, not by initiate and not by these approve endpoints. | Approve with a passkey or a device grant, which is what you should be doing regardless. |
| 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 | 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 game could not be resolved. | Check the destination game 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 |
|---|---|---|---|---|
| 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 game, so no player token can be minted and none of /api/sdk/* is reachable. | Contact Invo to enable it. |
| 404 | player_not_found | error | No such player in this game. Tokens are only minted for players who already exist. | Initiate first (it creates or matches the sender), or create the player. |
| 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 games 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 | STEAM_VALUE_NON_TRANSFERABLE / NON_STEAM_VALUE_INTO_STEAM_BLOCKED | error_code | Value that originated on a platform rail cannot leave it, and value from elsewhere cannot enter it. | The body says how much is movable. Offer that amount instead. |
| 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. |
Resends, and the legacy claim-code route
| HTTP | Code | Key | Means | Action |
|---|---|---|---|---|
| 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 | This recipient collects in-app, so no message is sent for this transaction and a resend must not become a way to text one anyway. | Point the sender at the fact that the recipient has already been notified in-app. |
| 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-game route instead. |
| 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.