HMAC-SHA256

Webhook signing & verification

Every forwarded webhook can be cryptographically signed so your server can prove it came from WebhookWhisper. The signature pattern matches what Stripe, GitHub, and most webhook providers use — so verification code is short and standard.

Quick reference

Header nameX-WebhookWhisper-Signature
Header formatt=<unix-timestamp>,v1=<hex-hmac>
AlgorithmHMAC-SHA256
Signed payload<timestamp>.<raw_request_body>
Recommended tolerance300 seconds (5 minutes)
ComparisonConstant-time only (timingSafeEqual / hmac.compare_digest / hash_equals)

How verification works

  1. Generate a signing secret on your endpoint detail page in the dashboard. The secret is shown once — save it to your environment variables (e.g.WEBHOOKWHISPER_SECRET) immediately. If you lose it, rotate to generate a new one.
  2. Read the raw request body. Don't parse the JSON before verifying — even re-serializing it will change a byte and break the signature. Frameworks like Express need express.raw() for this route; Flask exposes the raw body via request.get_data().
  3. Parse the header X-WebhookWhisper-Signature into its t= and v1= components.
  4. Check the timestamp is within your tolerance window. We recommend 5 minutes. This prevents an attacker who captures a valid request from replaying it days later.
  5. Recompute the HMAC as HMAC_SHA256(secret, "<t>.<raw_body>"). Compare the hex-encoded result against v1= using a constant-time comparison.
  6. Reject the request if the timestamp is out of tolerance, the header is malformed, or the signatures don't match. Return 400 or 401 — we'll retry the delivery according to your endpoint's retry policy.

Verification recipes

Node.js (Express)

verify.js

import crypto from 'node:crypto'

const SECRET = process.env.WEBHOOKWHISPER_SECRET // from your dashboard
const TOLERANCE_SECONDS = 300                    // 5-minute replay window

export function verifyWebhookWhisperSignature(rawBody, header) {
  if (!header) throw new Error('Missing signature header')

  // Header looks like: t=1714512345,v1=ab12cd...
  const parts = Object.fromEntries(
    header.split(',').map((kv) => {
      const [k, v] = kv.split('=')
      return [k.trim(), v.trim()]
    })
  )

  const timestamp = Number(parts.t)
  const signature = parts.v1
  if (!timestamp || !signature) throw new Error('Malformed signature header')

  // Replay defense
  const now = Math.floor(Date.now() / 1000)
  if (Math.abs(now - timestamp) > TOLERANCE_SECONDS) {
    throw new Error('Signature timestamp outside tolerance window')
  }

  // Recompute HMAC over "<t>.<raw_body>"
  const expected = crypto
    .createHmac('sha256', SECRET)
    .update(`${timestamp}.${rawBody}`, 'utf8')
    .digest('hex')

  // Constant-time compare
  const a = Buffer.from(expected, 'hex')
  const b = Buffer.from(signature, 'hex')
  if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
    throw new Error('Signature mismatch')
  }
}

// Express handler — note express.raw() to get the unparsed body
app.post(
  '/webhook',
  express.raw({ type: 'application/json' }),
  (req, res) => {
    try {
      verifyWebhookWhisperSignature(
        req.body.toString('utf8'),
        req.header('X-WebhookWhisper-Signature')
      )
    } catch (err) {
      return res.status(400).send(`Invalid signature: ${err.message}`)
    }
    const payload = JSON.parse(req.body.toString('utf8'))
    // ... your logic
    res.sendStatus(200)
  }
)

Python (Flask)

verify.py

import hmac
import hashlib
import os
import time

SECRET = os.environ['WEBHOOKWHISPER_SECRET'].encode('utf-8')
TOLERANCE_SECONDS = 300


def verify_signature(raw_body: bytes, header: str) -> None:
    if not header:
        raise ValueError('Missing signature header')

    parts = dict(p.strip().split('=', 1) for p in header.split(','))
    timestamp = int(parts.get('t', 0))
    signature = parts.get('v1', '')
    if not timestamp or not signature:
        raise ValueError('Malformed signature header')

    if abs(int(time.time()) - timestamp) > TOLERANCE_SECONDS:
        raise ValueError('Signature timestamp outside tolerance window')

    signed = f'{timestamp}.'.encode('utf-8') + raw_body
    expected = hmac.new(SECRET, signed, hashlib.sha256).hexdigest()

    if not hmac.compare_digest(expected, signature):
        raise ValueError('Signature mismatch')


