Webhook signature tester
Verify your ToneGrid webhook signature handling without hitting any of our servers. Paste a webhook secret, the raw request body, and either a received X-ToneGrid-Signature value (to check it matches) or click Generate to mint a fresh sample for testing your verification code.
Input
Result
Waiting for input
Fill in secret + body, then click Verify or Generate.
- Computed signature
- —
- Full X-ToneGrid-Signature header
- —
- Signed payload (
ts.body) - —
How the signature is computed
- Every webhook POST carries the header
X-ToneGrid-Signature: t=<unix_ts>,v1=<hex_sha256_hmac>. - Parse the header into
t(timestamp, integer) andv1(signature, lowercase hex). - Reject events where
|now - t| > 300seconds — prevents replay attacks. - Compute the signed payload by concatenating:
t + "." + raw_body. Use the raw body bytes, not a re-serialized JSON string — even whitespace differences will break the signature. - Compute
expected = HMAC_SHA256(secret, signed_payload)as lowercase hex. - Constant-time compare
expectedagainstv1. If they match, the event is authentic.
The same shape Stripe uses. If you've integrated a Stripe webhook, the verification code translates almost line-for-line.
Verification snippets
function tg_verify(string $rawBody, string $sigHeader, string $secret, int $tolerance = 300): bool {
if (!preg_match('/t=(\d+),v1=([0-9a-f]+)/', $sigHeader, $m)) {
return false;
}
[, $ts, $v1] = $m;
if (abs(time() - (int)$ts) > $tolerance) {
return false; // replay protection
}
$expected = hash_hmac('sha256', $ts . '.' . $rawBody, $secret);
return hash_equals($expected, $v1); // constant-time compare
}
// In your endpoint:
$raw = file_get_contents('php://input');
$sig = $_SERVER['HTTP_X_TONEGRID_SIGNATURE'] ?? '';
if (!tg_verify($raw, $sig, getenv('TG_WEBHOOK_SECRET'))) {
http_response_code(401);
return;
}
$event = json_decode($raw, true);
// handle $event…
import crypto from 'node:crypto';
export function tgVerify(rawBody, sigHeader, secret, toleranceSec = 300) {
const m = (sigHeader || '').match(/^t=(\d+),v1=([0-9a-f]+)$/);
if (!m) return false;
const [, ts, v1] = m;
if (Math.abs(Date.now() / 1000 - Number(ts)) > toleranceSec) return false;
const expected = crypto
.createHmac('sha256', secret)
.update(ts + '.' + rawBody)
.digest('hex');
return crypto.timingSafeEqual(
Buffer.from(expected, 'hex'),
Buffer.from(v1, 'hex')
);
}
// Express handler — use the raw body parser:
app.post('/webhook', express.raw({ type: 'application/json' }), (req, res) => {
const raw = req.body.toString('utf8');
if (!tgVerify(raw, req.header('x-tonegrid-signature'), process.env.TG_WEBHOOK_SECRET)) {
return res.sendStatus(401);
}
const event = JSON.parse(raw);
res.sendStatus(200);
});
import hmac, hashlib, time, re
def tg_verify(raw_body: bytes, sig_header: str, secret: str, tolerance: int = 300) -> bool:
m = re.fullmatch(r't=(\d+),v1=([0-9a-f]+)', sig_header or '')
if not m:
return False
ts, v1 = m.groups()
if abs(int(time.time()) - int(ts)) > tolerance:
return False
payload = (ts + '.').encode() + raw_body
expected = hmac.new(secret.encode(), payload, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, v1) # constant-time
# Flask handler:
@app.post('/webhook')
def webhook():
if not tg_verify(request.get_data(),
request.headers.get('X-ToneGrid-Signature', ''),
os.environ['TG_WEBHOOK_SECRET']):
abort(401)
event = request.get_json()
return ('', 200)
package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"fmt"
"regexp"
"strconv"
"time"
)
var sigRe = regexp.MustCompile(`^t=(\d+),v1=([0-9a-f]+)$`)
func TGVerify(rawBody []byte, sigHeader, secret string, toleranceSec int64) bool {
m := sigRe.FindStringSubmatch(sigHeader)
if m == nil { return false }
ts, _ := strconv.ParseInt(m[1], 10, 64)
if abs(time.Now().Unix() - ts) > toleranceSec { return false }
mac := hmac.New(sha256.New, []byte(secret))
fmt.Fprintf(mac, "%d.", ts)
mac.Write(rawBody)
expected := hex.EncodeToString(mac.Sum(nil))
return hmac.Equal([]byte(expected), []byte(m[2]))
}
func abs(x int64) int64 { if x < 0 { return -x }; return x }
warning Common gotchas
- Re-parsed JSON breaks the signature. Use the raw request bytes
beforeany JSON parser touches them — Express needsexpress.raw(), Flask needsrequest.get_data(), Spring needs the byte[] body. - String comparison is timing-attack-prone. Always use
hash_equals(PHP),crypto.timingSafeEqual(Node),hmac.compare_digest(Python),hmac.Equal(Go) — never==. - Check the timestamp tolerance. Without the
|now - t| < 300check, an attacker who recorded one valid request can replay it forever. 5 minutes is the standard window. - Don't trust the body before verification. Verify first, then parse JSON. Otherwise you're processing unverified input.
- The secret is the
secret_hashfield from thePOST /webhooksresponse — that's the value ToneGrid signs with. Treat it like a password; rotate it viaPUT /webhooks/:uuidif it ever leaks. - Hex is lowercase. If you uppercase the hex,
hmac.compare_digestwill fail even on an otherwise-valid signature. - Return 200 quickly, process async. We retry any non-2xx with exponential backoff for 24h. Slow handlers cause queue back-pressure on both sides.