Перейти к содержанию

Вебхуки и подпись

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:

X-Kassa-Signature: t=<t>,v1=<hex(HMAC_SHA256(secret, "<t>." + body))>

Алгоритм проверки:

  1. Разобрать заголовок: разделить по ,, каждую часть — по первому =; собрать t и список всех v1.
  2. Отклонить, если |now − t| > 300 секунд (защита от повторного воспроизведения; часы сервера должны быть синхронизированы по NTP).
  3. Взять сырое тело запроса байт в байт — до любого разбора JSON и без переформатирования.
  4. Вычислить HMAC-SHA256(secret, t + "." + body) и записать в hex нижним регистром.
  5. Сравнить с каждым v1 функцией сравнения за постоянное время; совпадение с любым — подпись верна (во время ротации секрета подписей две, см. «Ротация секрета» ниже).
  6. При любой ошибке ответить 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:

X-Kassa-Signature: t=1789034400,v1=<подпись прежним секретом>,v1=<подпись новым секретом>

Порядок подписей в заголовке не определён и меняться может от доставки к доставке. Доставка подлинна, если совпала любая из них с подписью, вычисленной по любому из ваших действующих секретов, — именно поэтому в примерах выше проверка идёт по списку v1, а не по первому значению.

Порядок безопасной ротации:

  1. вызвать rotate_secret: true и сохранить новый секрет рядом со старым (два действующих значения в хранилище секретов);
  2. проверять подпись по обоим: событие подлинно, если совпала хотя бы одна пара «секрет × подпись». Код из раздела выше меняется на цикл по секретам;
  3. по истечении 24 часов (и убедившись, что доставки проходят) удалить старый секрет из хранилища.

Ротируйте секрет при смене подрядчика, утечке или подозрении на неё, а также по расписанию вашей политики. До завершения перекрытия старые события остаются проверяемыми: KASSA не пересчитывает подписи уже отправленных доставок, но переотправка (POST /events/{event_id}/resend) подписывается действующими на момент отправки секретами.

4. Идемпотентная обработка

Одно и то же событие может прийти несколько раз (повторы после таймаута, ручная переотправка, POST /events/{event_id}/resend), а события разных объектов могут прийти в любом порядке. Поэтому:

  1. Ключ дедупликации — id события (evt_…; он же в заголовке X-Kassa-Event-Id). Храните обработанные идентификаторы в таблице вида kassa_processed_events(event_id PRIMARY KEY, processed_at) не менее 72 часов.
  2. Вставляйте event_id и применяйте изменения к заказу в одной транзакции вашей БД; если вставка нарушила уникальность — событие уже обработано: ответьте 200 и ничего не делайте.
  3. Применяйте только переходы «вперёд»: если заказ уже отмечен оплаченным, повторное payment.succeeded — no-op; payment.canceled после payment.succeeded по тому же платежу невозможно (финальные статусы не меняются), но при сомнениях сверяйте created_at событий и запрашивайте актуальный объект GET /payments/{payment_id}.
  4. Сопоставляйте объект с заказом по data.object.order_id или по metadata, а не по порядку прихода. Поле order_id есть у Payment, Refund, PaymentLink, Payout и Dispute; у возврата, выплаты и спора это заказ исходного платежа. У объектов Settlement и SettlementStatus заказа нет — они относятся к мерчанту, а не к отдельному платежу.
  5. Возвращайте 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. Вернуть адрес в activePATCH /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 секунд:

{ "decision": "allow" }

или

{ "decision": "deny", "reason": "Товара нет в наличии" }

Отказ переводит платёж в canceled с причиной authorize_request_denied. Отсутствие ответа трактуется по настройке магазина (по умолчанию — отказ). Подпись и проверка — те же, что у обычных событий.

7. Требования к адресу и безопасности

  • Только https с действительным сертификатом; HTTP и самоподписанные сертификаты не принимаются.
  • Публичный хост: приватные и loopback-диапазоны IP, а также редиректы отклоняются при регистрации и при доставке.
  • Не используйте IP-фильтрацию как единственную защиту — проверяйте подпись; список исходящих адресов KASSA может меняться.
  • Секрет адреса — в хранилище секретов, не в коде и не в репозитории; ротируйте через rotate_secret.
  • Тестовые события ("test": true) приходят на те же адреса; не смешивайте их с боевыми заказами — проверяйте поле test.
  • В тестовом режиме отправить событие вручную можно кнопкой «Отправить тестовое событие» в кабинете (см. Тестовый режим).