# Flask handler
from flask import Flask, request, abort

app = Flask(__name__)


@app.post('/webhook')
def webhook():
    try:
        verify_signature(
            request.get_data(),
            request.headers.get('X-WebhookWhisper-Signature', ''),
        )
    except ValueError as e:
        return f'Invalid signature: {e}', 400
    payload = request.get_json()
    # ... your logic
    return '', 200

Go (net/http)

verify.go

package main

import (
	"crypto/hmac"
	"crypto/sha256"
	"encoding/hex"
	"errors"
	"fmt"
	"io"
	"net/http"
	"os"
	"strconv"
	"strings"
	"time"
)

var secret = []byte(os.Getenv("WEBHOOKWHISPER_SECRET"))

const toleranceSeconds = int64(300)

func verifySignature(rawBody []byte, header string) error {
	if header == "" {
		return errors.New("missing signature header")
	}

	parts := map[string]string{}
	for _, kv := range strings.Split(header, ",") {
		bits := strings.SplitN(strings.TrimSpace(kv), "=", 2)
		if len(bits) == 2 {
			parts[bits[0]] = bits[1]
		}
	}

	tsStr, sig := parts["t"], parts["v1"]
	if tsStr == "" || sig == "" {
		return errors.New("malformed signature header")
	}
	ts, err := strconv.ParseInt(tsStr, 10, 64)
	if err != nil {
		return fmt.Errorf("bad timestamp: %w", err)
	}

	now := time.Now().Unix()
	if diff := now - ts; diff < -toleranceSeconds || diff > toleranceSeconds {
		return errors.New("signature timestamp outside tolerance window")
	}

	mac := hmac.New(sha256.New, secret)
	fmt.Fprintf(mac, "%d.", ts)
	mac.Write(rawBody)
	expected := hex.EncodeToString(mac.Sum(nil))

	if !hmac.Equal([]byte(expected), []byte(sig)) {
		return errors.New("signature mismatch")
	}
	return nil
}

func handleWebhook(w http.ResponseWriter, r *http.Request) {
	body, err := io.ReadAll(r.Body)
	if err != nil {
		http.Error(w, err.Error(), 400)
		return
	}
	if err := verifySignature(body, r.Header.Get("X-WebhookWhisper-Signature")); err != nil {
		http.Error(w, "invalid signature: "+err.Error(), 400)
		return
	}
	// payload is body — JSON decode if needed
	w.WriteHeader(200)
}

Ruby (Rack)

verify.rb

require 'openssl'

SECRET = ENV.fetch('WEBHOOKWHISPER_SECRET')
TOLERANCE_SECONDS = 300

def verify_webhookwhisper_signature(raw_body, header)
  raise 'Missing signature header' if header.nil? || header.empty?

  parts = header.split(',').to_h { |kv| kv.strip.split('=', 2) }
  timestamp = parts['t'].to_i
  signature = parts['v1']
  raise 'Malformed signature header' if timestamp.zero? || signature.nil?

  now = Time.now.to_i
  raise 'Signature timestamp outside tolerance window' if (now - timestamp).abs > TOLERANCE_SECONDS

  expected = OpenSSL::HMAC.hexdigest('sha256', SECRET, "#{timestamp}.#{raw_body}")

  # Rack::Utils provides constant-time compare in newer versions; otherwise:
  # OpenSSL.fixed_length_secure_compare(expected, signature)
  raise 'Signature mismatch' unless OpenSSL.fixed_length_secure_compare(expected, signature)
end

# Rails / Rack handler:
post '/webhook' do
  raw = request.body.read
  begin
    verify_webhookwhisper_signature(raw, request.env['HTTP_X_WEBHOOKWHISPER_SIGNATURE'])
  rescue StandardError => e
    halt 400, "Invalid signature: #{e.message}"
  end
  # parse + handle
  status 200
end

PHP

verify.php

<?php

const TOLERANCE_SECONDS = 300;

