Webhook: события, подпись и доставка
Подключите свой HTTPS endpoint, подтвердите владение и принимайте подписанные события о счетах и платежах. Доставка хранится в очереди; повторное событие должно быть безопасным для вашего заказа.
В этой статье
Откуда берутся события#
invoice.created не означает оплату. Платёжные события создаются из проверенной серверной операции после получения актуального ответа шлюза; одного названия входящего события недостаточно.
Входящий шлюз → Kevmi и исходящий Kevmi → магазин — разные направления с отдельными секретами. Браузер, возврат и сообщения виджета не устанавливают финансовый результат.
| Событие | Источник |
|---|---|
| invoice.created | Счёт сохранён |
| invoice.updated | Состояние ссылки изменено |
| payment.succeeded | Точная оплата подтверждена сервером |
| payment.failed | Проверена неуспешная операция |
| payment.refunded | Подтверждён полный возврат |
| payment.partially_refunded | Подтверждён частичный возврат |
| payment.disputed | Подтверждён спор по операции |
| payment.review_required | Операции нужна дополнительная проверка |
Добавьте и подтвердите endpoint#
Адрес не может указывать на IP, localhost, внутреннюю сеть, нестандартный порт или содержать пароль. DNS проверяется перед отправкой; исходящий POST не следует перенаправлениям. В одном аккаунте может быть до пяти endpoints.
Проверочное событие имеет livemode=false. Успешного HTTP-кода недостаточно: для проверки требуется точное подтверждение challenge. После смены секрета endpoint снова нуждается в проверке.
- 1
В кабинете /account#webhooks укажите публичный HTTPS-адрес, описание и нужные типы событий.
- 2
Сохраните выданный секрет в серверном хранилище. Полное значение показывается один раз.
- 3
Реализуйте проверку подписи по исходным байтам запроса.
- 4
Запустите проверку endpoint. На endpoint.verification верните JSON с тем же data.challenge: {"challenge":"полученное значение"}.
- 5
После успешной проверки включите endpoint и проверьте журнал доставки.
Формат события#
id стабилен при всех попытках доставки одного события. createdAt — Unix timestamp в миллисекундах, apiVersion фиксирует версию контракта. Поле data содержит сохранённый серверный объект; пример выше сокращён.
livemode=true отличает рабочее серверное событие от проверки endpoint. Это поле не означает, что счёт оплачен: проверяйте type и данные операции. Обрабатывайте только известные типы и валидируйте схему.
{
"id": "evt_example",
"type": "invoice.created",
"apiVersion": "2026-10-21",
"createdAt": 1792540800000,
"livemode": true,
"data": {
"invoice": {
"id": "lnk_example",
"orderId": "order-1042",
"currency": "USD",
"amountMinor": 500,
"vatCustomerPercent": 100,
"feeCustomerPercent": 0
}
}
}Проверка HMAC-SHA256#
Kevmi-Signature имеет вид t=секунды,v1=hex. Подпись — HMAC-SHA256 от строки времени, точки и исходного тела: timestamp + "." + rawBody. Используйте секрет конкретного endpoint и постоянное по времени сравнение.
Отклоняйте подпись, если время отличается от серверного более чем на 300 секунд в любую сторону. Синхронизируйте часы. Не разбирайте и не сериализуйте JSON заново перед проверкой: пробелы и порядок полей влияют на байты.
import { createHmac, timingSafeEqual } from 'node:crypto';
function verifyKevmi(rawBody, header, secret, nowSeconds) {
const match = /^t=(\d{10}),v1=([a-f0-9]{64})$/.exec(header || '');
if (!match || Math.abs(nowSeconds - Number(match[1])) > 300) {
return false;
}
const expected = createHmac('sha256', secret)
.update(match[1] + '.')
.update(rawBody)
.digest();
const received = Buffer.from(match[2], 'hex');
return expected.length === received.length
&& timingSafeEqual(expected, received);
}
// Read bytes before JSON parsing; cap the request body.
// secret comes from your server secret store.
const valid = verifyKevmi(rawBody, signature, secret,
Math.floor(Date.now() / 1000));
if (!valid) throw new Error('Invalid webhook signature');
const event = JSON.parse(rawBody.toString('utf8'));
// Validate the event schema and persist its ID together
// with your order update in one database transaction.
// Return a 2xx response after durable acceptance.Повторы и подтверждение приёма#
Для обычного события любой ответ 2xx означает принятие. Надёжно сохраните событие до такого ответа; тяжёлую обработку можно выполнять собственной очередью. Тайм-аут ответа — 8 секунд. Ошибка сети и ответ вне диапазона 2xx вызывают повтор.
Всего выполняется до шести попыток: первоначальная и повторы с задержками 1 минута, 5 минут, 30 минут, 2 часа и 12 часов после очередной неудачи. Это расписание очереди, а не гарантия точной секунды доставки. После исчерпания попыток запись получает failed.
Доставка допускает повтор одного события и не обещает строгий порядок. Сохраняйте event ID с уникальным ограничением в той же транзакции, что и изменение заказа. Повтор уже применённого события возвращает 2xx без повторной выдачи товара.
Ручной повтор в журнале заново запускает попытки и сохраняет исходные event ID, delivery ID и точное тело. Подпись получает свежее время для каждой доставки. Приостановленный endpoint сохраняет очередь; удалённый отменяет ожидающие и неудачные доставки.
Журнал и восстановление#
В интерфейсе доступны состояние endpoint, последние доставки, число попыток, последний HTTP-ответ, ошибка и время следующей попытки. Причины проверки не заменяют серверный журнал вашего заказа.
GET /api/v1/events с правом events:read возвращает до 50 событий и nextCursor. Передавайте непрозрачный nextCursor без изменений как cursor; null означает конец списка. Порядок фиксируется по createdAt и id. Прежний before в Unix ms поддерживается отдельно, но его нельзя сочетать с cursor. Подтверждённые endpoints получают выбранные события не раньше своей регистрации; при паузе очередь продолжает накапливаться и обрабатывается после возобновления. Это не обещает доставку истории до создания endpoint.
Проверка входящего события шлюза#
Шлюз v3 отправляет POST /api/gateway/webhook с X-Gateway-Event-Id, X-Gateway-Timestamp и X-Gateway-Signature: v1=<hex>. HMAC-SHA256 подписывает timestamp + "." + rawBody с GATEWAY_WEBHOOK_SECRET; допускается отклонение часов до 300 секунд. Проверяются совпадение ID заголовка и тела, версия API, live-режим и хеш тела.
Повтор event ID с другим телом отклоняется. Подписанное уведомление запускает GET конкретной операции с серверным ключом. Проверяются заказ, метод, валюта, базовая и согласованная итоговая суммы и версия. Для успеха требуются fulfillment_ready=true и verification=provider_confirmed, точная сумма и отсутствие спора; криптовалюта требует подтверждённых данных расчёта.
Получение входящего события, запись квитанции и исходящее событие магазина защищены от дублей. Старые форматы браузерных сообщений не устанавливают успешную оплату. В исходящий webhook магазина не включаются реквизиты, банковские коды, QR, ключи провайдера или секрет шлюза.
Telegram использует отдельный контракт#
POST /api/telegram/webhook проверяет X-Telegram-Bot-Api-Secret-Token, структуру update и отправителя. update_id защищает от повторной обработки. Этот маршрут обслуживает вход, команды, поддержку и действия модерации; он не принимает финансовые квитанции.
Токен бота, секрет webhook, регистрация setWebhook и доступный HTTPS-адрес настраиваются владельцем развёртывания. Они не совпадают с секретом endpoint магазина.
Несколько магазинов и единая цена в USD#
В меню магазина выберите «Все магазины» для общего обзора и операций или конкретный магазин для его данных. Каждый магазин имеет собственный storeId, название, правило НДС, счета, API-ключи, вебхуки, журнал событий и заявку на проверку сайта. Уведомления и обращения принадлежат аккаунту. Можно создать до 100 магазинов. Ранее созданные данные сохранены в первом магазине.
Новые ссылки из кабинета используют pricingMode="universal": базовая цена хранится в USD. Any включает card, alipay, sbp и crypto; при необходимости магазин выбирает подмножество. Карты и Alipay оплачиваются в USD, СБП — в RUB по официальному курсу USD/RUB Банка России, криптовалюта — в USDT по явно установленному соотношению 1 USD = 1 USDT. Это правило расчёта счёта, а не рыночная котировка USDT. Курс другой монеты и точные реквизиты предоставляет шлюз перед переводом.
Курс ЦБ и обе суммы сохраняются при создании. Ссылки с рублёвой конвертацией действуют не более 24 часов. Если курс получить не удалось, создание возвращает FX_UNAVAILABLE; выдуманный или старый запасной курс не применяется. amountMinor и quotes в ответе владельцу выражены в базовой валюте USD; quotes[method].settlement содержит сумму и валюту конкретного способа. Публичный checkout возвращает выбранную валюту оплаты в currency, исходную цену в baseAmountMinor/baseCurrency и курс в fx. Возврат к другой валюте после отправки платежа запрещён: у счёта одна операция шлюза.
Для серверной интеграции создайте API-ключ внутри выбранного магазина и передавайте Authorization: Bearer <key>. Привязка ключа к магазину неизменна: заголовок X-Kevmi-Store не позволяет получить данные другого магазина. Для браузерной сессии передавайте X-Kevmi-Store: <storeId>; без заголовка используется первый магазин. Значение all разрешено только для GET /api/account/dashboard и GET /api/payment-links. Изменения требуют выбранного магазина, Origin и X-CSRF-Token. ID аккаунта и ID магазина — разные идентификаторы.
Без pricingMode="universal" API сохраняет прежний режим native и исходные ограничения валют для совместимости. Старые ссылки не пересчитываются. Подтверждённые поступления в обзоре — это проверенные платежи за вычетом расходов по сохранённому расчёту, а не доступный остаток для вывода. Валюты старых счетов учитываются раздельно; выплаты согласуются с поддержкой. Подтверждение исполнения заказа проверяйте через payment.fulfillmentReady=true и payment.status="succeeded" в серверном API, журнале событий или подписанном вебхуке.