Платформа отправляет POST на адрес вебхука.1. События#
Имя события лежит в заголовке X-Event-Type.| Событие | Когда |
|---|
payment.created | Платёж создан и платформа попыталась подобрать реквизит. В теле reason всегда created. |
payment.status.changed | Статус изменился или платформа снова просит подтверждение. |
Повторное создание с тем же merchantTransactionId может прислать payment.created ещё раз, с тем же id платежа. Обрабатывайте платёж по id, а попытку доставки по X-Delivery-Id.2. Запрос#
Метод POST. Путь платформа не дописывает: уходит ровно тот URL, который получился при создании платежа.| Заголовок | Пример | Заметка |
|---|
Content-Type | application/json | Подписывайте сырые байты, до разбора JSON. |
X-Event-Type | payment.status.changed | payment.created или payment.status.changed. |
X-Delivery-Id | 3b1c... | Один идентификатор на все повторы одного уведомления. |
X-Signature | 64 шестнадцатеричных символа | Заголовка нет, если секрет мерчанту не задан. |
Тело - одна строка JSON, без пробелов и без перевода строки в конце. Подпись считается по этим байтам. Пересобранный объект даст другую подпись.Таймаут на стороне платформы - 10 секунд. Ответ нужно успеть отдать раньше.3. Ответ#
Код от 200 до 299 означает, что уведомление принято. Тело ответа платформа не читает.Любой другой код, обрыв связи или таймаут - повод для повтора с тем же телом.Пока подпись не проверена, не отвечайте 2xx: платформа сочтёт уведомление принятым и больше его не пришлёт.1.
Прочитать сырое тело до разбора JSON.
3.
Если подпись не сошлась, ответить 401.
4.
Сохранить доставку по X-Delivery-Id. Если такой идентификатор уже был, ответить 200 и ничего не менять.
5.
Обновить заказ по id и merchantTransactionId.
Долгую работу ставьте в очередь после 200.4. Повторы#
Платформа делает до 10 попыток.После неудачи пауза растёт так: 2 секунды, 4, 8, 16, 32, 64, 128, 256, 512 секунд. После десятой неудачи достав ка прекращается.Повтор несёт тот же X-Delivery-Id, то же тело и ту же подпись. Новый статус - новое уведомление и новый X-Delivery-Id.Храните обработанные X-Delivery-Id хотя бы несколько дней.5. Поля тела#
Оба события несут один набор полей. Различаются заголовок X-Event-Type и значение reason.| Поле | Всегда | Смысл |
|---|
id | да | Идентификатор платежа, тот же, что в API. |
merchantTransactionId | да | Ваш номер заказа. |
type | да | in - приём, out - выплата. |
status | да | Текущий статус. Имена в следующем разделе. |
amount | да | Сумма заказа строкой. |
currency | да | Валюта, например RUB. |
paidAmount | нет | Сколько фактически пришло. Поля нет, пока оплаты не было. |
amountUsd | нет | Сумма в долларах, если она посчитана. |
rail | да | Кто проводит платёж. Для трейдера - trader. Пустая строка, если реквизит ещё не найден. |
reason | да | Почему отправлено это уведомление. |
occurredAt | да | Время в UTC, RFC3339 с долями секунды. |
confirmationKind | нет | Только когда просят файл: receipt, video или other. |
confirmationNote | нет | Текст просьбы, если его написали. |
В вебхуке нет merchantRate, реквизитов и ссылки на страницу. Курс и реквизиты читайте через GET /v1/payments/{id}.Пустые paidAmount, amountUsd, confirmationKind и confirmationNote в JSON не попадают. Пустой rail приходит как "".6. Статусы в теле#
| status | Когда так бывает |
|---|
process | Платёж ещё идёт. Так выглядит payment.created, если реквизит найден или выплата только поставлена в очередь. |
confirmation_requested | Нужен чек, видео или другой файл. Смотрите confirmationKind. |
paid | Пришла ровно сумма заказа. |
underpaid | Пришло меньше. Смотрите paidAmount. |
overpaid | Пришло больше. Смотрите paidAmount. |
expired | Время вышло. |
cancel | Платёж отменён. |
chargeback | Оплата оспорена уже после успеха. |
error | Провести не удалось. Текст в reason. |
В вебхуке статус подтверждения называется confirmation_requested. В ответе GET /v1/payments/{id} то же состояние приходит строкой 9. Как это читать в API, см. таблицу статусов в integratsiya.md.7. Причины#
reason - короткая строка перехода. Ниже те, которые платформа ставит сама. При сбое удержания баланса к префиксу дописывается текст ошибки учёта.| reason | Событие | status |
|---|
created | payment.created | process |
paid 1500 of 1500 | payment.status.changed | paid, underpaid или overpaid. Числа - фактическая сумма и сумма заказа. |
expired | payment.status.changed | expired |
cancel | payment.status.changed | cancel |
confirmation_requested | payment.status.changed | confirmation_requested |
rematched | payment.status.changed | process. Реквизит подобрали заново. |
rerouted | payment.status.changed | process. Платёж ушёл на другой способ проведения. |
ledger hold failed: | payment.status.changed | error. Не хватило баланса или учёт не записал удержание. |
| свободный текст | payment.status.changed | chargeback. Отдельной фиксированной строки нет, ветвьтесь по status. |
Фразу paid X of Y сравнивайте по началу paid , целиком строка от платежа к платежу меняется. У ledger hold failed: сравнивайте префикс: хвост зависит от текста ошибки.8. Примеры тел#
Примеры - ровно те байты, по которым считается подпись: одна строка, без пробелов.Платёж создан, реквизит есть#
Заголовок X-Event-Type: payment.created.{"id":"7c2a9e14-8b3f-4d61-a5e0-6f18c2d94b70","merchantTransactionId":"order-1842","type":"in","status":"process","amount":"1500","currency":"RUB","amountUsd":"16.04","rail":"trader","reason":"created","occurredAt":"2026-03-12T14:05:07.184332901Z"}
Платёж создан, реквизита ещё нет#
rail пустой. Запросите GET /v1/payments/{id} ещё раз или повторите создание с тем же номером заказа.{"id":"7c2a9e14-8b3f-4d61-a5e0-6f18c2d94b70","merchantTransactionId":"order-1842","type":"in","status":"process","amount":"1500","currency":"RUB","rail":"","reason":"created","occurredAt":"2026-03-12T14:05:07.184332901Z"}
Оплачен ровно#
Заголовок X-Event-Type: payment.status.changed.{"id":"7c2a9e14-8b3f-4d61-a5e0-6f18c2d94b70","merchantTransactionId":"order-1842","type":"in","status":"paid","amount":"1500","currency":"RUB","paidAmount":"1500","amountUsd":"16.04","rail":"trader","reason":"paid 1500 of 1500","occurredAt":"2026-03-12T14:11:42.907155008Z"}
Недоплата#
{"id":"7c2a9e14-8b3f-4d61-a5e0-6f18c2d94b70","merchantTransactionId":"order-1842","type":"in","status":"underpaid","amount":"1500","currency":"RUB","paidAmount":"1000","amountUsd":"16.04","rail":"trader","reason":"paid 1000 of 1500","occurredAt":"2026-03-12T14:11:42.907155008Z"}
Переплата#
{"id":"7c2a9e14-8b3f-4d61-a5e0-6f18c2d94b70","merchantTransactionId":"order-1842","type":"in","status":"overpaid","amount":"1500","currency":"RUB","paidAmount":"2000","amountUsd":"16.04","rail":"trader","reason":"paid 2000 of 1500","occurredAt":"2026-03-12T14:12:03.551028774Z"}
Время вышло#
{"id":"7c2a9e14-8b3f-4d61-a5e0-6f18c2d94b70","merchantTransactionId":"order-1842","type":"in","status":"expired","amount":"1500","currency":"RUB","rail":"trader","reason":"expired","occurredAt":"2026-03-12T14:20:07.184332901Z"}
Отмена#
{"id":"7c2a9e14-8b3f-4d61-a5e0-6f18c2d94b70","merchantTransactionId":"order-1842","type":"in","status":"cancel","amount":"1500","currency":"RUB","rail":"trader","reason":"cancel","occurredAt":"2026-03-12T14:06:18.440291553Z"}
Просьба прислать чек#
{"id":"7c2a9e14-8b3f-4d61-a5e0-6f18c2d94b70","merchantTransactionId":"order-1842","type":"in","status":"confirmation_requested","amount":"1500","currency":"RUB","rail":"trader","reason":"confirmation_requested","occurredAt":"2026-03-12T14:08:55.662104837Z","confirmationKind":"receipt","confirmationNote":"Пришлите скрин перевода"}
confirmationKind принимает receipt, video или other. Файл загружается методом из раздела про подтверждение в integratsiya.md.Повторная просьба приходит новым уведомлением с той же причиной. Статус при этом может не меняться. Прочитайте confirmationKind и confirmationNote и в этом случае.Реквизит подобрали заново#
{"id":"7c2a9e14-8b3f-4d61-a5e0-6f18c2d94b70","merchantTransactionId":"order-1842","type":"in","status":"process","amount":"1500","currency":"RUB","rail":"trader","reason":"rematched","occurredAt":"2026-03-12T14:05:41.220184663Z"}
После rematched прочитайте платёж через API ещё раз: requisiteDetails могли смениться. Клиенту нужно показать новые реквизиты.Другой способ проведения#
{"id":"7c2a9e14-8b3f-4d61-a5e0-6f18c2d94b70","merchantTransactionId":"order-1842","type":"in","status":"process","amount":"1500","currency":"RUB","rail":"matching","reason":"rerouted","occurredAt":"2026-03-12T14:05:49.773041225Z"}
Реквизиты тоже нужно прочитать заново.Выплата не стартовала из-за баланса#
{"id":"7c2a9e14-8b3f-4d61-a5e0-6f18c2d94b70","merchantTransactionId":"payout-1842","type":"out","status":"error","amount":"1500","currency":"RUB","rail":"","reason":"ledger hold failed: payment: insufficient funds","occurredAt":"2026-03-12T14:05:08.015544902Z"}
Смотрите статус error и префикс ledger hold failed:. Что делать с ответом 422, написано в integratsiya.md.Оплата оспорена после успеха#
{"id":"7c2a9e14-8b3f-4d61-a5e0-6f18c2d94b70","merchantTransactionId":"order-1842","type":"in","status":"chargeback","amount":"1500","currency":"RUB","paidAmount":"1500","rail":"trader","reason":"оплата оспорена банком","occurredAt":"2026-03-14T09:17:22.338190446Z"}
9. Подпись#
1.
Возьмите сырые байты тела так, как они пришли в HTTP, до json.loads и до любой перекодировки.
2.
Возьмите путь вашего URL и, если есть, строку запроса. Схема, хост и якорь не входят.
Адрес https://shop.example/hooks/solvr?tenant=acme даёт строку /hooks/solvr?tenant=acme.
Адрес https://shop.example/hooks/solvr даёт строку /hooks/solvr.
3.
Склейте байты тела и эту строку пути. Между ними нет разделителя.
4.
Посчитайте HMAC-SHA256. Ключ - секрет вебхука мерчанта, как обычная строка байт, без hex и без base64.
5.
Подпись - hex в нижнем регистре, 64 символа.
6.
Сравните с заголовком X-Signature сравнением за постоянное время.
Меняется хоть один байт тела, путь или секрет - подпись другая.тело: {"id":"pay_1","status":"paid"}
путь: /hooks/solvr?tenant=acme
подпись: 8cf29a80860ab4b6abfad15d363670e98da7670c79757941de117ec61be4122d
Тот же расчёт на теле «Оплачен ровно» из раздела 8. Секрет whsec_example, путь /hooks/solvr.Подпись: c349ef59a1110fb9f02cf119473bb4c20b0151037b18d7813cf707e296a3e021.Если ваш код на тестовом векторе даёт другую строку, тело или путь меняются до подсчёта HMAC.Python#
raw_body берите из сырого тела запроса. webhook_url - адрес, на который платформа отправила запрос: из создания платежа или из подключения мерчанта. Подпись считается по пути именно этого адреса.Node.js#
rawBody должен быть Buffer сырого тела.PHP#
В PHP читайте тело через file_get_contents('php://input').10. Ошибки проверки#
Подпись не сходится, хотя секрет верный:Тело прочитали после парсера, который убрал или добавил пробелы.
В путь попал хост https://... или не попал query.
Секрет от старой ротации. После смены секрета старые уведомления ещё могут быть в пути, новые уже подписываются новым.
В сравнении участвуют перевод строки или префикс sha256=. Префикса нет, в заголовке только hex.
Регистр hex. Платформа шлёт нижний регистр. Перед сравнением приведите заголовок к нижнему.
Без проверки подписи любой, кто знает ваш URL, сможет прислать фальшивый paid.Если секрета нет, заголовка X-Signature тоже нет. Такие запросы не подтверждают оплату.11. Обработка у мерчанта#
1.
Найти заказ по merchantTransactionId. Если не нашли, найти по id. Если нет ни того ни другого, ответить 200 и записать запрос в журнал, чтобы не получать повторы.
2.
Если X-Delivery-Id уже обработан, ответить 200 и выйти.
3.
Если текущий статус заказа уже конечный (paid, underpaid, overpaid, expired, cancel, error, chargeback), не откатывать его из-за запоздалого payment.created или process.
4.
На paid закрыть заказ как оплаченный.
5.
На underpaid и overpaid записать paidAmount и не ставить полную оплату автоматически.
6.
На confirmation_requested запросить у клиента файл и отправить его в API.
7.
На rematched и rerouted заново прочитать реквизиты через API и обновить страницу оплаты.
8.
На expired, cancel, error закрыть заказ как неоплаченный.
9.
На chargeback снять успешную оплату.
Modified at 2026-10-24 01:14:36