Skip to content

How to Verify Webhook Signatures

Verify webhook signatures for Stripe, GitHub, Shopify, Meta, and 23 more signed providers using HMAC-SHA256, HMAC-SHA1, and asymmetric algorithms. Protect handlers from forged payloads.

Updated May 2026

Webhook signatures prove a payload came from the expected sender and was not tampered with in transit. Most providers sign payloads with HMAC-SHA256. Discord is the exception, using Ed25519 public-key cryptography. Twilio uses HMAC-SHA1. Verifying signatures is the single most important security step in any webhook handler. Skip it, and anyone who knows your endpoint URL can forge payloads.

The webhooks.cc SDK verifies signatures for 27 signed providers with a single function call. This guide walks through the process for each one. SendGrid is supported for templates and provider detection, but its Event Webhook security model uses IP allowlisting rather than cryptographic signatures.

Verify in the dashboard (no code)

Before writing verification code, you can verify signatures directly in the dashboard:

  1. Quick check — open any captured request, click the Signature tab, paste your secret, and click Verify. Runs in your browser; the secret never leaves the client.
  2. Automatic — configure a signing secret in endpoint settings. Every incoming webhook is verified server-side and the result (valid/invalid) appears as a badge in the request list.

See Dashboard Features: Signature Verification for the full walkthrough.

What you'll build

A webhook handler that verifies signatures before processing, with working examples for Stripe, GitHub, Shopify, Twilio, Slack, Paddle, Linear, Clerk, Discord, Vercel, GitLab, Typeform, Standard Webhooks (Polar, Svix, Resend), Meta (WhatsApp/Messenger/Instagram), Lemon Squeezy, Coinbase Commerce, Razorpay, Cal.com, Intercom, Telegram, Square, HubSpot, Mailgun, Calendly, Mux, Sentry, and Bitbucket.

Prerequisites

  • Node.js 18+ (required for crypto.subtle, used by Ed25519 verification)
  • @webhooks-cc/sdk installed in your project
  • Provider webhook secret from your provider's dashboard (or public key for Discord)

How signatures work

Every HMAC-based webhook signature follows the same four-step process:

  1. The provider computes a hash of the raw request body (sometimes combined with a timestamp or URL) using a shared secret only you and the provider know.
  2. The provider sends the hash in an HTTP header alongside the request. The header name varies by provider — stripe-signature, x-hub-signature-256, etc.
  3. Your handler computes the same hash over the raw body using the same secret.
  4. If the two hashes match, the payload is authentic and unmodified. If they do not match, the payload was either tampered with or signed with a different secret.

Discord works differently. Instead of a shared secret, Discord uses Ed25519 public-key cryptography. You verify using your application's public key (available in the Discord developer portal), not a shared secret.

Always verify signatures before processing webhook payloads. Unverified webhooks can be forged by anyone who knows your endpoint URL.

Step-by-step verification

1

Install the SDK

npm install @webhooks-cc/sdk
2

Verify with the generic function

The verifySignature function works with any supported provider. It accepts a request object (with body and headers fields) and returns { valid: boolean }.

import { verifySignature } from "@webhooks-cc/sdk";
 
// Works with any Request-like object that has body and headers
const result = await verifySignature(request, {
  provider: "stripe",
  secret: process.env.STRIPE_WEBHOOK_SECRET!,
});
 
if (!result.valid) {
  return new Response("Invalid signature", { status: 401 });
}
 
// Signature verified — safe to process the payload
const body = JSON.parse(request.body);

Replace "stripe" with any supported verification provider name: "github", "shopify", "twilio", "slack", "paddle", "linear", "clerk", "discord", "vercel", "gitlab", "typeform", "standard-webhooks", "meta", "lemonsqueezy", "coinbase-commerce", "razorpay", "cal", "intercom", "telegram", "square", "hubspot", "mailgun", "calendly", "mux", "sentry", or "bitbucket".

3

Auto-detect the provider first

When a handler receives traffic from several providers, use detectWebhookInfo to pick the right verification branch. Detection is based on headers first, then body shape for providers like SendGrid.

import { detectWebhookInfo, verifySignature } from "@webhooks-cc/sdk";
 
const info = detectWebhookInfo(request);
 
