> Источник: https://apipay.kz/guides/nastroyka-webhookov-apipay · Обновлено: 2026-10-25 · apipay.kz
> ApiPay — независимый сервис приёма платежей поверх вашего Kaspi Pay.

# Как настроить вебхуки ApiPay и проверить подпись?

**TL;DR.** Вебхук ApiPay — уведомление об оплате счёта Kaspi Pay на ваш сервер: при каждой смене статуса счёта (`paid`, `cancelled`, `expired` и других) ApiPay сам делает POST на ваш HTTPS-адрес. Для старта его можно не подключать — хватит опроса `GET /invoices/{id}` с сервера. Настройка вебхука — 5 минут: в кабинете apipay.kz → Настройки → «Подключение» укажите у API-ключа адрес уведомлений (`webhook_url`) → на карточке ключа «Ещё» → «Создать секретный ключ подписи» (это вебхук-секрет) → проверяйте заголовок `X-Webhook-Signature: sha256=<hex>` как HMAC-SHA256 от **сырого тела запроса** с этим секретом; пока секрета нет, заголовок не приходит и запрос ничем не подтверждён — перед выдачей товара сверяйте статус через `GET /invoices/{id}`. Отвечайте `200` быстрее 5 секунд. Для событий счетов в рабочем режиме предусмотрено до **11 попыток** (первая и 10 повторов); для других событий и песочницы условия ниже. После серии неудач подряд отправка по ключу встаёт на паузу, а при затянувшихся отказах отключается — обратно её включает успешная проверка: почините адрес и нажмите «Проверить уведомления» в кабинете.

## Что приходит от ApiPay и как отвечать — коротко?

| Вопрос | Ответ |
|---|---|
| Запрос от ApiPay | `POST {ваш webhook_url}`, `Content-Type: application/json`, `User-Agent: Kaspi-Pay-API/1.0` |
| Подпись | `X-Webhook-Signature: sha256=<hex>` = HMAC-SHA256(raw body, вебхук-секрет); заголовок присутствует, только если секрет задан |
| Требования к URL | HTTPS, публично доступен, без вашей авторизации и **без переадресации**; приватные IP отклоняются (422) |
| Ответ вашего сервера | Любой 2xx быстрее 5 секунд; обработка — асинхронно |
| Ретраи | Число попыток и интервалы зависят от события и режима — таблица в разделе о повторах ниже. Первая попытка включена в общее число |
| Что повторяется | HTTP ≥500, ровно 429, сетевые ошибки; прочие 4xx — нет |
| Редиректы | `307`/`308` — отрабатываем (до 2 переходов); `301`/`302`/`303` — недоставка **без ретрая**, тело POST теряется |
| Пауза доставки | После серии неудач подряд — пауза, время снятия в `GET /webhook-logs/delivery-state`; при затянувшихся отказах отправка отключается |
| Тест | Кнопка «Проверить уведомления» в кабинете → событие `webhook.test` (до 5 раз/мин) |
| Журнал доставок | `GET /webhook-logs`; деталь — `/webhook-logs/{id}`; хранятся 14 дней |

## Вебхук или опрос статуса — что выбрать?

**Для старта хватит опроса.** Проверяйте `GET /invoices/{id}` с сервера: раз в 3–5 секунд, пока покупатель на экране оплаты и счёт ждёт оплаты, дальше — реже, как фоновая сверка: пока счёт в `pending`, и ещё 24 часа после `cancelled`, `expired` или `error` — эти статусы могут смениться на `paid`. У этого запроса отдельный лимит — **1000 запросов в минуту на ключ**, общий лимит 200 запросов в минуту он не расходует. Ключ `X-API-Key` держите только на сервере.

**Вебхук нужен, когда важен мгновенный отклик** или счетов много: ApiPay сам сообщит о смене статуса, опрашивать ничего не придётся. Задача обработчика: принять POST, проверить подпись, ответить `200`. Опрос при подключённом вебхуке оставьте как страховку — на случай, если уведомление не дошло.

