Проверка подписи 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 не передается и для проверки подписи не используется.
Как формируется подпись
Система:
- формирует payload;
- один раз сериализует его в JSON-байты UTF-8;
- вычисляет HMAC-SHA256 с секретом партнера;
- кодирует результат в hex нижнего регистра;
- добавляет префикс
sha256=; - отправляет те же байты, по которым рассчитана подпись.
Формула:
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
Порядок проверки запроса
- Если используется IP allowlist, проверить исходящий адрес.
- Прочитать тело как исходный массив байтов.
- Получить
X-Coupon-Signature. - Проверить наличие префикса
sha256=. - Вычислить ожидаемую подпись по исходным байтам.
- Сравнить подписи функцией постоянного времени.
- При несовпадении вернуть HTTP
401. - Только после успешной проверки разобрать JSON.
- Проверить
event,batchId,couponCountиcoupons. - Обработать пакет и вернуть HTTP
200.
IP allowlist является только дополнительной защитой. HMAC нужно проверять независимо от того, используется ли фильтрация по IP.
Безопасное сравнение
Не сравнивайте подписи обычным оператором == или ===. Используйте функцию постоянного времени:
| Язык | Функция |
|---|---|
| PHP | hash_equals |
| Node.js | crypto.timingSafeEqual |
| Java | MessageDigest.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.
Следующий раздел: «Повторы и идемпотентность».