Webhooks

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:

HeaderValue
Gauge-Signaturet=<epoch ms>,v1=<hex>: the timestamp and the signature in one header. Verify this one.
x-pulse-signatureLegacy: sha256=<hex>. Kept for receivers written before API v2.
x-pulse-timestampLegacy: 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

  1. 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().
  2. Use the timestamp string exactly as sent in the signed string.
  3. Compare in constant time (timingSafeEqual, hmac.compare_digest, hash_equals), never with ==.
  4. 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).
  5. Answer 400 to a delivery that fails verification, and do not process it.

Node

verify-gauge-signature.js
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

verify_gauge_signature.py
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

verify_gauge_signature.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):

Secretwhsec_test_vector_secret_do_not_use
Previous secret (rotation)whsec_previous_vector_secret
t / x-pulse-timestamp1761048000000
Raw body{"id":"evt_0f1e2d3c4b5a69788796a5b4","event":"message.received","payload":{"text":"Hi"}}
Gauge-Signaturet=1761048000000,v1=4b725ffac75eac0450b0c605e5f6bd7955b2702a6fe001cd4425610107bfb218
x-pulse-signaturesha256=4b725ffac75eac0450b0c605e5f6bd7955b2702a6fe001cd4425610107bfb218
Gauge-Signature during a rotationt=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.

On this page