Signature Verification
Verify webhook signatures for Stripe, GitHub, Shopify, Twilio, Slack, Paddle, Linear, Clerk, Discord, Vercel, GitLab, Typeform, Standard Webhooks, Meta, Lemon Squeezy, Coinbase Commerce, Razorpay, Cal.com, Intercom, Telegram, Square, HubSpot, Mailgun, Calendly, Mux, Sentry, and Bitbucket using the SDK.
Updated May 2026
Overview
Most webhook providers sign payloads so you can verify authenticity before processing. The SDK includes verification functions for 27 signed providers, plus a generic verifySignature that dispatches by provider name. SendGrid is supported for templates and detection, but not cryptographic verification because SendGrid's Event Webhook security model uses IP allowlisting.
Generic verifier (works with any supported provider):
import { verifySignature } from "@webhooks-cc/sdk";Provider-specific verifiers (when you know the provider at build time):
import {
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,
} from "@webhooks-cc/sdk";Server-side verification
When you configure a signing secret on an endpoint (via the dashboard or SDK), the server verifies every incoming webhook automatically and stores the result on the request:
import { matchAll, matchMethod, matchVerified } from "@webhooks-cc/sdk";
const req = await client.requests.waitFor(slug, {
match: matchAll(matchMethod("POST"), matchVerified()),
timeout: "30s",
});
console.log(req.signatureVerified); // true
console.log(req.signingProvider); // "stripe"Configure signing via the SDK:
await client.endpoints.update(slug, {
signingProvider: "stripe",
signingSecret: "whsec_...",
});Clear it:
await client.endpoints.update(slug, {
signingProvider: null,
});The request object includes three verification fields:
| Field | Type | Description |
|---|---|---|
signatureVerified | boolean | null | true = valid, false = invalid, null = not configured |
signatureError | string | null | Structured JSON error when verification failed |
signingProvider | string | null | Provider that performed the verification |
Client-side verification
The SDK also includes client-side verification functions that work without server-side configuration. These are useful in your own webhook handlers:
Provider reference
| Provider | Header | Algorithm | Secret format |
|---|---|---|---|
| Stripe | stripe-signature | HMAC-SHA256 | whsec_... string |
| GitHub | x-hub-signature-256 | HMAC-SHA256 | Hex string |
| Shopify | x-shopify-hmac-sha256 | HMAC-SHA256 (base64) | Hex string |
| Twilio | x-twilio-signature | HMAC-SHA1 | Auth token |
| Slack | x-slack-signature + x-slack-request-timestamp | HMAC-SHA256 | Signing secret |
| Paddle | paddle-signature (ts + h1) | HMAC-SHA256 | Hex string |
| Linear | linear-signature | HMAC-SHA256 | Hex string |
| Clerk | webhook-signature (Standard Webhooks / Svix) | HMAC-SHA256 | Svix signing secret |
| Discord | x-signature-ed25519 + x-signature-timestamp | Ed25519 | Hex public key |
| Vercel | x-vercel-signature | HMAC-SHA1 | Hex string |
| GitLab | x-gitlab-token | Token comparison | Raw secret token |
| Typeform | typeform-signature | HMAC-SHA256 | Raw secret |
| Standard Webhooks | webhook-id + webhook-timestamp + webhook-signature | HMAC-SHA256 | whsec_... or raw |
| Meta | x-hub-signature-256 | HMAC-SHA256 | App secret |
| Lemon Squeezy | x-signature | HMAC-SHA256 | Signing secret |
| Coinbase Commerce | x-cc-webhook-signature | HMAC-SHA256 | Shared secret |
| Razorpay | x-razorpay-signature | HMAC-SHA256 | Webhook secret |
| Cal.com | x-cal-signature-256 | HMAC-SHA256 | Webhook secret |
| Intercom | x-hub-signature | HMAC-SHA1 | Client secret |
| Telegram | x-telegram-bot-api-secret-token | Token comparison | Raw secret token |
| Square | x-square-hmacsha256-signature | HMAC-SHA256 (base64) | Signature key; needs url |
| HubSpot | x-hubspot-signature-v3 + x-hubspot-request-timestamp | HMAC-SHA256 (base64) | Client secret; needs url, method |
| Mailgun | (body fields) | HMAC-SHA256 | HTTP signing key |
| Calendly | calendly-webhook-signature | HMAC-SHA256 | Webhook signing key |
| Mux | mux-signature | HMAC-SHA256 | Webhook signing secret |
| Sentry | sentry-hook-signature | HMAC-SHA256 | Client secret |
| Bitbucket | x-hub-signature | HMAC-SHA256 | Webhook secret |
Stripe
Stripe uses a t=timestamp,v1=hash format in the stripe-signature header. The SDK parses the header, reconstructs the signed payload as {timestamp}.{body}, and compares the HMAC-SHA256 hash.
const result = await verifySignature(request, {
provider: "stripe",
secret: process.env.STRIPE_WEBHOOK_SECRET!,
});GitHub
GitHub sends a sha256=hash value in x-hub-signature-256. The SDK computes HMAC-SHA256 over the raw body and compares.
const result = await verifySignature(request, {
provider: "github",
secret: process.env.GITHUB_WEBHOOK_SECRET!,
});Shopify
Shopify sends a base64-encoded HMAC-SHA256 hash in x-shopify-hmac-sha256. The SDK computes the hash over the raw body and compares the base64 output.
const result = await verifySignature(request, {
provider: "shopify",
secret: process.env.SHOPIFY_WEBHOOK_SECRET!,
});Twilio
Twilio verification requires the full signed URL -- the callback URL your endpoint was configured
at, not just the request body. Pass it as the url option.
Twilio signs using HMAC-SHA1 over the full callback URL concatenated with sorted form parameters. The signature is base64-encoded and sent in x-twilio-signature.
const result = await verifySignature(request, {
provider: "twilio",
secret: process.env.TWILIO_AUTH_TOKEN!,
url: "https://example.com/api/twilio/webhook",
});Without the url option, verification throws an error.
Slack
Slack sends two headers: x-slack-signature (format v0=hash) and x-slack-request-timestamp. The SDK constructs the base string as v0:{timestamp}:{body} and computes HMAC-SHA256.
Slack rejects signatures when the timestamp is older than 5 minutes, so verify promptly after capture.
const result = await verifySignature(request, {
provider: "slack",
secret: process.env.SLACK_SIGNING_SECRET!,
});Paddle
Paddle uses a two-part signature format: ts=timestamp;h1=hash in the paddle-signature header. The SDK parses both parts, reconstructs the signed payload as {timestamp}:{body}, and compares the HMAC-SHA256 hash.
const result = await verifySignature(request, {
provider: "paddle",
secret: process.env.PADDLE_WEBHOOK_SECRET!,
});Linear
Linear sends an HMAC-SHA256 hash in the linear-signature header, optionally prefixed with sha256=. The SDK handles both formats.
const result = await verifySignature(request, {
provider: "linear",
secret: process.env.LINEAR_WEBHOOK_SECRET!,
});Clerk
Clerk uses the Standard Webhooks (Svix) signing format. The SDK provides a dedicated verifyClerkSignature that delegates to Standard Webhooks verification using the Clerk-specific headers.
const result = await verifySignature(request, {
provider: "clerk",
secret: process.env.CLERK_WEBHOOK_SECRET!,
});Vercel
Vercel signs deploy hook payloads with HMAC-SHA1 and sends the hex-encoded signature in x-vercel-signature.
const result = await verifySignature(request, {
provider: "vercel",
secret: process.env.VERCEL_WEBHOOK_SECRET!,
});GitLab
GitLab uses raw token comparison rather than HMAC. The token you configure in GitLab's webhook settings is sent verbatim in the x-gitlab-token header. The SDK compares the header value against your secret using constant-time comparison.
const result = await verifySignature(request, {
provider: "gitlab",
secret: process.env.GITLAB_WEBHOOK_TOKEN!,
});Typeform
Typeform sends a Base64-encoded HMAC-SHA256 signature in the typeform-signature header with a sha256= prefix. The SDK computes the digest over the raw JSON body.
const result = await verifySignature(request, {
provider: "typeform",
secret: process.env.TYPEFORM_WEBHOOK_SECRET!,
});Discord
Discord uses Ed25519 public-key verification, not HMAC. Pass publicKey instead of secret. The
key is your Discord application's hex-encoded public key.
Discord sends x-signature-ed25519 (hex-encoded signature) and x-signature-timestamp. The SDK verifies using crypto.subtle.verify with Ed25519.
const result = await verifySignature(request, {
provider: "discord",
publicKey: process.env.DISCORD_PUBLIC_KEY!,
});Standard Webhooks
Standard Webhooks is used by Polar, Svix, Resend, Liveblocks, and Novu. It sends three headers: webhook-id, webhook-timestamp, and webhook-signature. The signed payload is {messageId}.{timestamp}.{body}, hashed with HMAC-SHA256 and base64-encoded. Clerk also uses Standard Webhooks signing but has its own dedicated verifyClerkSignature function and "clerk" provider name.
Secrets can include the whsec_ prefix (base64-encoded key after the prefix) or be passed as raw base64.
const result = await verifySignature(request, {
provider: "standard-webhooks",
secret: process.env.POLAR_WEBHOOK_SECRET!,
});Meta (WhatsApp / Messenger / Instagram)
Meta signs the raw body with HMAC-SHA256 and sends sha256=hash 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 (the SDK auto-detects Meta by body shape).
const result = await verifySignature(request, {
provider: "meta",
secret: process.env.META_APP_SECRET!,
});Lemon Squeezy, Coinbase Commerce, Razorpay, Cal.com
These four 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).
const result = await verifySignature(request, {
provider: "coinbase-commerce",
secret: process.env.COINBASE_COMMERCE_WEBHOOK_SECRET!,
});Intercom
Intercom signs the raw body with HMAC-SHA1 and sends sha1=hash 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!,
});Telegram
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 compares it against your secret using constant-time comparison.
const result = await verifySignature(request, {
provider: "telegram",
secret: process.env.TELEGRAM_WEBHOOK_SECRET_TOKEN!,
});Square
Square verification requires the notification URL — Square signs the URL it delivers to
concatenated with the raw body. Pass it as the url option.
Square computes a base64 HMAC-SHA256 over {url}{body} and sends it in x-square-hmacsha256-signature.
const result = await verifySignature(request, {
provider: "square",
secret: process.env.SQUARE_SIGNATURE_KEY!,
url: "https://example.com/webhooks/square",
});HubSpot
HubSpot v3 signs the HTTP method and request URI alongside the body. Pass both the url and
method options, and verify promptly — HubSpot rejects signatures whose timestamp is more than 5
minutes old.
HubSpot computes a base64 HMAC-SHA256 over {method}{url}{body}{timestamp} (the timestamp, in milliseconds, is sent in x-hubspot-request-timestamp) and signs it in x-hubspot-signature-v3. method defaults to "POST" when omitted.
const result = await verifySignature(request, {
provider: "hubspot",
secret: process.env.HUBSPOT_CLIENT_SECRET!,
url: "https://example.com/webhooks/hubspot",
method: "POST",
});Mailgun
Mailgun has no signature header. It embeds signature.{timestamp,token,signature} in the request body and signs {timestamp}{token} with HMAC-SHA256 (hex). The SDK reads the body directly and returns false (never throws) on malformed or non-JSON input.
const result = await verifySignature(request, {
provider: "mailgun",
secret: process.env.MAILGUN_SIGNING_KEY!,
});Calendly and Mux
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!,
});Sentry
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!,
});Bitbucket
Bitbucket signs the raw body with HMAC-SHA256 and sends sha256=hash in the x-hub-signature header — the same scheme as GitHub. The SDK distinguishes it from Intercom (which uses x-hub-signature with sha1=) by Bitbucket's unique x-event-key header during auto-detection.
const result = await verifySignature(request, {
provider: "bitbucket",
secret: process.env.BITBUCKET_WEBHOOK_SECRET!,
});Generic verifier
verifySignature accepts a captured request and dispatches to the correct provider-specific function. It returns { valid: boolean }.
import { verifySignature } from "@webhooks-cc/sdk";
const result = await verifySignature(request, {
provider: "stripe",
secret: process.env.STRIPE_WEBHOOK_SECRET!,
});
if (result.valid) {
// Signature verified — safe to process
}The options type is a discriminated union:
// HMAC/token-based providers
{
provider: "stripe" | "github" | "shopify" | "twilio" | "slack" | "paddle" | "linear" | "clerk" | "vercel" | "gitlab" | "typeform" | "standard-webhooks" | "meta" | "lemonsqueezy" | "coinbase-commerce" | "razorpay" | "cal" | "intercom" | "telegram" | "square" | "hubspot" | "mailgun" | "calendly" | "mux" | "sentry" | "bitbucket";
secret: string;
url?: string; // Required for twilio, square, and hubspot
method?: string; // Used by hubspot (defaults to "POST")
}
// Ed25519-based provider (discord)
{
provider: "discord";
publicKey: string;
}Using with captured requests
Verify a request captured by webhooks.cc. Headers and body are preserved exactly as received, so signatures remain valid.
const request = await client.requests.get(requestId);
const result = await verifySignature(request, {
provider: "stripe",
secret: process.env.STRIPE_WEBHOOK_SECRET!,
});
console.log(result.valid); // trueIf you are not sure which provider sent the request, detect it first and then pass the matching secret:
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!,
});
console.log(info.event, result.valid);
}You can also use provider-specific functions directly. These accept the raw body, signature header, and secret as separate arguments:
import { verifyStripeSignature } from "@webhooks-cc/sdk";
const request = await client.requests.get(requestId);
const valid = await verifyStripeSignature(
request.body,
request.headers["stripe-signature"],
process.env.STRIPE_WEBHOOK_SECRET!
);FAQ
Learn more
Matchers & Helpers
Filter and parse webhook requests.
Testing
CI/CD integration and testing patterns.
API Reference
Complete method reference for the SDK.