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
X-WebhookWhisper-Signaturet=<unix-timestamp>,v1=<hex-hmac>HMAC-SHA256<timestamp>.<raw_request_body>timingSafeEqual / hmac.compare_digest / hash_equals)How verification works
- 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. - 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 viarequest.get_data(). - Parse the header
X-WebhookWhisper-Signatureinto itst=andv1=components. - 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.
- Recompute the HMAC as
HMAC_SHA256(secret, "<t>.<raw_body>"). Compare the hex-encoded result againstv1=using a constant-time comparison. - Reject the request if the timestamp is out of tolerance, the header is malformed, or the signatures don't match. Return
400or401— 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 '', 200Go (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
endPHP
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
fiDebugging 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.