Signature Verification

This is the most important page in this section. A webhook endpoint is a public URL that mutates your state — if you don't verify signatures correctly, anyone who discovers the URL can forge delivered and failed events. Verification is the security boundary, and it is not optional boilerplate.

Sent signs webhooks Svix-style — the same HMAC scheme used by Svix, a widely-used webhook-infrastructure provider, so libraries and knowledge built around Svix's convention transfer directly. The scheme is small and exact; get every detail right and nothing else matters, get one wrong and either everything is rejected or everything is forged.

The SDKs do not verify inbound webhooks. There is no client.webhooks.verifySignature() — it does not exist. The SDKs expose webhook management (create, list, rotate, toggle, test), but verification is hand-rolled per language against the scheme below. If a future SDK adds a verifier with this exact scheme, prefer it — until then, this code is yours to own.

For the product-level overview, see Signature verification in the concepts docs. This page is the implementation.

Quick path. Copy your language's function from The verifier, in seven languages verbatim, call it with the raw request body plus the X-Webhook-ID / X-Webhook-Timestamp / X-Webhook-Signature headers and your stored secret, and reject the request unless it returns valid — before any other processing. The scheme details below explain why the code is shaped this way, and the rotation and mistakes sections are worth a read once, but the verifier function itself is the part you need first.

The scheme

Three lines. Memorize them:

key            = base64_decode( secret without the "whsec_" prefix )
signed_content = "{X-Webhook-ID}.{X-Webhook-Timestamp}.{raw_body}"
expected       = "v1," + base64( HMAC_SHA256(key, signed_content) )

Then constant-time compare expected against the X-Webhook-Signature header — never ==/===. A normal string comparison exits as soon as it finds a mismatched byte, so how long the comparison takes leaks how many leading bytes an attacker's guess got right. Repeated enough times, that timing difference alone lets someone forge a valid signature without ever knowing the secret. A constant-time compare always takes the same time regardless of how much matches, so there's nothing to measure.

Every piece matters:

  • The secret is base64, not UTF-8. Strip the whsec_ prefix, then base64-decode the rest. The HMAC key is the decoded bytes.
  • The MAC covers id.timestamp.body, joined by literal dots — not just the body. The id and timestamp are inside the signature.
  • Sign the raw body, the exact bytes Sent sent, before any JSON parse or re-serialization.
  • The output is versioned: v1, followed by the base64 of the digest.

The verifier, in seven languages

Same scheme, idiomatic per language.

import { createHmac, timingSafeEqual } from "node:crypto";

const SIGNATURE_VERSION = "v1";
const TOLERANCE_SECONDS = 300;

export function computeSignature(
  id: string, timestamp: string, rawBody: Buffer | string, secret: string,
): string {
  const keyMaterial = secret.startsWith("whsec_") ? secret.slice(6) : secret;
  const key = Buffer.from(keyMaterial, "base64");        // base64-decode the secret
  const body = typeof rawBody === "string" ? rawBody : rawBody.toString("utf8");
  const signedContent = `${id}.${timestamp}.${body}`;    // id.timestamp.body
  const digest = createHmac("sha256", key).update(signedContent, "utf8").digest("base64");
  return `${SIGNATURE_VERSION},${digest}`;               // v1,<base64>
}

function constantTimeEquals(a: string, b: string): boolean {
  const ab = Buffer.from(a), bb = Buffer.from(b);
  if (ab.length !== bb.length) return false;
  return timingSafeEqual(ab, bb);
}

export function verifyWebhook(input: {
  id?: string; timestamp?: string; signature?: string;
  rawBody: Buffer | string; secret: string;
}): { valid: true } | { valid: false; reason: string } {
  const { id, timestamp, signature, rawBody, secret } = input;
  if (!id || !timestamp || !signature) return { valid: false, reason: "missing signature headers" };
  if (!secret) return { valid: false, reason: "webhook secret not configured" };

  const ts = Number(timestamp);
  if (!Number.isFinite(ts)) return { valid: false, reason: "invalid timestamp" };
  const now = Math.floor(Date.now() / 1000);
  if (Math.abs(now - ts) > TOLERANCE_SECONDS) return { valid: false, reason: "timestamp outside tolerance window" };

  const expected = computeSignature(id, timestamp, rawBody, secret);
  // Rotation: the header may carry several space-separated signatures; accept any match.
  const matched = signature.split(" ").some((c) => constantTimeEquals(c.trim(), expected));
  return matched ? { valid: true } : { valid: false, reason: "signature mismatch" };
}
import base64, hashlib, hmac, time

