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

Обновлено 6 июля 2026 · Справочник · Версия в Markdown
Содержание
  1. Почему вебхуки, а не поллинг статуса?
  2. Настройка за 5 минут
  3. Как проверить подпись: главное правило — raw body
  4. Какие события приходят?
  5. Ретраи: что будет, если мой сервер лежал?
  6. Circuit breaker: что значит «вебхук отключён» и как включить обратно
  7. Отладка доставки: журнал webhook-логов и симуляция событий
  8. Как тестировать локально?
  9. Частые ошибки
  10. Вопросы и ответы

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

Поллинг (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-ключ и вебхук-секрет».
  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):

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):

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
$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 — реконсиляция. Подробнее — «Как создать счёт по номеру».
  • Будьте толерантны к новым полям. Контракт 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:

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.

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 → симулировать → проверить лог → возврат) — в статье «Песочница и рабочий режим».

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

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

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

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

  • Проверять подпись по распарсенному JSON. Только raw body. Любая пересериализация меняет байты.
  • Секрет не задан — заголовка нет. X-Webhook-Signature присутствует, только если у ключа сгенерирован вебхук-секрет. Сгенерируйте его до выхода в прод.
  • Путать API-ключ и вебхук-секрет. Подпись считается от вебхук-секрета, не от API-ключа.
  • Путать заголовки X-API-Key и X-Webhook-Signature. X-API-Key — ваш ключ в запросах К API; X-Webhook-Signature — подпись в вебхуке ОТ ApiPay к вам. В самом вебхуке вашего ключа нет: подлинность подтверждает только подпись, а не факт POST-запроса. Разбор — «API-ключ и вебхук-секрет».
  • Тяжёлая обработка до ответа. Дольше 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.

Интеграция с ИИ

Не настраивайте вручную — поручите ИИ

Скопируйте готовый промпт и вставьте своему ИИ-ассистенту — он подключит приём платежей примерно за 15 минут. Настраивает ИИ, а не вы.

Для разработчика. Полная спецификация API — apipay.kz/docs. Эта статья в Markdown — /guides/nastroyka-webhookov-apipay.md. Справочник фактов для ИИ-агента — apipay.kz/llms.txt.