Webhook signature verification
Every webhook request carries an HMAC-SHA256 signature of the raw body. Verify it before trusting the payload — otherwise an attacker who guesses your webhook URL can inject events.
Headers
| Header | Value |
|---|---|
tm-signature | Hex-encoded HMAC_SHA256(secret, raw_body) |
tm-timestamp | RFC 3339 timestamp of when the request was sent |
tm-event-id | UUID of the delivery (use for idempotency) |
The signing secret is the application’s webhook_signing_secret, generated when the webhook URL is set, and rotatable from the dashboard. It is not the client_secret.
Verify (Node.js)
import crypto from 'node:crypto';
import express from 'express';
const app = express();
// IMPORTANT: use raw body, not parsed JSON. Re-encoding changes whitespace
// and breaks the signature.
app.post('/webhooks/trymellon',
express.raw({ type: 'application/json' }),
(req, res) => {
const sig = req.header('tm-signature') ?? '';
const expected = crypto
.createHmac('sha256', process.env.TRYMELLON_WEBHOOK_SECRET!)
.update(req.body)
.digest('hex');
if (!crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) {
return res.status(401).end();
}
const event = JSON.parse(req.body.toString('utf8'));
// …handle event
res.status(200).end();
},
);
Verify (Go)
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"io"
"net/http"
"os"
)
func verify(w http.ResponseWriter, r *http.Request) bool {
body, _ := io.ReadAll(r.Body)
mac := hmac.New(sha256.New, []byte(os.Getenv("TRYMELLON_WEBHOOK_SECRET")))
mac.Write(body)
expected := hex.EncodeToString(mac.Sum(nil))
return hmac.Equal([]byte(r.Header.Get("tm-signature")), []byte(expected))
}
Verify (Python — Flask)
import hmac, hashlib, os
from flask import Flask, request, abort
app = Flask(__name__)
@app.post("/webhooks/trymellon")
def webhook():
secret = os.environ["TRYMELLON_WEBHOOK_SECRET"].encode()
expected = hmac.new(secret, request.data, hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, request.headers.get("tm-signature", "")):
abort(401)
# …handle JSON via request.get_json()
return ("", 200)
Verify (Ruby — Rails)
class WebhooksController < ActionController::API
def trymellon
body = request.raw_post
expected = OpenSSL::HMAC.hexdigest('SHA256', ENV.fetch('TRYMELLON_WEBHOOK_SECRET'), body)
head :unauthorized unless ActiveSupport::SecurityUtils.secure_compare(
expected, request.headers['tm-signature'].to_s
)
# …handle event
head :ok
end
end
Replay protection
Reject requests where tm-timestamp is older than 5 minutes. Combined with the tm-event-id idempotency check, this stops captured-and-replayed deliveries.
const sentAt = Date.parse(req.header('tm-timestamp') ?? '');
if (Math.abs(Date.now() - sentAt) > 5 * 60_000) return res.status(401).end();
Common mistakes
| Mistake | Symptom | Fix |
|---|---|---|
Verifying after express.json() | Signatures never match. | Use express.raw() on the webhook route. |
Using === to compare signatures | Vulnerable to timing attacks. | Use crypto.timingSafeEqual / hmac.compare_digest. |
Using client_secret to sign | 401 every request. | Use the dashboard’s webhook signing secret. |
| Logging the secret | Credential leak. | Never log the secret or the raw signature in production. |