Skip to content

Errors

The envelope

Every failure the API raises comes back in the same three-key shape:

{
  "statusCode": 422,
  "message": "INVALID_INVOICE_AMOUNT",
  "timestamp": "2026-01-14T09:00:00.000Z"
}

message is an error code — an upper-case identifier such as INVALID_INVOICE_AMOUNT, never a sentence. It is stable, machine-readable, and what you should branch on. There is no separate code field and no field-level breakdown of what was wrong with your body — a 400 INVALID_REQUEST_BODY tells you the body was rejected but not which field did it.

Two errors carry a fourth key, details. 422 NOTHING_TO_SWEEP adds the amount left on the address and the network fee that moving it would cost; see sweeping a deposit address. 422 DESTINATION_NOT_ACTIVATED adds minAmount, the smallest payout that creates the recipient's account; see withdrawing to RIPPLE. No other error has details.

statusCode duplicates the HTTP status. timestamp is when the error was produced, in UTC.

Match on message, not on the status

Several distinct conditions share a status. 403 alone covers a blocked merchant, an IP that is not whitelisted, and a resource belonging to someone else — three situations calling for three different responses. The status narrows the possibilities; message names the condition.

Generic codes

A failure with no more specific code answers with the generic code for its status — a route that does not exist, for instance, is 404 NOT_FOUND.

message Status
BAD_REQUEST 400, and any other 4xx without a code of its own
UNAUTHORIZED 401
FORBIDDEN 403
NOT_FOUND 404
CONFLICT 409
UNPROCESSABLE_ENTITY 422
TOO_MANY_REQUESTS 429
SERVICE_UNAVAILABLE 503. Listed in the reference, but every 5xx currently answers INTERNAL_ERROR; handle both the same way
INTERNAL_ERROR Every 5xx; see the 5xx section below

Treat a code you do not recognise by its status, using the table in retrying.

By status

400 — the request was malformed

message Cause
INVALID_REQUEST_BODY A required field is missing, or has the wrong type, or an enum value is not one of the permitted ones, or the body carries a field the endpoint does not know
INVALID_QUERY_PARAMETER A query parameter is unknown to the endpoint, sent twice where it takes one value, missing or malformed: an id filter that is not a UUID, a missing chainId or tokenId on the withdrawal fee quote, or on GET /api/v1/tokens/rate a token id missing or not a UUID, an amount that is not a positive integer of base units, or both amountIn and amountOut
INVALID_PATH_PARAMETER An id in the path is not a UUID, e.g. walletId on a sweep
UNSUPPORTED_SORT_BY sortBy names a field that is not sortable on that endpoint
TOO_MANY_FILTER_VALUES More than 500 values passed to one repeated filter parameter
INVALID_DATE_FILTER A date filter is not parseable: settledFrom / settledTo on GET /merchant/fees. A range that runs backwards is not an error; it matches nothing

Never retry a 400. The request will be rejected identically every time.

401 — you were not identified

message Cause
INVALID_API_KEY Missing, malformed, unknown or revoked x-api-key

The four causes are deliberately indistinguishable, so there is nothing to be learned from the response about which one applies.

403 — identified, but not allowed

message Cause
CALLER_IP_NOT_ALLOWED The key is valid but the source address is not on its whitelist
MERCHANT_BLOCKED The merchant is blocked
PERMISSION_DENIED The resource you named belongs to a different merchant

403 is not only about writes

MERCHANT_BLOCKED is raised during authentication, before the route runs, so it applies to reads as well. If your client treats 403 as "that write was refused", it will misreport a block as a per-request failure.

Note the difference between PERMISSION_DENIED and 404: an id that exists but is not yours is a 403; an id that exists nowhere is a 404. So a 403 on a GET confirms the resource exists.

404 — nothing there

message Names
INVOICE_NOT_FOUND invoiceId
INVOICE_PAYMENT_NOT_FOUND paymentId, or the payment is not on that invoice
STATIC_WALLET_NOT_FOUND staticWalletId
CHAIN_NOT_FOUND chainId
TOKEN_NOT_FOUND tokenId, or the token is not on that chain
WALLET_NOT_FOUND walletId on a sweep, or the address belongs to another merchant
MERCHANT_NOT_FOUND The merchant behind your key
TOKENS_RATE_NOT_FOUND The pair on GET /api/v1/tokens/rate cannot be priced right now

The two bolded rows are mismatch errors reported as lookups: a real paymentId under the wrong invoiceId reports the payment missing, and a real tokenId paired with the wrong chainId reports the token missing. Check the pairing before you conclude the id is bad.

WALLET_NOT_FOUND is the one exception to the 403-for-someone-else's rule above: sweeping an address of another merchant answers 404, so the answer does not confirm the id exists.

409 — it already exists

message Cause
INVOICE_EXTERNAL_ID_ALREADY_EXISTS An invoice with that externalId already exists for your merchant
WITHDRAWAL_EXTERNAL_ID_CONFLICT A withdrawal with that externalId already exists for your merchant, with different parameters
WALLET_HAS_NO_OWNER POST /wallets/{walletId}/sweep on an address not attributed to any merchant. Only support can fix it