if (info?.provider === "typeform") {
  const result = await verifySignature(request, {
    provider: "typeform",
    secret: process.env.TYPEFORM_WEBHOOK_SECRET!,
  });
 
  if (!result.valid) {
    return new Response("Invalid signature", { status: 401 });
  }
}
4

Or use provider-specific functions

When you know the provider at build time, use the dedicated function. These accept the raw body, signature header value, and secret as separate arguments, and return a plain boolean.

import { verifyStripeSignature } from "@webhooks-cc/sdk";
 
const valid = await verifyStripeSignature(
  request.body, // raw body string
  request.headers["stripe-signature"], // signature header value
  process.env.STRIPE_WEBHOOK_SECRET! // your signing secret
);
 
if (!valid) {
  return new Response("Invalid signature", { status: 401 });
}

Provider-specific functions are available for all supported providers: verifyStripeSignature, verifyGitHubSignature, verifyShopifySignature, verifyTwilioSignature, verifySlackSignature, verifyPaddleSignature, verifyLinearSignature, verifyClerkSignature, verifyDiscordSignature, verifyVercelSignature, verifyGitLabSignature, verifyTypeformSignature, verifyStandardWebhookSignature, verifyMetaSignature, verifyLemonSqueezySignature, verifyCoinbaseCommerceSignature, verifyRazorpaySignature, verifyCalSignature, verifyIntercomSignature, verifyTelegramSignature, verifySquareSignature, verifyHubSpotSignature, verifyMailgunSignature, verifyCalendlySignature, verifyMuxSignature, verifySentrySignature, verifyBitbucketSignature. SendGrid is excluded because it uses IP allowlisting instead of cryptographic signatures.

5

Handle Discord (Ed25519)

Discord does not use HMAC. Pass publicKey instead of secret. The key is your Discord application's hex-encoded public key from the developer portal.

import { verifySignature } from "@webhooks-cc/sdk";
 
const result = await verifySignature(request, {
  provider: "discord",
  publicKey: process.env.DISCORD_PUBLIC_KEY!,
});
 
if (!result.valid) {
  return new Response("Invalid signature", { status: 401 });
}

Or use the provider-specific function, which takes the body, full headers object, and public key:

import { verifyDiscordSignature } from "@webhooks-cc/sdk";
 
const valid = await verifyDiscordSignature(
  request.body,
  request.headers,
  process.env.DISCORD_PUBLIC_KEY!
);
6

Handle Twilio (requires URL)

Twilio's signature covers the full callback URL concatenated with sorted form parameters, not just the body. You must pass the exact URL Twilio was configured to call.

import { verifySignature } from "@webhooks-cc/sdk";
 
const result = await verifySignature(request, {
  provider: "twilio",
  secret: process.env.TWILIO_AUTH_TOKEN!,
  url: "https://your-app.com/api/webhooks/twilio",
});
 
if (!result.valid) {
  return new Response("Invalid signature", { status: 401 });
}

Without the url option, verification throws an error.

7

Handle Slack (timestamp window)

Slack sends two headers: x-slack-signature and x-slack-request-timestamp. The SDK reconstructs the base string as v0:{timestamp}:{body} and computes HMAC-SHA256.

import { verifySlackSignature } from "@webhooks-cc/sdk";
 
// Slack-specific function takes the body, full headers object, and secret
const valid = await verifySlackSignature(
  request.body,
  request.headers,
  process.env.SLACK_SIGNING_SECRET!
);

Slack rejects signatures when the timestamp is older than 5 minutes. If you are verifying captured webhooks, verify promptly after capture.

8

Handle Clerk (Standard Webhooks / Svix)

Clerk uses the Standard Webhooks (Svix) signing format. The SDK provides a dedicated "clerk" provider that delegates to Standard Webhooks verification.

const result = await verifySignature(request, {
  provider: "clerk",
  secret: process.env.CLERK_WEBHOOK_SECRET!,
});
9

Handle Vercel (HMAC-SHA1)

Vercel signs deploy hook payloads with HMAC-SHA1 and sends the hex-encoded signature in the x-vercel-signature header.

const result = await verifySignature(request, {
  provider: "vercel",
  secret: process.env.VERCEL_WEBHOOK_SECRET!,
});
10

Handle GitLab (token comparison)

GitLab sends the configured webhook token verbatim in the x-gitlab-token header. The SDK performs a constant-time comparison against your secret.