Полный перечень событий и полей payload — в [документации API](/docs). Код обработчика может написать ваш ИИ-ассистент: дайте ему [apipay.kz/for-ai](/for-ai), там готовая инструкция по подключению. Как устроен приём оплат Kaspi Pay через ApiPay целиком — в разделе «[Как принимать оплату Kaspi через API](/kaspi-api)».

Опрос других адресов в цикле тратит общий лимит 200 запросов в минуту: например, `GET /invoices/{id}/refunds` в плотном цикле быстро даст `429`.

## Как настроить вебхук за 5 минут?

1. **Кабинет apipay.kz → Настройки → «Подключение»**: укажите у ключа адрес для уведомлений (`webhook_url`) — публичный HTTPS-адрес вашего обработчика. У нового ключа — вариант **«Уведомления на мой сервер (webhook)»** при создании; у готового ключа — меню **«Ещё»** → **«Подключить уведомления»** или кнопка **«Изменить»**. URL с приватным IP (localhost, 192.168.…) не пройдёт валидацию (422).
2. **Секретный ключ подписи.** Если адрес указан при создании ключа, секрет показывается сразу, на экране «Готово». Если адрес добавлен позже — после сохранения адреса откройте на карточке ключа меню **«Ещё»** → **«Создать секретный ключ подписи»**. Это и есть **вебхук-секрет**. Он показывается один раз — сохраните в переменные окружения. Важно: вебхук-секрет ≠ API-ключ, это два разных credentials — разница разобрана в «[API-ключ и вебхук-секрет](/guides/api-klyuch-i-webhook-secret)».
3. Нажмите **«Проверить уведомления»** (до 5 раз в минуту) — придёт событие `webhook.test` с фиктивным счётом (`invoice.id: null`, `status: "test"`, `external_order_id: "TEST-ORDER-123"`), подписанное по-боевому. У `webhook.test` корневой `is_sandbox` всегда `true`: если в рабочем режиме обработчик отбрасывает песочные события (правило — в контрактных правилах ниже), он ответит на проверку `2xx`, и она пройдёт. Подпись проверяйте до отбрасывания — тогда успешная проверка подтверждает и верный секрет.
4. Убедитесь, что ваш обработчик ответил `2xx` и подпись сошлась. Готово.

## Как проверить подпись вебхука? Главное правило — raw body

Подпись считается от **сырых байтов тела запроса** — до какого-либо JSON-парсинга. Если ваш фреймворк сначала распарсил JSON, а вы подписываете `JSON.stringify(parsed)` — байты не совпадут и подпись «не сойдётся», хотя секрет верный. Это ошибка №1.

**Node.js (Express):**

```js
const crypto = require("crypto");
const express = require("express");
const app = express();

// ВАЖНО: raw body, а не json-парсер
app.post("/webhooks/apipay", express.raw({ type: "application/json" }), (req, res) => {
  const expected = "sha256=" + crypto
    .createHmac("sha256", process.env.APIPAY_WEBHOOK_SECRET)
    .update(req.body) // Buffer с сырым телом
    .digest("hex");
  const got = Buffer.from(req.get("X-Webhook-Signature") || "");
  const exp = Buffer.from(expected);
  if (got.length !== exp.length || !crypto.timingSafeEqual(exp, got)) {
    return res.status(401).end();
  }
  res.status(200).end();                 // отвечаем сразу (до 5 секунд!)
  const event = JSON.parse(req.body);    // обработка — после ответа
});
```

**Python (Flask):**

```python
import hmac, hashlib, os
from flask import Flask, request

app = Flask(__name__)

@app.post("/webhooks/apipay")
def apipay_webhook():
    raw = request.get_data()  # сырое тело, до парсинга JSON
    expected = "sha256=" + hmac.new(
        os.environ["APIPAY_WEBHOOK_SECRET"].encode(), raw, hashlib.sha256
    ).hexdigest()
    if not hmac.compare_digest(expected, request.headers.get("X-Webhook-Signature", "")):
        return "", 401
    # ответить 200 быстро; тяжёлую обработку — в очередь
    return "", 200
```

