Async

Webhooks

События платформы приходят POST'ом на ваш endpoint с HMAC-подписью. Параллельно есть SSE-канал — низкая задержка, без публичного URL.

На каждое финальное событие платформа шлёт POST на ваш endpoint. Тело — JSON, в заголовке X-Freefin-Signature подпись HMAC-SHA256 секретом webhook'а. Это другой секрет, не HMAC-секрет магазина — проверьте, что используете именно whs_…, выданный при создании endpoint'а.

Заголовок имеет два формата для постепенного перехода интеграторов:

ВерсияФорматЗащита от replay
v2 (рекомендуется)t=<unix_ts>,v1=<hex_v1>,v2=<hex_v2>Да: timestamp в подписи + проверка свежести у клиента (по умолчанию ±5 минут).
v1 (legacy)sha256=<hex>Нет. Подпись действительна вечно, перехваченный webhook можно повторно отправить.

Все наши SDK verifyWebhook(...) поддерживают оба формата одновременно: если пришёл t=...,v2=... — проверим v2 + tolerance timestamp'а; если только sha256=... — проверим v1.

Алгоритм v2: подпись считается от строки "<timestamp>.<raw_body>" с разделителем-точкой. Получатель проверяет, что timestamp недавний (защита от replay), потом сверяет HMAC. Это устойчиво к перехвату webhook'а на промежуточном прокси с целью повторной отправки.

Дополнительно мы дублируем timestamp в отдельный заголовок X-Freefin-Timestamp — удобно для логирования без парсинга Signature.

Где настроить

В ЛК: Интеграция → карточка магазина → блок Webhooks → кнопка Новый webhook. На один магазин можно завести несколько endpoint'ов, у каждого свой URL и свой набор событий. События одного магазина никогда не уйдут на endpoint другого магазина.

В диалоге создания указываются:

  • URL. Куда слать POST. На production — только HTTPS.
  • События. Чек-лист из списка ниже. Если не выбрано ничего, endpoint получает все события.

Секрет показывается один раз, сразу после создания. Сохраните. Дальше URL редактируется на месте, секрет можно перегенерировать кнопкой Ротация (старый перестаёт работать сразу). Удаление endpoint'а останавливает доставку.

Один webhook для одного платежа

В POST /payments есть поле webhook_url. Если передать его, события по этому конкретному платежу уйдут на указанный URL вместо настроенных в ЛК. Секрет при этом всё равно берётся от endpoint'а магазина. Удобно для разовых интеграций, B2B-сценариев или временных тестовых обработчиков.

События

eventкогда
payment.succeededбанк подтвердил оплату, status → succeeded.
payment.failedбанк отклонил оплату. Деньги не списались, но попытка оплаты была. Status → failed.
payment.cancelledмерчант или покупатель явно отменили pending-платёж. Попытки оплатить не было. Status → cancelled.
payment.expiredистёк TTL: либо нашей ссылки (никто не открыл), либо банковской сессии (банк не вернул финальный статус). Status → expired.
payment.refundedна платёж завели возврат, полный или частичный. Событие приходит на каждый возврат отдельно.
payout.succeededСБП-выплата прошла.
payout.failedСБП-выплату отклонил банк.
Фильтр событий строгий. Если в endpoint'е выбрали только payment.succeeded, на payment.failed доставка не пойдёт. Пустой список означает «все события».

Формат тела

{
  "id":         "evt_8f9a2c11",                              // стабильный ID события — храните для дедупа
  "event":      "payment.succeeded",
  "created_at": "2026-05-05T12:43:08Z",
  "data": {
    // Полный объект платежа (или payout) на момент события.
    // Те же поля, что вернёт GET /api/v1/public/payments/{id}.
    "id":          "5a331a39-32bf-4940-afb1-855e2fc6757f",
    "merchant_id": "22222222-…",
    "shop_id": "33333333-…",
    "order_id":    "ORDER-1042",
    "amount":      150000,
    "currency":    "RUB",
    "method":      "sbp",
    "status":      "succeeded",
    // ...
  }
}

