Ошибки¶
Конверт ошибки¶
Любая ошибка, которую возвращает 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.
Два исключения¶
Конверт действует везде, кроме двух мест.
Несуществующий курс отвечает иначе, потому что ошибку выбрасывает сам шлюз:
Текстовый message, лишний ключ error, timestamp отсутствует. Так делает только
GET /api/v1/tokens/rate.
Недоступный внешний сервис даёт голое:
Значит, запрос не дошёл до места, где его классифицировали бы. Считайте такой ответ повторяемым.
По статусам¶
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 отклоняется; он не возвращает уже
созданный вами инвойс. Выходите из положения чтением:
Выводы ведут
себя наоборот — там повторный externalId возвращает существующую запись.
422 — корректно, но недопустимо¶
Самое многочисленное семейство. Запрос разобран, ресурсы на месте — операцию не допускает текущее состояние.
message |
Где возникает |
|---|---|
InvalidInvoiceAmountError |
POST /invoices — amount нулевой, отрицательный или не похож на целое число базовых единиц |
InvalidInvoiceExpirationError |
POST /invoices — expiresAt в прошлом или вне допустимого окна |
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. Процитировав его, вы превратите
разговор с поддержкой об одном запросе в короткий.