Verifying webhook signatures
How to verify the HMAC signature on every delivered webhook so you can trust it came from us and was not tampered with.
Every webhook we deliver is signed with HMAC-SHA256 using the shared secret you saw once when you created the endpoint. Verifying the signature proves two things: the delivery came from us, and the body was not altered in transit. The scheme is Standard Webhooks, so a generic library works too, but the recipe below is all you need.
The headers on every delivery
Each POST carries three signing headers:
| Header | Value |
|---|---|
webhook-id | The event UUID (also your idempotency / dedup key). |
webhook-timestamp | The unix-seconds time the delivery was signed. |
webhook-signature | One or more space-delimited v1,<base64> tokens. |
The recipe (language-agnostic)
- Read the raw request body as bytes. Do not parse and re-serialize it first: you must verify the exact bytes you received.
- Build the signed message by concatenating, with literal dots between them:
webhook-id+.+webhook-timestamp+.+ the raw body. - Compute
HMAC-SHA256(secret, message)and base64-encode the digest (standard base64). Prefix it withv1,to get your expected token. - Split
webhook-signatureon spaces and compare your expected token against each one using a constant-time comparison. Accept if any token matches. (Multiple tokens appear only during a secret rotation, so both the old and new secret verify during the overlap.) - Reject the delivery if
|now - webhook-timestamp|is more than 5 minutes. This is your replay defense. - Dedup on
webhook-id: the same event UUID may be delivered more than once (we guarantee at-least-once), so treat a repeated id as already handled.
Verify the exact bytes
Sign and compare over the raw body you received. Any re-encoding (key reordering, added whitespace) changes the bytes and the signature will not match.
Go reference verifier
import (
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"fmt"
"strings"
"time"
)
// verify returns true when the signature header carries a token that matches the
// HMAC over id.timestamp.body with the shared secret.
func verify(secret []byte, id, ts string, body []byte, header string) bool {
mac := hmac.New(sha256.New, secret)
fmt.Fprintf(mac, "%s.%s.", id, ts) // id "." timestamp "."
mac.Write(body) // then the raw body bytes
want := "v1," + base64.StdEncoding.EncodeToString(mac.Sum(nil))
for _, got := range strings.Fields(header) { // space-delimited during rotation
if hmac.Equal([]byte(got), []byte(want)) {
return true
}
}
return false
}
// fresh is the replay defense: reject a timestamp more than 5 minutes from now.
func fresh(ts int64, now time.Time) bool {
d := now.Sub(time.Unix(ts, 0))
if d < 0 {
d = -d
}
return d <= 5*time.Minute
}Wire it into an HTTP handler like this:
func handle(secret []byte) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
id := r.Header.Get("webhook-id")
ts := r.Header.Get("webhook-timestamp")
sig := r.Header.Get("webhook-signature")
body, _ := io.ReadAll(io.LimitReader(r.Body, 1<<20))
n, err := strconv.ParseInt(ts, 10, 64)
if err != nil || !fresh(n, time.Now()) {
http.Error(w, "stale or bad timestamp", http.StatusBadRequest)
return
}
if !verify(secret, id, ts, body, sig) {
http.Error(w, "bad signature", http.StatusUnauthorized)
return
}
// alreadyHandled(id) => 200 and stop (at-least-once dedup on webhook-id).
w.WriteHeader(http.StatusOK)
}
}Node reference verifier
import crypto from "node:crypto";
function verify(secret, id, ts, body, header) {
const mac = crypto.createHmac("sha256", secret);
mac.update(`${id}.${ts}.`);
mac.update(body); // body is a Buffer of the raw bytes
const want = "v1," + mac.digest("base64");
for (const got of header.split(" ")) {
// timingSafeEqual needs equal-length buffers.
const a = Buffer.from(got);
const b = Buffer.from(want);
if (a.length === b.length && crypto.timingSafeEqual(a, b)) return true;
}
return false;
}Reject the request if Math.abs(Date.now() / 1000 - Number(ts)) > 300 (the same
5-minute skew check), and dedup on webhook-id.