function verifyWebhookWhisperSignature(string $rawBody, ?string $header): void
{
    $secret = getenv('WEBHOOKWHISPER_SECRET');
    if (!$header) {
        throw new RuntimeException('Missing signature header');
    }

    $parts = [];
    foreach (explode(',', $header) as $kv) {
        [$k, $v] = array_map('trim', explode('=', $kv, 2));
        $parts[$k] = $v;
    }

    $timestamp = (int) ($parts['t'] ?? 0);
    $signature = $parts['v1'] ?? '';
    if (!$timestamp || !$signature) {
        throw new RuntimeException('Malformed signature header');
    }

    if (abs(time() - $timestamp) > TOLERANCE_SECONDS) {
        throw new RuntimeException('Signature timestamp outside tolerance window');
    }

    $expected = hash_hmac('sha256', $timestamp . '.' . $rawBody, $secret);
    if (!hash_equals($expected, $signature)) {
        throw new RuntimeException('Signature mismatch');
    }
}

// Handler:
$rawBody = file_get_contents('php://input');
try {
    verifyWebhookWhisperSignature(
        $rawBody,
        $_SERVER['HTTP_X_WEBHOOKWHISPER_SIGNATURE'] ?? null
    );
} catch (RuntimeException $e) {
    http_response_code(400);
    echo 'Invalid signature: ' . $e->getMessage();
    exit;
}
$payload = json_decode($rawBody, true);
// ... your logic
http_response_code(200);

Bash (verify a captured request)

verify.sh

#!/usr/bin/env bash
# Verify a single captured WebhookWhisper request from the command line.
# Useful for one-off debugging — not for production.

set -euo pipefail

SECRET="${WEBHOOKWHISPER_SECRET:?set me}"
HEADER_VALUE="${1:?usage: verify.sh '<X-WebhookWhisper-Signature header>' '<raw_body>'}"
RAW_BODY="${2:?}"

# Parse t= and v1= out of the header value
TS=$(awk -F'[,=]' '{for(i=1;i<=NF;i++)if($i=="t")print $(i+1)}' <<< "$HEADER_VALUE")
SIG=$(awk -F'[,=]' '{for(i=1;i<=NF;i++)if($i=="v1")print $(i+1)}' <<< "$HEADER_VALUE")

if [[ -z "$TS" || -z "$SIG" ]]; then
  echo "malformed header: $HEADER_VALUE" >&2; exit 1
fi

# Recompute HMAC-SHA256 over "<t>.<raw_body>"
EXPECTED=$(printf '%s.%s' "$TS" "$RAW_BODY" \
  | openssl dgst -sha256 -hmac "$SECRET" -hex \
  | awk '{print $2}')

if [[ "$EXPECTED" == "$SIG" ]]; then
  echo "OK"
else
  echo "MISMATCH: expected=$EXPECTED got=$SIG" >&2; exit 1
fi

Debugging a signature mismatch right now?

Try the Webhook Signature Playground — paste your payload and secret, see the expected header, or paste what your server received and get verbose hints on why it doesn't match. Works for Stripe, GitHub, Shopify, Slack, and generic HMAC-SHA256. All in your browser — your secret never leaves your machine.

Troubleshooting

Signature mismatch but I'm using the right secret

The most common cause is parsing the JSON body and then re-serializing it before verifying. The signature is computed over the exact bytes we sent, including whitespace. Read the raw body as a string or buffer, verify, then parse JSON.

My framework already parsed the body before I could read it

Most frameworks have a way to opt out of body parsing for a specific route. Express: express.raw({type: 'application/json'}). FastAPI: read await request.body(). Rails: request.body.read. If you can't avoid the parse, you can re-serialize with identical formatting (no spaces, sorted keys, etc.) — but it's fragile; prefer raw-body access.

The timestamp keeps failing the tolerance check

Your server clock is probably drifting. Run timedatectl /ntpdate to confirm. The header timestamp is in seconds, not milliseconds — make sure you're comparing in the right unit.

Can I verify without a tolerance window?

You can, but you shouldn't. Without it an attacker who captures a single valid signed request can replay it indefinitely. 300 seconds (5 minutes) is a sensible default.

I rotated the secret and now everything is failing

Rotation invalidates the old secret immediately. Update your verifier'sWEBHOOKWHISPER_SECRET environment variable to the new value and redeploy.

Related guides

Need a hand?

Open a ticket from the dashboard or hit /contact. If verification works in our recipes but not in your code, paste the failing snippet — we'll diff it.