SportAPI Documentation
EN
C Product documentationCoupon API
v1
Service & pricing ↗ Get access ↗
Coupon API / Callback and HMAC verification

Coupon API — callback and HMAC verification

Callbacks deliver coupon settlement changes without continuous API polling. The receiver verifies an HMAC-SHA256 signature calculated over the exact bytes of the JSON body.

HMAC does not encrypt the payload: the JSON remains readable. The signature confirms the sender and verifies that the request body was not modified in transit.

What to obtain from your manager

To enable production delivery, contact your SportAPI manager and provide the public HTTPS URL of your callback handler. The manager must:

  1. enable callbacks for the account;
  2. configure the callback_secret secret phrase;
  3. provide the secret to the partner through a secure channel;
  4. provide the current outgoing IP address if an allowlist is also required.

Never send the production secret in chat or place it in documentation or Git. Store it only on the server, for example in the COUPON_CALLBACK_SECRET environment variable.

Pass the callback URL when placing a coupon:

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

Demonstration data

This example uses the following secret for local testing only:

sportapi-callback-demo-secret-v1

Raw body: callback.settled.json.

For the exact bytes of this file, including its final newline, the signature is:

X-Coupon-Signature: sha256=d7b3769b56d82dcd7419859c8697ec5939d9f935a7fc8ace2502725effce062f

Changing whitespace, field order, or a newline changes the signature. Verify the raw body first and parse the JSON only after successful verification.

Generate a test signature with 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"

Send the saved body to a local handler:

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'

Use --data-binary so cURL does not modify the file body.

Verification in JavaScript for 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);
}

For Express, configure raw-body middleware specifically for the callback route:

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'));
  // Store the payload and batchId atomically.
  return res.sendStatus(200);
});

Do not register express.json() before the raw callback handler for this route.

Verification in 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);
// Store the payload and batchId atomically.
http_response_code(200);

Verification in 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)

With Flask, retrieve the body before parsing 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)

Actual HTTP callback format

An object stored in the admin panel may display delivery metadata in _delivery and one coupon snapshot at the top level. The HTTP delivery uses a batch envelope:

event       ← coupons.settled
batchId     ← delivery identifier
clientId    ← account ID
couponCount ← number of coupon snapshots
coupons[]   ← one or more coupons

Process every entry in coupons, even if couponCount is usually 1.

Idempotency and responses

  1. Verify the signature against the raw body.
  2. Parse the JSON.
  3. Validate event, couponCount, and the coupons array.
  4. Store batchId with a unique constraint in a transaction.
  5. Update coupons by coupon_code and selections by uuid.
  6. Return HTTP 200 only after a successful commit.

A repeated batchId must not change a balance for a second time, but the handler should still return HTTP 200. A missing or invalid signature must return HTTP 401.

See Result callbacks, Callback signature verification, and Retries and idempotency.