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.
"<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 | СБП-выплату отклонил банк. |
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/jsonUser-Agent: freefin-webhook/1.0X-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-стрим изменения статусов
/api/v1/public/eventscurl -N https://api.balancedpay.pro/api/v1/public/events \ -H 'Authorization: Bearer sk_test_x9k…'
: 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_changedpayout.created,payout.status_changedrefund.created
Это внутренние события платформы, отличные от webhook-событий вида payment.succeeded. Множество пересекается, но SSE-поток содержит и промежуточные переходы статуса, недоступные в webhook-уведомлениях.
WebSocket и gRPC
В планах. Pub/sub-брокер реализован поверх Redis и не привязан к транспорту, поэтому добавление WS и gRPC — вопрос отдельного эндпоинта. Дата выхода будет указана в журнале изменений. Для большинства интеграций достаточно SSE: поддерживается всеми основными языками и фреймворками без дополнительных зависимостей.