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

Ошибки

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

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

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

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

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

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

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

Два исключения

Конверт действует везде, кроме двух мест.

Несуществующий курс отвечает иначе, потому что ошибку выбрасывает сам шлюз:

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

Текстовый message, лишний ключ error, timestamp отсутствует. Так делает только GET /api/v1/tokens/rate.

Недоступный внешний сервис даёт голое:

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

Значит, запрос не дошёл до места, где его классифицировали бы. Считайте такой ответ повторяемым.

По статусам

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

message Причина
InvalidRequestBodyError Нет обязательного поля, у поля неверный тип или значение enum недопустимо
UnsupportedSortByError sortBy называет поле, по которому на этом эндпоинте сортировать нельзя
TooManyFilterValuesError В один повторяющийся параметр фильтра передано больше 500 значений
InvalidDateFilterError createdAtFrom / createdAtTo не разбирается либо диапазон вывернут наизнанку

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

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

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

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

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

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

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

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

Разницу между PermissionDeniedError и 404 стоит запомнить: существующий, но не ваш id даёт 403, а id, которого нет нигде, — 404. Из чего следует, что 403 на GET подтверждает: ресурс существует.

404 — ничего нет

message Что именует
InvoiceWithIdNotFoundError invoiceId
InvoicePaymentWithIdNotFoundError paymentId, или платёж не относится к этому инвойсу
StaticWalletWithIdNotFoundError staticWalletId
WebhookWithIdNotFoundError webhookId
WebhookDeliveryWithIdNotFoundError Идентификатор доставки
ChainWithIdNotFoundError chainId
TokenWithIdNotFoundError tokenId, или токен не относится к этой сети, или он DISABLED
WalletWithIdNotFoundError Внутреннюю ссылку на кошелёк
MerchantWalletForChainTypeNotFoundError У вас не настроен кошелёк для этого семейства сетей
MerchantWithIdNotFoundError Мерчанта, стоящего за вашим ключом

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

MerchantWalletForChainTypeNotFoundError — проблема конфигурации, а не запроса: она означает, что никто не настроил в кабинете кошелёк для выплат в этом семействе. Повтор её не исправит.

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

message Причина
InvoiceWithExternalIdAlreadyExistsError Инвойс с таким externalId уже существует у вашего мерчанта

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

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

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

Выводы ведут себя наоборот — там повторный externalId возвращает существующую запись.

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

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

message Где возникает
InvalidInvoiceAmountError POST /invoicesamount нулевой, отрицательный или не похож на целое число базовых единиц
InvalidInvoiceExpirationError POST /invoicesexpiresAt в прошлом или вне допустимого окна
InvalidInvoiceStatusTransitionError Смена статуса, запрещённая жизненным циклом
ExpectedInvoicePaymentCannotBeResolvedError POST .../resolve для платежа EXPECTED, не провалившего скрининг
InvoicePaymentAlreadyResolvedError POST .../resolve для платежа, у которого resolution уже не PENDING
RefundAddressRequiredError resolution: "REFUNDED" отправлен без refundAddress
RefundAddressNotAllowedError refundAddress отправлен с резолюцией, отличной от REFUNDED
InvoicePaymentNotRefundedError POST .../refund до того, как платёж получил резолюцию REFUNDED
InvoicePaymentRefundAlreadyRecordedError Возврат по этому платежу уже зарегистрирован
InvalidStaticWalletReferenceError reference пуст или длиннее 128 символов
EmptyAMLAddressError address есть, но пуст

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

502 — сбой внешнего сервиса

message Внешний сервис
ChainServiceError Данные о сетях и чтение из блокчейна
WalletServiceError Выдача адресов и переводы
AMLServiceError Проверка адресов
UserServiceError Аккаунты

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

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

Повторы

Статус Повторять?
400, 422 Нет. Исправьте запрос или состояние
401, 403 Нет. Исправьте ключ, белый список или блокировку
404 Нет — разве что вы обгоняете ресурс, который ещё создаётся
409 Нет. Вместо этого прочитайте существующую запись
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 == "InvoiceWithExternalIdAlreadyExistsError":
    return fetch_invoice_by_external_id(body["externalId"])   # для вас это не ошибка
if message == "MerchantIsBlockedError":
    raise AccountBlocked()                                     # позовите дежурного
if resp.status_code >= 500:
    raise Retryable(message)                                   # задержка и новая попытка
raise Permanent(f"{resp.status_code} {message}")               # логируйте весь конверт

В каждой ветке пишите в лог весь конверт вместе с timestamp. Процитировав его, вы превратите разговор с поддержкой об одном запросе в короткий.