Verifying Webhook HMAC Signatures
For customers who have issued a webhook secret in the dashboard, the webhook requests sent by Asleep include an HMAC-SHA256 signature.
This document explains how to verify that a received webhook was actually sent by Asleep and that it was not tampered with in transit.
Signatures are included in the headers after a secret is issued
- No secret issued: Webhook requests do not include the
X-Asleep-SignatureandX-Asleep-Timestampheaders.- After a secret is issued: All webhook requests sent from that point on automatically include the signature headers.
To apply this verification, first issue a secret by referring to the Managing Webhook Secrets document.
Received Headers
When a secret has been issued, webhook requests include two headers required for verification.
| Header | Description |
|---|---|
X-Asleep-Timestamp | Time the webhook was sent (Unix timestamp, in seconds) |
X-Asleep-Signature | HMAC-SHA256 signature (in the form version=signature value)During rotation, multiple comma-separated signatures are included. ( v1=abc123...,v1=def456...) |
Handling missing headersIf a request arrives without these headers even though you have issued a secret, there may be a problem in the sending process. We recommend treating it as a verification failure and rejecting it, or logging it and monitoring.
Verification Procedure
-
Check that the
X-Asleep-TimestampandX-Asleep-Signatureheaders exist in the request. -
Construct the string to be signed as follows.
signed_payload = timestamp + "." + raw_body -
Compute the HMAC-SHA256 value of
signed_payloadwith the secret you have stored (hex encoded). -
Verification succeeds if the computed value matches one of the signatures in the header. Use a timing-safe comparison function for the comparison.
Be sure to verify with the raw bodyThe request body must be used exactly as received (raw bytes). If you parse it as JSON and re-serialize it, the signature will break due to differences in key order and whitespace. We recommend reading the body first, then parsing it after verification passes.
Code Examples
import hmac
import hashlib
import time
def verify_webhook(
secret: str,
timestamp_header: str | None,
signature_header: str | None,
raw_body: bytes,
) -> bool:
# 1. If you have issued a secret, the headers must be included
if not timestamp_header or not signature_header:
return False
# 2. Check timestamp freshness (within 5 minutes)
if abs(time.time() - int(timestamp_header)) > 300:
return False
# 3. Construct signed_payload
signed_payload = timestamp_header.encode() + b"." + raw_body
# 4. Compute HMAC-SHA256
expected = hmac.new(
key=secret.encode(),
msg=signed_payload,
digestmod=hashlib.sha256,
).hexdigest()
# 5. Compare with the signatures in the header
for sig in signature_header.split(","):
version, value = sig.split("=", 1)
if hmac.compare_digest(expected, value):
return True
return Falseimport java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.util.HexFormat;
public final class WebhookVerifier {
private static final long TOLERANCE_SECONDS = 300; // 5 minutes
private WebhookVerifier() {}
public static boolean verifyWebhook(
String secret,
String timestampHeader,
String signatureHeader,
byte[] rawBody) {
// 1. If you have issued a secret, the headers must be included
if (timestampHeader == null || timestampHeader.isEmpty()
|| signatureHeader == null || signatureHeader.isEmpty()) {
return false;
}
// 2. Check timestamp freshness (within 5 minutes)
final long timestamp;
try {
timestamp = Long.parseLong(timestampHeader);
} catch (NumberFormatException e) {
return false;
}
long now = System.currentTimeMillis() / 1000;
if (Math.abs(now - timestamp) > TOLERANCE_SECONDS) {
return false;
}
// 3. Construct signed_payload: timestamp + "." + raw_body
byte[] prefix = (timestampHeader + ".").getBytes(StandardCharsets.UTF_8);
byte[] signedPayload = new byte[prefix.length + rawBody.length];
System.arraycopy(prefix, 0, signedPayload, 0, prefix.length);
System.arraycopy(rawBody, 0, signedPayload, prefix.length, rawBody.length);
// 4. Compute HMAC-SHA256
String expected = hmacSha256Hex(secret, signedPayload);
// 5. Compare with the signatures in the header
for (String sig : signatureHeader.split(",")) {
int idx = sig.indexOf('=');
if (idx < 0) {
continue; // skip if not in "version=value" format
}
String value = sig.substring(idx + 1);
// constant-time comparison (equivalent to hmac.compare_digest)
if (MessageDigest.isEqual(
expected.getBytes(StandardCharsets.UTF_8),
value.getBytes(StandardCharsets.UTF_8))) {
return true;
}
}
return false;
}
private static String hmacSha256Hex(String secret, byte[] message) {
try {
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(
secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
return HexFormat.of().formatHex(mac.doFinal(message));
} catch (Exception e) {
throw new IllegalStateException("Failed to compute HMAC", e);
}
}
}const crypto = require("crypto");
function verifyWebhook(secret, timestampHeader, signatureHeader, rawBody) {
if (!timestampHeader || !signatureHeader) {
return false;
}
const timestamp = Number(timestampHeader);
if (!Number.isFinite(timestamp)) {
return false;
}
if (Math.abs(Date.now() / 1000 - timestamp) > 300) {
return false;
}
const signedPayload = Buffer.concat([
Buffer.from(timestampHeader),
Buffer.from("."),
Buffer.isBuffer(rawBody) ? rawBody : Buffer.from(rawBody),
]);
const expected = crypto
.createHmac("sha256", secret)
.update(signedPayload)
.digest("hex");
return signatureHeader.split(",").some((sig) => {
const [, value] = sig.split("=", 2);
if (!value) return false;
return crypto.timingSafeEqual(
Buffer.from(expected),
Buffer.from(value)
);
});
}
Protect against replay attacksWe recommend checking that the received
X-Asleep-Timestampis within 5 minutes of the current time. Ignore requests whose timestamp is too old. Using the same (timestamp, signature) combination as an idempotency key also prevents reprocessing.
Verification Sample
If the result you compute with the values below matches expected_signature, your verification logic is working correctly.
| Item | Value |
|---|---|
secret | whsec_test_1234567890abcdef |
X-Asleep-Timestamp | 1730000000 |
raw_body | {"event":"session_complete","session_id":"20260101_abcde"} |
expected_signature | v1=260861a40e2be5a307c85e2d00581c8952eb270867e3ff04e57163c2c693118a |
The sample values are not an actually issued secret. In real use, replace them with the secret issued from the dashboard.
Updated about 6 hours ago