SIGNATURE_VERSION = "v1"
DEFAULT_TOLERANCE_SECONDS = 300


def compute_signature(webhook_id: str, timestamp: str, raw_body: bytes, secret: str) -> str:
    key_material = secret[len("whsec_"):] if secret.startswith("whsec_") else secret
    key = base64.b64decode(key_material)                    # base64-decode the secret
    signed_content = b"%s.%s.%s" % (                        # id.timestamp.body (bytes)
        webhook_id.encode(), timestamp.encode(), raw_body,
    )
    digest = hmac.new(key, signed_content, hashlib.sha256).digest()
    return f"{SIGNATURE_VERSION},{base64.b64encode(digest).decode('ascii')}"


def verify_webhook(*, webhook_id, timestamp, signature, raw_body, secret,
                   tolerance_seconds=DEFAULT_TOLERANCE_SECONDS):
    if not webhook_id or not timestamp or not signature:
        return False, "missing signature headers"
    if not secret:
        return False, "webhook secret not configured"
    try:
        ts = int(timestamp)
    except (TypeError, ValueError):
        return False, "invalid timestamp"
    if abs(int(time.time()) - ts) > tolerance_seconds:
        return False, "timestamp outside tolerance window"

    expected = compute_signature(webhook_id, timestamp, raw_body, secret)
    # Rotation: accept if any space-separated candidate matches (constant-time).
    matched = any(
        hmac.compare_digest(candidate.strip(), expected)
        for candidate in signature.split(" ") if candidate.strip()
    )
    return (True, None) if matched else (False, "signature mismatch")
import (
    "crypto/hmac"
    "crypto/sha256"
    "encoding/base64"
    "strconv"
    "strings"
    "time"
)

const ToleranceSeconds = 300

func Verify(id, timestamp, signature string, rawBody []byte, secret string) (bool, string) {
    if secret == "" {
        return false, "webhook secret not configured"
    }
    if id == "" || timestamp == "" || signature == "" {
        return false, "missing signature headers"
    }
    ts, err := strconv.ParseInt(timestamp, 10, 64)
    if err != nil {
        return false, "invalid timestamp"
    }
    if diff := time.Now().Unix() - ts; diff > ToleranceSeconds || diff < -ToleranceSeconds {
        return false, "timestamp outside tolerance window"
    }
    expected, err := computeSignature(id, timestamp, rawBody, secret)
    if err != nil {
        return false, "invalid secret encoding"
    }
    // Rotation: the header may carry several space-separated signatures.
    for _, candidate := range strings.Fields(signature) {
        if hmac.Equal([]byte(candidate), []byte(expected)) { // constant-time
            return true, ""
        }
    }
    return false, "signature mismatch"
}

func computeSignature(id, timestamp string, body []byte, secret string) (string, error) {
    key := strings.TrimPrefix(secret, "whsec_")
    keyBytes, err := base64.StdEncoding.DecodeString(key) // base64-decode the secret
    if err != nil {
        return "", err
    }
    mac := hmac.New(sha256.New, keyBytes)
    mac.Write([]byte(id + "." + timestamp + ".")) // id.timestamp.
    mac.Write(body)                               // + raw body
    return "v1," + base64.StdEncoding.EncodeToString(mac.Sum(nil)), nil
}
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.Base64;

public class WebhookSignatureVerifier {
    private static final String SIGNATURE_VERSION = "v1";
    private static final String HMAC_ALGORITHM = "HmacSHA256";
    private static final long TOLERANCE_SECONDS = 300;

    public enum Result { VALID, MISSING_HEADERS, INVALID }

    public Result verify(String id, String timestamp, String signature, String rawBody, String secret) {
        return verify(id, timestamp, signature, rawBody, secret, System.currentTimeMillis() / 1000);
    }

    Result verify(String id, String timestamp, String signature, String rawBody, String secret, long nowSeconds) {
        if (isBlank(id) || isBlank(timestamp) || isBlank(signature)) return Result.MISSING_HEADERS;
        if (isBlank(secret)) return Result.INVALID; // fail closed

        final long ts;
        try { ts = Long.parseLong(timestamp.trim()); }
        catch (NumberFormatException e) { return Result.INVALID; }
        if (Math.abs(nowSeconds - ts) > TOLERANCE_SECONDS) return Result.INVALID;

        final String expected;
        try { expected = computeSignature(secret, id, timestamp.trim(), rawBody); }
        catch (Exception e) { return Result.INVALID; }

        // Rotation: accept if any space-separated candidate matches (constant-time).
        byte[] expectedBytes = expected.getBytes(StandardCharsets.UTF_8);
        for (String candidate : signature.trim().split(" ")) {
            if (candidate.isEmpty()) continue;
            if (MessageDigest.isEqual(candidate.trim().getBytes(StandardCharsets.UTF_8), expectedBytes))
                return Result.VALID;
        }
        return Result.INVALID;
    }

