Вебхуки и подпись¶
KASSA сообщает об изменениях объектов событиями. Каждое событие сохраняется (GET /events) и доставляется HTTP POST-запросом на каждый активный адрес магазина, подписанный на этот тип. Вебхуки — основной способ узнать результат платежа; опрос GET /payments/{payment_id} — дополнительный.
Что уже работает
События, адреса вебхуков и доставка работают: GET /events, GET /events/{event_id}, POST /events/{event_id}/resend и все операции /webhook-endpoints отвечают по контракту, вебхуки отправляются и повторяются по расписанию из раздела 3. Исключение одно: платёжные ссылки (/payment-links и событие payment_link.paid) относятся к этапу M5 и до него отвечают 501 not_implemented с названием этапа в description.
Типы событий¶
| Тип | Когда | data.object_type |
|---|---|---|
payment.waiting_for_capture |
платёж авторизован, ждёт capture (двухстадийная схема) |
payment |
payment.succeeded |
деньги списаны | payment |
payment.canceled |
платёж отменён или отклонён; причина — в cancellation_details |
payment |
payment.authorize_request |
синхронная предпроверка до движения денег (опция магазина, см. ниже) | payment |
refund.succeeded, refund.canceled |
итог возврата | refund |
payment_link.paid |
оплачена платёжная ссылка | payment_link |
payout.succeeded, payout.canceled |
итог выплаты (этап M5) | payout |
settlement.paid |
расчёт с мерчантом выплачен | settlement |
settlement.frozen |
выплаты мерчанту заморожены; причина — в frozen_reason (модель A) |
settlement_status |
settlement.unfrozen |
заморозка выплат снята | settlement_status |
dispute.opened |
открыт спор или чарджбэк; сумма удержана (споры) | dispute |
dispute.evidence_required |
запрошены доказательства; срок — в evidence_deadline_at |
dispute |
dispute.closed |
спор закрыт: won, lost или expired |
dispute |
События уровня мерчанта (settlement.*) приходят на активные адреса всех ваших магазинов, подписанные на этот тип: на каждый адрес — своё событие со своим id и одинаковым объектом внутри. Споры — в скоупе магазина: dispute.* приходят по платежам того магазина, которому принадлежит платёж.
Перечень типов расширяется — с записью в changelog, без переименования и удаления существующих. Событие незнакомого типа игнорируйте, отвечая 2xx: подписка на него могла появиться из кабинета.
1. Зарегистрировать адрес¶
POST /webhook-endpoints. Адрес — только https, публичный (не приватные диапазоны IP), без редиректов.
curl -X POST https://api.kassa.example/v1/webhook-endpoints \
-u "sh_01J8Z3H4M5N6P7Q8R9S0T1V2W3:test_ru_REPLACE_WITH_YOUR_SECRET" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: 9b7e2a44-1c3d-4e5f-8a90-b1c2d3e4f5a6" \
-d '{
"url": "https://shop.example/kassa/webhook",
"events": ["payment.succeeded", "payment.canceled", "payment.waiting_for_capture", "refund.succeeded", "refund.canceled"],
"description": "Основной обработчик заказов"
}'
Ответ 201 Created. Поле secret возвращается только в этом ответе — сохраните его в хранилище секретов:
{
"id": "whe_01J8Z3N4M5P6Q7R8S9T0V1W2X3",
"url": "https://shop.example/kassa/webhook",
"events": ["payment.succeeded", "payment.canceled", "payment.waiting_for_capture", "refund.succeeded", "refund.canceled"],
"status": "active",
"description": "Основной обработчик заказов",
"created_at": "2026-09-10T10:00:00Z",
"secret": "whsec_example_0000000000000000"
}
Ротация секрета — отдельный раздел ниже: она устроена так, чтобы адрес не пропустил ни одного события во время замены.
2. Формат доставки¶
Запрос на ваш адрес:
POST /kassa/webhook HTTP/1.1
Host: shop.example
Content-Type: application/json
User-Agent: Kassa-Webhooks/1.0
X-Kassa-Event-Id: evt_01J8Z3KABCDEFGHJKMNPQRSTVW
X-Kassa-Signature: t=1789034400,v1=2a07b2b05d8806e21e5165d60765a96a60185afccbb1eb82aa31c979aec3aa35
Тело — объект Event (тот же, что возвращает GET /events/{event_id}): id, type, created_at, test и data из двух полей — object_type (что лежит внутри) и object — полный объект (Payment, Refund, PaymentLink, Payout, Settlement, SettlementStatus, Dispute) на момент события.
{
"id": "evt_01J8Z3KABCDEFGHJKMNPQRSTVW",
"type": "payment.succeeded",
"created_at": "2026-09-10T10:00:00Z",
"test": true,
"data": {
"object_type": "payment",
"object": {
"id": "pay_01J8Z3K4M5N6P7Q8R9S0T1V2W3",
"status": "succeeded",
"paid": true,
"refundable": true,
"test": true,
"amount": { "value": "1500.00", "currency": "RUB" },
"captured_amount": { "value": "1500.00", "currency": "RUB" },
"refunded_amount": { "value": "0.00", "currency": "RUB" },
"refundable_amount": { "value": "1500.00", "currency": "RUB" },
"fee_amount": { "value": "45.00", "currency": "RUB" },
"merchant_display_name": "Демо-магазин",
"statement_descriptor": "KASSA*DEMO-SHOP",
"order_id": "ORD-10042",
"description": "Заказ №10042",
"capture": true,
"payment_method": { "type": "bank_card", "card": { "first6": "220000", "last4": "0004", "brand": "mir" } },
"metadata": { "cart_id": "c-77" },
"created_at": "2026-09-10T09:58:00Z",
"authorized_at": "2026-09-10T09:59:40Z",
"captured_at": "2026-09-10T10:00:00Z"
}
}
}
Ответьте любым кодом 2xx в течение 10 секунд. Тело ответа не читается. Всё тяжёлое (письма, синхронизация с учётной системой) делайте после ответа, в фоне.
Что внутри data.object: разбирайте по object_type¶
Тип вложенного объекта определяет только поле data.object_type — не префикс data.object.id и не префикс типа события. Разбор по идентификатору выглядит рабочим ровно до первого события расчётов: объект settlement_status — это состояние выплат мерчанта, у него нет поля id, и ветка «посмотреть на префикс» на нём падает или молча уходит в else.
object_type |
Что в object |
Типы событий |
|---|---|---|
payment |
Payment |
payment.* |
refund |
Refund (в нём payment_id и order_id исходного платежа) |
refund.* |
payment_link |
PaymentLink |
payment_link.paid |
payout |
Payout |
payout.* |
settlement |
Settlement (выписка за период) |
settlement.paid |
settlement_status |
SettlementStatus — тот же фрагмент, что Shop.settlement.status; идентификатора нет |
settlement.frozen, settlement.unfrozen |
dispute |
Dispute |
dispute.* |
handlers = {
"payment": on_payment,
"refund": on_refund,
"dispute": on_dispute,
"settlement_status": on_settlement_status,
}
handler = handlers.get(event["data"]["object_type"])
if handler is None:
return 200 # незнакомый тип объекта — подтвердить и не обрабатывать
handler(event["type"], event["data"]["object"])
Пара «type + object_type» устойчива: object_type говорит, как читать объект, type — что произошло. Незнакомое значение object_type (справочник пополняется) обрабатывайте как в примере: отвечайте 2xx и пропускайте — иначе новое событие уронит адрес в failing.
3. Проверка подписи¶
Заголовок X-Kassa-Signature содержит метку времени t (Unix-секунды, UTC) и одну или несколько подписей v1:
Алгоритм проверки:
- Разобрать заголовок: разделить по
,, каждую часть — по первому=; собратьtи список всехv1. - Отклонить, если
|now − t| > 300секунд (защита от повторного воспроизведения; часы сервера должны быть синхронизированы по NTP). - Взять сырое тело запроса байт в байт — до любого разбора JSON и без переформатирования.
- Вычислить
HMAC-SHA256(secret, t + "." + body)и записать в hex нижним регистром. - Сравнить с каждым
v1функцией сравнения за постоянное время; совпадение с любым — подпись верна (во время ротации секрета подписей две, см. «Ротация секрета» ниже). - При любой ошибке ответить
400и не обрабатывать событие.
import hmac
import hashlib
import time
SIGNATURE_WINDOW_SECONDS = 300
def verify_kassa_signature(header: str, body: bytes, secret: str, now: int | None = None) -> bool:
"""Проверяет X-Kassa-Signature. body — сырые байты запроса, не разобранный JSON."""
timestamp: int | None = None
signatures: list[str] = []
for part in header.split(","):
key, _, value = part.strip().partition("=")
if key == "t" and value.isdigit():
timestamp = int(value)
elif key == "v1" and value:
signatures.append(value)
if timestamp is None or not signatures:
return False
now = int(time.time()) if now is None else now
if abs(now - timestamp) > SIGNATURE_WINDOW_SECONDS:
return False
expected = hmac.new(
secret.encode("utf-8"),
f"{timestamp}.".encode("utf-8") + body,
hashlib.sha256,
).hexdigest()
return any(hmac.compare_digest(expected, candidate) for candidate in signatures)
# Пример для FastAPI: тело берётся как bytes, до валидации моделью.
# from fastapi import FastAPI, Request, HTTPException
# app = FastAPI()
#
# @app.post("/kassa/webhook")
# async def kassa_webhook(request: Request) -> dict[str, str]:
# body = await request.body()
# header = request.headers.get("X-Kassa-Signature", "")
# if not verify_kassa_signature(header, body, KASSA_WEBHOOK_SECRET):
# raise HTTPException(status_code=400, detail="invalid signature")
# event = json.loads(body)
# handle_event_once(event) # см. «Идемпотентная обработка»
# return {"status": "ok"}
const crypto = require("node:crypto");
const SIGNATURE_WINDOW_SECONDS = 300;
/**
* Проверяет X-Kassa-Signature.
* @param {string} header значение заголовка X-Kassa-Signature
* @param {Buffer} rawBody сырое тело запроса (Buffer), не разобранный JSON
* @param {string} secret секрет адреса вебхука
*/
function verifyKassaSignature(header, rawBody, secret, now = Math.floor(Date.now() / 1000)) {
let timestamp = null;
const signatures = [];
for (const part of header.split(",")) {
const idx = part.indexOf("=");
if (idx === -1) continue;
const key = part.slice(0, idx).trim();
const value = part.slice(idx + 1).trim();
if (key === "t" && /^\d+$/.test(value)) timestamp = Number(value);
else if (key === "v1" && value) signatures.push(value);
}
if (timestamp === null || signatures.length === 0) return false;
if (Math.abs(now - timestamp) > SIGNATURE_WINDOW_SECONDS) return false;
const expected = crypto
.createHmac("sha256", secret)
.update(`${timestamp}.`)
.update(rawBody)
.digest();
return signatures.some((candidate) => {
const buf = Buffer.from(candidate, "hex");
return buf.length === expected.length && crypto.timingSafeEqual(buf, expected);
});
}
// Пример для Express: тело нужно получить сырым — express.raw(), а не express.json().
// const express = require("express");
// const app = express();
// app.post("/kassa/webhook", express.raw({ type: "application/json" }), (req, res) => {
// const header = req.get("X-Kassa-Signature") || "";
// if (!verifyKassaSignature(header, req.body, process.env.KASSA_WEBHOOK_SECRET)) {
// return res.status(400).send("invalid signature");
// }
// const event = JSON.parse(req.body.toString("utf8"));
// handleEventOnce(event); // см. «Идемпотентная обработка»
// res.status(200).end();
// });
<?php
declare(strict_types=1);
const KASSA_SIGNATURE_WINDOW_SECONDS = 300;
/**
* Проверяет X-Kassa-Signature. $body — сырое тело запроса (file_get_contents('php://input')).
*/
function verifyKassaSignature(string $header, string $body, string $secret, ?int $now = null): bool
{
$timestamp = null;
$signatures = [];
foreach (explode(',', $header) as $part) {
$pair = explode('=', trim($part), 2);
if (count($pair) !== 2) {
continue;
}
[$key, $value] = $pair;
if ($key === 't' && ctype_digit($value)) {
$timestamp = (int) $value;
} elseif ($key === 'v1' && $value !== '') {
$signatures[] = $value;
}
}
if ($timestamp === null || $signatures === []) {
return false;
}
$now = $now ?? time();
if (abs($now - $timestamp) > KASSA_SIGNATURE_WINDOW_SECONDS) {
return false;
}
$expected = hash_hmac('sha256', $timestamp . '.' . $body, $secret);
foreach ($signatures as $candidate) {
if (hash_equals($expected, $candidate)) {
return true;
}
}
return false;
}
// Пример обработчика:
// $body = file_get_contents('php://input');
// $header = $_SERVER['HTTP_X_KASSA_SIGNATURE'] ?? '';
// if (!verifyKassaSignature($header, $body, getenv('KASSA_WEBHOOK_SECRET'))) {
// http_response_code(400);
// exit('invalid signature');
// }
// $event = json_decode($body, true, 512, JSON_THROW_ON_ERROR);
// handleEventOnce($event); // см. «Идемпотентная обработка»
// http_response_code(200);
Контрольный пример¶
Проверьте свою реализацию на этих данных (секрет — учебный; подпись вычислена по точным байтам тела ниже — одна строка, без перевода строки в конце):
| Секрет | whsec_example_0000000000000000 |
t |
1789034400 |
| Заголовок | X-Kassa-Signature: t=1789034400,v1=2a07b2b05d8806e21e5165d60765a96a60185afccbb1eb82aa31c979aec3aa35 |
Тело (918 байт в UTF-8):
{"id":"evt_01J8Z3KABCDEFGHJKMNPQRSTVW","type":"payment.succeeded","created_at":"2026-09-10T10:00:00Z","test":true,"data":{"object_type":"payment","object":{"id":"pay_01J8Z3K4M5N6P7Q8R9S0T1V2W3","status":"succeeded","paid":true,"refundable":true,"test":true,"amount":{"value":"1500.00","currency":"RUB"},"captured_amount":{"value":"1500.00","currency":"RUB"},"refunded_amount":{"value":"0.00","currency":"RUB"},"refundable_amount":{"value":"1500.00","currency":"RUB"},"fee_amount":{"value":"45.00","currency":"RUB"},"merchant_display_name":"Демо-магазин","statement_descriptor":"KASSA*DEMO-SHOP","order_id":"ORD-10042","description":"Заказ №10042","capture":true,"payment_method":{"type":"bank_card","card":{"first6":"220000","last4":"0004","brand":"mir"}},"metadata":{"cart_id":"c-77"},"created_at":"2026-09-10T09:58:00Z","authorized_at":"2026-09-10T09:59:40Z","captured_at":"2026-09-10T10:00:00Z"}}}
При проверке передайте now = 1789034400 (иначе сработает окно 300 с). Подпись можно пересчитать и вручную, сохранив тело в body.json без завершающего перевода строки:
printf '%s' "1789034400.$(cat body.json)" | openssl dgst -sha256 -hmac "whsec_example_0000000000000000"
Частые ошибки: тело переформатировано JSON-библиотекой (другие пробелы или порядок ключей); фреймворк заранее разобрал JSON и «сырого» тела нет; подпись сравнивается обычным ==; часы сервера отстают больше чем на 5 минут; проверяется только первая v1, а во время ротации верной может быть вторая.
Ротация секрета: две подписи в период перекрытия¶
Секрет адреса меняется без остановки доставки — за счёт периода перекрытия в 24 часа, когда действительны оба секрета.
curl -X PATCH https://api.kassa.example/v1/webhook-endpoints/whe_01J8Z3N4M5P6Q7R8S9T0V1W2X3 \
-u "sh_01J8Z3H4M5N6P7Q8R9S0T1V2W3:test_ru_REPLACE_WITH_YOUR_SECRET" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: 2f4a6c8e-1b3d-4f5a-8c7e-9d0b1a2c3e4f" \
-d '{ "rotate_secret": true }'
Новый секрет приходит в поле secret — один раз, только в этом ответе; повторно получить его нельзя, можно лишь ротировать снова:
{
"id": "whe_01J8Z3N4M5P6Q7R8S9T0V1W2X3",
"url": "https://shop.example/kassa/webhook",
"events": ["payment.succeeded", "payment.canceled", "refund.succeeded"],
"status": "active",
"created_at": "2026-09-10T10:00:00Z",
"secret": "whsec_example_1111111111111111"
}
Следующие 24 часа заголовок каждой доставки несёт две подписи v1 — по одной на каждый действующий секрет, при одном общем t:
Порядок подписей в заголовке не определён и меняться может от доставки к доставке. Доставка подлинна, если совпала любая из них с подписью, вычисленной по любому из ваших действующих секретов, — именно поэтому в примерах выше проверка идёт по списку v1, а не по первому значению.
Порядок безопасной ротации:
- вызвать
rotate_secret: trueи сохранить новый секрет рядом со старым (два действующих значения в хранилище секретов); - проверять подпись по обоим: событие подлинно, если совпала хотя бы одна пара «секрет × подпись». Код из раздела выше меняется на цикл по секретам;
- по истечении 24 часов (и убедившись, что доставки проходят) удалить старый секрет из хранилища.
Ротируйте секрет при смене подрядчика, утечке или подозрении на неё, а также по расписанию вашей политики. До завершения перекрытия старые события остаются проверяемыми: KASSA не пересчитывает подписи уже отправленных доставок, но переотправка (POST /events/{event_id}/resend) подписывается действующими на момент отправки секретами.
4. Идемпотентная обработка¶
Одно и то же событие может прийти несколько раз (повторы после таймаута, ручная переотправка, POST /events/{event_id}/resend), а события разных объектов могут прийти в любом порядке. Поэтому:
- Ключ дедупликации —
idсобытия (evt_…; он же в заголовкеX-Kassa-Event-Id). Храните обработанные идентификаторы в таблице видаkassa_processed_events(event_id PRIMARY KEY, processed_at)не менее 72 часов. - Вставляйте
event_idи применяйте изменения к заказу в одной транзакции вашей БД; если вставка нарушила уникальность — событие уже обработано: ответьте200и ничего не делайте. - Применяйте только переходы «вперёд»: если заказ уже отмечен оплаченным, повторное
payment.succeeded— no-op;payment.canceledпослеpayment.succeededпо тому же платежу невозможно (финальные статусы не меняются), но при сомнениях сверяйтеcreated_atсобытий и запрашивайте актуальный объектGET /payments/{payment_id}. - Сопоставляйте объект с заказом по
data.object.order_idили поmetadata, а не по порядку прихода. Полеorder_idесть уPayment,Refund,PaymentLink,PayoutиDispute; у возврата, выплаты и спора это заказ исходного платежа. У объектовSettlementиSettlementStatusзаказа нет — они относятся к мерчанту, а не к отдельному платежу. - Возвращайте
2xxтолько после фиксации транзакции. Ошибка БД →5xx— KASSA повторит доставку.
Псевдокод:
BEGIN;
INSERT INTO kassa_processed_events(event_id) VALUES (:event.id); -- конфликт уникальности → ROLLBACK, ответить 200
UPDATE orders SET status = 'paid', payment_id = :object.id
WHERE external_id = :object.order_id AND status IN ('new', 'awaiting_payment');
COMMIT;
respond 200
5. Повторы и статус адреса¶
| Попытка | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 |
|---|---|---|---|---|---|---|---|---|---|
| Через | 1 мин | 5 мин | 15 мин | 1 ч | 4 ч | 12 ч | 24 ч | 48 ч | 72 ч |
Повтор происходит, если ответа нет 10 секунд или код ответа не 2xx. После девятой неудачной попытки адрес переходит в статус failing, магазин получает письмо и баннер в кабинете; события продолжают сохраняться и доступны в GET /events. Вернуть адрес в active — PATCH /webhook-endpoints/{endpoint_id} с { "status": "active" }; переотправить событие — POST /events/{event_id}/resend (на все активные адреса) или кнопка в кабинете. Журнал доставок с кодами ответов — в кабинете и в поле delivery объекта события из GET /events.
Тела ваших ответов логируются с обрезкой до 4 КБ — не возвращайте в ответе персональные данные.
6. Синхронная предпроверка payment.authorize_request¶
Опция магазина (capabilities.authorize_request_webhook). Если включена, перед созданием попытки у провайдера KASSA отправляет на адрес, подписанный на payment.authorize_request, событие с объектом платежа и ждёт ответ не более 5 секунд:
или
Отказ переводит платёж в canceled с причиной authorize_request_denied. Отсутствие ответа трактуется по настройке магазина (по умолчанию — отказ). Подпись и проверка — те же, что у обычных событий.
7. Требования к адресу и безопасности¶
- Только
httpsс действительным сертификатом; HTTP и самоподписанные сертификаты не принимаются. - Публичный хост: приватные и loopback-диапазоны IP, а также редиректы отклоняются при регистрации и при доставке.
- Не используйте IP-фильтрацию как единственную защиту — проверяйте подпись; список исходящих адресов KASSA может меняться.
- Секрет адреса — в хранилище секретов, не в коде и не в репозитории; ротируйте через
rotate_secret. - Тестовые события (
"test": true) приходят на те же адреса; не смешивайте их с боевыми заказами — проверяйте полеtest. - В тестовом режиме отправить событие вручную можно кнопкой «Отправить тестовое событие» в кабинете (см. Тестовый режим).