Verifying webhook signatures
A signature helps your system tell a real webhook from a fake one and check that its contents have not changed in transit. For example, before creating a CRM contact, your system can check that the request came from the expected sender.
The receiver checks the signature using a public key, a string you copy from account settings. Copy it and give it to the developer who maintains your integration. They will add verification to the receiving system.
Enable signing#
- Sign in as an administrator. Open the channel's developer settings or the CRM webhooks settings.
- Enable signing and copy the public key.
- Add the key to the system that receives webhooks and enable signature verification there.
One key covers all account webhooks: channels, CRM and API subscriptions. If signing is not enabled, webhooks continue to work as before.
What the receiver must check#
Before taking action, your system must:
- Verify the signature using the key from account settings. Reject requests with a missing or invalid signature.
- Check the sending time. It must be within 5 minutes of your server's time, in either direction.
- Check whether the webhook has already been processed. A repeated request must not, for example, create another CRM contact.
Get the key from account settings, never from an incoming request. Use HTTPS to receive webhooks.
Replacing the key#
Rotate the key when you need to replace it. Rotation changes the key for every receiver in the account.
You have 24 hours to update the key in all integrations. During that time, either the new or previous key can verify requests. After that, the previous key stops working. You can rotate again once these 24 hours have passed.
If verification stops working after rotation, copy the current key from account settings and update the receiver.
Examples for developers#
The functions below return true when the signature and timestamp are valid. Call them before processing the event. Pass keys as an array: usually one key, or temporarily two during rotation.
Pass the original request body before parsing JSON. Even a change in whitespace will make verification fail.
| Header | What to pass to the function |
|---|---|
Webhook-Timestamp | Sending time in Unix seconds |
Webhook-Id | Webhook ID |
Webhook-Signature | Request signature |
To prevent duplicates, store IDs together with the account in a shared database and prevent simultaneous processing of the same ID. Keep IDs for at least 15 minutes. Return 2xx once the event is durably accepted; return 2xx for an already accepted duplicate too. The examples do not implement this step. If an event is sent again with a different ID, you also need rules that prevent repeating the business action itself.
TypeScript (Node.js 20+)#
Pass the body as a Buffer, the three header values as strings and the public keys from settings. Node.js uses lowercase header names in request.headers. In Express, use express.raw({type: 'application/json'}) before express.json() on this route.
import {createPublicKey, verify} from 'node:crypto';
export function verifyWebhook(
rawBody: Buffer,
timestamp: string,
webhookId: string,
signatureHeader: string,
trustedPublicKeys: string[],
now = Math.floor(Date.now() / 1000)
): boolean {
if (!/^[0-9]+$/.test(timestamp)) {
return false;
}
if (!/^[0-9]+$/.test(webhookId)) {
return false;
}
const sentAt = Number(timestamp);
if (!Number.isSafeInteger(sentAt)) {
return false;
}
const timeToleranceSeconds = 300;
if (Math.abs(now - sentAt) > timeToleranceSeconds) {
return false;
}
const messagePrefix = Buffer.from(`${webhookId}.${timestamp}.`);
const message = Buffer.concat([messagePrefix, rawBody]);
for (const encodedPublicKey of trustedPublicKeys) {
if (!encodedPublicKey.startsWith('whpk_')) {
throw new Error('Invalid configured public key');
}
const decodedPublicKey = Buffer.from(encodedPublicKey.slice(5), 'base64');
if (decodedPublicKey.length !== 32) {
throw new Error('Invalid configured public key');
}
// Node.js needs a key container rather than the raw public key bytes.
const verificationKey = createPublicKey({
key: {kty: 'OKP', crv: 'Ed25519', x: decodedPublicKey.toString('base64url')},
format: 'jwk'
});
for (const signatureEntry of signatureHeader.split(' ')) {
const parts = signatureEntry.split(',');
if (parts.length !== 2) {
continue;
}
const [version, encodedSignature] = parts;
if (version !== 'v1a') {
continue;
}
const decodedSignature = Buffer.from(encodedSignature, 'base64');
if (decodedSignature.length !== 64) {
continue;
}
// Buffer.from tolerates malformed Base64, so check the round trip too.
if (decodedSignature.toString('base64') !== encodedSignature) {
continue;
}
if (verify(null, message, verificationKey, decodedSignature)) {
return true;
}
}
}
return false;
}
Go#
Read the original body with io.ReadAll(r.Body). Pass it, the header values from r.Header.Get(...), the public keys and time.Now().
package webhooks
import (
"crypto/ed25519"
"encoding/base64"
"regexp"
"strconv"
"strings"
"time"
)
var decimalDigits = regexp.MustCompile(`^[0-9]+$`)
func VerifyWebhook(
rawBody []byte,
timestamp string,
webhookID string,
signatureHeader string,
trustedPublicKeys []string,
now time.Time,
) bool {
if !decimalDigits.MatchString(timestamp) {
return false
}
if !decimalDigits.MatchString(webhookID) {
return false
}
sentAt, err := strconv.ParseInt(timestamp, 10, 64)
if err != nil {
return false
}
const timeToleranceSeconds = 300
if sentAt < now.Unix()-timeToleranceSeconds {
return false
}
if sentAt > now.Unix()+timeToleranceSeconds {
return false
}
messagePrefix := []byte(webhookID + "." + timestamp + ".")
message := append(messagePrefix, rawBody...)
for _, encodedPublicKey := range trustedPublicKeys {
if !strings.HasPrefix(encodedPublicKey, "whpk_") {
panic("invalid configured public key")
}
decodedPublicKey, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(encodedPublicKey, "whpk_"))
if err != nil {
panic("invalid configured public key")
}
if len(decodedPublicKey) != ed25519.PublicKeySize {
panic("invalid configured public key")
}
for _, signatureEntry := range strings.Split(signatureHeader, " ") {
parts := strings.Split(signatureEntry, ",")
if len(parts) != 2 {
continue
}
version, encodedSignature := parts[0], parts[1]
if version != "v1a" {
continue
}
decodedSignature, err := base64.StdEncoding.DecodeString(encodedSignature)
if err != nil {
continue
}
if len(decodedSignature) != ed25519.SignatureSize {
continue
}
if ed25519.Verify(ed25519.PublicKey(decodedPublicKey), message, decodedSignature) {
return true
}
}
}
return false
}
PHP 8+ (Sodium)#
The Sodium extension is required. Read the body with file_get_contents('php://input'). Headers are available in $_SERVER['HTTP_WEBHOOK_TIMESTAMP'], $_SERVER['HTTP_WEBHOOK_ID'] and $_SERVER['HTTP_WEBHOOK_SIGNATURE']. Pass an empty string for a missing header.
<?php
declare(strict_types=1);
function verifyWebhook(
string $rawBody,
string $timestamp,
string $webhookId,
string $signatureHeader,
array $trustedPublicKeys,
?int $now = null,
): bool {
$now ??= time();
if (!preg_match('/^[0-9]+$/D', $timestamp)) {
return false;
}
if (!preg_match('/^[0-9]+$/D', $webhookId)) {
return false;
}
$timeToleranceSeconds = 300;
if (abs($now - (float) $timestamp) > $timeToleranceSeconds) {
return false;
}
$message = $webhookId . '.' . $timestamp . '.' . $rawBody;
foreach ($trustedPublicKeys as $encodedPublicKey) {
if (!str_starts_with($encodedPublicKey, 'whpk_')) {
throw new RuntimeException('Invalid configured public key');
}
$decodedPublicKey = base64_decode(substr($encodedPublicKey, 5), true);
if ($decodedPublicKey === false) {
throw new RuntimeException('Invalid configured public key');
}
if (strlen($decodedPublicKey) !== SODIUM_CRYPTO_SIGN_PUBLICKEYBYTES) {
throw new RuntimeException('Invalid configured public key');
}
foreach (explode(' ', $signatureHeader) as $signatureEntry) {
$parts = explode(',', $signatureEntry);
if (count($parts) !== 2) {
continue;
}
[$version, $encodedSignature] = $parts;
if ($version !== 'v1a') {
continue;
}
$decodedSignature = base64_decode($encodedSignature, true);
if ($decodedSignature === false) {
continue;
}
if (strlen($decodedSignature) !== SODIUM_CRYPTO_SIGN_BYTES) {
continue;
}
if (sodium_crypto_sign_verify_detached($decodedSignature, $message, $decodedPublicKey)) {
return true;
}
}
}
return false;
}
Signature format#
Use this section if you are writing your own verifier. The public key has the format whpk_<Base64>: remove whpk_ before decoding the 32 Ed25519 public-key bytes. The signed data combines the header values and original body:
Webhook-Id + "." + Webhook-Timestamp + "." + rawBody
Webhook-Signature contains a v1a,<Base64 signature> entry. During rotation it contains two space-separated entries; one valid signature from a trusted key is enough. Do not hash the message separately.
The signed bytes, signature header and public-key format follow Standard Webhooks.
If verification fails, check the current key, server time, and whether the request body and headers arrive unchanged. Do not bypass verification to accept a failed request.
For event types and their contents, see Webhooks API.