SportAPI Документация
RU
C Документация продуктаCoupon API
v1
Услуга и цены ↗ Получить доступ ↗
Coupon API / Callback и проверка HMAC

Coupon API — callback и проверка HMAC

Callback позволяет получать изменения расчёта купонов без постоянного опроса API. Подлинность запроса проверяется подписью HMAC-SHA256 по точным байтам JSON-тела.

HMAC не шифрует payload: JSON остаётся читаемым. Подпись подтверждает источник и целостность тела запроса.

Что получить у менеджера

Для рабочей доставки обратитесь к менеджеру SportAPI и передайте публичный HTTPS URL обработчика. Менеджер должен:

  1. включить callback для аккаунта;
  2. настроить секретную фразу callback_secret;
  3. безопасно передать секрет партнёру;
  4. при необходимости сообщить актуальный исходящий IP для allowlist.

Реальный секрет нельзя отправлять в чат, помещать в документацию или хранить в Git. Сохраните его на сервере в секретной переменной COUPON_CALLBACK_SECRET.

При создании купона передайте URL:

{
  "callback_url": "https://partner.example.com/api/coupon-result"
}

Демонстрационные данные

Только для локальной проверки в этом примере используется секрет:

sportapi-callback-demo-secret-v1

Raw body: callback.settled.json.

Для точных байтов этого файла, включая завершающий перевод строки, подпись равна:

X-Coupon-Signature: sha256=d7b3769b56d82dcd7419859c8697ec5939d9f935a7fc8ace2502725effce062f

Изменение пробела, порядка полей или перевода строки изменит подпись. Поэтому сначала проверяйте raw body и только после этого разбирайте JSON.

Создание тестовой подписи через OpenSSL

export COUPON_CALLBACK_SECRET='sportapi-callback-demo-secret-v1'

HMAC=$(openssl dgst -sha256 \
  -hmac "$COUPON_CALLBACK_SECRET" \
  callback.settled.json | sed 's/^.*= //')

SIGNATURE="sha256=$HMAC"
printf '%s\n' "$SIGNATURE"

Локальная отправка сохранённых байтов:

curl --request POST \
  --url 'http://localhost:8080/api/coupon-result' \
  --header 'Content-Type: application/json' \
  --header "X-Coupon-Signature: $SIGNATURE" \
  --data-binary '@callback.settled.json'

Используйте --data-binary, чтобы cURL не менял тело файла.

Проверка в JavaScript для Node.js

import crypto from 'node:crypto';

export function verifyCouponSignature(rawBody, signature, secret) {
  if (!Buffer.isBuffer(rawBody) || typeof signature !== 'string') return false;

  const expected = `sha256=${crypto
    .createHmac('sha256', Buffer.from(secret, 'utf8'))
    .update(rawBody)
    .digest('hex')}`;

  const actualBuffer = Buffer.from(signature, 'ascii');
  const expectedBuffer = Buffer.from(expected, 'ascii');
  return actualBuffer.length === expectedBuffer.length
    && crypto.timingSafeEqual(actualBuffer, expectedBuffer);
}

Для Express настройте raw middleware только для callback-маршрута:

app.post('/api/coupon-result', express.raw({type: 'application/json'}), (req, res) => {
  const valid = verifyCouponSignature(
    req.body,
    req.get('X-Coupon-Signature'),
    process.env.COUPON_CALLBACK_SECRET,
  );
  if (!valid) return res.sendStatus(401);

  const payload = JSON.parse(req.body.toString('utf8'));
  // Атомарно сохранить payload и batchId.
  return res.sendStatus(200);
});

Не подключайте express.json() перед raw-обработчиком этого маршрута.

Проверка в PHP

<?php

$rawBody = file_get_contents('php://input');
$actual = $_SERVER['HTTP_X_COUPON_SIGNATURE'] ?? '';
$secret = getenv('COUPON_CALLBACK_SECRET') ?: '';

$expected = 'sha256=' . hash_hmac('sha256', $rawBody, $secret);
if ($secret === '' || !hash_equals($expected, $actual)) {
    http_response_code(401);
    exit;
}

$payload = json_decode($rawBody, true, 512, JSON_THROW_ON_ERROR);
// Атомарно сохранить payload и batchId.
http_response_code(200);

Проверка в Python

import hashlib
import hmac


def verify_coupon_signature(raw_body: bytes, signature: str, secret: str) -> bool:
    digest = hmac.new(
        secret.encode('utf-8'),
        raw_body,
        hashlib.sha256,
    ).hexdigest()
    expected = f'sha256={digest}'
    return hmac.compare_digest(expected, signature)

Во Flask получите тело до разбора JSON:

raw_body = request.get_data(cache=True)
signature = request.headers.get('X-Coupon-Signature', '')

if not verify_coupon_signature(
    raw_body,
    signature,
    os.environ['COUPON_CALLBACK_SECRET'],
):
    abort(401)

payload = json.loads(raw_body)

Формат фактического HTTP callback

Объект, сохранённый в админке, может показывать служебные данные в _delivery и один снимок купона на верхнем уровне. HTTP-доставка использует пакетную оболочку:

event       ← coupons.settled
batchId     ← идентификатор доставки
clientId    ← ID аккаунта
couponCount ← количество снимков
coupons[]   ← один или несколько купонов

Обрабатывайте все элементы coupons, даже если couponCount обычно равен 1.

Идемпотентность и ответы

  1. Проверить подпись по raw body.
  2. Разобрать JSON.
  3. Проверить event, couponCount и массив coupons.
  4. В транзакции сохранить batchId с уникальным ограничением.
  5. Обновить купоны по coupon_code, ставки — по uuid.
  6. Вернуть HTTP 200 только после успешного commit.

Повторный batchId не должен повторно изменять баланс, но на него следует вернуть HTTP 200. Неверная или отсутствующая подпись должна возвращать HTTP 401.

Подробнее: «Callback результатов», «Проверка подписи callback» и «Повторы и идемпотентность».