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

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

Каждый callback подписывается с помощью HMAC-SHA256. Проверка подписи подтверждает, что тело запроса сформировано Системой расчета купонов SportAPI и не было изменено при передаче.

Проверять подпись нужно до разбора JSON и до любых изменений баланса или данных купона.

Получение секрета

После подключения callback менеджер создает секретную фразу callback_secret и передает ее партнеру безопасным способом.

Храните секрет:

  • только на серверной стороне;
  • в защищенной переменной окружения или хранилище секретов;
  • отдельно для каждого аккаунта партнера, если их несколько.

Не передавайте секрет в браузер, мобильное приложение, URL или клиентские логи.

Секрет используется как строка UTF-8. Не декодируйте его из Base64.

Заголовок подписи

Подпись передается в заголовке:

X-Coupon-Signature: sha256=<hex_hmac_sha256>

Пример:

X-Coupon-Signature: sha256=3cdb9d2b...

После sha256= находится HMAC в шестнадцатеричном формате нижнего регистра.

JWT партнера в callback не передается и для проверки подписи не используется.

Как формируется подпись

Система:

  1. формирует payload;
  2. один раз сериализует его в JSON-байты UTF-8;
  3. вычисляет HMAC-SHA256 с секретом партнера;
  4. кодирует результат в hex нижнего регистра;
  5. добавляет префикс sha256=;
  6. отправляет те же байты, по которым рассчитана подпись.

Формула:

expected_signature =
    "sha256=" + hex_lowercase(
        HMAC-SHA256(
            key = callback_secret,
            data = raw_request_body
        )
    )

Используйте исходные байты

Подпись необходимо вычислять по точным байтам HTTP-тела:

raw_request_body

Неправильный порядок:

получить JSON
→ разобрать JSON в объект
→ сериализовать объект обратно
→ вычислить HMAC

При повторной сериализации могут измениться:

  • порядок полей;
  • пробелы и переносы строк;
  • формат чисел, например 2.00 станет 2;
  • экранирование символов;
  • представление Unicode.

Получившийся JSON может описывать те же данные, но его байты будут другими, поэтому подпись не совпадет.

Правильный порядок:

получить исходные байты
→ вычислить и проверить HMAC
→ только после успешной проверки разобрать JSON

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

  1. Если используется IP allowlist, проверить исходящий адрес.
  2. Прочитать тело как исходный массив байтов.
  3. Получить X-Coupon-Signature.
  4. Проверить наличие префикса sha256=.
  5. Вычислить ожидаемую подпись по исходным байтам.
  6. Сравнить подписи функцией постоянного времени.
  7. При несовпадении вернуть HTTP 401.
  8. Только после успешной проверки разобрать JSON.
  9. Проверить event, batchId, couponCount и coupons.
  10. Обработать пакет и вернуть HTTP 200.

IP allowlist является только дополнительной защитой. HMAC нужно проверять независимо от того, используется ли фильтрация по IP.

Безопасное сравнение

Не сравнивайте подписи обычным оператором == или ===. Используйте функцию постоянного времени:

ЯзыкФункция
PHPhash_equals
Node.jscrypto.timingSafeEqual
JavaMessageDigest.isEqual

Для timingSafeEqual буферы должны иметь одинаковую длину. Сначала проверьте длины, затем вызывайте функцию сравнения.

Пример для PHP

<?php

declare(strict_types=1);

$callbackSecret = getenv('COUPON_CALLBACK_SECRET');

if ($callbackSecret === false || $callbackSecret === '') {
    http_response_code(500);
    exit;
}

$rawBody = file_get_contents('php://input');
$actualSignature = $_SERVER['HTTP_X_COUPON_SIGNATURE'] ?? '';

$expectedSignature = 'sha256=' . hash_hmac(
    'sha256',
    $rawBody,
    $callbackSecret
);

if (!hash_equals($expectedSignature, $actualSignature)) {
    http_response_code(401);
    exit;
}

try {
    $payload = json_decode(
        $rawBody,
        true,
        512,
        JSON_THROW_ON_ERROR
    );
} catch (JsonException $exception) {
    http_response_code(400);
    exit;
}

// Сохраните batchId и обработайте все элементы coupons.

http_response_code(200);
header('Content-Type: application/json');

echo json_encode([
    'success' => true,
    'processed' => count($payload['coupons'] ?? []),
]);

file_get_contents('php://input') нужно вызвать до любых преобразований тела.

Пример для Node.js и Express

Маршрут callback должен получать Buffer, а не уже разобранный объект:

import crypto from 'node:crypto';
import express from 'express';

const app = express();

