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:
Prose message, an extra error key, no timestamp. Only
GET /api/v1/tokens/rate does this.
An unreachable upstream produces a bare:
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:
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 /invoices — amount is zero, negative or not a valid base-unit integer |
InvalidInvoiceExpirationError |
POST /invoices — expiresAt 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.