const result = await verifySignature(request, {
  provider: "gitlab",
  secret: process.env.GITLAB_WEBHOOK_TOKEN!,
});
11

Handle Typeform (HMAC-SHA256)

Typeform signs the exact raw JSON payload with HMAC-SHA256 and sends the base64 signature in the typeform-signature header with a sha256= prefix.

const result = await verifySignature(request, {
  provider: "typeform",
  secret: process.env.TYPEFORM_WEBHOOK_SECRET!,
});
12

Handle Standard Webhooks (Polar, Svix, Resend)

Standard Webhooks uses three headers: webhook-id, webhook-timestamp, and webhook-signature. Many services use this spec, including Polar.sh, Svix, Resend, Liveblocks, and Novu.

import { verifySignature } from "@webhooks-cc/sdk";
 
const result = await verifySignature(request, {
  provider: "standard-webhooks",
  secret: process.env.POLAR_WEBHOOK_SECRET!,
});

Secrets with a whsec_ prefix are handled automatically. The SDK detects whether the key after the prefix is base64-encoded (Svix) or raw UTF-8 (Polar.sh).

13

Handle Meta (WhatsApp / Messenger / Instagram)

Meta signs the raw body with HMAC-SHA256 and sends sha256={hex} in x-hub-signature-256 — the same scheme as GitHub — keyed by your app secret. Because Meta shares GitHub's header, configure meta explicitly rather than relying on header auto-detection.

const result = await verifySignature(request, {
  provider: "meta",
  secret: process.env.META_APP_SECRET!,
});
14

Handle hex HMAC-SHA256 providers (Lemon Squeezy, Coinbase Commerce, Razorpay, Cal.com)

These providers each send a raw hex HMAC-SHA256 of the body in their own header — x-signature (Lemon Squeezy), x-cc-webhook-signature (Coinbase Commerce), x-razorpay-signature (Razorpay), and x-cal-signature-256 (Cal.com). Pass the matching provider id:

const result = await verifySignature(request, {
  provider: "coinbase-commerce",
  secret: process.env.COINBASE_COMMERCE_WEBHOOK_SECRET!,
});
15

Handle Intercom (HMAC-SHA1)

Intercom signs the raw body with HMAC-SHA1 and sends sha1={hex} in the x-hub-signature header, keyed by your app's client secret. The sha1= prefix distinguishes it from GitHub (x-hub-signature-256) and Bitbucket (sha256=).

const result = await verifySignature(request, {
  provider: "intercom",
  secret: process.env.INTERCOM_CLIENT_SECRET!,
});
16

Handle Telegram (secret token)

Telegram does not use HMAC. It echoes the secret_token you registered with setWebhook verbatim in the x-telegram-bot-api-secret-token header. The SDK performs a constant-time comparison against your secret.

const result = await verifySignature(request, {
  provider: "telegram",
  secret: process.env.TELEGRAM_WEBHOOK_SECRET_TOKEN!,
});
17

Handle Square and HubSpot (URL and method)

Square and HubSpot mix request context into the signed payload, so verification needs more than the body and secret.

Square signs the notification URL concatenated with the raw body (base64 HMAC-SHA256 in x-square-hmacsha256-signature). Pass the exact URL Square delivers to via the url option:

const result = await verifySignature(request, {
  provider: "square",
  secret: process.env.SQUARE_SIGNATURE_KEY!,
  url: "https://your-app.com/api/webhooks/square",
});

HubSpot v3 signs {method}{url}{body}{timestamp} (base64 HMAC-SHA256 in x-hubspot-signature-v3, with the millisecond timestamp in x-hubspot-request-timestamp). Pass both url and method. HubSpot also rejects signatures whose timestamp is more than 5 minutes old, so verify promptly after capture:

const result = await verifySignature(request, {
  provider: "hubspot",
  secret: process.env.HUBSPOT_CLIENT_SECRET!,
  url: "https://your-app.com/api/webhooks/hubspot",
  method: "POST",
});

Without the required url option, verification throws an error for both providers. method defaults to "POST" when omitted for HubSpot.

18

Handle Mailgun (body-embedded signature)

