Signature Verification

How It Works

The signature is computed over {webhook-id}.{webhook-timestamp}.{raw-body} using your endpoint's signing secret. The timestamp allows your consumer to reject replayed requests or discard outdated payloads.

Code Samples

The examples below verify the signature using only standard library HMAC-SHA256 — no additional dependencies required.

import base64
import hashlib
import hmac
import json
import time

class WebhookVerificationError(Exception):
    pass

def verify_webhook(raw_body: bytes, headers: dict, secret: str) -> dict:
    msg_id        = headers["webhook-id"]
    msg_timestamp = headers["webhook-timestamp"]
    msg_signature = headers["webhook-signature"]

    # Reject stale payloads (older than 5 minutes)
    if abs(time.time() - int(msg_timestamp)) > 300:
        raise WebhookVerificationError("Timestamp too old")

    # Construct the signed content
    to_sign = f"{msg_id}.{msg_timestamp}.{raw_body.decode()}".encode()

    # Decode the signing secret (strip "whsec_" prefix, then base64-decode)
    key = base64.b64decode(secret.removeprefix("whsec_"))

    # Compute expected signature
    expected = base64.b64encode(
        hmac.new(key, to_sign, hashlib.sha256).digest()
    ).decode()

    # Compare against all provided signatures (space-separated, each prefixed "v1,")
    provided = [s.split(",", 1)[1] for s in msg_signature.split() if s.startswith("v1,")]
    if not any(hmac.compare_digest(expected, s) for s in provided):
        raise WebhookVerificationError("Invalid signature")

    return json.loads(raw_body)
const crypto = require("crypto");

class WebhookVerificationError extends Error {}

function verifyWebhook(rawBody, headers, secret) {
  const msgId        = headers["webhook-id"];
  const msgTimestamp = headers["webhook-timestamp"];
  const msgSignature = headers["webhook-signature"];

  // Reject stale payloads (older than 5 minutes)
  if (Math.abs(Date.now() / 1000 - parseInt(msgTimestamp)) > 300) {
    throw new WebhookVerificationError("Timestamp too old");
  }

  // Construct the signed content
  const toSign = `${msgId}.${msgTimestamp}.${rawBody}`;

  // Decode the signing secret (strip "whsec_" prefix, then base64-decode)
  const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");

  // Compute expected signature
  const expected = crypto.createHmac("sha256", key).update(toSign).digest("base64");

  // Compare against all provided signatures (space-separated, each prefixed "v1,")
  const provided = msgSignature.split(" ")
    .filter(s => s.startsWith("v1,"))
    .map(s => s.slice(3));

  if (!provided.some(s => crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(s)))) {
    throw new WebhookVerificationError("Invalid signature");
  }

  return JSON.parse(rawBody);
}
require "base64"
require "json"
require "openssl"

class WebhookVerificationError < StandardError; end

def verify_webhook(raw_body, headers, secret)
  msg_id        = headers["webhook-id"]
  msg_timestamp = headers["webhook-timestamp"]
  msg_signature = headers["webhook-signature"]

  # Reject stale payloads (older than 5 minutes)
  raise WebhookVerificationError, "Timestamp too old" if (Time.now.to_i - msg_timestamp.to_i).abs > 300

  # Construct the signed content
  to_sign = "#{msg_id}.#{msg_timestamp}.#{raw_body}"

  # Decode the signing secret (strip "whsec_" prefix, then base64-decode)
  key = Base64.strict_decode64(secret.delete_prefix("whsec_"))

  # Compute expected signature
  expected = Base64.strict_encode64(OpenSSL::HMAC.digest("sha256", key, to_sign))

  # Compare against all provided signatures (space-separated, each prefixed "v1,")
  provided = msg_signature.split.filter_map { |s| s.delete_prefix("v1,") if s.start_with?("v1,") }
  raise WebhookVerificationError, "Invalid signature" unless provided.any? { 
    |s| s.bytesize == expected.bytesize && OpenSSL.fixed_length_secure_compare(expected, s)
  }

  JSON.parse(raw_body)
end
import (
    "crypto/hmac"
    "crypto/sha256"
    "encoding/base64"
    "encoding/json"
    "errors"
    "fmt"
    "math"
    "net/http"
    "strconv"
    "strings"
    "time"
)

var ErrWebhookVerification = errors.New("webhook verification failed")

func verifyWebhook(body []byte, headers http.Header, secret string) (map[string]any, error) {
    msgID        := headers.Get("webhook-id")
    msgTimestamp := headers.Get("webhook-timestamp")
    msgSignature := headers.Get("webhook-signature")

    // Reject stale payloads (older than 5 minutes)
    ts, err := strconv.ParseInt(msgTimestamp, 10, 64)
    if err != nil || math.Abs(float64(time.Now().Unix()-ts)) > 300 {
        return nil, fmt.Errorf("%w: stale timestamp", ErrWebhookVerification)
    }

    // Construct the signed content
    toSign := fmt.Sprintf("%s.%s.%s", msgID, msgTimestamp, body)

    // Decode the signing secret (strip "whsec_" prefix, then base64-decode)
    key, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(secret, "whsec_"))
    if err != nil {
        return nil, err
    }

    // Compute expected signature
    mac := hmac.New(sha256.New, key)
    mac.Write([]byte(toSign))
    expected := base64.StdEncoding.EncodeToString(mac.Sum(nil))

    // Compare against all provided signatures (space-separated, each prefixed "v1,")
    for _, sig := range strings.Fields(msgSignature) {
        if strings.HasPrefix(sig, "v1,") {
            if hmac.Equal([]byte(expected), []byte(strings.TrimPrefix(sig, "v1,"))) {
                var payload map[string]any
                return payload, json.Unmarshal(body, &payload)
            }
        }
    }
    return nil, ErrWebhookVerification
}

Important for Node.js: rawBody must be a string, not a parsed object. In Express, use app.use(express.raw({ type: "application/json" })) and call rawBody.toString() before passing it to verifyWebhook.

Common Mistakes

Verifying against a parsed body: The signature is computed against the raw bytes. If you parse the JSON first and re-serialize, the bytes will differ and verification will fail.

CSRF middleware stripping headers: Some frameworks strip unknown headers before they reach your handler. Disable CSRF protection for your webhook endpoint path.

Wrong secret: Each endpoint has its own signing secret. Verify you are using the secret for the correct endpoint.


What’s Next