Solvr API для мерчанта
    • Начало работы
    • Вебхуки
    • API документация
      • Платежи
        • Создать платёж
        • Список платежей мерчанта
        • Один платёж
        • Отменить платёж
        • История статусов
      • Страница оплаты
        • Страница оплаты по id платежа
        • Страница оплаты по токену
      • Чеки
        • Список чеков и файлов по платежу
        • Приложить чек по ссылке
        • Загрузить файл подтверждения
      • Баланс
        • Баланс мерчанта
      • Споры
        • Открыть спор по платежу
        • Список споров мерчанта
        • Один спор
      • Schemas
        • PaymentMethod
        • PaymentType
        • PaymentStatus
        • CreatePayment
        • Payment
        • FormPage
        • StatusHistory
        • Receipt
        • Appeal
        • Balance
        • Error

    Вебхуки

    Платформа отправляет POST на адрес вебхука.

    1. События#

    Имя события лежит в заголовке X-Event-Type.
    СобытиеКогда
    payment.createdПлатёж создан и платформа попыталась подобрать реквизит. В теле reason всегда created.
    payment.status.changedСтатус изменился или платформа снова просит подтверждение.
    Повторное создание с тем же merchantTransactionId может прислать payment.created ещё раз, с тем же id платежа. Обрабатывайте платёж по id, а попытку доставки по X-Delivery-Id.

    2. Запрос#

    Метод POST. Путь платформа не дописывает: уходит ровно тот URL, который получился при создании платежа.
    ЗаголовокПримерЗаметка
    Content-Typeapplication/jsonПодписывайте сырые байты, до разбора JSON.
    X-Event-Typepayment.status.changedpayment.created или payment.status.changed.
    X-Delivery-Id3b1c...Один идентификатор на все повторы одного уведомления.
    X-Signature64 шестнадцатеричных символаЗаголовка нет, если секрет мерчанту не задан.
    Тело - одна строка JSON, без пробелов и без перевода строки в конце. Подпись считается по этим байтам. Пересобранный объект даст другую подпись.
    Таймаут на стороне платформы - 10 секунд. Ответ нужно успеть отдать раньше.

    3. Ответ#

    Код от 200 до 299 означает, что уведомление принято. Тело ответа платформа не читает.
    Любой другой код, обрыв связи или таймаут - повод для повтора с тем же телом.
    Пока подпись не проверена, не отвечайте 2xx: платформа сочтёт уведомление принятым и больше его не пришлёт.
    Порядок в обработчике:
    1.
    Прочитать сырое тело до разбора JSON.
    2.
    Проверить X-Signature.
    3.
    Если подпись не сошлась, ответить 401.
    4.
    Сохранить доставку по X-Delivery-Id. Если такой идентификатор уже был, ответить 200 и ничего не менять.
    5.
    Обновить заказ по id и merchantTransactionId.
    6.
    Ответить 200.
    Долгую работу ставьте в очередь после 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
    createdpayment.createdprocess
    paid 1500 of 1500payment.status.changedpaid, underpaid или overpaid. Числа - фактическая сумма и сумма заказа.
    expiredpayment.status.changedexpired
    cancelpayment.status.changedcancel
    confirmation_requestedpayment.status.changedconfirmation_requested
    rematchedpayment.status.changedprocess. Реквизит подобрали заново.
    reroutedpayment.status.changedprocess. Платёж ушёл на другой способ проведения.
    ledger hold failed:payment.status.changederror. Не хватило баланса или учёт не записал удержание.
    свободный текстpayment.status.changedchargeback. Отдельной фиксированной строки нет, ветвьтесь по 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 сравнением за постоянное время.
    Меняется хоть один байт тела, путь или секрет - подпись другая.
    Тестовый вектор:
    секрет: whs_test-secret
    тело: {"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 снять успешную оплату.
    10.
    Ответить 200.
    Modified at 2026-10-24 01:14:36
    Previous
    Начало работы
    Next
    Создать платёж
    Built with