Verify signatures
Check every delivery's HMAC-SHA256 signature against the raw body before you trust it. Node, Python and PHP.
Anyone can POST to your endpoint. The signature proves a delivery came from Gauge, unmodified, and recently. Verify every delivery before acting on it.
The scheme
Gauge signs the string <t>.<raw body> with HMAC-SHA256, keyed with your endpoint's signing
secret, where t is the send time in epoch milliseconds. Every delivery to every endpoint
carries the signature twice, the same HMAC in two formats:
| Header | Value |
|---|---|
Gauge-Signature | t=<epoch ms>,v1=<hex>: the timestamp and the signature in one header. Verify this one. |
x-pulse-signature | Legacy: sha256=<hex>. Kept for receivers written before API v2. |
x-pulse-timestamp | Legacy: t, epoch milliseconds. |
A delivery also carries Gauge-Event-Id, Gauge-Event-Type, Gauge-Delivery-Attempt and
Gauge-Webhook-Id (see Delivery). The snippets below accept either
signature format, so code written against the legacy pair keeps working.
During a secret rotation, a delivery carries two signatures, one per secret, new first
(t=…,v1=<new>,v1=<old>, and sha256=<new>,sha256=<old>). Accept the delivery if any of them
verifies.
The rules
- Use the raw body, the exact bytes received. Parsing the JSON and re-serialising it changes
the bytes and breaks the signature. In Express, use
express.raw()on the webhook route; in Django,request.body; in Laravel,$request->getContent(). - Use the timestamp string exactly as sent in the signed string.
- Compare in constant time (
timingSafeEqual,hmac.compare_digest,hash_equals), never with==. - Reject old deliveries: more than 5 minutes from your clock. This stops a captured delivery from being replayed later. Keep your server's clock in sync (NTP).
- Answer
400to a delivery that fails verification, and do not process it.
Node
import crypto from 'node:crypto';
const TOLERANCE_MS = 5 * 60 * 1000;
/**
* @param {Record<string, string | string[] | undefined>} headers lower-cased (Node does this)
* @param {Buffer} rawBody the exact bytes received
* @param {string} secret the endpoint's signing secret
*/
export function verifyGaugeSignature(headers, rawBody, secret, now = Date.now()) {
const header = (name) => [headers[name]].flat()[0] ?? '';
let timestamp = '';
let signatures = [];
if (header('gauge-signature')) {
for (const part of header('gauge-signature').split(',')) {
const [key, value = ''] = part.trim().split('=', 2);
if (key === 't') timestamp = value;
else if (key === 'v1') signatures.push(value);
}
} else {
timestamp = header('x-pulse-timestamp');
signatures = header('x-pulse-signature')
.split(',')
.map((s) => s.trim().replace(/^sha256=/, ''))
.filter(Boolean);
}
if (!/^\d+$/.test(timestamp) || signatures.length === 0) return false;
// `t` is epoch milliseconds.
if (Math.abs(now - Number(timestamp)) > TOLERANCE_MS) return false;
const expected = crypto.createHmac('sha256', secret).update(`${timestamp}.`).update(rawBody).digest();
return signatures.some((sig) => {
const given = Buffer.from(sig, 'hex');
return given.length === expected.length && crypto.timingSafeEqual(given, expected);
});
}Use it with the raw body:
app.post('/gauge/webhooks', express.raw({ type: 'application/json' }), (req, res) => {
if (!verifyGaugeSignature(req.headers, req.body, process.env.GAUGE_WEBHOOK_SECRET)) {
return res.sendStatus(400);
}
const event = JSON.parse(req.body);
res.sendStatus(204);
// …handle `event` after answering
});Python
import hashlib
import hmac
import time
TOLERANCE_MS = 5 * 60 * 1000
def verify_gauge_signature(headers, raw_body: bytes, secret: str, now_ms=None) -> bool:
h = {k.lower(): v for k, v in headers.items()}
timestamp, signatures = "", []
if h.get("gauge-signature"):
for part in h["gauge-signature"].split(","):
key, _, value = part.strip().partition("=")
if key == "t":
timestamp = value
elif key == "v1":
signatures.append(value)
else:
timestamp = h.get("x-pulse-timestamp", "")
signatures = [
s.strip()[len("sha256="):] if s.strip().startswith("sha256=") else s.strip()
for s in h.get("x-pulse-signature", "").split(",")
if s.strip()
]
if not timestamp.isdigit() or not signatures:
return False
# `t` is epoch milliseconds.
now_ms = int(time.time() * 1000) if now_ms is None else now_ms
if abs(now_ms - int(timestamp)) > TOLERANCE_MS:
return False
expected = hmac.new(secret.encode(), timestamp.encode() + b"." + raw_body, hashlib.sha256).hexdigest()
return any(hmac.compare_digest(expected, sig) for sig in signatures)In Django: verify_gauge_signature(request.headers, request.body, settings.GAUGE_WEBHOOK_SECRET).
In Flask: verify_gauge_signature(request.headers, request.get_data(), secret).
PHP
<?php
function verify_gauge_signature(array $headers, string $rawBody, string $secret, ?int $nowMs = null): bool
{
$h = array_change_key_case($headers, CASE_LOWER);
$timestamp = '';
$signatures = [];
if (!empty($h['gauge-signature'])) {
foreach (explode(',', $h['gauge-signature']) as $part) {
[$key, $value] = array_pad(explode('=', trim($part), 2), 2, '');
if ($key === 't') {
$timestamp = $value;
} elseif ($key === 'v1') {
$signatures[] = $value;
}
}
} else {
$timestamp = $h['x-pulse-timestamp'] ?? '';
foreach (explode(',', $h['x-pulse-signature'] ?? '') as $sig) {
$sig = trim($sig);
if ($sig !== '') {
$signatures[] = preg_replace('/^sha256=/', '', $sig);
}
}
}
if (!ctype_digit($timestamp) || $signatures === []) {
return false;
}
// `t` is epoch milliseconds.
$nowMs = $nowMs ?? (int) floor(microtime(true) * 1000);
if (abs($nowMs - (int) $timestamp) > 300000) {
return false;
}
$expected = hash_hmac('sha256', $timestamp . '.' . $rawBody, $secret);
foreach ($signatures as $sig) {
if (hash_equals($expected, $sig)) {
return true;
}
}
return false;
}
// Plain PHP:
// verify_gauge_signature(getallheaders(), file_get_contents('php://input'), getenv('GAUGE_WEBHOOK_SECRET'));In Laravel, headers come as arrays and the raw body from getContent():
$headers = array_map(fn ($values) => $values[0] ?? '', $request->headers->all());
$ok = verify_gauge_signature($headers, $request->getContent(), config('services.gauge.webhook_secret'));Test it
Choose Send test event on the endpoint in the console. It is signed with the endpoint's real secret, so a test that passes verification proves your code.
For a unit test, use this fixed vector (the same one Gauge's own signer is tested against):
| Secret | whsec_test_vector_secret_do_not_use |
| Previous secret (rotation) | whsec_previous_vector_secret |
t / x-pulse-timestamp | 1761048000000 |
| Raw body | {"id":"evt_0f1e2d3c4b5a69788796a5b4","event":"message.received","payload":{"text":"Hi"}} |
Gauge-Signature | t=1761048000000,v1=4b725ffac75eac0450b0c605e5f6bd7955b2702a6fe001cd4425610107bfb218 |
x-pulse-signature | sha256=4b725ffac75eac0450b0c605e5f6bd7955b2702a6fe001cd4425610107bfb218 |
Gauge-Signature during a rotation | t=1761048000000,v1=4b725ffac75eac0450b0c605e5f6bd7955b2702a6fe001cd4425610107bfb218,v1=c4f9d9e4b3742521417ca7956cf37cb8415c92fa3afaeaf32809151df16ba49c |
Pass now = 1761048000000 (Node), now_ms=1761048000000 (Python) or $nowMs = 1761048000000 (PHP)
so the 5-minute check passes for the old timestamp. The rotation header verifies under either
secret. You can reproduce the signature with
printf '%s' '1761048000000.{"id":"evt_0f1e2d3c4b5a69788796a5b4","event":"message.received","payload":{"text":"Hi"}}' | openssl dgst -sha256 -hmac whsec_test_vector_secret_do_not_use.
Rotating the secret
Rotate from the endpoint in the console, or with
POST /v2/webhook-endpoints/{id}/rotate-secret ({ "overlapHours": 24 } or 0; the new secret is in
the answer, once). Choose a 24-hour overlap for a planned rotation: for a
day, every delivery is signed with both secrets, so you can deploy the new secret without dropping
a delivery. Choose no overlap if the secret leaked: the old one stops signing at once.