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

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

**TL;DR.** Вебхук — единственный правильный способ узнавать об оплатах: ApiPay сам делает POST на ваш HTTPS-URL при каждой смене статуса. Настройка — 5 минут: указать URL в кабинете → нажать «Сгенерировать подпись» (это вебхук-секрет) → проверять заголовок `X-Webhook-Signature: sha256=<hex>` как HMAC-SHA256 от **сырого тела запроса**. Отвечайте `200` быстрее 5 секунд. Недоставленный вебхук повторяется **11 раз** с нарастающими паузами (до 1 часа); после **50** накопленных неудач доставка на ключ отключается до ручного включения.

## Коротко

| Вопрос | Ответ |
|---|---|
| Запрос от 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). Для ещё не одобренных орг в проде — только реальный домен: IP → `webhook_url_requires_domain`, туннели (ngrok) → `webhook_url_tunnel_forbidden` |
| Ответ вашего сервера | Любой 2xx быстрее 5 секунд; обработка — асинхронно |
| Ретраи | 11 попыток: 10 с, 30 с, 1, 1.5, 2, 5, 10, 15, 30, 60 мин (песочница — 3 попытки) |
| Что ретраится | HTTP ≥500, ровно 429, сетевые ошибки; прочие 4xx — нет |
| Circuit breaker | ≥5 неудач — пауза 5 мин; ≥10 — 30 мин; ≥20 — 2 ч; ≥50 — вебхук отключён |
| Тест | Кнопка в кабинете → событие `webhook.test` (до 5 раз/мин), подписан как боевой |
| Журнал доставок | `GET /webhook-logs` (фильтры `?event=`, `?status=`, `?invoice_id=`); деталь — `/webhook-logs/{id}`; хранятся 14 дней |
| Поллинг вместо вебхука | Плохо: лимит 200 req/min, задержки, пропуски. Вебхук — правильный путь |

## Почему вебхуки, а не поллинг статуса?

Поллинг (`GET /invoices/{id}` в цикле) кажется проще, но на практике: вы упираетесь в rate limit (**200 запросов/мин на ключ** — при 10 заказах и опросе раз в секунду лимит съеден), узнаёте об оплате с опозданием на интервал опроса и пишете лишний код повторов. Типичная ошибка: интеграция в цикле опрашивает `GET /invoices/{id}/refunds` и ловит 429, хотя статусы возврата и так приходят вебхуком.

Вебхук решает всё это: ApiPay сам приходит к вам в момент события. Ваша задача — принять POST, проверить подпись, быстро ответить `200`.

Исключение: если ваша система физически не умеет принимать HTTP (офлайн-скрипты, 1С-style), опрашивайте `GET /invoices/{id}` — этот эндпоинт читает из кэша ApiPay и выдерживает до 1000 req/min, но это запасной путь, не основной.

## Настройка за 5 минут

1. **Кабинет apipay.kz → Настройки → API-ключи**: у ключа укажите `webhook_url` — публичный HTTPS-адрес вашего обработчика. URL с приватным IP (localhost, 192.168.…) не пройдёт валидацию (422).
2. После сохранения URL появится кнопка **«Сгенерировать подпись»** — нажмите. Это и есть **вебхук-секрет**. Он показывается один раз — сохраните в переменные окружения. Важно: вебхук-секрет ≠ API-ключ, это два разных credentials — разница разобрана в «[API-ключ и вебхук-секрет](/guides/api-klyuch-i-webhook-secret)».
3. Нажмите **«Проверить»** (или `POST /api/api-keys/{id}/test-webhook`, до 5 раз/мин) — придёт событие `webhook.test` с фиктивным счётом (`invoice.id: null`, `status: "test"`, `external_order_id: "TEST-ORDER-123"`), подписанное по-боевому.
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`), не `==`.

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

Основные: `invoice.status_changed` (переходы в `pending`, `paid`, `cancelled`, `expired`, `error`, `partially_refunded`), `invoice.refunded` (возврат `completed`/`failed`), `invoice.qr_scanned` (QR отсканирован — один раз на QR), семь событий `subscription.*` и `webhook.test`. Конверт: `{event, invoice{...}, source, timestamp}`, все даты — ISO 8601 UTC.

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

- **Дедуплицируйте на своей стороне.** У `invoice.status_changed` есть серверный гейт «один вебхук на реальный переход», но у `invoice.refunded` и `subscription.*` гейта нет — дубли возможны. Ключи дедупа: `(invoice.id, invoice.status)`, `(refund.id, refund.status)`, `(event, subscription.id, invoice_id)`.
- **Ждите «странных» последовательностей.** `cancelled → paid` и `expired → paid` легитимны (оплата выиграла гонку), `error → pending` — реконсиляция. Подробнее — «[Как создать счёт по номеру](/guides/kak-sozdat-schet-kaspi-po-nomeru)».
- **Будьте толерантны к новым полям.** Контракт additive: поля добавляются без предупреждения, неизвестное — игнорируйте, не падайте.

## Ретраи: что будет, если мой сервер лежал?

- Успех = любой **2xx**. Всё остальное — неудача попытки.
- Ретраятся: HTTP ≥500, ровно 429 и сетевые ошибки — **11 попыток** с паузами 10 с → 30 с → 1 → 1.5 → 2 → 5 → 10 → 15 → 30 → 60 минут. Итого окно доставки — порядка 2 часов. В песочнице — 3 попытки (5 с, 15 с).
- **4xx (кроме 429) не ретраится**: система считает, что вы осознанно отвергли событие. Автоповторов больше не будет; повторить можно вручную — кабинет → Webhook-логи → Retry (только для `failed`, пауза между повторами 10 с).
- Таймауты запроса к вам: 3 с соединение + 5 с ответ. Отсюда правило: `200` сразу, обработка потом.

## Circuit breaker: что значит «вебхук отключён» и как включить обратно

Неудачи копятся на API-ключ. Пороги: **≥5 подряд неудач — пауза 5 минут**, **≥10 — 30 минут**, **≥20 — 2 часа**, **≥50 — доставка полностью отключается**. Пока breaker открыт, вебхуки **не отправляются и не откладываются** — эти переходы для канала потеряны (статусы затем сверяйте по `GET /invoices/{id}` или в кабинете).

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

## Отладка доставки: журнал webhook-логов и симуляция событий

Вебхук не пришёл или пришёл с ошибкой — не гадайте, откройте журнал доставок. ApiPay хранит логи входящих доставок вашей организации **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` (например `invoice.status_changed`, `invoice.qr_scanned`, `invoice.refunded`), `status` (`success` | `failed`), `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`. По каждой записи видно, что именно мы отправили, что ответил ваш сервер и за сколько, — этого хватает, чтобы отличить «клиент вернул `401` (подпись не сошлась)» от «сервер не ответил за 5 секунд (таймаут)».

