Errors¶
The envelope¶
Every failure the API raises comes back in the same three-key shape:
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:
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.