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

HTTPCodeKeyMeansAction
400TRANSACTION_NOT_PENDINGcodeThe 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.
400DEVICE_APPROVAL_NOT_APPROVEDcodeThe 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.
400INVALID_INPUTcodeNo 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.
400no_registered_device_keyerrorThe 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.
403WEBAUTHN_NOT_ENABLED_FOR_TENANTcodeThe 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.
400WEBAUTHN_NO_CREDENTIALcodeThis 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.
401SDK_TOKEN_MALFORMEDcodeThe bearer token is not a player token, or carries no identity.Re-mint from /api/sdk/player-token and send it as Authorization: Bearer.
401SDK_TOKEN_EXPIREDcodeThe 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.
401WEBAUTHN_UV_REQUIREDcodeThe assertion verified but carried no user verification, so it is not a step-up.Request user verification in the ceremony and try again.
403PLAYER_TOKEN_STALEcode (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.
403not_send_sender / not_transfer_sendererrorThe 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.
403not_intended_receivererrorOn 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.
403WEBAUTHN_TENANT_MISMATCHcodeThe 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.)
403PASSKEY_RECOVERY_COOLDOWNcodeThis 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.
403RECIPIENT_IDENTITY_DECLINEDcodeA 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.
403DEVICE_REQUIRES_ELEVATIONcodeThis 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.
409receiver_not_enrolled_use_claim_codeerrorA 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.
409PHONE_SHARE_APPROVAL_REQUIREDerror_code
hosted 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.
400WALLET_WEBAUTHN_UNSUPPORTEDcodeA 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.
202GUARDIAN_APPROVAL_PENDINGerror_codeA 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.
202RECIPIENT_IDENTITY_PENDINGcodeA 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.
202STEP_UP_REQUIREDerror_codeThe 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.
202RISK_HOLDerror_codeThe 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.
403HIGH_VALUE_REQUIRES_APPerror_codeReturned 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.
410GUARDIAN_APPROVAL_REJECTEDerror_codeThe guardian said no. Terminal.Stop. The reservation is swept back when the approval window lapses.
410GUARDIAN_APPROVAL_EXPIREDerror_codeThe guardian never answered in time. Terminal.Stop. Start a new transaction if the player still wants it.
400SEND_APPROVE_FAILEDcodeOur 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.
400TRANSFER_APPROVE_FAILEDcodeOur fault, and ambiguous. As above, on the transfer rail.Never retry, never refund. Read the status first, then quote error_ref.
400CONFIRM_RECEIPT_FAILEDcodeOur fault, and ambiguous. As above, on either collect endpoint.Never retry, never refund. Read the status first, then quote error_ref.
503GUARDIAN_APPROVAL_CHECK_UNAVAILABLEerror_codeThe guardian gate could not be evaluated, and the endpoint fails closed rather than guessing.Transient. Retry shortly.
503IDENTITY_CHECK_UNAVAILABLEcode (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.
503sdk_verification_disablederrorIn-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.
404nonenoneNo 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.
404receiving_tenant_unavailableerrorThe 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.

HTTPCodeKeyMeansAction
400authorization_pendingerrorNobody 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.
400slow_downerrorYou polled faster than interval.Increase your interval and resume. Do not treat it as a failure.
400expired_tokenerrorThe grant passed its expires_in.Begin a new grant, if the transaction’s own window still allows it.
400access_deniederrorThe player declined, either on the page, or by denying an enrolment from your screen.Stop. Do not silently begin another grant; ask them first.
400invalid_granterrorUnknown 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.
409DEVICE_APPROVAL_ALREADY_PENDINGcodeAn 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.
409DEVICE_APPROVAL_NO_ENROLLMENT_PENDINGcodeYou answered an enrolment prompt that does not exist.Only call confirm-enrollment when a poll carried an enrollment object.
409DEVICE_APPROVAL_ENROLLMENT_ALREADY_DECIDEDcodeThat enrolment was already answered. The body says decided and via.Resume polling; the outcome already applies.
400INVALID_INPUTcodeMissing 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.
403not_transfer_sender / not_intended_receiver / tenant_mismatcherrorThe 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.
503DEVICE_APPROVAL_NOT_CONFIGUREDcodeThe device-approval pages are not available in this environment.Contact Invo. Use the in-client passkey path meanwhile if the client supports it.
500DEVICE_APPROVAL_BEGIN_FAILEDcodeThe grant could not be minted.One retry is reasonable here. If it persists, raise it.

Initiating, and minting a player token

HTTPCodeKeyMeansAction
400AMOUNT_BELOW_MINIMUMerror_codeFrom 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.
400AMOUNT_ABOVE_MAXIMUMerror_codeThe 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.
400PHONE_MISMATCHerror_codeFrom 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).
401INVALID_GAME_SECRETcodeMissing, malformed or unknown X-Game-Secret-Key.Check the header and the environment, a production secret does not work against sandbox.
403TENANT_NOT_MIGRATEDcodeIn-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.
404player_not_founderrorFrom /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.
200phone_mismatch: truebody flagNot 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.
400INVALID_INPUT / INVALID_PHONEcodeplayer_email missing, or player_phone not a valid number.Send the phone in E.164 form, for example +15555550100.
409PHONE_SHARE_APPROVAL_REQUIREDerror_codeCreating 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.
422identity_unavailableerrorThe player row has no usable identity hash.Raise it with Invo; it is a data condition, not a call you can fix.
403SOURCE_GAME_NOT_LIVE / TARGET_GAME_NOT_LIVEerror_codeOne 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.
403TRANSFER_POLICY_VIOLATION / CURRENCY_SEND_POLICY_VIOLATIONerror_codeThe destination is not permitted by your transfer policy.Only offer destinations the destinations endpoint returned.
403GUARDIAN_REQUIREDerror_codeA minor account with no usable guardian on file.Nothing is reserved. The account needs a guardian before it can send.
409duplicate client_request_idnoneYou already created this transaction. The idempotency key did its job.Look up the existing transaction rather than creating another.
409NON_STEAM_VALUE_INTO_STEAM_BLOCKEDerror_codeThe 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.
409STEAM_VALUE_NON_TRANSFERABLEerror_codeCurrency 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.
409SMS_UNAVAILABLE_RECENT_PHONE_CHANGEerror_codeThe 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.
429rate and velocity limitserrorPer player, per network, or a repeated-transaction guard.Honour retry_after where present and back off. Do not loop.
503flow_pausederrorSends 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.