Повторно отправить конкретную неудачную доставку **через API нельзя** — только из кабинета (Webhook-логи → Retry, для `status: failed`).

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

В рабочем режиме события приходят от реального Kaspi, а в песочнице их удобно вызывать самому. Метод переводит **тестовый** счёт (`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/мин не расходуется. Так вы прогоняете весь путь «событие → доставка → ваш приёмник», не трогая Kaspi. Полный цикл прогона (создать → дождаться `pending` → симулировать → проверить лог → возврат) — в статье «[Песочница и рабочий режим](/guides/pesochnitsa-i-rabochiy-rezhim)».

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

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

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

## Частые ошибки

- **Проверять подпись по распарсенному JSON.** Только raw body. Любая пересериализация меняет байты.
- **Секрет не задан — заголовка нет.** `X-Webhook-Signature` присутствует, только если у ключа сгенерирован вебхук-секрет. Сгенерируйте его до выхода в прод.
- **Путать API-ключ и вебхук-секрет.** Подпись считается от вебхук-секрета, не от API-ключа.
- **Путать заголовки `X-API-Key` и `X-Webhook-Signature`.** `X-API-Key` — ваш ключ в запросах **К** API; `X-Webhook-Signature` — подпись в вебхуке **ОТ** ApiPay к вам. В самом вебхуке вашего ключа нет: подлинность подтверждает только подпись, а не факт POST-запроса. Разбор — «[API-ключ и вебхук-секрет](/guides/api-klyuch-i-webhook-secret)».
- **Тяжёлая обработка до ответа.** Дольше 5 секунд = таймаут = неудача попытки → ретраи и рост счётчика breaker'а.
- **Отвечать 401/403/404 на легитимные события.** Это 4xx — ретраев не будет, событие потеряно до ручного Retry.
- **Ставить авторизацию на webhook-URL.** ApiPay не знает ваших паролей; аутентификация вебхука — подпись.
- **Игнорировать `is_sandbox`.** Тестовые события помечены `is_sandbox: true` — не зачисляйте их как боевые оплаты.

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

**Где взять вебхук-секрет?**
Кабинет → Настройки → API-ключи: после указания `webhook_url` появляется кнопка «Сгенерировать подпись». Показывается один раз, далее — только маска. Перегенерация — кнопкой рядом (старый секрет сразу перестаёт действовать).

**Заголовок подписи — X-Signature или X-Webhook-Signature?**
`X-Webhook-Signature` (формат `sha256=<hex>`). Упоминания `X-Signature` в старых материалах — ошибка.

**Сколько раз повторяется недоставленный вебхук?**
11 попыток за ~2 часа (паузы от 10 секунд до 1 часа). 4xx-ответы (кроме 429) не повторяются. После исчерпания — только ручной Retry из Webhook-логов.

**Может ли один вебхук прийти дважды?**
Да, у `invoice.refunded` и `subscription.*` дубли возможны by design. Дедуплицируйте по `(id, status)`.

**Куда приходят вебхуки, если ключей несколько?**
До двух получателей: ключ, создавший счёт, и org-default ключ организации (если это другой ключ). Настраивайте org-default осознанно.

**Обязателен ли вебхук для работы?**
Технически счета выставляются и без него, но об оплатах вы будете узнавать с задержкой и опросами. Для любых автоматизаций вебхук обязателен по здравому смыслу.

**Как проверить, дошёл ли вебхук?**
Смотрите журнал доставок: `GET /webhook-logs` (фильтры `?event=`, `?status=failed`, `?invoice_id=`) — по каждой доставке виден HTTP-ответ вашего сервера и время ответа. Детали одной доставки с полным телом — `GET /webhook-logs/{id}`. Логи хранятся 14 дней. В песочнице событие для проверки можно вызвать самому — `POST /invoices/{id}/simulate-status`.

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

---

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