Skip to main content
Every failed request returns a non-2xx status and a body of the same shape.

The error body

array
required
One or more error details.
Always read code, never message. Message text is written for humans and may be reworded; codes are stable.

Status codes

Only 429 and 5xx are worth retrying. Retrying a 400, 401, 403, 409 or 422 will fail identically every time and will count against your rate limit.
Two 5xx codes are not worth retrying in a loop.CANNOT_CREATE_QUOTE is the catch-all for a quote that could not be built — an unknown token, an unroutable pair, a provider declining the route, a handle that belongs to nobody. None of those becomes true on a second attempt, and swaps in particular have many unroutable pairs. Retry it once, then stop and report which pair failed.CANNOT_PROCESS_EXISTING_TRANSACTION means the hash is already recorded in a way you cannot re-post — see below. Do not retry it at all.Every other 500 is worth ordinary backoff.
400 and 422 both mean “fix the request”, and the difference is where it was caught. A 422 came from schema validation — a field is the wrong type, missing, or out of bounds, and message describes the rule that failed. A 400 means the body parsed and every field was individually valid, but the request is refused as it stands: a keyed quote missing fromAddress or toAddress, an address on the wrong chain family for its chain id, a swap destination outside the key owner’s account, or an amount below the network’s minimum.The same code can arrive with either status — see the note under the Send and Swap table below.

Error codes

code is the stable identifier. Branch on it, never on message.

Every endpoint

Send and Swap

Two codes carry two statuses. The status says where the request was stopped: a 422 came from schema validation, so the value you sent is malformed; a 400 means every field parsed and the request is refused as it stands — a keyed rule, or an address on the wrong chain family. Branch on the pair, not on the code alone.
A malformed request body is also a 422, raised by request validation before any of the codes above. Its message describes the rule that failed and its code is not from this list — treat any unrecognised 422 as “fix the request”.
INVALID_API_KEY deliberately covers unknown, revoked and deactivated keys with one identical message. Distinguishing them would tell an attacker whether a guessed key ever existed. EXPIRED_API_KEY is separate because expiry is a state the legitimate owner needs to diagnose, and it reveals nothing to anyone who did not already hold the key.

The ones worth understanding

The key is valid and correctly scoped, but the account itself is not ready. It means one of two different things depending on the product, and neither is transient.Receiving: the owner has no default wallet, or no handle. They have to claim a Moove Handle and set a default wallet with a settlement token.Sending or swapping (SEND_ACCOUNT_NOT_READY): fromAddress is not a wallet the owner connected by signature. A receive-only address, added by pasting it into settings, cannot be the source of a send or a swap, because pasting an address is not proof that you hold its key. This is the single most common integration failure.With a key, POST /v1/quote/send and POST /v1/quote/swap both check this before you sign, so it normally arrives at the quote and nothing has moved. No amount of retrying will connect a wallet; the owner has to do it in the Moove App or the dashboard.
Getting it from POST /v1/transaction instead — because the wallet was disconnected between quote and record — is not a lost payment. That call only records; the funds moved on chain regardless. Reconnect the wallet, then record the same hash again. Do not re-broadcast, or you will send twice.
Surface it to the user rather than retrying.
Three kinds of 400 reach a quote, and they differ in who they apply to.Rules for keyed callers only. The website and the Moove App are not subject to these; an agent is, because it has no person watching the screen to notice an address is wrong.
  • INVALID_SOURCE / INVALID_DESTINATION — an address the schema marks optional, because the logged-out website quotes without one, but which a key must supply. Left out, the quote would be built against a placeholder you do not control. INVALID_DESTINATION is also returned for a toAddress no keyed payout or swap may be sent to, such as a wallet moove.xyz operates itself.
  • UNOWNED_SWAP_DESTINATION — a swap returns its output to you, so its toAddress must be a wallet on your own account. To pay somebody else, use Moove Send.
  • INVALID_SWAP_PAIR — the same token on the same chain is a transfer, not a swap. Use POST /v1/quote/send.
A rule for every caller, with or without a key. Each address must be of its own leg’s chain family — EVM, SVM, TVM or BVM, read from the chain catalogue. A fromAddress that is not of fromChainId’s family returns INVALID_SOURCE, and a toAddress that is not of toChainId’s family returns INVALID_DESTINATION, with a message naming both families — which is how you tell it apart from a missing address. On a send, a handle is checked through the wallet it pays, its owner’s default wallet. A bridge across families is fine as long as each address matches its own chain.Rules for every send. CANNOT_CREATE_SELF_QUOTE (source, destination and token all identical), and on Bitcoin and Tron SEND_AMOUNT_BELOW_MINIMUM (below the network’s minimum, which the message names) and INSUFFICIENT_TOKEN_BALANCE.Every one is refused before you sign, so nothing has moved. None is retryable. The keyed ones are cheap to avoid: supply both addresses, and use the endpoint that matches what you are doing.
The request body did not match the schema. message describes the rule that failed.Common causes: toAmount missing or not greater than zero, description over 500 characters, maxUsage below 1, expirationDate not an ISO 8601 timestamp or not in the future.There is also one 422 that schema validation cannot catch: INVALID_PAYMENT_LINK_AMOUNT. toAmount is quantised to the settlement token’s decimals, and rejected if that changes the value — so 49.9999999 fails against a 6-decimal USDC wallet while 49.999999 succeeds. Trailing zeros are not significant, so 100.0000000 is fine.The limit belongs to the destination wallet’s token, not to your request. See toAmount.
A keyed POST /v1/transaction must set product to "send" or "swap" — a bridge is "swap" too. The scope the request needs is derived from it, so a missing or unsupported value has no scope to check against and is rejected rather than defaulted — defaulting would pick a permission on your behalf.Anonymous callers may omit product; a key may not.
POST /v1/quote/send, POST /v1/quote/swap, POST /v1/transaction and GET /v1/transaction/{id} also serve callers with no credential, because the website and the Moove App use them logged out. A request to one of them without an X-API-Key header is not refused — it runs anonymously, with none of the key’s checks, nothing attributed to the key, and redacted record and poll responses. Only GET /v1/transaction answers a missing key with 401 UNAUTHENTICATED.An unset or empty key variable is the usual way to get there: curl drops a header whose value is empty. Check the key is set before the first call. A key that is sent and is invalid still fails with 401 INVALID_API_KEY or 401 EXPIRED_API_KEY — it never falls back to anonymous.
With a key, re-posting the same body — the same hash and the same product — for a transaction your own account already recorded is not an error: it returns that transaction again, so a record call whose response you lost is safe to retry unchanged.This code means the hash is recorded by another account, was re-posted with a different product (a swap’s hash as "send", or the reverse), or was re-posted without a key. Retrying will not change it.
Limits apply per API key and per source IP, whichever is reached first. Back off exponentially with jitter.Rate Limits →

Handling errors

Next

Rate Limits

What triggers a 429.

Authentication

401 and 403 in detail.

Moove Payment Links

The endpoints.