Включение
Адрес приёма задаётся в кабинете на странице «Вебхуки» организации:
только HTTPS, адреса внутренних сетей и localhost не принимаются. Секрет
подписи выдаётся там же. Машинным путём CPA-сети адрес задаёт
PUT /federation/v1/cpa/self/webhook (право
cpa:self:write): секрет приходит в ответе на первый вызов
или при rotate_secret, повторный вызов его не возвращает —
сохраните сразу.
Конверт
Тело одно на все события:
{
"event_id": "fwd_01K5...",
"event_type": "cpa.conversion.approved",
"event_version": "1",
"organization_id": "org_01K5...",
"occurred_at": "2026-10-08T10:00:00.000Z",
"delivered_at": "2026-10-08T10:00:03.120Z",
"data": { ... }
} event_id — идентификатор доставки: он не меняется при
повторах, по нему удобно отсекать дубли. occurred_at — когда
случилось событие, delivered_at — когда отправлено; у событий
cpa.* первое есть, у остальных его нет. Заголовки запроса —
X-Recca-Signature, X-Recca-Delivery-Id и
X-Recca-Event. Таймаут ответа — 10 секунд.
Проверка подписи
X-Recca-Signature: t=<unix-секунды>,v1=<hex(HMAC-SHA256(secret, "<t>.<сырое тело>"))> Подписывается строка «метка времени, точка, тело запроса байт в байт» — разбирать JSON до проверки нельзя. Запросы с меткой старше пяти минут отклоняйте: так перехваченную доставку не воспроизвести.
app.post(
"/api/recca/webhook",
express.raw({ type: "application/json" }), // ВАЖНО: сырое тело
(req, res) => {
const m = /t=(\d+),v1=([0-9a-f]{64})/i.exec(req.header("X-Recca-Signature") || "")
if (!m) return res.status(401).end()
const expected = crypto
.createHmac("sha256", process.env.RECCA_WEBHOOK_SECRET)
.update(m[1] + "." + req.body.toString("utf-8"))
.digest("hex")
const fresh = Math.abs(Date.now() / 1000 - Number(m[1])) <= 300
const ok = crypto.timingSafeEqual(Buffer.from(m[2], "hex"), Buffer.from(expected, "hex"))
if (!fresh || !ok) return res.status(401).end()
const event = JSON.parse(req.body.toString("utf-8"))
// разбор по event.event_type; отвечайте 2xx быстро
res.json({ received: true })
}
) Каталог событий
| Событие | Когда приходит | Поля data |
|---|---|---|
lead.created | Принята заявка (POST /federation/v1/leads). | lead_id, referrer_user_id, deeplink_id, external_id, lead_source, contact, metadata, created_at |
bonus.approved | Начисленный бонус подтверждён и стал доступен. | bonus_transaction_id, user_id, amount_rub, source_type, source_id, approved_at |
conversion.cascaded | Конверсия принята (POST /federation/v1/conversions), вознаграждение распределено по цепочке. | conversion_id, external_id, event_type, amount_rub, cascade_amount_rub, cascade_legs_count, cascade_legs |
conversion.reversed | Конверсия отменена или возвращена, начисленное списывается. | conversion_id, external_id, status, reversed_amount_rub, fully_reversed, reversed_legs, unreversed_legs, reason |
cpa.conversion.approved | CPA-сеть: холд конверсии дозрел, вознаграждение доступно партнёру. | external_id, goal, payout_rub, status, partner_external_id |
cpa.conversion.declined | CPA-сеть: конверсия отклонена. | external_id, goal, payout_rub, status, partner_external_id |
cpa.conversion.reversed | CPA-сеть: подтверждённая конверсия отменена. | external_id, goal, payout_rub, status, partner_external_id |
cpa.payout.paid | CPA-сеть: оператор отметил выплату выплаченной. | payout_id, amount_rub, partner_external_id, paid_at |
Суммы — в рублях (amount_rub, payout_rub).
Состав событий может пополняться новыми полями: разбирайте тело
терпимо к лишним ключам.
Повторы и идемпотентность
Доставка считается успешной при ответе 2xx. Любой другой ответ или таймаут — повтор с паузами 1 минута, 5 минут, 15 минут, 1 час, 6 часов; всего шесть попыток. После шестой неудачи событие уходит в очередь недоставленных, а в журнале аудита организации появляется запись «webhook.delivery_dlq».
Событие может прийти больше одного раза: храните уже обработанные
event_id (достаточно 30 суток) и повторные пропускайте.
Подробности по CPA-двери и клиентская проверка подписи в Go — в разделе SDK.