Mailgun is the only supported provider with no signature header. It embeds the signature in the request body under signature.{timestamp,token,signature} and signs {timestamp}{token} with HMAC-SHA256 (hex). The SDK reads the body directly and returns false (never throws) on malformed input:

const result = await verifySignature(request, {
  provider: "mailgun",
  secret: process.env.MAILGUN_SIGNING_KEY!,
});
19

Handle Calendly and Mux (Stripe-style header)

Calendly and Mux both use the Stripe-style t=<timestamp>,v1=<hash> header format — calendly-webhook-signature and mux-signature respectively — computing HMAC-SHA256 over {timestamp}.{body} (hex):

const calendly = await verifySignature(request, {
  provider: "calendly",
  secret: process.env.CALENDLY_SIGNING_KEY!,
});
 
const mux = await verifySignature(request, {
  provider: "mux",
  secret: process.env.MUX_SIGNING_SECRET!,
});
20

Handle Sentry (hex HMAC-SHA256)

Sentry signs the raw body with HMAC-SHA256 and sends the hex digest in sentry-hook-signature. The resource type travels in sentry-hook-resource:

const result = await verifySignature(request, {
  provider: "sentry",
  secret: process.env.SENTRY_CLIENT_SECRET!,
});
21

Handle Bitbucket (GitHub-style sha256=)

Bitbucket signs the raw body with HMAC-SHA256 and sends sha256={hex} in the x-hub-signature header — the same scheme as GitHub. Auto-detection distinguishes it from Intercom (which also uses x-hub-signature, but with sha1=) by Bitbucket's unique x-event-key header:

const result = await verifySignature(request, {
  provider: "bitbucket",
  secret: process.env.BITBUCKET_WEBHOOK_SECRET!,
});

Provider reference

ProviderHeaderAlgorithmNotes
Stripestripe-signatureHMAC-SHA256t=timestamp,v1=hash format
GitHubx-hub-signature-256HMAC-SHA256sha256=hash hex-encoded
Shopifyx-shopify-hmac-sha256HMAC-SHA256Base64-encoded hash
Twiliox-twilio-signatureHMAC-SHA1Requires full callback URL
Slackx-slack-signature + x-slack-request-timestampHMAC-SHA2565-minute timestamp window
Paddlepaddle-signatureHMAC-SHA256ts=timestamp;h1=hash format
Linearlinear-signatureHMAC-SHA256Hex-encoded, optional sha256= prefix
Clerkwebhook-signature (Standard Webhooks / Svix)HMAC-SHA256Delegates to Standard Webhooks signing
Discordx-signature-ed25519 + x-signature-timestampEd25519Uses public key, not shared secret
Vercelx-vercel-signatureHMAC-SHA1Hex-encoded hash
GitLabx-gitlab-tokenToken comparisonRaw secret sent in header
Typeformtypeform-signatureHMAC-SHA256sha256={base64} format
Standard Webhookswebhook-id + webhook-timestamp + webhook-signatureHMAC-SHA256Used by Polar, Svix, Resend
Metax-hub-signature-256HMAC-SHA256sha256=hash, keyed by app secret
Lemon Squeezyx-signatureHMAC-SHA256Raw hex hash
Coinbase Commercex-cc-webhook-signatureHMAC-SHA256Raw hex hash
Razorpayx-razorpay-signatureHMAC-SHA256Raw hex hash
Cal.comx-cal-signature-256HMAC-SHA256Raw hex hash
Intercomx-hub-signatureHMAC-SHA1sha1=hash hex-encoded
Telegramx-telegram-bot-api-secret-tokenToken comparisonRaw secret token sent in header
Squarex-square-hmacsha256-signatureHMAC-SHA256base64 over URL + body; needs url
HubSpotx-hubspot-signature-v3 + x-hubspot-request-timestampHMAC-SHA256base64 over method+url+body+ts; needs url, method; 5-min window
Mailgun(body fields) signature.{timestamp,token,signature}HMAC-SHA256hex over timestamp+token; no header
Calendlycalendly-webhook-signatureHMAC-SHA256Stripe-style t=,v1= hex
Muxmux-signatureHMAC-SHA256Stripe-style t=,v1= hex
Sentrysentry-hook-signatureHMAC-SHA256Raw hex hash over body
Bitbucketx-hub-signatureHMAC-SHA256sha256=hash; detected via x-event-key

