Coupon API — callback и проверка HMAC
Callback позволяет получать изменения расчёта купонов без постоянного опроса API. Подлинность запроса проверяется подписью HMAC-SHA256 по точным байтам JSON-тела.
HMAC не шифрует payload: JSON остаётся читаемым. Подпись подтверждает источник и целостность тела запроса.
Что получить у менеджера
Для рабочей доставки обратитесь к менеджеру SportAPI и передайте публичный HTTPS URL обработчика. Менеджер должен:
- включить callback для аккаунта;
- настроить секретную фразу
callback_secret; - безопасно передать секрет партнёру;
- при необходимости сообщить актуальный исходящий 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.
Идемпотентность и ответы
- Проверить подпись по raw body.
- Разобрать JSON.
- Проверить
event,couponCountи массивcoupons. - В транзакции сохранить
batchIdс уникальным ограничением. - Обновить купоны по
coupon_code, ставки — поuuid. - Вернуть HTTP
200только после успешного commit.
Повторный batchId не должен повторно изменять баланс, но на него следует вернуть
HTTP 200. Неверная или отсутствующая подпись должна возвращать HTTP 401.
Подробнее: «Callback результатов», «Проверка подписи callback» и «Повторы и идемпотентность».