    private String computeSignature(String secret, String id, String timestamp, String rawBody) throws Exception {
        String keyMaterial = secret.startsWith("whsec_") ? secret.substring(6) : secret;
        byte[] key = Base64.getDecoder().decode(keyMaterial);   // base64-decode the secret
        String signedContent = id + "." + timestamp + "." + rawBody; // id.timestamp.body
        Mac mac = Mac.getInstance(HMAC_ALGORITHM);
        mac.init(new SecretKeySpec(key, HMAC_ALGORITHM));
        byte[] digest = mac.doFinal(signedContent.getBytes(StandardCharsets.UTF_8));
        return SIGNATURE_VERSION + "," + Base64.getEncoder().encodeToString(digest);
    }

    private static boolean isBlank(String s) { return s == null || s.isBlank(); }
}
using System.Security.Cryptography;
using System.Text;

public class WebhookSignatureVerifier
{
    public const int ToleranceSeconds = 300;

    public readonly record struct Result(bool Valid, string Reason)
    {
        public static Result Ok() => new(true, string.Empty);
        public static Result Fail(string reason) => new(false, reason);
    }

    public Result Verify(string? id, string? timestamp, string? signature, byte[] rawBody, string? secret)
    {
        if (string.IsNullOrEmpty(secret)) return Result.Fail("webhook secret not configured");
        if (string.IsNullOrEmpty(id) || string.IsNullOrEmpty(timestamp) || string.IsNullOrEmpty(signature))
            return Result.Fail("missing signature headers");
        if (!long.TryParse(timestamp, out var ts)) return Result.Fail("invalid timestamp");

        var diff = DateTimeOffset.UtcNow.ToUnixTimeSeconds() - ts;
        if (diff > ToleranceSeconds || diff < -ToleranceSeconds)
            return Result.Fail("timestamp outside tolerance window");

        if (!TryComputeSignature(id, timestamp, rawBody, secret, out var expected))
            return Result.Fail("invalid secret encoding");

        var expectedBytes = Encoding.UTF8.GetBytes(expected);
        // Rotation: the header may carry several space-separated signatures.
        foreach (var candidate in signature.Split(' ', StringSplitOptions.RemoveEmptyEntries))
        {
            if (CryptographicOperations.FixedTimeEquals(Encoding.UTF8.GetBytes(candidate), expectedBytes))
                return Result.Ok();
        }
        return Result.Fail("signature mismatch");
    }

    private static bool TryComputeSignature(string id, string timestamp, byte[] body, string secret, out string signature)
    {
        signature = string.Empty;
        var key = secret.StartsWith("whsec_", StringComparison.Ordinal) ? secret["whsec_".Length..] : secret;
        byte[] keyBytes;
        try { keyBytes = Convert.FromBase64String(key); }       // base64-decode the secret
        catch (FormatException) { return false; }

        var prefix = Encoding.UTF8.GetBytes($"{id}.{timestamp}."); // id.timestamp.
        var signedContent = new byte[prefix.Length + body.Length];
        Buffer.BlockCopy(prefix, 0, signedContent, 0, prefix.Length);
        Buffer.BlockCopy(body, 0, signedContent, prefix.Length, body.Length); // + raw body bytes

        using var hmac = new HMACSHA256(keyBytes);
        signature = "v1," + Convert.ToBase64String(hmac.ComputeHash(signedContent));
        return true;
    }
}
<?php

class WebhookSignatureVerifier
{
    public const TOLERANCE_SECONDS = 300;

    public function __construct(private readonly ?string $secret) {}

    public function verify(string $webhookId, string $timestamp, string $signature, string $rawBody): bool
    {
        if (empty($this->secret)) return false;
        if ($webhookId === '' || $timestamp === '' || $signature === '') return false;
        if (!is_numeric($timestamp) || abs(time() - (int) $timestamp) > self::TOLERANCE_SECONDS) return false;

        $expected = $this->expectedSignature($webhookId, $timestamp, $rawBody);
        if ($expected === null) return false;

        // Rotation: accept if any space-separated candidate matches (constant-time).
        foreach (preg_split('/\s+/', trim($signature)) as $candidate) {
            if ($candidate !== '' && hash_equals($expected, $candidate)) return true;
        }
        return false;
    }