app.post(
  '/api/coupon-result',
  express.raw({ type: 'application/json' }),
  (request, response) => {
    const callbackSecret = process.env.COUPON_CALLBACK_SECRET;

    if (!callbackSecret) {
      return response.sendStatus(500);
    }

    const rawBody = request.body;
    const actualSignature =
      request.get('X-Coupon-Signature') ?? '';

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

    const actualBuffer = Buffer.from(actualSignature, 'ascii');
    const expectedBuffer = Buffer.from(expectedSignature, 'ascii');

    const valid =
      actualBuffer.length === expectedBuffer.length &&
      crypto.timingSafeEqual(actualBuffer, expectedBuffer);

    if (!valid) {
      return response.sendStatus(401);
    }

    let payload;

    try {
      payload = JSON.parse(rawBody.toString('utf8'));
    } catch {
      return response.sendStatus(400);
    }

    if (
      !Array.isArray(payload.coupons) ||
      payload.couponCount !== payload.coupons.length
    ) {
      return response.sendStatus(400);
    }

    // Сохраните batchId и обработайте все элементы coupons.

    return response.status(200).json({
      success: true,
      processed: payload.coupons.length,
    });
  },
);

Не устанавливайте express.json() перед express.raw() для этого маршрута. Иначе тело может быть разобрано до вычисления HMAC.

Если приложение использует глобальный JSON-parser, зарегистрируйте callback-маршрут раньше него либо настройте сохранение исходного Buffer.

Пример для Java и Spring

Получайте тело как byte[]:

import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.HexFormat;
import java.util.Map;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class CouponCallbackController {

    private final byte[] callbackSecret;
    private final ObjectMapper objectMapper;

    public CouponCallbackController(
        @Value("${coupon.callback-secret}") String callbackSecret,
        ObjectMapper objectMapper
    ) {
        this.callbackSecret =
            callbackSecret.getBytes(StandardCharsets.UTF_8);
        this.objectMapper = objectMapper;
    }

    @PostMapping(
        path = "/api/coupon-result",
        consumes = "application/json"
    )
    public ResponseEntity<?> receive(
        @RequestBody byte[] rawBody,
        @RequestHeader(
            value = "X-Coupon-Signature",
            required = false
        ) String actualSignature
    ) throws Exception {
        Mac mac = Mac.getInstance("HmacSHA256");
        mac.init(new SecretKeySpec(
            callbackSecret,
            "HmacSHA256"
        ));

        String expectedSignature =
            "sha256=" + HexFormat.of()
                .formatHex(mac.doFinal(rawBody));

        boolean valid =
            actualSignature != null &&
            MessageDigest.isEqual(
                expectedSignature.getBytes(
                    StandardCharsets.US_ASCII
                ),
                actualSignature.getBytes(
                    StandardCharsets.US_ASCII
                )
            );

        if (!valid) {
            return ResponseEntity.status(401).build();
        }

        JsonNode payload = objectMapper.readTree(rawBody);

        JsonNode coupons = payload.path("coupons");

        if (
            !coupons.isArray() ||
            payload.path("couponCount").asInt(-1) != coupons.size()
        ) {
            return ResponseEntity.badRequest().build();
        }

        // Сохраните batchId и обработайте все элементы coupons.

        int processed = coupons.size();

        return ResponseEntity.ok(Map.of(
            "success", true,
            "processed", processed
        ));
    }
}

JSON разбирается через objectMapper.readTree только после успешной проверки HMAC.

Неверная подпись

Если заголовок отсутствует, имеет неправильный формат или подпись не совпадает, верните:

HTTP/1.1 401 Unauthorized

HTTP 401 считается финальным отказом и автоматически не повторяется. Не используйте 401 для временной ошибки приложения.

Если запрос отклонен только из-за IP allowlist, используйте HTTP 403.

Смена секрета

Смена callback_secret должна быть согласована с менеджером. После изменения следующие callback подписываются новым секретом.

Если принимающая сторона продолжит проверять старым секретом, она вернет 401, и доставка не будет повторена. Поэтому обновляйте секрет на обеих сторонах согласованно.

Типичные причины несовпадения

  • JSON разобран до проверки подписи.
  • Для HMAC использована повторно сериализованная строка.
  • Тело было обрезано, дополнено переводом строки или изменено middleware.
  • Сравнивается только hex без префикса sha256=.
  • Используется Base64 вместо hex нижнего регистра.
  • Секрет ошибочно декодируется как Base64.
  • Используется другая кодировка секрета вместо UTF-8.
  • Подписи сравниваются обычным оператором.
  • В Node.js перед express.raw() сработал express.json().

Контрольный список

  • Секрет получен у менеджера и хранится на серверной стороне.
  • Тело читается как исходные байты.
  • HMAC вычисляется с алгоритмом SHA-256.
  • Секрет используется как UTF-8.
  • Результат кодируется в hex нижнего регистра.
  • К результату добавляется sha256=.
  • Сравнение выполняется безопасной функцией.
  • JSON разбирается только после успешной проверки.
  • Неверная подпись возвращает HTTP 401.
  • IP allowlist не заменяет HMAC.

Следующий раздел: «Повторы и идемпотентность».