**PHP:**

```php
<?php
$raw = file_get_contents('php://input'); // сырое тело
$expected = 'sha256=' . hash_hmac('sha256', $raw, getenv('APIPAY_WEBHOOK_SECRET'));
$got = $_SERVER['HTTP_X_WEBHOOK_SIGNATURE'] ?? '';
if (!hash_equals($expected, $got)) {
    http_response_code(401);
    exit;
}
http_response_code(200); // обработку события — после ответа / в очередь
$event = json_decode($raw, true);
```

Во всех трёх примерах сравнение — константное по времени (`timingSafeEqual` / `compare_digest` / `hash_equals`), не `==`.

## Какие события приходят?

Все события приходят на один `webhook_url`.

| Группа | События | Сколько |
|---|---|---|
| Счета | `invoice.status_changed` (переходы в `pending`, `paid`, `cancelled`, `expired`, `error`, `partially_refunded`), `invoice.qr_scanned` (QR отсканирован — один раз на QR), `invoice.refunded` (возврат `completed` / `failed`) | 3 |
| Возвраты по QR | `qr_refund.identified`, `qr_refund.completed`, `qr_refund.expired`, `qr_refund.failed`, `qr_refund.execution_uncertain` | 5 |
| Каталог | `catalog.item_processed` — **по каждой позиции каталога** | 1 |
| Фискальные чеки | `receipt.issued`, `receipt.failed` | 2 |
| Подписки | `subscription.*` | 9 |
| Кассовая смена | `cashbox.shift_closed`, `cashbox.shift_close_failed` | 2 |
| Тест | `webhook.test` | 1 |

Итого **двадцать три** события.

⛔ `qr_refund.execution_uncertain` — исход возврата не доказан: обработчик обязан не запускать повторный возврат, а завести задачу на ручной разбор.

**Всегда ветвитесь по полю `event`** и молча игнорируйте незнакомое: новые события добавляются без предупреждения и приходят на уже настроенный адрес.

⛔ Событие `catalog.batch_processed` из этого списка убрано и **не отправляется никогда**. Адрес и подпись прежние, просто тишина — обработчик, который его ждёт, об этом не узнает. Итог по каталогу приходит событием `catalog.item_processed` по каждой позиции: заливка на 50 позиций даёт до 50 доставок.

Технические статусы `processing` и `cancelling` вебхуков не порождают никогда. Конверт: `{event, is_sandbox, invoice{...}, source, timestamp}`, все даты — ISO 8601 UTC. Корневой `is_sandbox` есть у каждого события и лежит внутри подписанного тела; вложенные `is_sandbox` совпадают с ним.

Четыре контрактных правила:

- **Дедуплицируйте на своей стороне.** У `invoice.status_changed` есть серверный гейт «один вебхук на реальный переход», но у `invoice.refunded` и `subscription.*` гейта нет — дубли возможны. Ключи дедупа: `(invoice.id, invoice.status)`, `(refund.id, refund.status)`, `(event, subscription.id, invoice_id)` — `invoice_id` есть только в `subscription.payment_succeeded`, `subscription.payment_failed` и `subscription.cancelled` с `reason` `payer_refused` или `payer_error`; для `subscription.period_skipped` — `(event, subscription.id, billing_period_start)`. `receipt.*` — по `(event, receipt.id)`: уведомление о чеке тоже может прийти дважды. После `error` по счёту может снова прийти `pending`, а затем снова `error` (например, при отмене), даже если этот статус по нему уже приходил. `pending` после `error` и `error` после такого `pending` — новые переходы, а не дубли: не отбрасывайте их. Пришёл `error` — снимите ключ `pending` этого счёта, пришёл `pending` — ключ `error`.
- **Ждите «странных» последовательностей.** `cancelled → paid` и `expired → paid` легитимны (оплата выиграла гонку), `error → pending` — реконсиляция. Подробнее — «[Как создать счёт по номеру](/guides/kak-sozdat-schet-kaspi-po-nomeru)».
- **Будьте толерантны к новым полям.** Контракт additive: поля добавляются без предупреждения, неизвестное — игнорируйте, не падайте.
- **В рабочем режиме отбрасывайте события с `is_sandbox: true` в корне тела:** отвечайте `2xx` и не исполняйте. Событие по песочному объекту, отправленное до перевода организации в рабочий режим, может прийти уже после него. Корневой `is_sandbox` относится к объекту события, а не к текущему режиму организации: свой режим ваша система знает сама.

