Вебхуки
Push-события биллинга и задач с подписью HMAC-SHA256.
Вебхуки — push-уведомления о событиях биллинга и задач. Регистрируйте HTTPS-эндпоинт, подписывайтесь на типы событий — и получайте POST-запросы с подписью HMAC-SHA256.
Быстрый старт
Замените https://example.com/franklab-hook на свой публичный HTTPS-обработчик. Сохраните endpoint.id и секрет из ответа регистрации; вместо <id> в следующем запросе подставьте полученный идентификатор.
# зарегистрировать эндпоинт (секрет показывается один раз!)
curl -X POST -H "X-API-Key: $FRANKLAB_KEY" -H "Content-Type: application/json" \
-d '{"url":"https://example.com/franklab-hook","eventTypes":["billing.settled","task.succeeded"]}' \
https://apergrex.ru/franklab/api/v1/webhooks/endpoints
# тестовая доставка
curl -X POST -H "X-API-Key: $FRANKLAB_KEY" \
https://apergrex.ru/franklab/api/v1/webhooks/endpoints/<id>/testТипы событий
| Событие | Когда |
|---|---|
billing.settled | подтверждено списание (с финальной суммой) |
billing.refunded | возврат резерва при неудаче задачи |
billing.topup.confirmed | зачислено пополнение |
billing.low_balance | баланс ниже 1 000 ₣ — тот же порог, что isLow и lowBalanceThresholdFranks в GET /v1/billing/balance (не чаще раза в 24 ч) |
billing.invoice.status_changed | счёт юрлица сменил статус/оплачен |
task.succeeded / task.failed / task.refunded | терминальный статус задачи генерации |
webhook.test | тестовое событие (по команде) |
Формат доставки
Заголовки:
X-Franklab-Event: billing.settled
X-Franklab-Event-Id: <uuid события — ключ идемпотентности>
X-Franklab-Timestamp: <unix-секунды>
X-Franklab-Signature: sha256=<HMAC-SHA256(secret, "{timestamp}.{body}")>
X-Franklab-Signature-Previous: sha256=<HMAC прежним секретом — только 6 ч после ротации>Тело — общий конверт:
{
"eventId": "…",
"eventType": "billing.settled",
"occurredAt": "2026-09-04T12:00:00.000Z",
"partnerId": "…",
"schemaVersion": 1,
"data": { "transactionId": "…", "amountFranks": 84 }
}Проверка подписи
import { createHmac, timingSafeEqual } from 'node:crypto';
// headers: lowercase header names; rawBody: the original request Buffer.
export function verifyWebhook(secret, headers, rawBody, nowMs = Date.now()) {
const timestamp = headers['x-franklab-timestamp'];
if (typeof secret !== 'string' || !secret || !Buffer.isBuffer(rawBody)) return false;
if (typeof timestamp !== 'string' || !/^[0-9]{10,}$/.test(timestamp)) return false;
const seconds = Number(timestamp);
if (!Number.isSafeInteger(seconds) || !Number.isFinite(nowMs)) return false;
if (Math.abs(nowMs / 1000 - seconds) > 300) return false;
const expected = createHmac('sha256', secret)
.update(timestamp + '.')
.update(rawBody)
.digest();
return ['x-franklab-signature', 'x-franklab-signature-previous'].some((name) => {
const signature = headers[name];
if (typeof signature !== 'string' || !/^sha256=[a-f0-9]{64}$/.test(signature)) return false;
const received = Buffer.from(signature.slice(7), 'hex');
return received.length === expected.length && timingSafeEqual(received, expected);
});
}Передавайте исходные байты тела до JSON-разбора (например, express.raw({ type: 'application/json' }) на маршруте вебхука до express.json()). JSON.stringify(req.body) не заменяет исходное тело. Секрет храните на сервере; во время перехода можно проверить текущую или предыдущую подпись известным вам секретом.
После проверки подписи разберите JSON и надёжно сохраните eventId с событием. Уникальный eventId и запись события должны сохраняться атомарно: повторное событие возвращает 2xx без повторного бизнес-действия. Подтверждайте 2xx после надёжного приёма, тяжёлую обработку выполняйте отдельно. Если сохранить событие не удалось, не отвечайте успешным кодом. Проверка подписи сама по себе не заменяет дедупликацию.
Ротация секрета и grace-окно
После POST /v1/webhooks/endpoints/{id}/rotate-secret доставки в течение 6 часов несут
две подписи: X-Franklab-Signature — новым секретом, X-Franklab-Signature-Previous —
прежним. Проверяйте любую из них: если верна хотя бы одна, событие подлинно. Окно в 6 часов не покрывает всю цепочку повторов: сумма задержек составляет 8 ч 36 мин, не считая обработки и ожидания очереди. Получатель должен перейти на новый секрет до окончания окна. По
истечении окна заголовок -Previous исчезает, а прежний секрет затирается на сервере.
Гарантии доставки
- At-least-once: дубликаты возможны — идемпотентируйте по
eventId. eventIdсобытий из источников (биллинг, задачи, счета) — детерминированный uuid: повторная доставка того же бизнес-события приходит с тем жеeventId, так что дедупликация по нему полная. Исключение —billing.low_balance(лимитированная повторная эмиссия раз в 24 ч) иwebhook.test: у них каждый выпуск — новое событие с новымeventId.- Дубликаты возможны и после рестарта поллера — запасной ключ дедупликации:
eventType+data.transactionId/data.taskId/data.invoiceId. - Задержка — до минуты (поллер).
- Повторы при неудаче: 1 мин → 5 мин → 30 мин → 2 ч → 6 ч, затем dead-letter (виден в
GET /v1/webhooks/deliveries?status=dead_lettered). - Статус
uncertain— запрос отправлен, но исход неизвестен: такие доставки не повторяются автоматически. Сначала сверьте обработку на получателе поeventId, затем при необходимости повторите черезPOST /v1/webhooks/deliveries/{id}/replay. - Отвечайте 2xx быстро; таймаут — 10 секунд.
Двойная ротация в окне 6 ч: хранится только один предыдущий секрет — при второй ротации подпись поколением N−2 умирает немедленно; переключайте приёмник на новый секрет без промедления.
Управление
| Операция | Эндпоинт |
|---|---|
| Список | GET /v1/webhooks/endpoints |
| Регистрация | POST /v1/webhooks/endpoints |
| Удаление | DELETE /v1/webhooks/endpoints/{id} |
| Ротация секрета | POST /v1/webhooks/endpoints/{id}/rotate-secret |
| Тест | POST /v1/webhooks/endpoints/{id}/test |
| История доставок | GET /v1/webhooks/deliveries?endpointId=…&status=… |
| Повтор доставки | POST /v1/webhooks/deliveries/{id}/replay |
Ограничения: до 10 эндпоинтов на партнёра; URL — только публичный HTTPS (внутренние адреса и IP-литералы отклоняются); редиректы не следуются.