Verifying Webhook Signatures

Last updated January 31, 2026

Verifying Webhook Signatures

All webhook requests are signed using HMAC-SHA256. Always verify signatures to ensure requests are authentic and haven't been tampered with.

Why Verify Signatures?

Without signature verification, anyone who discovers your webhook URL could send fake events to your endpoint. Verification ensures:

  • Requests originate from us, not a malicious actor
  • Payloads haven't been modified in transit
  • Protection against replay attacks (using timestamp validation)

Signature Headers

Each webhook request includes these headers:

Header Description
X-Webhook-Signature HMAC-SHA256 signature in format v1=<hex>
X-Webhook-Timestamp Unix timestamp when the request was sent
X-Webhook-Event The event type (e.g., user.created)
X-Webhook-Delivery Unique delivery ID for idempotency

Verification Steps

  1. Extract the timestamp and signature from headers
  2. Construct the signed payload: {timestamp}.{raw_body}
  3. Compute the expected signature using HMAC-SHA256
  4. Compare signatures using a timing-safe comparison
  5. Optionally validate the timestamp to prevent replay attacks

Code Examples

Ruby

require 'openssl'

def verify_webhook(request, secret)
  signature = request.headers['X-Webhook-Signature']
  timestamp = request.headers['X-Webhook-Timestamp']
  body = request.raw_post

  return false unless signature&.start_with?('v1=')

  expected_sig = OpenSSL::HMAC.hexdigest(
    'SHA256',
    secret,
    "#{timestamp}.#{body}"
  )

  received_sig = signature.delete_prefix('v1=')

  # Timing-safe comparison
  ActiveSupport::SecurityUtils.secure_compare(expected_sig, received_sig)
end

Node.js

const crypto = require('crypto');

function verifyWebhook(req, secret) {
  const signature = req.headers['x-webhook-signature'];
  const timestamp = req.headers['x-webhook-timestamp'];
  const body = req.rawBody; // Ensure you capture the raw body

  if (!signature?.startsWith('v1=')) return false;

  const expectedSig = crypto
    .createHmac('sha256', secret)
    .update(`${timestamp}.${body}`)
    .digest('hex');

  const receivedSig = signature.slice(3); // Remove 'v1=' prefix

  // Timing-safe comparison
  return crypto.timingSafeEqual(
    Buffer.from(expectedSig),
    Buffer.from(receivedSig)
  );
}

Python

import hmac
import hashlib

def verify_webhook(request, secret):
    signature = request.headers.get('X-Webhook-Signature', '')
    timestamp = request.headers.get('X-Webhook-Timestamp', '')
    body = request.data.decode('utf-8')

    if not signature.startswith('v1='):
        return False

    expected_sig = hmac.new(
        secret.encode(),
        f"{timestamp}.{body}".encode(),
        hashlib.sha256
    ).hexdigest()

    received_sig = signature[3:]  # Remove 'v1=' prefix

    # Timing-safe comparison
    return hmac.compare_digest(expected_sig, received_sig)

PHP

function verifyWebhook($headers, $body, $secret) {
    $signature = $headers['X-Webhook-Signature'] ?? '';
    $timestamp = $headers['X-Webhook-Timestamp'] ?? '';

    if (strpos($signature, 'v1=') !== 0) {
        return false;
    }

    $expectedSig = hash_hmac('sha256', "{$timestamp}.{$body}", $secret);
    $receivedSig = substr($signature, 3); // Remove 'v1=' prefix

    // Timing-safe comparison
    return hash_equals($expectedSig, $receivedSig);
}

Go

import (
    "crypto/hmac"
    "crypto/sha256"
    "encoding/hex"
    "strings"
)

func verifyWebhook(signature, timestamp, body, secret string) bool {
    if !strings.HasPrefix(signature, "v1=") {
        return false
    }

    mac := hmac.New(sha256.New, []byte(secret))
    mac.Write([]byte(timestamp + "." + body))
    expectedSig := hex.EncodeToString(mac.Sum(nil))

    receivedSig := strings.TrimPrefix(signature, "v1=")

    // Timing-safe comparison
    return hmac.Equal([]byte(expectedSig), []byte(receivedSig))
}

Preventing Replay Attacks

Validate the timestamp to reject old requests:

def timestamp_valid?(timestamp, tolerance: 300)
  request_time = Time.at(timestamp.to_i)
  (Time.current - request_time).abs < tolerance
end

We recommend rejecting requests older than 5 minutes (300 seconds).

Troubleshooting

Signature mismatch - Ensure you're using the raw request body, not a parsed/modified version - Verify you're using the correct signing secret - Check that the secret hasn't been regenerated

Encoding issues - The body must be the exact bytes received, including whitespace - Don't re-serialize JSON - use the original string

Still need help?

Can't find what you're looking for? Get in touch with our support team.