Verify a webhook signature
Every Hypeline webhook is HMAC-signed following the Standard Webhooks convention, so you can prove a delivery really came from us and has not been altered. This is the whole recipe, in three languages, with replay protection included.
What arrives at your endpoint
POST /your/webhook HTTP/1.1
webhook-id: 01981f2a-9c4d-7b3e-8f6a-2d5c1e0b7a94
webhook-timestamp: 1752398643
webhook-signature: v1,K5oNQ8xkjWkzhi0wROBmRlIgs+g0pI9b9b2fc4a1w1s=
Content-Type: application/json
{"id":"01981f2a-9c4d-7b3e-8f6a-2d5c1e0b7a94","source_type":"html", ...} The signed message is the exact string <webhook-id>.<webhook-timestamp>.<raw body>, HMAC-SHA256 keyed with your endpoint secret. The secret is shown as whsec_<base64>; the key is the base64-decoded part after the prefix. Three rules keep you safe:
- Verify over the raw request bytes. Never re-serialize the parsed JSON first.
- Compare in constant time, and accept if any space-separated token matches (that is how secret rotation stays zero-downtime).
- Reject stale timestamps. Five minutes of tolerance is the convention.
Node
import { createHmac, timingSafeEqual } from "node:crypto";
export function verifyWebhook(secret, headers, rawBody) {
const id = headers["webhook-id"];
const ts = headers["webhook-timestamp"];
const sigHeader = headers["webhook-signature"] ?? "";
// replay defense: reject signatures older than 5 minutes
if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false;
const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
const mac = createHmac("sha256", key)
.update(`${id}.${ts}.`).update(rawBody).digest("base64");
const want = Buffer.from(`v1,${mac}`);
return sigHeader.split(" ").some((tok) => {
const got = Buffer.from(tok);
return got.length === want.length && timingSafeEqual(got, want);
});
} Python
import base64, hashlib, hmac, time
def verify_webhook(secret: str, headers: dict, raw_body: bytes) -> bool:
wid = headers["webhook-id"]
ts = headers["webhook-timestamp"]
if abs(time.time() - int(ts)) > 300: # replay defense
return False
key = base64.b64decode(secret.removeprefix("whsec_"))
mac = hmac.new(key, f"{wid}.{ts}.".encode() + raw_body, hashlib.sha256)
want = "v1," + base64.b64encode(mac.digest()).decode()
return any(hmac.compare_digest(tok, want)
for tok in headers["webhook-signature"].split(" ")) Go
func VerifyWebhook(secret, id, ts string, body []byte, sigHeader string) bool {
key := []byte(secret)
if b, err := base64.StdEncoding.DecodeString(
strings.TrimPrefix(secret, "whsec_")); err == nil {
key = b
}
mac := hmac.New(sha256.New, key)
fmt.Fprintf(mac, "%s.%s.", id, ts)
mac.Write(body)
want := "v1," + base64.StdEncoding.EncodeToString(mac.Sum(nil))
for _, got := range strings.Fields(sigHeader) {
if hmac.Equal([]byte(got), []byte(want)) {
return true
}
}
return false
} Respond 200 OK once verified. Deliveries that fail or time out are retried with backoff and dead-lettered rather than silently dropped, so a transient failure on your side never loses an event.
Frequently asked questions
Which headers does a Hypeline webhook carry?
Three, following the Standard Webhooks convention: webhook-id (the event id, a UUIDv7 you can also dedupe on), webhook-timestamp (unix seconds when the delivery was signed), and webhook-signature (one or more space-separated "v1,<base64>" tokens).
What exactly is signed?
HMAC-SHA256 over the exact string "<webhook-id>.<webhook-timestamp>.<raw body bytes>", keyed with your endpoint secret. Verify against the raw bytes you received; re-parsing and re-serializing the JSON changes the bytes and breaks the check.
Why can webhook-signature contain several tokens?
Secret rotation. During a rollover the delivery is signed with every active secret, one token per secret, and your receiver accepts if any token verifies. That makes rotating a secret zero-downtime.
Is this compatible with Standard Webhooks libraries?
Yes. The secret is displayed as whsec_<base64> and the scheme matches the Standard Webhooks spec, so any off-the-shelf Standard Webhooks verifier works as-is.
Do I also need to check the Ed25519 event signature?
The HMAC already proves the delivery came from Hypeline and arrived intact. The Ed25519 signature inside the event is an optional second layer that proves the content itself on any transport; verify it too when events cross trust boundaries.
Be first to make the static web live
Add your email and we'll tell you the moment it goes live.
No spam. One email when it goes live.