Callback Signature Verification
Every callback is signed with HMAC-SHA256. Signature verification confirms that the request body was created by the SportAPI Coupon Settlement System and was not modified in transit.
Verify the signature before parsing the JSON and before changing any balance or coupon data.
Obtaining the secret
After callbacks are enabled, the manager creates a secret phrase named callback_secret and provides it to the partner through a secure channel.
Store the secret:
- only on the server side;
- in a protected environment variable or secret store;
- separately for each partner account, if there is more than one.
Do not expose the secret to a browser, mobile application, URL, or client-side logs.
The secret is used as a UTF-8 string. Do not decode it from Base64.
Signature header
The signature is provided in this header:
X-Coupon-Signature: sha256=<hex_hmac_sha256>
Example:
X-Coupon-Signature: sha256=3cdb9d2b...
The value after sha256= is a lowercase hexadecimal HMAC.
The partner JWT is not included in callbacks and is not used for signature verification.
How the signature is created
The system:
- creates the payload;
- serializes it once into UTF-8 JSON bytes;
- calculates HMAC-SHA256 using the partner secret;
- encodes the result as lowercase hexadecimal;
- adds the
sha256=prefix; - sends the same bytes for which the signature was calculated.
Formula:
expected_signature =
"sha256=" + hex_lowercase(
HMAC-SHA256(
key = callback_secret,
data = raw_request_body
)
)
Use the original bytes
The signature must be calculated from the exact HTTP body bytes:
raw_request_body
Incorrect order:
receive JSON
→ parse JSON into an object
→ serialize the object again
→ calculate HMAC
Reserialization may change:
- field order;
- spaces and line breaks;
- number formatting, for example
2.00may become2; - character escaping;
- Unicode representation.
The resulting JSON may describe the same data, but its bytes will differ, so the signature will not match.
Correct order:
receive the original bytes
→ calculate and verify HMAC
→ parse JSON only after successful verification
Request verification order
- If an IP allowlist is used, verify the outgoing address.
- Read the body as the original byte array.
- Read
X-Coupon-Signature. - Verify that the
sha256=prefix is present. - Calculate the expected signature from the original bytes.
- Compare the signatures with a constant-time function.
- Return HTTP
401if they do not match. - Parse the JSON only after successful verification.
- Validate
event,batchId,couponCount, andcoupons. - Process the batch and return HTTP
200.
An IP allowlist is only an additional protection layer. HMAC must be verified whether or not IP filtering is used.
Secure comparison
Do not compare signatures with the regular == or === operator. Use a constant-time function:
| Language | Function |
|---|---|
| PHP | hash_equals |
| Node.js | crypto.timingSafeEqual |
| Java | MessageDigest.isEqual |
For timingSafeEqual, both buffers must have the same length. Check the lengths first, and only then call the comparison function.
PHP example
<?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;
}
// Store batchId and process every entry in coupons.
http_response_code(200);
header('Content-Type: application/json');
echo json_encode([
'success' => true,
'processed' => count($payload['coupons'] ?? []),
]);
Call file_get_contents('php://input') before applying any transformation to the body.
Node.js and Express example
The callback route must receive a Buffer, not an already parsed object:
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);
}
// Store batchId and process every entry in coupons.
return response.status(200).json({
success: true,
processed: payload.coupons.length,
});
},
);
Do not register express.json() before express.raw() for this route. Otherwise, the body may be parsed before the HMAC is calculated.
If the application uses a global JSON parser, register the callback route before it or configure the application to retain the original Buffer.
Java and Spring example
Receive the body as 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();
}
// Store batchId and process every entry in coupons.
int processed = coupons.size();
return ResponseEntity.ok(Map.of(
"success", true,
"processed", processed
));
}
}
JSON is parsed with objectMapper.readTree only after successful HMAC verification.
Invalid signature
If the header is missing, malformed, or the signature does not match, return:
HTTP/1.1 401 Unauthorized
HTTP 401 is treated as a final rejection and is not retried automatically. Do not use 401 for a temporary application error.
If the request is rejected only because of the IP allowlist, use HTTP 403.
Secret rotation
Changing callback_secret must be coordinated with the manager. After the secret is changed, subsequent callbacks are signed with the new secret.
If the receiving side continues verification with the old secret, it will return 401, and delivery will not be retried. Update the secret on both sides in a coordinated manner.
Common mismatch causes
- JSON was parsed before signature verification.
- HMAC was calculated from a reserialized string.
- The body was truncated, given an additional line break, or modified by middleware.
- Only the hexadecimal value is compared without the
sha256=prefix. - Base64 is used instead of lowercase hexadecimal.
- The secret is incorrectly decoded as Base64.
- An encoding other than UTF-8 is used for the secret.
- Signatures are compared with a regular operator.
- In Node.js,
express.json()ran beforeexpress.raw().
Checklist
- The secret was obtained from the manager and is stored on the server side.
- The body is read as the original bytes.
- HMAC is calculated with SHA-256.
- The secret is used as UTF-8.
- The result is encoded as lowercase hexadecimal.
sha256=is added to the result.- A secure comparison function is used.
- JSON is parsed only after successful verification.
- An invalid signature returns HTTP
401. - An IP allowlist does not replace HMAC.
Next section: Retries and idempotency.