The scheme
X-Webhook-Signature: t=1754750731,v1=5f2a8c...
signed_content = "{timestamp}.{raw_request_body}"
signature = HMAC_SHA256(webhook_secret, signed_content)
Sign the raw body bytes. If your framework parses JSON and you re-serialise it, key order and whitespace change and the signature will never match. Configure a raw-body parser for this route.
Implementation
import crypto from "crypto";
function verify(rawBody, header, secret) {
const parts = Object.fromEntries(
header.split(",").map((p) => p.split("=", 2))
);
const { t: timestamp, v1: signature } = parts;
// Replay protection
const age = Math.floor(Date.now() / 1000) - Number(timestamp);
if (Number.isNaN(age) || Math.abs(age) > 300) return false;
const expected = crypto
.createHmac("sha256", secret)
.update(`${timestamp}.${rawBody}`)
.digest("hex");
const a = Buffer.from(signature, "utf8");
const b = Buffer.from(expected, "utf8");
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
app.post("/webhooks/cryptocheckout",
express.raw({ type: "application/json" }),
(req, res) => {
const ok = verify(
req.body.toString("utf8"),
req.headers["x-webhook-signature"],
process.env.CC_WEBHOOK_SECRET
);
if (!ok) return res.status(400).send("bad signature");
res.sendStatus(200);
});
import hmac, hashlib, time, os
from flask import request, abort
def verify(raw_body: str, header: str, secret: str) -> bool:
parts = dict(p.split("=", 1) for p in header.split(","))
timestamp, signature = parts["t"], parts["v1"]
if abs(int(time.time()) - int(timestamp)) > 300:
return False
expected = hmac.new(
secret.encode(),
f"{timestamp}.{raw_body}".encode(),
hashlib.sha256,
).hexdigest()
return hmac.compare_digest(signature, expected)
@app.post("/webhooks/cryptocheckout")
def webhook():
if not verify(
request.get_data(as_text=True),
request.headers["X-Webhook-Signature"],
os.environ["CC_WEBHOOK_SECRET"],
):
abort(400)
return "", 200
<?php
function cc_verify(string $rawBody, string $header, string $secret): bool {
$parts = [];
foreach (explode(',', $header) as $p) {
[$k, $v] = explode('=', $p, 2);
$parts[$k] = $v;
}
if (abs(time() - (int)$parts['t']) > 300) return false;
$expected = hash_hmac('sha256', $parts['t'] . '.' . $rawBody, $secret);
return hash_equals($expected, $parts['v1']);
}
$raw = file_get_contents('php://input');
if (!cc_verify($raw, $_SERVER['HTTP_X_WEBHOOK_SIGNATURE'], getenv('CC_WEBHOOK_SECRET'))) {
http_response_code(400);
exit('bad signature');
}
http_response_code(200);
package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"io"
"math"
"net/http"
"os"
"strconv"
"strings"
"time"
)
func verify(rawBody []byte, header, secret string) bool {
parts := map[string]string{}
for _, p := range strings.Split(header, ",") {
kv := strings.SplitN(p, "=", 2)
if len(kv) == 2 {
parts[kv[0]] = kv[1]
}
}
ts, err := strconv.ParseInt(parts["t"], 10, 64)
if err != nil || math.Abs(float64(time.Now().Unix()-ts)) > 300 {
return false
}
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(parts["t"] + "." + string(rawBody)))
expected := hex.EncodeToString(mac.Sum(nil))
return hmac.Equal([]byte(expected), []byte(parts["v1"]))
}
func handler(w http.ResponseWriter, r *http.Request) {
raw, _ := io.ReadAll(r.Body)
if !verify(raw, r.Header.Get("X-Webhook-Signature"), os.Getenv("CC_WEBHOOK_SECRET")) {
http.Error(w, "bad signature", http.StatusBadRequest)
return
}
w.WriteHeader(http.StatusOK)
}
Checklist
Constant-time compare
timingSafeEqual, hmac.compare_digest, hash_equals, hmac.Equal. Never == on the signature.Check the timestamp
Reject anything older than about five minutes, or a captured delivery can be replayed indefinitely.
Raw body only
Configure your framework to hand you unparsed bytes on this route.
Secret in env, not source
Rotate it in the dashboard if it’s ever exposed.
If your webhook secret is unset, the signature is delivered as the literal string
none. Treat that as a failure and refuse the delivery — set a secret before going live.