Перейти к содержанию

Ошибки

Конверт ошибки

Любая ошибка, которую возвращает API, выглядит одинаково — три ключа:

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

message — это код ошибки: идентификатор в верхнем регистре вроде INVALID_INVOICE_AMOUNT, а не фраза. Он стабилен, машиночитаем, и ветвиться нужно именно по нему. Отдельного поля code нет, и разбора по полям, что именно не так с телом запроса, тоже нет: 400 INVALID_REQUEST_BODY говорит, что тело отклонено, но не говорит, какое поле в этом виновато.

У двух ошибок есть четвёртый ключ, details. 422 NOTHING_TO_SWEEP добавляет сумму, оставшуюся на адресе, и сетевую комиссию, в которую обошлось бы её перемещение; см. свип депозитного адреса. 422 DESTINATION_NOT_ACTIVATED добавляет minAmount — наименьшую выплату, которая создаёт счёт получателя; см. вывод на RIPPLE. Других ошибок с details нет.

statusCode дублирует HTTP-статус. timestamp — момент формирования ошибки, в UTC.

Сопоставляйте по message, а не по статусу

Один статус достаётся сразу нескольким разным ситуациям. Тот же 403 покрывает и заблокированного мерчанта, и IP вне белого списка, и чужой ресурс — три случая, на которые реагировать надо по-разному. Статус лишь сужает круг причин, а конкретную называет message.

Общие коды

Ошибка, у которой нет более точного кода, отвечает общим кодом своего статуса — например, несуществующий маршрут даёт 404 NOT_FOUND.

message Статус
BAD_REQUEST 400, а также любой другой 4xx без собственного кода
UNAUTHORIZED 401
FORBIDDEN 403
NOT_FOUND 404
CONFLICT 409
UNPROCESSABLE_ENTITY 422
TOO_MANY_REQUESTS 429
SERVICE_UNAVAILABLE 503. Есть в справочнике, но сейчас любой 5xx отвечает INTERNAL_ERROR; обрабатывайте оба одинаково
INTERNAL_ERROR Любой 5xx; см. раздел про 5xx ниже

Незнакомый код обрабатывайте по его статусу, по таблице из раздела повторы.

По статусам

400 — запрос сформирован неверно

message Причина
INVALID_REQUEST_BODY Нет обязательного поля, у поля неверный тип, значение enum недопустимо или в теле есть поле, которого эндпоинт не знает
INVALID_QUERY_PARAMETER Query-параметр эндпоинту неизвестен, передан дважды там, где значение одно, не передан или некорректен: фильтр по id — не UUID, в оценке комиссии вывода не передан chainId или tokenId, либо в GET /api/v1/tokens/rate идентификатор токена не передан или не UUID, сумма — не положительное целое в базовых единицах, или переданы и amountIn, и amountOut
INVALID_PATH_PARAMETER Идентификатор в пути — не UUID, например walletId при свипе
UNSUPPORTED_SORT_BY sortBy называет поле, по которому на этом эндпоинте сортировать нельзя
TOO_MANY_FILTER_VALUES В один повторяющийся параметр фильтра передано больше 500 значений
INVALID_DATE_FILTER Фильтр по дате не разбирается: settledFrom / settledTo в GET /merchant/fees. Вывернутый наизнанку диапазон — не ошибка: под него просто ничего не подходит

400 повторять бессмысленно: запрос отклонят ровно так же.

401 — вас не опознали

message Причина
INVALID_API_KEY Отсутствующий, некорректный, неизвестный или отозванный x-api-key

Все четыре причины намеренно неразличимы — по ответу не понять, какая сработала.

403 — опознали, но не разрешили

message Причина
CALLER_IP_NOT_ALLOWED Ключ верный, но адрес источника не входит в его белый список
MERCHANT_BLOCKED Мерчант заблокирован
PERMISSION_DENIED Указанный вами ресурс принадлежит другому мерчанту

403 — это не только про запись

MERCHANT_BLOCKED выбрасывается при аутентификации, до выполнения маршрута, поэтому относится и к чтению. Если ваш клиент трактует 403 как «в записи отказано», блокировка мерчанта покажется ему сбоем одного отдельного запроса.

Обратите внимание на разницу между PERMISSION_DENIED и 404: существующий, но не ваш id даёт 403, а id, которого нет нигде, — 404. Поэтому 403 на GET подтверждает, что ресурс существует.

404 — ничего нет

message Что именует
INVOICE_NOT_FOUND invoiceId
INVOICE_PAYMENT_NOT_FOUND paymentId, или платёж не относится к этому инвойсу
STATIC_WALLET_NOT_FOUND staticWalletId
CHAIN_NOT_FOUND chainId
TOKEN_NOT_FOUND tokenId, или токен не относится к этой сети
WALLET_NOT_FOUND walletId при свипе, или адрес принадлежит другому мерчанту
MERCHANT_NOT_FOUND Мерчанта, стоящего за вашим ключом
TOKENS_RATE_NOT_FOUND Пару в GET /api/v1/tokens/rate сейчас невозможно оценить

Две выделенные строки — это ошибки несоответствия, поданные как «объект не найден»: настоящий paymentId под чужим invoiceId даст «платёж не найден», а настоящий tokenId в паре не с той chainId — «токен не найден». Прежде чем решить, что id неверен, проверьте пару.

WALLET_NOT_FOUND — единственное исключение из правила «чужое — это 403» выше: свип адреса другого мерчанта даёт 404, так что ответ не подтверждает, что такой id существует.

409 — уже существует

message Причина
INVOICE_EXTERNAL_ID_ALREADY_EXISTS Инвойс с таким externalId уже существует у вашего мерчанта
WITHDRAWAL_EXTERNAL_ID_CONFLICT Вывод с таким externalId у вашего мерчанта уже есть, но с другими параметрами
WALLET_HAS_NO_OWNER POST /wallets/{walletId}/sweep для адреса, не закреплённого ни за одним мерчантом. Исправить это может только поддержка

