Приём платежей

Вебхуки

Вебхук — это HTTPS-запрос, который Sezgir Pay отправляет на ваш сервер, когда что-то происходит с платежом или возвратом. Это основной способ узнать, что заказ оплачен.

Подключение

  1. Откройте кабинет → сервис → вкладка Вебхуки и добавьте адрес. Тестовые и боевые вебхуки настраиваются отдельно: события тестовых платежей уходят только на тестовые адреса.
  2. Выберите события или оставьте список пустым — тогда придут все события сервиса, включая типы, которые появятся в будущем.
  3. Скопируйте секрет подписи whsec_…. Его можно посмотреть позже или сменить; каждый просмотр и смена записываются в журнал действий.
  4. Нажмите «Отправить тест» — на адрес придёт 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) не выполняются и считаются неуспешной доставкой.
Локальная разработка
Чтобы принимать тестовые вебхуки на своём компьютере, пробросьте локальный порт через туннель с публичным HTTPS-адресом и укажите его как тестовый вебхук.

Типы событий

typeКогда отправляетсяdata.object
payment.createdПлатёж создан (через API или кабинет)Payment
payment.succeededKaspi подтвердил оплату. Может прийти после expired, canceled или failedPayment
payment.failedKaspi отклонил оплату по QR или счётуPayment
payment.expiredСрок оплаты истёкPayment
payment.canceledПлатёж отменён вами или покупатель отказался от оплаты в KaspiPayment
payment.refundedВыполнен возврат — полный или частичныйPayment
refund.succeededВозврат выполнен (сразу или после ручного подтверждения)Refund
refund.failedВозврат не выполненRefund
webhook.testТестовая отправка из кабинета; приходит только на выбранный адрес{ message, endpoint_id }

Список типов может расширяться. Отвечайте 2xx и на незнакомые события — иначе они будут повторяться.

Формат запроса

Sezgir Pay отправляет POST с JSON-телом — объектом Event. В data.object лежит снимок платежа или возврата на момент события.

ЗаголовокЗначение
Content-Typeapplication/json
User-AgentSezgirPay-Webhooks/1.0
Sezgir-Event-IdИдентификатор события, evt_… — совпадает с id в теле
Sezgir-Event-TypeТип события, например payment.succeeded
Sezgir-Signaturet=<unix-время>,v1=<hex-подпись>
Тело запроса · payment.succeeded
{
  "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"
    }
  }
}
Тело запроса · webhook.test
{
  "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 ) )
  1. Разберите заголовок на t и v1.
  2. Возьмите исходные байты тела — до парсинга JSON. Пересобранный JSON (другой порядок ключей, пробелы, экранирование кириллицы) даст другую подпись.
  3. Посчитайте HMAC-SHA256 от "<t>.<тело>" с секретом целиком, включая префикс whsec_.
  4. Сравните с v1 функцией сравнения за постоянное время.
  5. Отклоните запрос, если 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
curl "https://pay.sezgir.com/api/v1/events?type=payment.succeeded&limit=50" \
  -H "Authorization: Bearer $SEZGIR_API_KEY"
Ответ · 200 OK
{
  "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 последнего события в текущей. Подробнее — в разделе Пагинация.