Заголовки

  • Content-Type: application/json
  • User-Agent: freefin-webhook/1.0
  • X-Freefin-Event-Id: <evt_…> — тот же, что в теле; удобно логировать на стороне приёмника, не парся JSON.
  • X-Freefin-Signature: sha256=<hex>

Проверка подписи

Алгоритм один и тот же. Берёте секрет endpoint'а (whs_…), считаете от raw body запроса (байты, не распарсенный JSON) HMAC-SHA256, переводите в hex и сравниваете с тем, что прислали в X-Freefin-Signature: sha256=<hex>. Сравнение должно быть constant-time, иначе вы открываете тайминг-атаку.

import crypto from 'node:crypto'
import express from 'express'

const app = express()
// ВАЖНО: raw body, не json-парсер — нам нужны те же байты, по которым
// balancedpay считал HMAC.
app.post('/balancedpay/hook', express.raw({ type: 'application/json' }), (req, res) => {
  const sig = (req.header('X-Freefin-Signature') || '').replace(/^sha256=/, '')
  const expected = crypto.createHmac('sha256', process.env.FREEFIN_WEBHOOK_SECRET)
    .update(req.body) // Buffer
    .digest('hex')
  const ok = sig.length === expected.length &&
    crypto.timingSafeEqual(Buffer.from(sig, 'hex'), Buffer.from(expected, 'hex'))
  if (!ok) return res.status(401).send('bad signature')

  const event = JSON.parse(req.body.toString('utf8'))
  // ... обработка event.kind / event.data
  res.sendStatus(200)
})

Ретраи и идемпотентность

Доставка считается успешной, если за 15 секунд вернётся любой 2xx. Иначе включается экспоненциальный backoff: 10 секунд, 1 минута, 5 минут, 30 минут, 2 часа, 6 часов, 24 часа, 48 часов. После восьмой неудачной попытки (~3.5 дня) доставка останавливается; событие можно переотправить вручную из ЛК.

id события стабилен между ретраями — храните его и используйте для дедупа на своей стороне. Событие может прийти больше одного раза, если у вас flaky-сеть и вы не успели ответить за 15 секунд.

Realtime (SSE)

Параллельно с webhook-уведомлениями события доступны через Server-Sent Events. Подходит для бэкендов, которым нужна минимальная задержка (например, серверного дашборда) или которым неудобно поднимать публичный webhook-endpoint.

SSE-стрим изменения статусов

GET/api/v1/public/events
Длинный keep-alive-канал. Авторизация — Bearer-ключ из ЛК. Одно подключение на ключ; при разрыве клиент переподключается самостоятельно.
Запрос
curl -N https://api.balancedpay.pro/api/v1/public/events \
  -H 'Authorization: Bearer sk_test_x9k…'
Ответ (SSE-стрим)
: connected

event: payment.status_changed
data: {"kind":"payment.status_changed","payload":{"id":"5a331a39-…","status":"succeeded"},"at":"2026-05-05T12:43:11Z"}

event: payment.created
data: {"kind":"payment.created","payload":{"id":"…","amount":150000,"status":"pending"},"at":"…"}

: ping

Передаваемые события

  • payment.created, payment.status_changed
  • payout.created, payout.status_changed
  • refund.created

Это внутренние события платформы, отличные от webhook-событий вида payment.succeeded. Множество пересекается, но SSE-поток содержит и промежуточные переходы статуса, недоступные в webhook-уведомлениях.

SSE доставляет события по best-effort: при перегрузке клиентского канала отдельные сообщения могут быть пропущены. Для гарантированной доставки используйте webhook — ретрай до ~3.5 дней. Рекомендованная связка: SSE для UI/realtime, webhook для учёта.

WebSocket и gRPC

В планах. Pub/sub-брокер реализован поверх Redis и не привязан к транспорту, поэтому добавление WS и gRPC — вопрос отдельного эндпоинта. Дата выхода будет указана в журнале изменений. Для большинства интеграций достаточно SSE: поддерживается всеми основными языками и фреймворками без дополнительных зависимостей.