Это не идемпотентность

Повторный POST /api/v1/invoices с тем же externalId отклоняется; он не возвращает уже созданный вами инвойс. Выходите из положения чтением:

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

Выводы ведут себя наоборот — там повторный externalId с теми же параметрами возвращает существующую запись, и только повтор с другими параметрами даёт 409.

422 — корректно, но недопустимо

Самое многочисленное семейство. Запрос разобран, ресурсы на месте — операцию не допускает текущее состояние.

message Где возникает
INVALID_INVOICE_AMOUNT POST /invoices — expectedAmount нулевой, отрицательный или не похож на целое число базовых единиц
INVALID_INVOICE_EXPIRATION POST /invoices — expiresInSeconds вне диапазона 60–604800
INVALID_INVOICE_STATUS_TRANSITION Смена статуса, запрещённая жизненным циклом
EXPECTED_INVOICE_PAYMENT_CANNOT_BE_RESOLVED POST .../resolve для платежа EXPECTED, не провалившего скрининг
INVOICE_PAYMENT_ALREADY_RESOLVED POST .../resolve для платежа, у которого resolution уже не PENDING
REFUND_ADDRESS_REQUIRED resolution: "REFUNDED" отправлен без refundAddress
REFUND_ADDRESS_NOT_ALLOWED refundAddress или refundTag отправлен с резолюцией, отличной от REFUNDED
INVOICE_PAYMENT_NOT_REFUNDED POST .../refund до того, как платёж получил резолюцию REFUNDED
INVOICE_PAYMENT_REFUND_ALREADY_RECORDED Возврат по этому платежу уже зарегистрирован
INVALID_STATIC_WALLET_REFERENCE reference состоит только из пробелов или длиннее 128 символов после обрезки
TOKEN_IS_DISABLED POST /invoices — токен не принимает платежи: его srcStatus равен DISABLED
NOTHING_TO_SWEEP POST /wallets/{walletId}/sweep — на адресе нет ничего чистого сверх пыли. Несёт details
SWEEP_AMOUNT_TOO_SMALL POST /wallets/{walletId}/sweep — слишком мало, чтобы перемещать. Не ожидается: на пыль отвечает NOTHING_TO_SWEEP; обрабатывайте так же
INSUFFICIENT_MERCHANT_BALANCE POST /merchant/withdrawals — баланс не покрывает сумму и комиссию
TOKEN_PRICE_UNAVAILABLE POST /merchant/withdrawals, GET /merchant/withdrawals/fee-quote — у токена нет курса, чтобы взять в нём комиссию
FEE_EXCEEDS_MAX_FEE POST /merchant/withdrawals — комиссия оказалась выше maxFee
AMOUNT_DOES_NOT_COVER_FEE POST /merchant/withdrawals — задан subtractFeeFromAmount, а комиссия не меньше amount
RECIPIENT_ADDRESS_BLOCKED POST /merchant/withdrawals — адрес назначения не прошёл скрининг
WITHDRAWAL_ADDRESS_NOT_WHITELISTED POST /merchant/withdrawals — ваш белый список выводов включён, а адреса назначения в нём для этой сети нет; на RIPPLE — нет с этим toTag
DESTINATION_TAG_REQUIRED POST /merchant/withdrawals на RIPPLE — счёт получателя не принимает платежи без тега назначения, а toTag не передан
DESTINATION_NOT_ACTIVATED POST /merchant/withdrawals на RIPPLE — счёта получателя ещё нет, а выплата меньше суммы, нужной для его создания. Несёт details

422 без изменений повторять бессмысленно: должно поменяться либо состояние, либо сам запрос.

5xx — запрос не выполнен на нашей стороне

Любой 5xx отвечает INTERNAL_ERROR. Код не говорит, что именно сломалось; статус говорит, где примерно.

Статус Значение
500 Непредвиденная ошибка при обработке запроса
502 Часть платформы за API дала сбой или не ответила — в том числе когда запрос не дошёл до места, где его классифицировали бы
503, 504 Часть платформы за API была недоступна или не ответила вовремя. Обрабатывайте как 502

502 означает, что ответ неизвестен, а не что ничего не произошло

При чтении это безобидно — просто повторите. При записи операция могла успеть пройти до того, как ответ потерялся. POST /merchant/withdrawals и POST /static-wallets повторяйте свободно: оба идемпотентны по вашему собственному референсу — вывод, только если вы передали externalId. POST /wallets/{walletId}/sweep можно повторять как есть. А перед повтором POST /invoices сначала поищите запись по externalId; см. безопасные повторы.

Повторы

Статус Повторять?
400, 422 Нет. Исправьте запрос или состояние
401, 403 Нет. Исправьте ключ, белый список или блокировку
404 Нет — разве что вы обгоняете ресурс, который ещё создаётся
409 Нет. Вместо этого прочитайте существующую запись или отправьте новый externalId
500, 502, 503, 504 Да, с задержкой

Используйте экспоненциальную задержку с джиттером и ограничивайте число попыток. Бесконечные повторы 502 во время сбоя внешнего сервиса только добавляют ему нагрузки.

Пример обработчика

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"])   # для вас это не ошибка
if message == "MERCHANT_BLOCKED":
    raise AccountBlocked()                                     # позовите дежурного
if resp.status_code >= 500:
    raise Retryable(message)                                   # задержка и новая попытка
raise Permanent(f"{resp.status_code} {message}")               # логируйте весь конверт

В каждой ветке пишите в лог весь конверт вместе с timestamp: именно по нему поддержка находит конкретный запрос.