Skip to content

Errors

The envelope

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

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

message is the name of the error class, not a sentence. It is stable, machine-readable, and what you should branch on. There is no code field, no details, and no field-level breakdown of what was wrong with your body — a 400 InvalidRequestBodyError tells you the body was rejected but not which field did it.

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 it; message identifies it.

Two exceptions

The envelope holds everywhere except in two places.

A rate that does not exist answers a different shape, because the gateway raises it directly:

{ "statusCode": 404, "message": "Rate not found", "error": "Not Found" }

Prose message, an extra error key, no timestamp. Only GET /api/v1/tokens/rate does this.

An unreachable upstream produces a bare:

{ "statusCode": 500, "message": "Internal server error" }

which means the request never got far enough to be classified. Treat it as retryable.

By status

400 — the request was malformed

message Cause
InvalidRequestBodyError A required field is missing, or has the wrong type, or an enum value is not one of the permitted ones
UnsupportedSortByError sortBy names a field that is not sortable on that endpoint
TooManyFilterValuesError More than 500 values passed to one repeated filter parameter
InvalidDateFilterError createdAtFrom / createdAtTo is not parseable, or the range runs backwards

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

401 — you were not identified

message Cause
InvalidApiKeyError 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
CallerIpNotAllowedError The key is valid but the source address is not on its whitelist
MerchantIsBlockedError The merchant is blocked
PermissionDeniedError The resource you named belongs to a different merchant

403 is not only about writes

MerchantIsBlockedError 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.

PermissionDeniedError versus 404 is worth internalising: 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
InvoiceWithIdNotFoundError invoiceId
InvoicePaymentWithIdNotFoundError paymentId, or the payment is not on that invoice
StaticWalletWithIdNotFoundError staticWalletId
WebhookWithIdNotFoundError webhookId
WebhookDeliveryWithIdNotFoundError A delivery id
ChainWithIdNotFoundError chainId
TokenWithIdNotFoundError tokenId, or the token is not on that chain, or it is DISABLED
WalletWithIdNotFoundError An internal wallet reference
MerchantWalletForChainTypeNotFoundError You have no wallet configured for that chain family
MerchantWithIdNotFoundError The merchant behind your key

The two bolded rows catch people out. Both are mismatch errors dressed 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.

MerchantWalletForChainTypeNotFoundError is a configuration problem, not a request problem — it means nobody has set up a payout wallet for that family in the cabinet. Retrying will not fix it.

409 — it already exists

message Cause
InvoiceWithExternalIdAlreadyExistsError An invoice with that externalId already exists for your merchant

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 returns the existing record.

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
InvalidInvoiceAmountError POST /invoicesamount is zero, negative or not a valid base-unit integer
InvalidInvoiceExpirationError POST /invoicesexpiresAt is in the past or outside the permitted window
InvalidInvoiceStatusTransitionError A status change the lifecycle forbids
ExpectedInvoicePaymentCannotBeResolvedError POST .../resolve on an EXPECTED payment that did not fail screening
InvoicePaymentAlreadyResolvedError POST .../resolve on a payment whose resolution is no longer PENDING
RefundAddressRequiredError resolution: "REFUNDED" sent without a refundAddress
RefundAddressNotAllowedError refundAddress sent with a resolution other than REFUNDED
InvoicePaymentNotRefundedError POST .../refund before the payment was resolved as REFUNDED
InvoicePaymentRefundAlreadyRecordedError A refund has already been recorded for that payment
InvalidStaticWalletReferenceError reference is empty or longer than 128 characters
EmptyAMLAddressError address was present but blank

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

502 — an upstream failed

message Upstream
ChainServiceError Chain data and on-chain reads
WalletServiceError Address issuance and transfers
AMLServiceError Address screening
UserServiceError Accounts

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. 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
500, 502, 503, 504 Yes, with backoff

Use exponential backoff with jitter, and cap the attempts. A client that retries a 502 forever during an upstream outage becomes part of the outage.

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 == "InvoiceWithExternalIdAlreadyExistsError":
    return fetch_invoice_by_external_id(body["externalId"])   # not an error for you
if message == "MerchantIsBlockedError":
    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. Quoting it makes a support conversation about a single request short instead of long.