This is not idempotency

A repeated POST /api/v1/invoices with the same externalId is rejected; it does not return the invoice you already raised. Recover by reading it:

GET /api/v1/invoices?externalId=order-1043&limit=1

Withdrawals behave the opposite way — there, a repeated externalId with the same parameters returns the existing record, and only a repeat with different parameters is a 409.

422 — well-formed, but not permitted

The largest family. The request parsed and the resources exist; the state does not allow the operation.

message Raised by
INVALID_INVOICE_AMOUNT POST /invoices — expectedAmount is zero, negative or not a valid base-unit integer
INVALID_INVOICE_EXPIRATION POST /invoices — expiresInSeconds is outside 60–604800
INVALID_INVOICE_STATUS_TRANSITION A status change the lifecycle forbids
EXPECTED_INVOICE_PAYMENT_CANNOT_BE_RESOLVED POST .../resolve on an EXPECTED payment that did not fail screening
INVOICE_PAYMENT_ALREADY_RESOLVED POST .../resolve on a payment whose resolution is no longer PENDING
REFUND_ADDRESS_REQUIRED resolution: "REFUNDED" sent without a refundAddress
REFUND_ADDRESS_NOT_ALLOWED refundAddress or refundTag sent with a resolution other than REFUNDED
INVOICE_PAYMENT_NOT_REFUNDED POST .../refund before the payment was resolved as REFUNDED
INVOICE_PAYMENT_REFUND_ALREADY_RECORDED A refund has already been recorded for that payment
INVALID_STATIC_WALLET_REFERENCE reference is only whitespace, or longer than 128 characters once trimmed
TOKEN_IS_DISABLED POST /invoices — the token does not accept payments: its srcStatus is DISABLED
NOTHING_TO_SWEEP POST /wallets/{walletId}/sweep — nothing clean above dust on the address. Carries details
SWEEP_AMOUNT_TOO_SMALL POST /wallets/{walletId}/sweep — too little to move. Not expected, since dust is answered with NOTHING_TO_SWEEP; handle it the same way
INSUFFICIENT_MERCHANT_BALANCE POST /merchant/withdrawals — the balance does not cover the amount and the fee
TOKEN_PRICE_UNAVAILABLE POST /merchant/withdrawals, GET /merchant/withdrawals/fee-quote — the token has no price to charge the fee in
FEE_EXCEEDS_MAX_FEE POST /merchant/withdrawals — the fee came out above maxFee
AMOUNT_DOES_NOT_COVER_FEE POST /merchant/withdrawals — subtractFeeFromAmount set and the fee is not smaller than amount
RECIPIENT_ADDRESS_BLOCKED POST /merchant/withdrawals — the destination address failed screening
WITHDRAWAL_ADDRESS_NOT_WHITELISTED POST /merchant/withdrawals — your withdrawal whitelist is on and the destination is not on it for that chain; on RIPPLE, not with that toTag
DESTINATION_TAG_REQUIRED POST /merchant/withdrawals on RIPPLE — the recipient account refuses payments without a destination tag and no toTag was given
DESTINATION_NOT_ACTIVATED POST /merchant/withdrawals on RIPPLE — the recipient account does not exist yet and the payout is below what creating it takes. Carries details

A 422 is never worth retrying unmodified. Either the state has to change or your request does.

5xx — the request failed on our side

Every 5xx answers INTERNAL_ERROR. The code does not say what failed; the status says roughly where.

Status Meaning
500 An unexpected error while handling the request
502 A part of the platform behind the API failed or did not answer — including when the request never got far enough to be classified
503, 504 A part of the platform behind the API was unavailable or timed out. Handle as a 502

A 502 means the answer is unknown, not that nothing happened

On a read it is harmless — retry. On a write, the operation may have been carried out before the response was lost. Retry POST /merchant/withdrawals and POST /static-wallets freely, since both are idempotent on a reference you supply — a withdrawal only if you sent externalId. POST /wallets/{walletId}/sweep is safe to repeat as it is. For POST /invoices, read back by externalId before retrying; see retrying safely.

Retrying

Status Retry?
400, 422 No. Fix the request or the state
401, 403 No. Fix the key, the whitelist, or the block
404 No, unless you are racing a resource that is still being created
409 No. Read the existing record instead, or send a new externalId
500, 502, 503, 504 Yes, with backoff

Use exponential backoff with jitter, and cap the attempts. Retrying a 502 without a limit during an upstream outage only adds load to it.

A worked handler

resp = requests.post(url, headers=headers, json=body, timeout=30)

if resp.ok:
    return resp.json()

err = resp.json()
message = err.get("message")

if message == "INVOICE_EXTERNAL_ID_ALREADY_EXISTS":
    return fetch_invoice_by_external_id(body["externalId"])   # not an error for you
if message == "MERCHANT_BLOCKED":
    raise AccountBlocked()                                     # page someone
if resp.status_code >= 500:
    raise Retryable(message)                                   # backoff and try again
raise Permanent(f"{resp.status_code} {message}")               # log the full envelope

Log the whole envelope, timestamp included, on every branch. It is what support needs to locate a single request.