Verifying Signatures
Anyone who learns your endpoint's URL can send it requests. Verify the signature on every delivery before you act on it, and reject anything that fails.
Signatures follow the open Standard Webhooks scheme, so you can also use one of the libraries that implement it for your language.
The headers
| Header | Value |
|---|---|
webhook-id | The event id, the same as id in the body. It does not change between retries. |
webhook-timestamp | When this attempt was signed, in Unix seconds. Every attempt, including a retry or a redelivery, is signed afresh with a new timestamp. |
webhook-signature | One or more signatures, separated by spaces, each in the form v1,<signature> |
Every delivery also carries Content-Type: application/json and User-Agent: Webhooks/1.0.
How the signature is computed
- Take the signing secret, remove the
whsec_prefix and base64-decode the rest. The result is the key. - Build the signed content by joining the id, the timestamp and the raw request body with full stops:
{webhook-id}.{webhook-timestamp}.{raw body}. - Compute HMAC-SHA256 of the signed content with the key, and base64-encode the result.
- The delivery is genuine if that value equals any
v1signature inwebhook-signature.
Test vector
Use these values to check step 1 to 3 of your implementation. The timestamp is fixed, so switch the timestamp check off for this test only.
| Input | Value |
|---|---|
| Secret | whsec_YSBmaXhlZCBleGFtcGxlIGtleSwgbmV2ZXIgdXNlIGl0 (an example, never use it) |
webhook-id | evt_01JABCDEFGHJKMNPQRSTVWXYZ |
webhook-timestamp | 1791824652 |
| Raw body | The single line below, with no trailing newline |
{"id":"evt_01JABCDEFGHJKMNPQRSTVWXYZ","type":"webhook.test","api_version":"v1","created_at":"2026-10-12T17:04:11.512Z","organization_id":"cmorg0000000000000000000","data":{"object":{"id":"cmwhk0000000000000000000","object":"webhook_endpoint","message":"A test event, sent on request to check that this endpoint receives and verifies events."}}}
The expected webhook-signature value is:
v1,7/SrARBfCegLSNnPiRNB2zj/Ii8MbpAkGAy7mhDmVN8=
Rules for verifying
- Use the raw body exactly as received. Parsing the JSON and serializing it again changes whitespace and key order, and the signature will not match. Read the body as bytes before any JSON middleware touches it.
- Compare in constant time, using your language's constant-time comparison, never
==. - Reject old or future timestamps. Refuse a delivery whose
webhook-timestampis more than 5 minutes away from your server's clock, in either direction. This stops a captured request from being replayed later. Keep your server's clock synchronised. - Accept any matching signature. While a secret is being rotated, each delivery carries two signatures, one for the old secret and one for the new, separated by a space. Accept the delivery if either matches the secret you hold.
- Respond with
401or400when verification fails, and do not process the payload.
Code samples
Each sample returns successfully only for a genuine, fresh delivery. Load the secret from your secret store, never from source code.
import crypto from 'node:crypto'
const TOLERANCE_SECONDS = 5 * 60
// rawBody: the request body as a Buffer, before JSON parsing
// (with Express: app.post('/webhooks', express.raw({ type: 'application/json' }), ...))
export function verifyWebhook(rawBody, headers, secret) {
const id = headers['webhook-id']
const timestamp = headers['webhook-timestamp']
const signatures = headers['webhook-signature']
if (!id || !timestamp || !signatures) throw new Error('Missing webhook headers')
const ts = Number(timestamp)
const now = Math.floor(Date.now() / 1000)
if (!Number.isInteger(ts) || Math.abs(now - ts) > TOLERANCE_SECONDS) {
throw new Error('Timestamp outside the tolerance')
}
const key = Buffer.from(secret.replace(/^whsec_/, ''), 'base64')
const expected = crypto
.createHmac('sha256', key)
.update(`${id}.${timestamp}.`)
.update(rawBody)
.digest()
for (const entry of signatures.split(' ')) {
const [version, signature] = entry.split(',')
if (version !== 'v1' || !signature) continue
const given = Buffer.from(signature, 'base64')
if (given.length === expected.length && crypto.timingSafeEqual(given, expected)) {
return JSON.parse(rawBody.toString('utf8'))
}
}
throw new Error('No matching signature')
}
# Python 3.9 or later
import base64
import hashlib
import hmac
import json
import time
TOLERANCE_SECONDS = 5 * 60
def verify_webhook(raw_body: bytes, headers, secret: str):
"""raw_body: the request body as bytes, before JSON parsing."""
msg_id = headers.get("webhook-id")
timestamp = headers.get("webhook-timestamp")
signatures = headers.get("webhook-signature")
if not msg_id or not timestamp or not signatures:
raise ValueError("Missing webhook headers")
try:
ts = int(timestamp)
except ValueError:
raise ValueError("Invalid timestamp")
if abs(int(time.time()) - ts) > TOLERANCE_SECONDS:
raise ValueError("Timestamp outside the tolerance")
key = base64.b64decode(secret.removeprefix("whsec_"))
signed = f"{msg_id}.{timestamp}.".encode() + raw_body
expected = base64.b64encode(hmac.new(key, signed, hashlib.sha256).digest()).decode()
for entry in signatures.split(" "):
version, _, signature = entry.partition(",")
if version == "v1" and hmac.compare_digest(signature, expected):
return json.loads(raw_body)
raise ValueError("No matching signature")
<?php
const TOLERANCE_SECONDS = 300;
/**
* $rawBody: file_get_contents('php://input'), before json_decode.
* $headers: lower-case header names, e.g. from getallheaders() normalised.
*/
function verifyWebhook(string $rawBody, array $headers, string $secret): array
{
$id = $headers['webhook-id'] ?? '';
$timestamp = $headers['webhook-timestamp'] ?? '';
$signatures = $headers['webhook-signature'] ?? '';
if ($id === '' || $timestamp === '' || $signatures === '') {
throw new RuntimeException('Missing webhook headers');
}
if (!ctype_digit($timestamp) || abs(time() - (int) $timestamp) > TOLERANCE_SECONDS) {
throw new RuntimeException('Timestamp outside the tolerance');
}
$key = base64_decode(preg_replace('/^whsec_/', '', $secret), true);
$expected = base64_encode(hash_hmac('sha256', "{$id}.{$timestamp}.{$rawBody}", $key, true));
foreach (explode(' ', $signatures) as $entry) {
[$version, $signature] = array_pad(explode(',', $entry, 2), 2, '');
if ($version === 'v1' && hash_equals($expected, $signature)) {
return json_decode($rawBody, true, 512, JSON_THROW_ON_ERROR);
}
}
throw new RuntimeException('No matching signature');
}
# Ruby 2.6 or later
require "base64"
require "json"
require "openssl"
TOLERANCE_SECONDS = 5 * 60
# raw_body: request.raw_post (Rails) or request.body.read (Rack), before JSON parsing
def verify_webhook(raw_body, headers, secret)
id = headers["webhook-id"]
timestamp = headers["webhook-timestamp"]
signatures = headers["webhook-signature"]
raise "Missing webhook headers" if [id, timestamp, signatures].any? { |v| v.nil? || v.empty? }
ts = Integer(timestamp, exception: false)
raise "Timestamp outside the tolerance" if ts.nil? || (Time.now.to_i - ts).abs > TOLERANCE_SECONDS
key = Base64.strict_decode64(secret.delete_prefix("whsec_"))
digest = OpenSSL::HMAC.digest("SHA256", key, "#{id}.#{timestamp}.#{raw_body}")
expected = Base64.strict_encode64(digest)
signatures.split(" ").each do |entry|
version, signature = entry.split(",", 2)
next unless version == "v1" && signature
return JSON.parse(raw_body) if secure_compare(signature, expected)
end
raise "No matching signature"
end
# Constant-time string comparison.
def secure_compare(a, b)
return false unless a.bytesize == b.bytesize
result = 0
a.bytes.zip(b.bytes) { |x, y| result |= x ^ y }
result.zero?
end
package webhooks
import (
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"errors"
"net/http"
"strconv"
"strings"
"time"
)
const toleranceSeconds = 5 * 60
// rawBody: the request body bytes, read before any JSON decoding.
func VerifyWebhook(rawBody []byte, header http.Header, secret string) error {
id := header.Get("webhook-id")
timestamp := header.Get("webhook-timestamp")
signatures := header.Get("webhook-signature")
if id == "" || timestamp == "" || signatures == "" {
return errors.New("missing webhook headers")
}
ts, err := strconv.ParseInt(timestamp, 10, 64)
if err != nil {
return errors.New("invalid timestamp")
}
if diff := time.Now().Unix() - ts; diff > toleranceSeconds || diff < -toleranceSeconds {
return errors.New("timestamp outside the tolerance")
}
key, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(secret, "whsec_"))
if err != nil {
return errors.New("invalid secret")
}
mac := hmac.New(sha256.New, key)
mac.Write([]byte(id + "." + timestamp + "."))
mac.Write(rawBody)
expected := mac.Sum(nil)
for _, entry := range strings.Split(signatures, " ") {
version, signature, found := strings.Cut(entry, ",")
if !found || version != "v1" {
continue
}
given, err := base64.StdEncoding.DecodeString(signature)
if err == nil && hmac.Equal(given, expected) {
return nil
}
}
return errors.New("no matching signature")
}
using System;
using System.Security.Cryptography;
using System.Text;
public static class WebhookVerifier
{
private const long ToleranceSeconds = 5 * 60;
// rawBody: the request body bytes, read before any JSON deserialisation.
public static void Verify(byte[] rawBody, string id, string timestamp, string signatures, string secret)
{
if (string.IsNullOrEmpty(id) || string.IsNullOrEmpty(timestamp) || string.IsNullOrEmpty(signatures))
throw new UnauthorizedAccessException("Missing webhook headers");
if (!long.TryParse(timestamp, out var ts) ||
Math.Abs(DateTimeOffset.UtcNow.ToUnixTimeSeconds() - ts) > ToleranceSeconds)
throw new UnauthorizedAccessException("Timestamp outside the tolerance");
var key = Convert.FromBase64String(secret.StartsWith("whsec_") ? secret[6..] : secret);
var prefix = Encoding.UTF8.GetBytes($"{id}.{timestamp}.");
var signed = new byte[prefix.Length + rawBody.Length];
Buffer.BlockCopy(prefix, 0, signed, 0, prefix.Length);
Buffer.BlockCopy(rawBody, 0, signed, prefix.Length, rawBody.Length);
using var hmac = new HMACSHA256(key);
var expected = hmac.ComputeHash(signed);
foreach (var entry in signatures.Split(' ', StringSplitOptions.RemoveEmptyEntries))
{
var parts = entry.Split(',', 2);
if (parts.Length != 2 || parts[0] != "v1") continue;
byte[] given;
try { given = Convert.FromBase64String(parts[1]); } catch (FormatException) { continue; }
if (CryptographicOperations.FixedTimeEquals(given, expected)) return;
}
throw new UnauthorizedAccessException("No matching signature");
}
}
Rotating the signing secret
Rotate the secret when it may have been exposed, when someone who knew it leaves, or as routine hygiene.
- In Settings > API, open the Webhooks tab and choose Rotate secret in the endpoint's menu. You need to own the endpoint or have a role above its owner's.
- Copy the new secret. It is shown once.
- Deploy it to your receiver within 24 hours.
For 24 hours after a rotation the old secret keeps signing too: every delivery carries two signatures, the new secret's first and the old secret's second. Because your code accepts any matching signature, no delivery is rejected while you switch. After 24 hours only the new secret signs.
Rotating again within those 24 hours ends the overlap for the oldest secret: only the secret being replaced keeps signing alongside the new one.
Troubleshooting
| Symptom | Likely cause |
|---|---|
| Signature never matches | The body was parsed and re-serialized before verifying, or the secret was copied with extra whitespace or without the whsec_ prefix handling |
| Only some deliveries fail | A proxy or framework rewrites some bodies (for example, normalising line endings or character encoding) |
| Fails with "timestamp outside the tolerance" | Your server's clock is wrong. Enable time synchronisation (NTP) |
| Fails after rotating | The old secret is still deployed after its 24-hour overlap ended |
| Fails only on retries | The timestamp was checked against the first attempt's time. Each attempt is signed with its own timestamp; verify every attempt as it arrives |