Webhook signature verification (HMAC SHA-256)
Each webhook request includes a cryptographic signature in the X-Signature-SHA256 header.
To verify the webhook:
- Read the raw request body bytes exactly as received (before JSON parsing / normalisation).
- Compute an HMAC using SHA-256 with your signing key and those raw bytes as the message.
- Encode the HMAC digest as lowercase hex (see “Signature format” below).
- Constant-time compare your computed signature with the X-Signature-SHA256 header value.
- If they match, you can trust the webhook is genuine; if not, reject with 401/403.
Only the request body is signed. There is no timestamp, request ID or header content included in the signature, and nothing is concatenated onto the body before signing.
What you need
Signing key: the webhook signing key you obtained from us (treat it like a password). See “Your signing key” below.
Header: X-Signature-SHA256 (string).
Raw request body: bytes as received.
Your signing key
Your signing key is issued as a UUID, for example 0f8c1a52-3d94-4e7b-9c26-5b7a1f0e8d43.
Use it as a UTF-8 string exactly as issued, including the hyphens. Although it looks like a UUID, do not hex-decode it to 16 raw bytes, do not strip the hyphens, and do not change its case.
Signing keys are scoped per destination URL and per environment:
- Your sandbox key and your production key are different, unrelated values, even for the same URL.
- If you change the host your webhooks are delivered to, that new host is issued its own key.
A sandbox key will never verify a production webhook, or the reverse. If verification fails for every request, confirm you are using the key for the environment the webhook was actually sent from before investigating your code.
If you route both sandbox and production webhooks to the same endpoint, that endpoint will receive requests signed with two different keys, and the payload contains nothing that distinguishes them. Either use a separate endpoint per environment, or have your handler accept a match against either key.
Contact us if you need a key reissued, or if you are unsure which key applies to an endpoint.
Signature format
We sign the webhook body using:
Algorithm: HMAC-SHA256
Message: raw HTTP request body (bytes)
Key: your signing key, as a UTF-8 string
Encoding: hex (lowercase, no prefix)
hex( HMAC_SHA256(secret, raw_body) )Reference example
Use this to confirm your implementation before going live. The key below is an example only — substitute your own.
Signing key:
0f8c1a52-3d94-4e7b-9c26-5b7a1f0e8d43
Raw body (87 bytes):
{"status": "awaiting_confirmation", "token": "6G1c-4Auj-7aFm-1Wzm-6IU1-25PY-1YVz-8nGb"}Expected signature:
6aad3fb2cf4839bc536c22ad296999095747f7038328bb06eecf893e05791bff
You can check this from the command line:
KEY='0f8c1a52-3d94-4e7b-9c26-5b7a1f0e8d43'
BODY='{"status": "awaiting_confirmation", "token": "6G1c-4Auj-7aFm-1Wzm-6IU1-25PY-1YVz-8nGb"}'
printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$KEY" -r | cut -d' ' -f1Use printf '%s' rather than echo — echo appends a newline, which changes the digest.
Or in Python:
import hashlib
import hmac
KEY = "0f8c1a52-3d94-4e7b-9c26-5b7a1f0e8d43"
BODY = b'{"status": "awaiting_confirmation", "token": "6G1c-4Auj-7aFm-1Wzm-6IU1-25PY-1YVz-8nGb"}'
signature = hmac.new(KEY.encode("utf-8"), BODY, hashlib.sha256).hexdigest()
print(signature)
# 6aad3fb2cf4839bc536c22ad296999095747f7038328bb06eecf893e05791bffIn your handler, compare against the header with a constant-time function:
hmac.compare_digest(signature, request.headers["X-Signature-SHA256"])Common pitfalls
- Do not re-serialise JSON and then sign it — whitespace and key ordering will change the digest. Do not reorder or sort the keys; sign the bytes as they arrived.
- Ensure you're using the raw body bytes, not req.body / parsed objects. Many frameworks parse and discard the raw body by default, so you may need to configure a raw-body reader on the route.
- Do not add or strip a trailing newline.
- Ensure you're comparing like-for-like: hex vs base64 and any prefix (we use plain lowercase hex, no prefix).
- Use the signing key as an ASCII/UTF-8 string, not as decoded UUID bytes.
- Check the environment: a sandbox key cannot verify a production webhook.
- Use a constant-time compare (timingSafeEqual, compare_digest, hash_equals, etc.) to avoid leaking information.
Recommended response behaviour
- If the signature is missing or invalid: return 401 Unauthorized (or 403) and do not process the event.
- If valid: process the event and return a 2xx response.
Troubleshooting
If verification fails, check in this order:
- Byte length. Print the length of the body you are hashing and compare it against the
Content-Lengthheader. A mismatch means you are not hashing what we sent — usually a re-serialised body or an added newline. - Environment. Confirm whether the request came from sandbox or production, and that you are using that environment's key.
- Key form. Confirm the key is being used as a hyphenated UTF-8 string.
- Reference example. Run the example above. If it produces the expected signature, your HMAC code is correct and the problem is the key or the body bytes.
When contacting us about a failing webhook, include the Request-ID and Correlation-Id header values from the request, the destination URL, and the environment. That lets us locate the exact delivery and the signature we sent.
Need help?
For any questions related to the integration, please contact:
- For HeyLight (Switzerland): [email protected]
- For HeyLight (Italia): [email protected]
