Verifying an alert’s HMAC-SHA256 signature

Help Center › API keys & connections › Verifying an alert's HMAC-SHA256 signature

Verifying an alert’s HMAC-SHA256 signature

Check that an alert really came from VisionScaper using the X-VisionScaper-Signature header and your secret, with PHP, Node and Python examples.

Anyone can send a POST to your address. The signature proves it came from VisionScaper.

How it is built

Each alert carries this header:

X-VisionScaper-Signature: t=1767621600,v1=9f2c...
  • t is a Unix time in seconds.
  • v1 is the lowercase hex of HMAC-SHA256 over the string t + "." + raw body, keyed with the alert’s secret (whsec_...).

Steps to verify

  1. Read the raw request body. Do not parse and re-encode it first.
  2. Read the header. Split it on the comma to get t and v1.
  3. Compute HMAC-SHA256(secret, t + "." + body) as hex.
  4. Compare it to v1 in constant time.
  5. Reject if t is more than 5 minutes (300 seconds) from your clock.
  6. Only then trust and parse the JSON.

PHP

$raw = file_get_contents('php://input');
[$t, $v] = array_map(fn($p) => explode('=', $p, 2)[1],
  explode(',', $_SERVER['HTTP_X_VISIONSCAPER_SIGNATURE']));
$ok = hash_equals(hash_hmac('sha256', $t . '.' . $raw, $secret), $v)
   && abs(time() - (int)$t) < 300;
if (!$ok) { http_response_code(400); exit; }

Node.js

const crypto = require('crypto');
function verify(rawBody, header, secret) {
  const parts = Object.fromEntries(header.split(',').map(p => p.split('=')));
  const mac = crypto.createHmac('sha256', secret)
    .update(parts.t + '.' + rawBody).digest('hex');
  const same = mac.length === parts.v1.length &&
    crypto.timingSafeEqual(Buffer.from(mac), Buffer.from(parts.v1));
  return same && Math.abs(Date.now()/1000 - Number(parts.t)) < 300;
}

Python

import hmac, hashlib, time
def verify(raw: bytes, header: str, secret: str) -> bool:
    d = dict(p.split('=', 1) for p in header.split(','))
    mac = hmac.new(secret.encode(), (d['t'] + '.').encode() + raw, hashlib.sha256).hexdigest()
    return hmac.compare_digest(mac, d['v1']) and abs(time.time() - int(d['t'])) < 300

(The Node and Python examples follow the same rule as the PHP one in the product notes. They were not run here.)

Tips

  • Keep the secret out of source control.
  • Use the delivery id (X-VisionScaper-Delivery) and event id to ignore repeats.
  • A new secret can be made from the alert. The old one stops working at once. Update your receiver first, then rotate.
  • A fresh timestamp is sent on each retry, so the 5-minute rule works for retries.

If something goes wrong

  • Signatures never match: you probably hashed a re-encoded body. Use the raw bytes.
  • Only some fail: check clock drift on your server.
  • Header missing: the request was not from VisionScaper.

Related articles

Still need help?

Tell us what you were trying to do and a person will reply.

Contact support