    private function expectedSignature(string $webhookId, string $timestamp, string $rawBody): ?string
    {
        $key = $this->secret;
        if (str_starts_with($key, 'whsec_')) $key = substr($key, strlen('whsec_'));

        $keyBytes = base64_decode($key, true);              // base64-decode the secret
        if ($keyBytes === false) return null;

        $signed = "{$webhookId}.{$timestamp}.{$rawBody}";   // id.timestamp.body
        return 'v1,' . base64_encode(hash_hmac('sha256', $signed, $keyBytes, true));
    }
}
require "base64"
require "openssl"

module SentDm
  class SignatureVerifier
    SIGNATURE_VERSION = "v1".freeze
    TOLERANCE_SECONDS = 300

    def initialize(id:, timestamp:, signature:, raw_body:, secret:, now: Time.now.to_i)
      @id, @timestamp, @signature = id, timestamp, signature
      @raw_body, @secret, @now = raw_body, secret, now
    end

    def self.verify(...) = new(...).verify

    def verify
      return [false, "missing signature headers"] if @id.to_s.empty? || @timestamp.to_s.empty? || @signature.to_s.empty?
      return [false, "webhook secret not configured"] if @secret.to_s.empty?

      ts = Integer(@timestamp, exception: false)
      return [false, "invalid timestamp"] if ts.nil?
      return [false, "timestamp outside tolerance window"] if (@now - ts).abs > TOLERANCE_SECONDS

      expected = expected_signature(ts)
      # Rotation: accept if any space-separated candidate matches (constant-time).
      matched = @signature.split(" ").any? { |c| secure_compare(c.strip, expected) }
      matched ? [true, nil] : [false, "signature mismatch"]
    end

    private

    def expected_signature(timestamp)
      key    = Base64.decode64(@secret.to_s.delete_prefix("whsec_")) # base64-decode the secret
      signed = "#{@id}.#{timestamp}.#{@raw_body}"                    # id.timestamp.body
      digest = OpenSSL::HMAC.digest("sha256", key, signed)
      "#{SIGNATURE_VERSION},#{Base64.strict_encode64(digest)}"
    end

    def secure_compare(a, b)
      # Plain Ruby + openssl (already required above) — works outside Rails too,
      # unlike ActiveSupport::SecurityUtils.secure_compare.
      OpenSSL.fixed_length_secure_compare(a, b)
    rescue ArgumentError
      false # differing lengths — not a match, and not constant-time either way
    end
  end
end

The replay window

A valid signature on a captured request is still an attack if it's replayed hours later. So reject any event whose timestamp is more than 300 seconds (5 minutes) away from now, in either direction:

reject if abs(now - X-Webhook-Timestamp) > 300

Because the timestamp is part of the signed content, an attacker can't rewrite it to a fresh value without invalidating the signature. The window and the signature reinforce each other.

Rotation: accept any

When a secret is rotated, there's a transition window where both the old and new secrets are in play. Two things make this seamless:

  1. Multiple signatures per header. X-Webhook-Signature may carry several space-separated tokens (v1,aaa v1,bbb). Accept the event if any token matches your expected signature.
  2. Multiple candidate secrets. Your verifier holds a set of secrets (old + new during rotation) and tries each. The receiver accepts on the first that verifies.

Every verifier above splits the header and iterates candidates. Pair this with Endpoint management, where the store holds both secrets until you retire the old one.

Three mistakes to avoid

1. Not decoding the secret. The most common bug. HMAC(secret_string, body) — using the literal whsec_... string as the key — will never match. Strip the prefix and base64-decode the rest; the key is the decoded bytes.

2. Signing only the body. The MAC covers {id}.{timestamp}.{body}, joined by literal dots. Signing just the body drops the id and timestamp from the signature and breaks cross-endpoint and replay protection.

3. Ignoring the replay window. A signature verifies forever unless you bound it in time. Without the ±300s check, a captured-and-replayed request sails through. Enforce it.

Two more that bite in practice: comparing with == instead of a constant-time compare (leaks timing information — always use timingSafeEqual / hmac.compare_digest / hmac.Equal / MessageDigest.isEqual / FixedTimeEquals / hash_equals / secure_compare), and verifying against parsed-then-reserialized JSON instead of the raw bytes (covered in The webhook receiver).

Next steps

On this page