## Что будет, если мой сервер не ответил? Повторы доставки

- Успех = любой **2xx**. Всё остальное — неудача попытки.
- Ретраятся: HTTP ≥500, ровно 429 и сетевые ошибки. Число попыток и интервалы зависят от события — таблица ниже. Число включает первую попытку: 11 попыток — это до 10 повторов, 3 попытки — до 2 повторов.
- **Ваш эндпоинт не должен отвечать редиректом.** По `307` и `308` мы повторяем тот же POST с тем же телом и той же подписью — до 2 переходов, каждый новый адрес проверяется заново (петля или пустой `Location` — недоставка). `301`, `302` и `303` — **недоставка без ретрая**: по ним HTTP-клиент обязан сменить метод на GET и потерять тело, то есть ваш обработчик payload не получит вовсе. Типовой случай — `http://` в `webhook_url` при редиректе сервера на `https://`, поэтому указывайте сразу конечный `https`-адрес, отвечающий без переадресации. Счётчик circuit breaker'а такая недоставка **не увеличивает** — канал остаётся `active`, и в кабинете всё выглядит рабочим.
- **4xx (кроме 429) не повторяется**: автоповторов не будет, повторить можно вручную — кабинет → «Лог уведомлений» → повтор у записи со статусом `failed` (не чаще раза в 10 с). Поэтому не отвечайте `401`/`403`/`404` на легитимные события — они будут потеряны до ручного Retry.
- Таймауты запроса к вам: 3 с соединение + 5 с ответ. Отсюда правило: `200` сразу, обработка потом.

| Событие | Рабочий режим | Песочница |
|---|---|---|
| `invoice.status_changed` | До 11 попыток | До 3 попыток |
| `invoice.refunded`, `subscription.*` | До 11 попыток | До 11 попыток |
| `qr_refund.*`, `cashbox.*` | До 11 попыток, повторы через 10 с, 30 с, 1, 1.5, 2, 5 минут, далее по 10 минут — около 50 минут | До 3 попыток |
| `invoice.qr_scanned` | До 11 попыток, повторы через 10 с, 30 с, 1 минуту, далее по 2 минуты — около 16 минут | До 3 попыток |
| `catalog.*`, `receipt.*` | До 3 попыток, повторы через 10 с и 1 минуту | До 3 попыток |

Длинная сетка 10 с → 30 с → 1 → 1.5 → 2 → 5 → 10 → 15 → 30 → 60 минут (около двух часов) применяется к `invoice.status_changed` в рабочем режиме, а также к `invoice.refunded` и `subscription.*` в обоих режимах. Не переносите её на события сканирования QR, QR-возвратов и кассы: у них сетка короче — таблица выше. Для `invoice.status_changed`, `invoice.qr_scanned`, `qr_refund.*` и `cashbox.*` в песочнице паузы — 5 и 15 секунд. Каталожные и чековые события получают до 3 попыток; их итог дополнительно сверяйте чтением: каталог — точечным `GET /catalog?external_refs[]=…` (и `GET /catalog/errors` по отказам), чеки — `GET /receipts/{id}`.

## Что значит «вебхук отключён» (circuit breaker) и как включить его обратно?

