Webhook signature verifier
Check an HMAC SHA256 webhook signature: compute it over the exact signed payload with your secret, encode it like the sender and compare in constant time. Every step is shown.
- Runs in your browser
- Nothing uploaded
- No sign up
Signature valid
Signature valid. Accept the request and answer with a 2xx status.
› scheme
Placeholders: {id}, {timestamp}, {body}. The ${id} form works too.
pipeline
- 1
signed payload · 101 bytes
3f6c2b1e-8d4a-4f7b-9c2e-5a1d0b7e6f43.1790760000.{"type":"post.outcome","data":{"status":"published"}} - 2
key · 32 bytes from hex
Hex pairs decoded to raw bytes.
- 3
HMAC SHA-256 · raw digest
9e45b3eb7ece95db8cf32c4320334dfb00bcf138f0ef9e52e2bd70edcaaab616
- 4
encode · hex + "v1="
v1=9e45b3eb7ece95db8cf32c4320334dfb00bcf138f0ef9e52e2bd70edcaaab616
received
v1=9e45b3eb7ece95db8cf32c4320334dfb00bcf138f0ef9e52e2bd70edcaaab616
- 5
compare in constant time
equal
timestamp age 12s, window ±300s: fresh
Signature valid. Accept the request and answer with a 2xx status.
Computed with the Web Crypto API in this tab. Secrets and bodies are never sent anywhere. Use a test secret, not a live one.
How to verify a webhook signature
The console opens with a valid request. Change one character in the body and watch stage five fail.
# 1 choose the scheme
Pick the Kairo Post preset, a generic preset, or set the scheme yourself.
# 2 paste the secret
Enter a test secret and say whether it is text, hex or base64.
# 3 paste the headers
Paste the request headers as Name: value lines.
# 4 paste the raw body
Paste the body exactly as it arrived, before any JSON parsing.
# 5 read the pipeline
Follow the five stages; on a mismatch the tool names the likely cause.
Why webhook signatures need a constant time comparison
== stops at the first differing byte, so a guess that starts right takes slightly longer to reject. Measured often enough, that rebuilds a signature one character at a time.
Use this, not ==
- Node
crypto.timingSafeEqual - Python
hmac.compare_digest - Go
hmac.Equal
Node's version throws on different lengths, so check length first. Length is not secret.
# expected vs guess, one byte at a time
== stops at byte 8, answer arrives early
constant time reads all bytes, same delay every time
Webhook verification code for Node, Python and Go
Each example reads the raw body, rejects stale timestamps and compares in constant time before parsing anything.
Another sender? Change three things
- 1Signed string
often just {body} - 2Encoding
digest("base64") or b64encode - 3Prefix
sha256=, v1= or none
import crypto from "node:crypto";
import express from "express";
const app = express();
// The endpoint secret: 64 hex characters, decoded to 32 raw bytes.
const SECRET = Buffer.from(process.env.KAIRO_WEBHOOK_SECRET, "hex");
const TOLERANCE_SECONDS = 300;
// express.raw keeps the exact bytes. Never verify a body that was parsed and serialised again.
app.post("/webhooks/kairo", express.raw({ type: "application/json" }), (req, res) => {
const id = req.get("Kairo-Webhook-Id") ?? "";
const timestamp = req.get("Kairo-Webhook-Timestamp") ?? "";
const signature = req.get("Kairo-Webhook-Signature") ?? "";
const now = Math.floor(Date.now() / 1000);
if (!/^\d+$/.test(timestamp) || Math.abs(now - Number(timestamp)) > TOLERANCE_SECONDS) {
return res.status(400).send("stale or missing timestamp");
}
const signed = Buffer.concat([Buffer.from(`${id}.${timestamp}.`), req.body]);
const expected = "v1=" + crypto.createHmac("sha256", SECRET).update(signed).digest("hex");
const a = Buffer.from(expected);
const b = Buffer.from(signature);
// timingSafeEqual throws on different lengths, so check the length first.
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
return res.status(401).send("bad signature");
}
const event = JSON.parse(req.body.toString("utf8"));
// Store id and skip any delivery you have already handled.
res.sendStatus(204);
});
app.listen(3000);import hashlib
import hmac
import os
import time
from flask import Flask, abort, request
app = Flask(__name__)
# The endpoint secret: 64 hex characters, decoded to 32 raw bytes.
SECRET = bytes.fromhex(os.environ["KAIRO_WEBHOOK_SECRET"])
TOLERANCE_SECONDS = 300
@app.post("/webhooks/kairo")
def kairo_webhook():
msg_id = request.headers.get("Kairo-Webhook-Id", "")
timestamp = request.headers.get("Kairo-Webhook-Timestamp", "")
signature = request.headers.get("Kairo-Webhook-Signature", "")
body = request.get_data() # raw bytes, exactly as sent
if not timestamp.isdigit() or abs(time.time() - int(timestamp)) > TOLERANCE_SECONDS:
abort(400)
signed = f"{msg_id}.{timestamp}.".encode() + body
expected = "v1=" + hmac.new(SECRET, signed, hashlib.sha256).hexdigest()
# compare_digest takes the same time wherever the strings differ.
if not hmac.compare_digest(expected, signature):
abort(401)
event = request.get_json()
# Store msg_id and skip any delivery you have already handled.
return "", 204package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"io"
"net/http"
"os"
"strconv"
"time"
)
const toleranceSeconds = 300
// The endpoint secret: 64 hex characters, decoded to 32 raw bytes.
var secret = mustHex(os.Getenv("KAIRO_WEBHOOK_SECRET"))
func mustHex(s string) []byte {
b, err := hex.DecodeString(s)
if err != nil || len(b) == 0 {
panic("KAIRO_WEBHOOK_SECRET must be hex")
}
return b
}
func kairoWebhook(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 1<<20))
if err != nil {
http.Error(w, "unreadable body", http.StatusBadRequest)
return
}
id := r.Header.Get("Kairo-Webhook-Id")
ts := r.Header.Get("Kairo-Webhook-Timestamp")
sig := r.Header.Get("Kairo-Webhook-Signature")
t, err := strconv.ParseInt(ts, 10, 64)
age := time.Now().Unix() - t
if err != nil || age > toleranceSeconds || age < -toleranceSeconds {
http.Error(w, "stale or missing timestamp", http.StatusBadRequest)
return
}
mac := hmac.New(sha256.New, secret)
mac.Write([]byte(id + "." + ts + "."))
mac.Write(body)
expected := "v1=" + hex.EncodeToString(mac.Sum(nil))
// hmac.Equal compares in constant time.
if !hmac.Equal([]byte(expected), []byte(sig)) {
http.Error(w, "bad signature", http.StatusUnauthorized)
return
}
// Parse body here. Store id and skip any delivery you have already handled.
w.WriteHeader(http.StatusNoContent)
}
func main() {
http.HandleFunc("/webhooks/kairo", kairoWebhook)
http.ListenAndServe(":8080", nil)
}Common webhook signature failures
Nearly every failed check is one of these. The console tests several for you on a mismatch.
| failure | symptom | fix |
|---|---|---|
| Parsed body, not raw | Always mismatches | Verify raw bytes before any JSON middleware. |
| Wrong output encoding | Twice as long, or has + and / | SHA256 hex is 64 characters; base64 is 44. |
| Secret decoded wrongly | Mismatch with the right secret | Decode a hex secret to bytes first. |
| Missing or extra prefix | Everything after the prefix matches | Add or strip v1= or sha256= before comparing. |
| Trailing newline | Only pasted or logged bodies fail | Test with the body from the request, not a log. |
| Clock skew | Valid, rejected as stale | Use network time; allow a window both ways. |
| Header renamed by a proxy | Signature header is empty | Log the header names your app receives. |
| Wrong character encoding | Fails only with accents or emoji | Verify bytes; decode UTF-8 afterwards. |
Webhook replay protection with timestamps
A valid signature proves who sent a request, not that it is new. A captured request would verify again.
Sign the timestamp
Then changing it breaks the signature, and keeping it ages the request out. Check both directions.
Store delivery ids
Keep every accepted id for at least the window and skip repeats. Retries become harmless too.
Window ±300 seconds around now
How Kairo Post signs its webhooks
Kairo Post can send signed events, such as a finished publish or an approval request, to an HTTPS endpoint you control. This is what our delivery code does.
- Algorithm
- HMAC SHA256
- Key
- 32 random bytes, shown once as 64 hex characters
- Signed payload
- {id}.{timestamp}.{body}
- Signature header
- Kairo-Webhook-Signature: v1=<64 hex>
- Delivery id
- Kairo-Webhook-Id: a UUID
- Timestamp
- Kairo-Webhook-Timestamp: Unix seconds
- Key id
- Kairo-Webhook-Key-Id: which secret signed it
- Other headers
- Content-Type: application/json, User-Agent: KairoPost-Webhooks/1.0
Delivery rules
Public HTTPS on port 443
No credentials or query string in the URL.
Bodies up to 16 KB
Our reference verifier allows 300 seconds of clock difference.
Any 2xx is delivered
Redirects are never followed; 10 second timeout.
New endpoints start switched off
Install the secret first. Rotating switches it off and cancels queued deliveries.
Retries on 408, 425, 429, 5xx · up to 8 attempts
- 30s
- 2m
- 10m
- 1h
- 6h
- 12h
- 24h
A Retry-After header can lengthen a wait, up to 24 hours.
$ man webhook signatures
How do I verify an HMAC SHA256 webhook signature?
Is it safe to paste my webhook secret here?
Why does my signature not match?
What is a constant time comparison?
Why do webhooks include a timestamp?
How does Kairo Post sign webhooks?
Which algorithms does this tool support?
Building on X as well? Estimate API usage with the X API cost calculator.
Written by Jonas Ben, founder of Kairo Post. Updated .
More tools
All toolsPost at the right moment.
Starting today.
Connect an account, describe your voice and let Kairo Post do the legwork.