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-Signature and X-Asleep-Timestamp headers.
  • 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.

HeaderDescription
X-Asleep-TimestampTime the webhook was sent (Unix timestamp, in seconds)
X-Asleep-SignatureHMAC-SHA256 signature (in the form version=signature value)
During rotation, multiple comma-separated signatures are included. (v1=abc123...,v1=def456...)
🚧

Handling missing headers

If 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

  1. Check that the X-Asleep-Timestamp and X-Asleep-Signature headers exist in the request.

  2. Construct the string to be signed as follows.

    signed_payload = timestamp + "." + raw_body

  3. Compute the HMAC-SHA256 value of signed_payload with the secret you have stored (hex encoded).

  4. 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 body

The 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 False
import 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 attacks

We recommend checking that the received X-Asleep-Timestamp is 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.

ItemValue
secretwhsec_test_1234567890abcdef
X-Asleep-Timestamp1730000000
raw_body{"event":"session_complete","session_id":"20260101_abcde"}
expected_signaturev1=260861a40e2be5a307c85e2d00581c8952eb270867e3ff04e57163c2c693118a
📘

The sample values are not an actually issued secret. In real use, replace them with the secret issued from the dashboard.


Did this page help you?