Неудачи считаются подряд на API-ключ: после серии неудач отправка встаёт на паузу (`state: paused`), и пауза снимается сама после `paused_until` — его видно в `GET /webhook-logs/delivery-state`. При затянувшихся отказах отправка отключается (`state: disabled`). Успешная доставка события счёта, возврата, `qr_refund.*` или `cashbox.*` обнуляет счётчик (успех `subscription.*` и `receipt.*` его не трогает); ошибки до и после неё не складываются. Пока breaker открыт, вебхуки счетов, возвратов и `qr_refund.*` **не отправляются и не откладываются** — эти переходы для канала потеряны (статусы затем сверяйте по `GET /invoices/{id}` или в кабинете). События `subscription.*`, `receipt.*`, `cashbox.*` и `catalog.item_processed` паузу не соблюдают и уходят с обычными повторами.

Как вернуть доставку: почините свой endpoint и нажмите **«Проверить уведомления»** в кабинете или отправьте `POST /webhook-test` — успешная проверка сбрасывает счётчик и включает канал. Отключённую отправку включает и смена адреса уведомлений или секрета (в том числе выпуск нового секрета). Те же действия снимают и паузу досрочно. Текущее состояние видно в списке API-ключей: `webhook_status: active | paused | disabled`.

## Где смотреть журнал доставок и как сгенерировать тестовое событие?

Журнал доставок хранится **14 дней** и доступен по публичному API.

**Журнал доставок — `GET /webhook-logs`:**

```bash
curl "https://api.apipay.kz/api/v1/webhook-logs?event=invoice.status_changed&status=failed" \
  -H "X-API-Key: YOUR_API_KEY"
```

Фильтры: `invoice_id`, `event`, `status` (`success` | `failed` | `skipped`), `date_from`/`date_to`, сортировка (`sort_by` = `created_at` | `response_time_ms` | `response_status`, `sort_order`), `per_page` (≤100), `page`. Пагинация плоская: `{ current_page, data, total }`. Одна доставка со всеми деталями — полные `request_body` и `response_body` (ответ обрезан до 4096 байт), `response_status`, `response_time_ms`, `error_message`, `retry_of` — по `GET /webhook-logs/{id}`; чужой лог отдаёт `404`. В журнале есть события счетов, возвратов, `qr_refund.*`, `subscription.*`, `receipt.*`, `cashbox.*` и `webhook.test`. Событие, не отправленное из-за паузы отправки ключа, лежит строкой `status: skipped` и не повторяется; состояние паузы — `GET /webhook-logs/delivery-state`. `catalog.*` пишутся в отдельный журнал `GET /catalog/webhook-logs` с ротацией 3 дня.

В рабочем режиме повторить конкретную неудачную доставку можно только из кабинета («Лог уведомлений» → повтор у записи со `status: failed`). В песочнице это делает `POST /webhook-logs/{id}/retry`. Строку `skipped` не повторяет ни один из способов.

**Сгенерировать событие в песочнице — `POST /invoices/{id}/simulate-status`:**

Метод переводит **тестовый** счёт (`is_sandbox: true`) в `paid`/`cancelled`/`expired`/`error` и шлёт настоящий `invoice.status_changed` на ваш `webhook_url`; для QR-счёта `status: qr_scanned` шлёт `invoice.qr_scanned`.

```bash
curl -X POST "https://api.apipay.kz/api/v1/invoices/42/simulate-status" \
  -H "X-API-Key: YOUR_API_KEY" -H "Content-Type: application/json" \
  -d '{"status":"paid"}'
```

Только песочница (боевой счёт → `403 not_sandbox`); свой лимит — 60 запросов/мин на ключ, общий бюджет 200/мин не расходуется. Тестовые события помечены `is_sandbox: true` в корне тела; что делать с ними в рабочем режиме — в контрактных правилах выше. Полный цикл прогона (создать → дождаться `pending` → симулировать → проверить лог → возврат) — в статье «[Песочница и рабочий режим](/guides/pesochnitsa-i-rabochiy-rezhim)».

