Почему вебхуки, а не поллинг статуса?
Общий лимит Public API — 200 запросов/мин на ключ: опрос GET /invoices/{id} раз в секунду по 10 заказам съедает его целиком, а GET /invoices/{id}/refunds в цикле даёт 429. Задача обработчика вебхука: принять POST, проверить подпись, ответить 200. Исключение: если ваша система физически не умеет принимать HTTP (офлайн-скрипты, 1С-style), опрашивайте GET /invoices/{id} — этот эндпоинт читает из кэша ApiPay и выдерживает до 1000 req/min, но это запасной путь, не основной.
Настройка за 5 минут
- Кабинет apipay.kz → Настройки → «Подключение»: у ключа укажите
webhook_url— публичный HTTPS-адрес вашего обработчика. URL с приватным IP (localhost, 192.168.…) не пройдёт валидацию (422). - После сохранения URL в блоке «Уведомления об оплатах» нажмите кнопку «Сгенерировать» у поля «Секретный ключ». Это и есть вебхук-секрет. Он показывается один раз — сохраните в переменные окружения. Важно: вебхук-секрет ≠ API-ключ, это два разных credentials — разница разобрана в «API-ключ и вебхук-секрет».
- Нажмите «Проверить уведомления» (или
POST /api/api-keys/{id}/test-webhook, до 5 раз/мин) — придёт событиеwebhook.testс фиктивным счётом (invoice.id: null,status: "test",external_order_id: "TEST-ORDER-123"), подписанное по-боевому. - Убедитесь, что ваш обработчик ответил
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), не ==.
Какие события приходят?
Все события приходят на один 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 |
3 |
| Каталог | catalog.item_processed — по каждой позиции каталога |
1 |
| Фискальные чеки | receipt.issued, receipt.failed |
2 |
| Подписки | subscription.* |
8 |
| Кассовая смена | cashbox.shift_closed, cashbox.shift_close_failed |
2 |
| Тест | webhook.test |
1 |
Итого двадцать событий.
Всегда ветвитесь по полю event и молча игнорируйте незнакомое: новые события добавляются без предупреждения и приходят на уже настроенный адрес.
⛔ Событие catalog.batch_processed из этого списка убрано и не отправляется никогда. Адрес и подпись прежние, просто тишина — обработчик, который его ждёт, об этом не узнает. Итог по каталогу приходит событием catalog.item_processed по каждой позиции: заливка на 50 позиций даёт до 50 доставок.
Технические статусы processing и cancelling вебхуков не порождают никогда. Конверт: {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 часов. Эта сетка — у событий счетов, возвратов и подписок; у каталожных (
catalog.*) и чековых (receipt.*) событий сетка короче — до трёх попыток, поэтому их итог дополнительно сверяйте чтением: каталог — точечнымGET /catalog?external_refs[]=…(иGET /catalog/errorsпо отказам), чеки —GET /receipts/{id}. Сокращённая сетка песочницы (3 попытки: 5 с и 15 с) действует только на события счетов; возвраты и подписки ретраятся по полной сетке в обоих режимах. - Ваш эндпоинт не должен отвечать редиректом. По
307и308мы повторяем тот же POST с тем же телом и той же подписью — до 2 переходов, каждый новый адрес проверяется заново (петля или пустойLocation— недоставка).301,302и303— недоставка без ретрая: по ним HTTP-клиент обязан сменить метод на GET и потерять тело, то есть ваш обработчик payload не получит вовсе. Типовой случай —http://вwebhook_urlпри редиректе сервера наhttps://, поэтому указывайте сразу конечныйhttps-адрес, отвечающий без переадресации. Счётчик circuit breaker'а такая недоставка не увеличивает — канал остаётсяactive, и в кабинете всё выглядит рабочим. - 4xx (кроме 429) не повторяется: автоповторов не будет, повторить можно вручную — кабинет → Webhook-логи → Retry (только для
failed, пауза между повторами 10 с). Поэтому не отвечайте401/403/404на легитимные события — они будут потеряны до ручного Retry. - Таймауты запроса к вам: 3 с соединение + 5 с ответ. Отсюда правило:
200сразу, обработка потом.
Circuit breaker: что значит «вебхук отключён» и как включить обратно
Неудачи копятся на API-ключ по порогам из таблицы выше. Пока breaker открыт, вебхуки не отправляются и не откладываются — эти переходы для канала потеряны (статусы затем сверяйте по GET /invoices/{id} или в кабинете).
Как вернуть доставку: почините свой endpoint и нажмите «Проверить уведомления» (test-webhook) в кабинете — любая успешная доставка сбрасывает счётчик и включает канал. Текущее состояние видно в списке API-ключей: webhook_status: active | paused | disabled.
Отладка доставки: журнал webhook-логов и симуляция событий
Журнал доставок хранится 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, 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. Журнал покрывает только события счетов, возвратов и партнёрские. У subscription.* и receipt.* строк доставки нет вовсе — недоставку по ним в журнале не видно. catalog.* пишутся в отдельный журнал GET /catalog/webhook-logs с ротацией 3 дня.
Повторно отправить конкретную неудачную доставку через API нельзя — только из кабинета (Webhook-логи → Retry, для status: failed).
Сгенерировать событие в песочнице — POST /invoices/{id}/simulate-status:
Метод переводит тестовый счёт (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/мин не расходуется. Тестовые события помечены is_sandbox: true — не зачисляйте их как боевые оплаты. Полный цикл прогона (создать → дождаться 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. Снимает это ограничение одобрение анкеты о бизнесе — см. «Анкета о бизнесе и лимит 1 платёж в день».
Вопросы и ответы
Куда приходят вебхуки, если ключей несколько?
До двух получателей: ключ, создавший счёт, и org-default ключ организации (если это другой ключ; дедуп по id ключа, не по URL — два ключа с одинаковым URL дадут два POST'а). Если не сработал ни один, для событий счетов и возвратов включается фолбэк — первый активный ключ организации с webhook_url. У subscription.* фолбэка нет: без вебхука ключа-создателя или org-default событие не доставляется. Поле source в payload — всегда имя ключа-создателя, даже в копии для org-default.
Как проверить, дошёл ли вебхук?
Смотрите журнал доставок GET /webhook-logs с фильтрами ?event=, ?status=failed, ?invoice_id=. Если вебхук не приходит вовсе — «Вебхук не приходит: диагностика».