A webhook signature mismatch means the HMAC value you computed from the payload does not equal the value the provider sent in the signature header. The provider sent the correct signature. The mismatch is always on the receiver side.
The Five Root Causes
1. Using the parsed body instead of the raw body
HMAC is computed over the raw bytes of the request body — the exact sequence of characters as transmitted. When you let a JSON parser touch the body first, it reformats whitespace, reorders keys, or normalizes encoding. The byte sequence changes and the HMAC will never match.
// Node.js / Express — always use express.raw() for webhook routes
app.post('/webhook', express.raw({ type: 'application/json' }), handler)
// Django — access request.body (raw bytes) not request.data
raw_body = request.body # correct
payload = json.loads(raw_body) # parse after verification
2. Wrong webhook secret
Webhook secrets are per-endpoint. If you register multiple webhook URLs, each gets a different secret. Using the secret from endpoint A to verify deliveries to endpoint B always mismatches.
3. Wrong encoding
Stripe and GitHub use hex encoding. Shopify uses base64. Comparing a hex string to a base64 string always fails. Check the provider docs for the exact encoding.
4. Wrong algorithm
Most providers use HMAC-SHA256. Twilio uses HMAC-SHA1. Using sha1 where sha256 is expected always mismatches.
5. Extra whitespace or BOM in the secret
If you copy-paste the secret and accidentally include a trailing newline or space, the HMAC will never match. Always trim: process.env.WEBHOOK_SECRET.trim().
Algorithm and Encoding by Provider
| Provider | Header | Algorithm | Encoding |
|---|---|---|---|
| Stripe | Stripe-Signature | HMAC-SHA256 | Hex (with timestamp prefix) |
| GitHub | X-Hub-Signature-256 | HMAC-SHA256 | Hex with sha256= prefix |
| Shopify | X-Shopify-Hmac-Sha256 | HMAC-SHA256 | Base64 |
| Twilio | X-Twilio-Signature | HMAC-SHA1 | Base64 |
| WooCommerce | X-WC-Webhook-Signature | HMAC-SHA256 | Base64 |
| SendGrid | X-Twilio-Email-Event-Webhook-Signature | ECDSA-SHA256 | Base64 |
Node.js Generic Verifier
const crypto = require('crypto')
function verifyWebhookSignature(rawBody, headerSig, secret, algorithm, encoding) {
const hmac = crypto.createHmac(algorithm, secret.trim())
hmac.update(rawBody)
const computed = hmac.digest(encoding)
return crypto.timingSafeEqual(Buffer.from(headerSig), Buffer.from(computed))
}
Python Generic Verifier
import hashlib, hmac, base64
def verify_signature(raw_body, header_sig, secret, algorithm='sha256', encoding='hex'):
key = secret.strip().encode()
h = hmac.new(key, raw_body, getattr(hashlib, algorithm))
computed = base64.b64encode(h.digest()).decode() if encoding == 'base64' else h.hexdigest()
return hmac.compare_digest(header_sig, computed)
FAQ
The signature matched yesterday but fails today — what changed?
Most likely the webhook secret was rotated in the provider dashboard. Check for a new secret and update your environment variable. Some providers like Stripe allow a tolerance window during rotation where both old and new secrets are accepted.
How can I log the computed HMAC to compare with the header?
Temporarily add console.log('computed', computed, 'header', headerSig). If computed is empty or undefined, the raw body is not reaching the verifier. If lengths differ, check the encoding.
Does WebhookWhisper show the raw body?
Yes. WebhookWhisper captures the exact raw bytes of every incoming request. Copy the raw body from the inspector, paste it into a local script with your secret, and compute the expected HMAC to confirm the mismatch before deploying your fix.