## Как тестировать локально?

Localhost в `webhook_url` не пройдёт (нужен публичный HTTPS). Решение — туннель: `ngrok http 3000` → полученный `https://…ngrok…/webhooks/apipay` вписать в кабинет → кнопка «Проверить уведомления». У многих туннелей бесплатный адрес меняется при каждом перезапуске — новый адрес вписывайте в кабинете заново (карточка ключа → «Изменить»), иначе уведомления перестанут приходить. В песочнице можно обойтись и без туннеля: команда ApiPay `apipay-cli listen` передаёт уведомления о счетах и возвратах обработчику на вашем компьютере (подписки, чеки, касса, каталог и «Проверить уведомления» — только на публичный адрес). Пошаговый гайд с готовым мини-сервером и обоими способами — на странице «[Локальное тестирование вебхуков](/local-testing)». Типовая ошибка 401 при «Проверить уведомления»: на вашем URL включена собственная авторизация (Basic auth, middleware) — endpoint вебхука должен быть открыт, его защита — подпись.

Важно: туннель годится **только для теста в песочнице**. Для **рабочего режима** у ещё не одобренной организации адрес вебхука должен быть на **реальном домене**: IP-адрес вернёт `422 webhook_url_requires_domain`, а туннель (ngrok и подобные) — `422 webhook_url_tunnel_forbidden`. Снимает это ограничение одобрение анкеты о бизнесе — см. «[Анкета о бизнесе](/guides/anketa-o-biznese-i-limit)».

## Вопросы и ответы

**Как проверить подпись вебхука ApiPay?**
Возьмите сырое тело запроса (raw body, до JSON-парсинга) и посчитайте HMAC-SHA256 с вебхук-секретом. Результат в формате `sha256=<hex>` сравните с заголовком `X-Webhook-Signature` константным по времени сравнением (`hash_equals`, `hmac.compare_digest`, `timingSafeEqual`).

**Почему подпись вебхука не сходится?**
Причина №1 — подпись считается по распарсенному и пересериализованному JSON вместо сырых байтов тела. Причина №2 — вместо вебхук-секрета взят API-ключ: разница — в «[API-ключ и вебхук-секрет](/guides/api-klyuch-i-webhook-secret)». Причина №3 — секрет не создан, и заголовка `X-Webhook-Signature` в запросе нет; без подписи статус оплаты сверяйте через `GET /invoices/{id}`.

**Куда приходят вебхуки, если ключей несколько?**
До двух получателей: ключ, создавший счёт, и org-default ключ организации (если это другой ключ; дедуп по id ключа, не по URL — два ключа с одинаковым URL дадут два POST'а). Если не сработал ни один, для событий счетов и возвратов включается фолбэк — первый активный ключ организации с `webhook_url`. У `subscription.*` фолбэка нет: без вебхука ключа-создателя или org-default событие не доставляется. Поле `source` в payload — всегда имя ключа-создателя, даже в копии для org-default.

**Как проверить, дошёл ли вебхук?**
Смотрите журнал доставок `GET /webhook-logs` с фильтрами `?event=`, `?status=failed`, `?invoice_id=`. Если вебхук не приходит вовсе — «[Вебхук не приходит: диагностика](/guides/webhook-ne-prihodit)».

Смотрите также: [API-ключ и вебхук-секрет](/guides/api-klyuch-i-webhook-secret) · [Как создать счёт по номеру](/guides/kak-sozdat-schet-kaspi-po-nomeru) · пиллар «[Как принимать оплату Kaspi через API](/kaspi-api)» · [Документация API](/docs).

---

ApiPay — независимый сервис и не аффилирован с АО «Kaspi Bank». Kaspi и Kaspi Pay — товарные знаки их правообладателя.
База знаний: https://apipay.kz/guides