HTTPCodeKeyMeansAction
409PHONE_CHANGE_REQUIRES_VERIFICATIONcodeThe 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.
409PHONE_SHARE_APPROVAL_REQUIREDerror_codeThe 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.
409PENDING_TRANSFERS_TO_OLD_PHONE / PENDING_TRANSFERS_TO_NEW_PHONEcodeFrom 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.
409PHONE_CHANGE_RECOVERY_COOLDOWNcodeFrom 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.
409DEVICE_ENROLLED_AFTER_REQUESTcodeFrom 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.
409CONFIRM_ON_INVO_PAGEcodeFrom 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.
409PHONE_MISMATCHcodeFrom POST /api/sdk/players: the player is already in your title with a different phone.Change it with POST /api/sdk/players/phone.
404player_not_founderrorFrom the phone change: no player with that email in your title.Register them first, or mint a token with player_phone, which registers them.
400INVALID_INPUT / INVALID_PHONEcodeA required field is missing, or the number is not valid.Send E.164, for example +15555550100.
503PHONE_UPDATE_UNAVAILABLEcodeThe change could not be checked right now. Nothing changed.Transient. Retry shortly.
200identity_rekeyed: truebody flagNot 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.

HTTPCodeKeyMeansAction
400No original PIN message found to resend.messageresend-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.
429RESEND_COOLDOWNcodeA resend went out too recently. Carries retry_after in seconds.Disable the button for that many seconds.
409CLAIM_SMS_SUPPRESSEDcoderesend-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.
409CLAIM_DELIVERY_WITHHELDcoderesend-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.
400NOT_CLAIMABLEcodeThe transaction is not awaiting a claim, usually not yet approved, or already collected.Read the status endpoint and act on the real state.
400CLAIM_EXPIREDcodeThe claim window has closed. The sender is refunded by the sweep.Start a new transaction.
400NO_RECEIVER_PHONEcodeNothing to deliver a notification to.Collect through the in-client route instead.
400SEND_INCOMPLETEcoderesend-claim: the send is missing data needed to rebuild the message.Do not retry. Read the status endpoint; contact support if it persists.
400RESEND_CLAIM_FAILEDcoderesend-claim failed server-side. Nothing was re-sent.Do not loop. Try once later from a button, and read the status endpoint.
400WRONG_CLAIM_ENDPOINTerror_codeA 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.
429CLAIM_LOCKEDerror_codeToo 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.
200needs_account_selectionstatusNot 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.