Вебхуки
Вебхук — это HTTPS-запрос, который Sezgir Pay отправляет на ваш сервер, когда что-то происходит с платежом или возвратом. Это основной способ узнать, что заказ оплачен.
Подключение
- Откройте кабинет → сервис → вкладка Вебхуки и добавьте адрес. Тестовые и боевые вебхуки настраиваются отдельно: события тестовых платежей уходят только на тестовые адреса.
- Выберите события или оставьте список пустым — тогда придут все события сервиса, включая типы, которые появятся в будущем.
- Скопируйте секрет подписи
whsec_…. Его можно посмотреть позже или сменить; каждый просмотр и смена записываются в журнал действий. - Нажмите «Отправить тест» — на адрес придёт
webhook.test, а в журнале доставок появится ответ вашего сервера.
Требования к адресу (на сервис — до 16 адресов в каждом режиме):
- для боевого режима — только
https://; для тестового допускаетсяhttp://; - адрес должен быть доступен из интернета:
localhost, адреса внутренних сетей (10.0.0.0/8,172.16.0.0/12,192.168.0.0/16,127.0.0.0/8, link-local, CGNAT и т. п.) и домены, которые в них резолвятся, отклоняются — и при сохранении, и при каждой доставке; - логин и пароль в URL (
https://user:pass@…) не допускаются — для защиты используйте подпись; - перенаправления (3xx) не выполняются и считаются неуспешной доставкой.
Типы событий
| type | Когда отправляется | data.object |
|---|---|---|
payment.created | Платёж создан (через API или кабинет) | Payment |
payment.succeeded | Kaspi подтвердил оплату. Может прийти после expired, canceled или failed | Payment |
payment.failed | Kaspi отклонил оплату по QR или счёту | Payment |
payment.expired | Срок оплаты истёк | Payment |
payment.canceled | Платёж отменён вами или покупатель отказался от оплаты в Kaspi | Payment |
payment.refunded | Выполнен возврат — полный или частичный | Payment |
refund.succeeded | Возврат выполнен (сразу или после ручного подтверждения) | Refund |
refund.failed | Возврат не выполнен | Refund |
webhook.test | Тестовая отправка из кабинета; приходит только на выбранный адрес | { message, endpoint_id } |
Список типов может расширяться. Отвечайте 2xx и на незнакомые события — иначе они будут повторяться.
Формат запроса
Sezgir Pay отправляет POST с JSON-телом — объектом Event. В data.object лежит снимок платежа или возврата на момент события.
| Заголовок | Значение |
|---|---|
Content-Type | application/json |
User-Agent | SezgirPay-Webhooks/1.0 |
Sezgir-Event-Id | Идентификатор события, evt_… — совпадает с id в теле |
Sezgir-Event-Type | Тип события, например payment.succeeded |
Sezgir-Signature | t=<unix-время>,v1=<hex-подпись> |
{
"id": "evt_3Lm8Qz1VbN6cX2wR9tKp4HsD",
"object": "event",
"type": "payment.succeeded",
"livemode": true,
"project_id": "prj_7HcTq2LwZ9mRk4VbN1sXe8Dy",
"created": 1792922592,
"data": {
"object": {
"id": "pay_8Rt2XvL5nQ1mZ7cK4wB9pJ3s",
"object": "payment",
"livemode": true,
"project_id": "prj_7HcTq2LwZ9mRk4VbN1sXe8Dy",
"type": "qr",
"status": "succeeded",
"amount": 2500,
"amount_refunded": 0,
"currency": "KZT",
"description": "Кофе и круассан",
"phone": null,
"external_id": "pos-77-1029",
"metadata": {},
"checkout_url": "https://pay.sezgir.com/pay/pay_8Rt2XvL5nQ1mZ7cK4wB9pJ3s",
"provider": {
"name": "kaspi",
"operation_id": "18734512",
"kind": "qr",
"status": "Processed",
"qr_url": "https://pay.kaspi.kz/pay/fT9kq2Lx7mWc",
"qr_expires_at": "2026-10-25T10:05:41.000Z"
},
"failure_reason": null,
"requires_review": false,
"expires_at": "2026-10-25T10:06:41.000Z",
"paid_at": "2026-10-25T10:03:12.881Z",
"canceled_at": null,
"created_at": "2026-10-25T10:02:41.204Z"
}
}
}{
"id": "evt_9QwE2rT5yU8iO1pA4sD7fG0h",
"object": "event",
"type": "webhook.test",
"livemode": false,
"project_id": "prj_7HcTq2LwZ9mRk4VbN1sXe8Dy",
"created": 1792922592,
"data": {
"object": {
"message": "Тестовое событие Sezgir Pay",
"endpoint_id": "we_6YhN3bV8cX1zL4kJ7mQ2wE5r"
}
}
}Проверка подписи
Каждый запрос подписан секретом вебхука. Заголовок Sezgir-Signature содержит время отправки t (Unix, секунды) и подпись v1 — HMAC-SHA256 в hex от строки "<t>.<тело запроса>":
Sezgir-Signature: t=1792922592,v1=5f2b8e…c41a
v1 = hex( HMAC_SHA256( key = "whsec_…", message = t + "." + raw_body ) )- Разберите заголовок на
tиv1. - Возьмите исходные байты тела — до парсинга JSON. Пересобранный JSON (другой порядок ключей, пробелы, экранирование кириллицы) даст другую подпись.
- Посчитайте HMAC-SHA256 от
"<t>.<тело>"с секретом целиком, включая префиксwhsec_. - Сравните с
v1функцией сравнения за постоянное время. - Отклоните запрос, если
tотличается от текущего времени больше чем на 5 минут, — это защищает от повторной отправки перехваченных запросов.
import crypto from 'node:crypto';
import express from 'express';
const SECRET = process.env.SEZGIR_WEBHOOK_SECRET; // whsec_… целиком, вместе с префиксом
const TOLERANCE_SEC = 300; // отклоняем подписи старше 5 минут
function verifySignature(rawBody, header) {
const parts = Object.fromEntries(
String(header ?? '').split(',').map((p) => p.trim().split('=')),
);
const t = Number(parts.t);
if (!Number.isInteger(t) || !parts.v1) return false;
if (Math.abs(Date.now() / 1000 - t) > TOLERANCE_SEC) return false;
const expected = crypto.createHmac('sha256', SECRET).update(`${t}.${rawBody}`).digest('hex');
const a = Buffer.from(expected);
const b = Buffer.from(parts.v1);
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
const app = express();
// Подпись считается от исходных байтов тела — не пропускайте его через express.json()
app.post('/webhooks/sezgir', express.raw({ type: 'application/json' }), async (req, res) => {
const raw = req.body.toString('utf8');
if (!verifySignature(raw, req.get('Sezgir-Signature'))) {
return res.status(400).send('invalid signature');
}
const event = JSON.parse(raw);
// INSERT … ON CONFLICT (id) DO NOTHING — повторная доставка не обработается дважды
const isNew = await saveEventOnce(event);
res.sendStatus(200); // отвечаем сразу, тяжёлую работу — в фон
if (isNew) await queue.add('sezgir-event', { id: event.id });
});
app.listen(3000);Обработка событий
Ориентируйтесь на type и состояние объекта в data.object. Порядок доставки не гарантирован: из-за повторов payment.created может прийти позже payment.succeeded. Если важен самый свежий статус, сравнивайте created событий или запросите объект через GET /payments/:id.
async function handleEvent(event) {
const obj = event.data.object;
switch (event.type) {
case 'payment.succeeded':
// Может прийти и после expired/canceled — заказ всё равно оплачен
await orders.markPaid(obj.external_id, { paymentId: obj.id, amount: obj.amount });
break;
case 'payment.failed':
case 'payment.expired':
case 'payment.canceled':
await orders.markUnpaid(obj.external_id, obj.status); // не удаляйте заказ
break;
case 'payment.refunded':
await orders.setRefunded(obj.external_id, obj.amount_refunded, obj.status);
break;
case 'refund.succeeded':
case 'refund.failed':
await refunds.update(obj.id, obj.status);
break;
default:
// webhook.test и типы, появившиеся позже, — просто подтверждаем
}
}Повторные доставки
Доставка считается успешной, если ваш сервер ответил кодом 2xx в течение 10 секунд. Любой другой код, таймаут или ошибка соединения — неуспешная попытка. Всего делается до 10 попыток в течение примерно двух суток; интервал отсчитывается от предыдущей попытки:
| Попытка | Когда |
|---|---|
| 1 | Сразу после события |
| 2 | Через 30 секунд после попытки 1 |
| 3 | Через 2 минуты после попытки 2 |
| 4 | Через 10 минут после попытки 3 |
| 5 | Через 30 минут после попытки 4 |
| 6 | Через 1 час после попытки 5 |
| 7 | Через 3 часа после попытки 6 |
| 8 | Через 6 часов после попытки 7 |
| 9 | Через 12 часов после попытки 8 |
| 10 | Через 24 часа после попытки 9 |
- После десятой неудачи доставка помечается как неуспешная. Только такую доставку можно отправить ещё раз вручную — кнопкой «Отправить ещё раз» в журнале доставок вебхука.
- Если вебхук отключён в кабинете, ожидающие доставки на него завершаются неуспешно без повторов.
- В журнале видно каждую попытку: код ответа, первые 1000 символов тела ответа, длительность и текст ошибки — например «Таймаут 10 с» или «Адрес ведёт во внутреннюю сеть».
Рекомендации
- Проверяйте подпись у каждого запроса и отклоняйте подписи старше 5 минут. Без проверки любой может «оплатить» заказ поддельным запросом.
- Дедуплицируйте по id события. Одно событие может прийти несколько раз — например, если ваш сервер обработал его, но не успел ответить за 10 секунд. Сохраняйте
idобработанных событий с уникальным индексом. - Отвечайте 2xx быстро. Сохраните событие и сразу ответьте, а отгрузку, письма и запросы к другим системам выполняйте в фоновой очереди.
- Не доверяйте только вебхуку при спорных ситуациях. Перед дорогой операцией можно перепроверить статус через
GET /payments/:id. - Будьте готовы к payment.succeeded в любой момент — даже для заказа, который вы считали отменённым.
- Сверяйтесь через GET /events. Если сервер был недоступен дольше, чем длится расписание повторов, заберите пропущенные события сами.
Сверка через /events
GET /events возвращает события сервиса от новых к старым — те же объекты, что приходят в вебхуках. Периодически (например, раз в несколько минут) проходите по списку до последнего обработанного события и обрабатывайте то, чего ещё нет в вашей базе. Фильтр type оставляет события одного типа.
curl "https://pay.sezgir.com/api/v1/events?type=payment.succeeded&limit=50" \
-H "Authorization: Bearer $SEZGIR_API_KEY"{
"object": "list",
"data": [
{
"id": "evt_3Lm8Qz1VbN6cX2wR9tKp4HsD",
"object": "event",
"type": "payment.succeeded",
"livemode": true,
"project_id": "prj_7HcTq2LwZ9mRk4VbN1sXe8Dy",
"created": 1792922592,
"data": {
"object": {
"id": "pay_8Rt2XvL5nQ1mZ7cK4wB9pJ3s",
"object": "payment",
"livemode": true,
"project_id": "prj_7HcTq2LwZ9mRk4VbN1sXe8Dy",
"type": "qr",
"status": "succeeded",
"amount": 2500,
"amount_refunded": 0,
"currency": "KZT",
"description": "Кофе и круассан",
"phone": null,
"external_id": "pos-77-1029",
"metadata": {},
"checkout_url": "https://pay.sezgir.com/pay/pay_8Rt2XvL5nQ1mZ7cK4wB9pJ3s",
"provider": {
"name": "kaspi",
"operation_id": "18734512",
"kind": "qr",
"status": "Processed",
"qr_url": "https://pay.kaspi.kz/pay/fT9kq2Lx7mWc",
"qr_expires_at": "2026-10-25T10:05:41.000Z"
},
"failure_reason": null,
"requires_review": false,
"expires_at": "2026-10-25T10:06:41.000Z",
"paid_at": "2026-10-25T10:03:12.881Z",
"canceled_at": null,
"created_at": "2026-10-25T10:02:41.204Z"
}
}
}
],
"has_more": true
}Как пройти по страницам
Если has_more равно true, запросите следующую страницу с starting_after = id последнего события в текущей. Подробнее — в разделе Пагинация.