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

    Вебхуки для мерчанта

    Платформа сама делает POST на адрес вебхука.
    Адрес берётся из поля webhookUrl в запросе на создание платежа. Если поле не передано, используется глобальный адрес, который администратор задал при онбординге. Переданный webhookUrl имеет приоритет над глобальным.
    Если нет ни адреса в запросе, ни глобального, по этому платежу уведомлений не будет. Секрет подписи один на мерчанта.

    1. Когда приходит письмо#

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

    2. Как выглядит запрос#

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

    3. Что отвечать#

    Любой код от 200 до 299 значит «принял». Тело ответа платформа не разбирает.
    Любой другой код, обрыв связи или таймаут значит «не принял». Платформа повторит то же тело.
    Пока подпись не проверена, отвечайте не 2xx. Иначе платформа решит, что вы письмо приняли, и больше его не пришлёт.
    Рекомендуемый порядок в вашем обработчике:
    1.
    Прочитать сырое тело до разбора JSON.
    2.
    Проверить X-Signature.
    3.
    Если подпись не сошлась, ответить 401.
    4.
    Сохранить письмо по X-Delivery-Id. Если такой 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да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. Это одно состояние.

    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. Не хватило баланса или учёт не записал удержание.
    Фразу paid X of Y сравнивайте по началу paid , а не как фиксированную строку. Суммы внутри зависят от платежа.

    8. Примеры тел#

    Все примеры это ровно те байты, которые нужно подписывать: одна строка, без пробелов.

    Платёж создан, реквизит есть#

    Заголовок X-Event-Type: payment.created.
    {"id":"6f1c2a40-1b2e-4c3d-9a8b-0e7d6c5b4a39","merchantTransactionId":"order-1001","type":"in","status":"process","amount":"1500","currency":"RUB","amountUsd":"16.04","rail":"trader","reason":"created","occurredAt":"2026-10-23T11:48:00.123456789Z"}

    Платёж создан, реквизита ещё нет#

    rail пустой. Имеет смысл ещё раз спросить GET /v1/payments/{id} или повторить создание с тем же номером заказа.
    {"id":"6f1c2a40-1b2e-4c3d-9a8b-0e7d6c5b4a39","merchantTransactionId":"order-1001","type":"in","status":"process","amount":"1500","currency":"RUB","rail":"","reason":"created","occurredAt":"2026-10-23T11:48:00.123456789Z"}

    Оплачен ровно#

    Заголовок X-Event-Type: payment.status.changed.
    {"id":"6f1c2a40-1b2e-4c3d-9a8b-0e7d6c5b4a39","merchantTransactionId":"order-1001","type":"in","status":"paid","amount":"1500","currency":"RUB","paidAmount":"1500","amountUsd":"16.04","rail":"trader","reason":"paid 1500 of 1500","occurredAt":"2026-10-23T11:55:00.123456789Z"}

    Недоплата#

    {"id":"6f1c2a40-1b2e-4c3d-9a8b-0e7d6c5b4a39","merchantTransactionId":"order-1001","type":"in","status":"underpaid","amount":"1500","currency":"RUB","paidAmount":"1000","amountUsd":"16.04","rail":"trader","reason":"paid 1000 of 1500","occurredAt":"2026-10-23T11:55:00.123456789Z"}

    Переплата#

    {"id":"6f1c2a40-1b2e-4c3d-9a8b-0e7d6c5b4a39","merchantTransactionId":"order-1001","type":"in","status":"overpaid","amount":"1500","currency":"RUB","paidAmount":"2000","amountUsd":"16.04","rail":"trader","reason":"paid 2000 of 1500","occurredAt":"2026-10-23T11:55:00.123456789Z"}

    Время вышло#

    {"id":"6f1c2a40-1b2e-4c3d-9a8b-0e7d6c5b4a39","merchantTransactionId":"order-1001","type":"in","status":"expired","amount":"1500","currency":"RUB","rail":"trader","reason":"expired","occurredAt":"2026-10-23T12:18:00.123456789Z"}

    Отмена#

    {"id":"6f1c2a40-1b2e-4c3d-9a8b-0e7d6c5b4a39","merchantTransactionId":"order-1001","type":"in","status":"cancel","amount":"1500","currency":"RUB","rail":"trader","reason":"cancel","occurredAt":"2026-10-23T11:50:00.123456789Z"}

    Просьба прислать чек#

    {"id":"6f1c2a40-1b2e-4c3d-9a8b-0e7d6c5b4a39","merchantTransactionId":"order-1001","type":"in","status":"confirmation_requested","amount":"1500","currency":"RUB","rail":"trader","reason":"confirmation_requested","occurredAt":"2026-10-23T11:52:00.123456789Z","confirmationKind":"receipt","confirmationNote":"Пришлите скрин перевода"}
    confirmationKind может быть receipt, video или other. Файл загружается методом из раздела про подтверждение в integratsiya.md.
    Повторная просьба присылает новое письмо с той же причиной. Статус при этом может не меняться. Всё равно обработайте письмо: посмотрите confirmationKind и confirmationNote.

    Реквизит подобрали заново#

    {"id":"6f1c2a40-1b2e-4c3d-9a8b-0e7d6c5b4a39","merchantTransactionId":"order-1001","type":"in","status":"process","amount":"1500","currency":"RUB","rail":"trader","reason":"rematched","occurredAt":"2026-10-23T11:49:00.123456789Z"}
    После rematched заберите платёж через API ещё раз: requisiteDetails могли смениться. Клиенту нужно показать новые реквизиты.

    Ушли на другой способ проведения#

    {"id":"6f1c2a40-1b2e-4c3d-9a8b-0e7d6c5b4a39","merchantTransactionId":"order-1001","type":"in","status":"process","amount":"1500","currency":"RUB","rail":"matching","reason":"rerouted","occurredAt":"2026-10-23T11:49:30.123456789Z"}
    Тоже обновите реквизиты из API.

    Выплата не стартовала из-за баланса#

    {"id":"6f1c2a40-1b2e-4c3d-9a8b-0e7d6c5b4a39","merchantTransactionId":"payout-1001","type":"out","status":"error","amount":"1500","currency":"RUB","rail":"","reason":"ledger hold failed: insufficient funds","occurredAt":"2026-10-23T11:48:02.123456789Z"}
    Текст после двоеточия может отличаться. Смотрите статус error и начало причины.

    Оплата оспорена после успеха#

    {"id":"6f1c2a40-1b2e-4c3d-9a8b-0e7d6c5b4a39","merchantTransactionId":"order-1001","type":"in","status":"chargeback","amount":"1500","currency":"RUB","paidAmount":"1500","rail":"trader","reason":"chargeback","occurredAt":"2026-10-24T09:00:00.123456789Z"}
    Причина chargeback в этом примере показана как типичный текст. Если платформа пришлёт другую строку в reason, решение принимайте по status, не по точному совпадению причины.

    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
    Второй пример, ближе к боевому телу. Секрет whsec_example, путь /hooks/solvr, тело:
    {"id":"6f1c2a40-1b2e-4c3d-9a8b-0e7d6c5b4a39","merchantTransactionId":"order-1001","type":"in","status":"paid","amount":"1500","currency":"RUB","paidAmount":"1500","amountUsd":"16.04","rail":"trader","reason":"paid 1500 of 1500","occurredAt":"2026-10-23T11:48:00.123456789Z"}
    Подпись: 2398b8e7a3e9f2daaec2f1c4937ac7b9767c8468640e40e3ce4f389b246d9953.
    Если ваш код на этом векторе даёт другую строку, он портит тело или путь до подписи.

    Python#

    raw_body берите из сырого тела запроса. webhook_url это адрес, на который платформа отправила письмо: из запроса или глобальный. Подпись считается по пути именно этого адреса.

    Node.js#

    rawBody должен быть Buffer, не уже разобранный объект.

    PHP#

    В PHP читайте тело через file_get_contents('php://input'), не через $_POST.

    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-23 12:24:43
    Previous
    Как мерчанту подключить платежи
    Next
    Создать платёж
    Built with