Verifying signatures
Why verify
An endpoint URL is not a secret. It can be guessed, logged by a proxy, or leak from a configuration file. Without verification, anyone who learns the URL can post invented results to your integration.
Every delivery is signed with the endpoint's own secret. Check the signature on every delivery and reject anything that does not match.
How the signature is made
| Part | Value |
|---|---|
| Header | X-NextExams-Signature |
| Format | sha256= followed by the lowercase hex digest |
| Algorithm | HMAC-SHA256 |
| Signed bytes | The raw request body, exactly as received |
| Key | The endpoint's signing secret, as shown in the webhook editor |
Warning: compute the HMAC over the raw bytes you received, not over a re-encoded copy of the parsed JSON. Re-encoding changes key order and whitespace, and the signature will not match. This is the most common integration mistake.
Compare with a constant-time function (hash_equals in PHP, hmac.compare_digest in Python, crypto.timingSafeEqual in Node).
Examples
PHP
$body = file_get_contents('php://input');
$expected = 'sha256=' . hash_hmac('sha256', $body, $secret);
$given = $_SERVER['HTTP_X_NEXTEXAMS_SIGNATURE'] ?? '';
if (!hash_equals($expected, $given)) {
http_response_code(401);
exit;
}
$event = json_decode($body, true);
Node.js
import crypto from 'node:crypto';
// `raw` must be the unparsed body, e.g. express.raw({ type: 'application/json' }).
function verify(raw, header, secret) {
const expected = 'sha256=' + crypto.createHmac('sha256', secret).update(raw).digest('hex');
const a = Buffer.from(expected);
const b = Buffer.from(header || '');
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
Python
import hashlib, hmac
def verify(raw: bytes, header: str, secret: str) -> bool:
expected = 'sha256=' + hmac.new(secret.encode(), raw, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, header or '')
Your signing secret
- Each endpoint has its own secret, generated when you create it.
- Regenerate it at any time from the webhook editor. The new secret signs the next delivery, so update your receiver first.
- Treat it like a password: keep it out of version control and logs.
Replays
The signature covers the body, and the body carries timestamp. A receiver that must refuse replayed deliveries can reject any whose timestamp is older than a few minutes, and keep the X-NextExams-Delivery ids it has already processed.
Leave enough room for retries: a retried delivery is sent again with the same id and body, up to about 100 minutes after the first attempt. Dedupe on the id rather than rejecting old timestamps outright if you want retries to get through.