Вебхук об оплатах: настройка и формат

Адрес, секрет, подпись, формат JSON, повторы доставки и проверка подписи на вашем сервере.

Включение

  1. 1
    Откройте «Настройки школы → Вебхук об оплатах»
    Нужно право «Управление настройками».
  2. 2
    Укажите адрес приёмника и нажмите «Сохранить»
    Только публичный https-адрес. Локальные и внутренние адреса не принимаются.
  3. 3
    Скопируйте секрет подписи
    Он показывается один раз. Потеряли — нажмите «Выпустить новый секрет» (старый перестанет действовать).
  4. 4
    Нажмите «Отправить тест», затем включите тумблер
    Тест пришлёт событие test с вымышленным заказом — убедитесь, что ваш сервер ответил 2xx.

Как приходит запрос

  • POST на ваш адрес, тело — JSON (UTF-8), заголовок Content-Type: application/json.
  • X-1Study-Event — тип события: payment.succeeded, payment.refunded или test.
  • X-1Study-Delivery — уникальный id доставки (совпадает с полем id в теле). По нему удобно отсекать повторы.
  • X-1Study-Signature — подпись: t=<unix-время>,v1=<HMAC-SHA256 от строки "<t>.<тело>" с вашим секретом, hex>.
  • Ответ 2xx в течение 10 секунд = доставлено. Иначе повторим через 1 мин, 5 мин, 30 мин, 2 ч и 12 ч (всего 6 попыток), после чего доставка помечается как неудачная. Журнал последних доставок виден в настройках.

Пример тела payment.succeeded

{
  "id": "0f6c1d2e-…",
  "event": "payment.succeeded",
  "created_at": "2026-10-15T10:21:08.512Z",
  "test": false,
  "school": { "id": "c7065db2-…", "username": "school" },
  "order": {
    "id": "a1b2c3d4-…",
    "status": "granted",
    "amount": 4990,
    "currency": "KZT",
    "refunded_amount": 0,
    "created_at": "2026-10-15T10:19:40.000Z",
    "payment_method": "kaspi",
    "source": "guest-checkout",
    "quantity": 1,
    "item": { "type": "course", "id": "ca86dbbd-…", "title": "Говори Дорого" },
    "buyer": { "client_id": "…", "name": "Айгерим Ахметова", "phone": "+77010000000", "email": null },
    "attribution": {
      "ref": "blogger1",
      "utm_source": "instagram",
      "utm_medium": "blogger",
      "landing": "/school",
      "capturedAt": "2026-10-12T18:03:11.000Z"
    }
  }
}

При возврате приходит payment.refunded с тем же order.id: в refunded_amount — возвращённая сумма, в status — refunded (полный) или partially_refunded (частичный). Если покупатель пришёл без размеченной ссылки, attribution будет null. item.type — course, product или ticket.

Проверка подписи

Проверяйте подпись по сырому телу запроса (до разбора JSON) и отбрасывайте события старше 5 минут — это защита от подделки и повторной отправки.

// Node.js (Express): app.post('/1study/webhook', express.raw({ type: 'application/json' }), handler)
const crypto = require('crypto');
function verify(req, secret) {
  const header = req.get('X-1Study-Signature') || '';
  const parts = Object.fromEntries(header.split(',').map((kv) => kv.split('=')));
  const expected = crypto.createHmac('sha256', secret).update(parts.t + '.' + req.body.toString('utf8')).digest('hex');
  const fresh = Math.abs(Date.now() / 1000 - Number(parts.t)) < 300;
  return fresh && parts.v1 && crypto.timingSafeEqual(Buffer.from(parts.v1), Buffer.from(expected));
}
<?php
$raw = file_get_contents('php://input');
$header = $_SERVER['HTTP_X_1STUDY_SIGNATURE'] ?? '';
parse_str(str_replace(',', '&', $header), $parts);
$expected = hash_hmac('sha256', $parts['t'] . '.' . $raw, $secret);
$ok = abs(time() - (int)$parts['t']) < 300 && hash_equals($expected, $parts['v1'] ?? '');
Внимание
Одно и то же событие может прийти повторно (например, если ваш сервер ответил ошибкой, а потом обработал запрос). Храните id доставки и игнорируйте уже обработанные.
Совет
Для быстрой проверки без своего сервера можно временно указать адрес https://api.1app.kz/api/webhooks/echo — он отвечает 200 и ничего не сохраняет. Потом замените на свой.