Verifying captured requests

webhooks.cc preserves the original headers and raw body exactly as received. Signature verification works on captured requests the same way it works on live requests:

import { WebhooksCC, verifySignature } from "@webhooks-cc/sdk";
 
const client = new WebhooksCC({ apiKey: process.env.WHK_API_KEY! });
 
// Fetch a captured request
const request = await client.requests.get(requestId);
 
// Verify its signature
const result = await verifySignature(request, {
  provider: "stripe",
  secret: process.env.STRIPE_WEBHOOK_SECRET!,
});
 
console.log(result.valid); // true — signature matches

This is useful for debugging signature failures. Capture the webhook, inspect the raw headers and body in the dashboard, then run verification against it programmatically to isolate whether the issue is in your secret, your body parsing, or something else.

Full handler example

Here is a complete webhook handler for a Next.js API route that verifies a Stripe signature and processes the event:

import { verifySignature } from "@webhooks-cc/sdk";
import type { NextRequest } from "next/server";
 
export async function POST(req: NextRequest) {
  const body = await req.text();
  const headers: Record<string, string> = {};
  req.headers.forEach((value, key) => {
    headers[key] = value;
  });
 
  const result = await verifySignature(
    { body, headers },
    {
      provider: "stripe",
      secret: process.env.STRIPE_WEBHOOK_SECRET!,
    }
  );
 
  if (!result.valid) {
    return new Response("Invalid signature", { status: 401 });
  }
 
  const event = JSON.parse(body);
 
  switch (event.type) {
    case "checkout.session.completed":
      await handleCheckoutComplete(event.data.object);
      break;
    case "invoice.paid":
      await handleInvoicePaid(event.data.object);
      break;
  }
 
  return new Response("OK", { status: 200 });
}

Read the request body as raw text before verifying. If your framework parses the body as JSON first, the re-serialized output may differ from the original bytes, causing HMAC verification to fail.

Testing signature verification

Use sendTo to send properly signed webhooks to your local handler and verify that your verification logic works:

import { WebhooksCC } from "@webhooks-cc/sdk";
import { describe, it, expect } from "vitest";
 
const client = new WebhooksCC({ apiKey: process.env.WHK_API_KEY! });
 
describe("signature verification", () => {
  it("accepts valid Stripe signatures", async () => {
    const res = await client.sendTo("http://localhost:3000/api/webhooks/stripe", {
      provider: "stripe",
      secret: process.env.STRIPE_WEBHOOK_SECRET!,
      body: {
        type: "payment_intent.succeeded",
        data: { object: { id: "pi_test_123", amount: 4999 } },
      },
    });
 
    expect(res.status).toBe(200);
  });
 
  it("rejects invalid signatures", async () => {
    const res = await client.sendTo("http://localhost:3000/api/webhooks/stripe", {
      provider: "stripe",
      secret: "whsec_wrong_secret",
      body: { type: "payment_intent.succeeded", data: {} },
    });
 
    expect(res.status).toBe(401);
  });
});

Common issues

"Signature always fails"

Your handler is probably parsing the body before verification. HMAC must be computed on the raw bytes, not on a re-serialized JSON object. In Express, use express.raw({ type: "application/json" }) for your webhook route. In Next.js, use req.text() instead of req.json().

"Twilio verification fails"

You must pass the exact URL Twilio was configured to call, including the protocol (https://) and full path. A mismatch in protocol, hostname, or path causes the signature to differ. Check your Twilio dashboard for the configured webhook URL.

"Slack timestamp check fails"

Slack rejects signatures when the x-slack-request-timestamp header is older than 5 minutes. If your server's clock is skewed, NTP-sync it. If you are verifying a captured webhook, verify it within a few minutes of capture.

"Standard Webhooks secret format mismatch"

The SDK auto-detects whether a whsec_-prefixed secret is base64-encoded (Svix, Clerk) or raw UTF-8 (Polar.sh). If you are passing a raw base64 string without the prefix, it is used directly as the key material.

FAQ

Next steps

Signature Verification Reference

Complete API reference for all verification functions and provider-specific options.

Webhook Testing

Send signed webhooks to your handler and assert on the results in CI/CD.

Webhook Testing in CI/CD

Run webhook integration tests in GitHub Actions and GitLab CI with deterministic payloads.