Blog
How Do I Verify Webhook Signatures across Multiple Providers?
Dev Tips

How Do I Verify Webhook Signatures across Multiple Providers without Writing One-off Crypto for Each?

Stop writing custom verifiers for every webhook. Learn how to standardize webhook security, handle provider differences, and centralize verification.
Oct 30, 2026
Brian Munz
Brian MunzDeveloper Advocate
How Do I Verify Webhook Signatures across Multiple Providers?
Key takeaways
  • Standardize your verification process, not provider signature formats. Providers differ in header, signed content, algorithm, encoding, and timestamp handling.
  • Verify the raw request body, not parsed JSON. The provider signed specific bytes, so a re-serialized JSON.stringify() output fails.
  • One verification core serves every provider. Describe each scheme as data (signature location, signed content, algorithm, encoding, timestamp, key format) and run it through one shared core with strict decoding, constant-time comparison, multi-signature support, and key rotation.
  • Timestamp checks don’t prevent duplicate processing. Reject timestamps outside a window (often five minutes) for Stripe, Slack, and standard webhooks, then deduplicate on the provider’s event identifier, such as GitHub’s X-GitHub-Delivery.
  • Test against provider vectors and negative cases. Use each provider’s documented payload and signature, then try a changed body byte, another tenant’s secret, a retired secret, a stale timestamp, and malformed encoding. Reject unverified requests with 401 or 400, never a 2xx.

One integration gets a careful verifyStripeSignature() function. Another uses a verifyShopifyHmac() function. And the next one is completely different from both of those.

This is how an integration catalog develops security risks. But there is a way to understand and manage those risks. While you cannot make providers agree on a signature format, you can standardize the verification process that you use to handle those signatures.

The provider differences

A webhook signature helps establish that a request was signed by the expected key and that the signed content was not modified. HMAC uses a shared secret; public-key schemes use a provider public key. A signature alone doesn't prove when a request was sent, so some schemes also sign a timestamp.

Here are some common differences (current as of the time of this writing):

ProviderSignature headerSigned contentAlgorithmEncodingTimestamp
GitHubX-Hub-Signature-256 (sha256=)Raw bodyHMAC-SHA256HexNone in the common scheme
StripeStripe-Signature (t=…, v1=…){timestamp}.{raw body}HMAC-SHA256HexIn the signature header
SlackX-Slack-Signature (v0=)v0:{timestamp}:{raw body}HMAC-SHA256HexSeparate header
ShopifyX-Shopify-Hmac-SHA256Raw HTTPS request bodyHMAC-SHA256Base64None in the common HTTPS scheme
Standard webhookswebhook-signature (v1,<base64>){webhook-id}.{webhook-timestamp}.{raw body}HMAC-SHA256Base64Separate headers
TwilioX-Twilio-SignatureFull URL plus sorted form parameters for form POSTsHMAC-SHA1Base64None in the common form scheme
DiscordX-Signature-Ed25519{timestamp}{raw body}Ed25519HexSeparate header

The patterns are similar, but the details get messy. Stripe and standard webhooks can include multiple signatures during rotation. Twilio's form-post scheme signs the URL and sorted parameters rather than the raw body. And Discord uses a public key and a different verification primitive.

Step 1 – Capture the raw body

You need to preserve the exact request representation that the provider signed.

Start by capturing the request before middleware transforms it, verify it first, and parse it only after verification succeeds.

The provider signed a specific sequence of bytes or a precisely defined body representation. Verify that representation – not a JavaScript object and not JSON.stringify() of a parsed object.

1234567891011121314
// Do not let a global JSON parser run before this route.
app.post(
"/webhooks/:provider",
express.raw({ type: "*/*", limit: "1mb" }),
(req, res) => {
const rawBody: Buffer = req.body;
const result = verifyWebhook(req.params.provider, req.headers, rawBody);
if (!result.ok) return res.status(401).send();
const event = JSON.parse(rawBody.toString("utf8"));
// ...queue or process the verified event...
},
);

Every provider has a raw-body option, verify callback, or lower-level request stream. Identify it before writing the webhook verification code. In addition, set a body-size limit on public webhook routes.

Check any intermediaries too. Proxies may decompress or normalize bodies, middleware may run in the wrong order, and URL reconstruction behind a load balancer can matter for patterns such as Twilio's. If a check passes locally but fails in production, investigate everything (secret selection, header handling, URL reconstruction, encoding, and timestamps) not just the body.

Step 2 – Describe each scheme as data

You need to make provider-specific differences explicit and reviewable. Put signature locations, signed content, algorithms, encodings, timestamps, and key formats in descriptors instead of writing a separate verifier for each provider.

A descriptor should answer four questions:

  • Where are the signature, any timestamp, and any message ID?
  • What content is signed, and in what order?
  • Which algorithm and encoding are used?
  • How are secrets or public keys represented?
1234567891011121314151617181920
type SignedValueSource =
| { header: string }
| { signatureKey: string };
type SignatureScheme = {
algorithm: "sha256" | "sha1";
encoding: "hex" | "base64";
signatureHeader: string;
signatureFormat:
| { kind: "plain" }
| { kind: "prefixed"; prefix: string }
| { kind: "keyValue"; key: string }
| { kind: "versionedList"; version: string };
signedPrefix: string; // ends immediately before the raw body
signedValues?: Record<string, SignedValueSource>;
timestamp?: {
source: SignedValueSource;
toleranceSeconds: number;
};
};

And here are some examples:

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152
const schemes: Record<string, SignatureScheme> = {
github: {
algorithm: "sha256",
encoding: "hex",
signatureHeader: "x-hub-signature-256",
signatureFormat: { kind: "prefixed", prefix: "sha256=" },
signedPrefix: "",
},
stripe: {
algorithm: "sha256",
encoding: "hex",
signatureHeader: "stripe-signature",
signatureFormat: { kind: "keyValue", key: "v1" },
signedPrefix: "{timestamp}.",
signedValues: { timestamp: { signatureKey: "t" } },
timestamp: { source: { signatureKey: "t" }, toleranceSeconds: 300 },
},
slack: {
algorithm: "sha256",
encoding: "hex",
signatureHeader: "x-slack-signature",
signatureFormat: { kind: "prefixed", prefix: "v0=" },
signedPrefix: "v0:{timestamp}:",
signedValues: { timestamp: { header: "x-slack-request-timestamp" } },
timestamp: {
source: { header: "x-slack-request-timestamp" },
toleranceSeconds: 300,
},
},
shopify: {
algorithm: "sha256",
encoding: "base64",
signatureHeader: "x-shopify-hmac-sha256",
signatureFormat: { kind: "plain" },
signedPrefix: "",
},
standardWebhooks: {
algorithm: "sha256",
encoding: "base64",
signatureHeader: "webhook-signature",
signatureFormat: { kind: "versionedList", version: "v1" },
signedPrefix: "{id}.{timestamp}.",
signedValues: {
id: { header: "webhook-id" },
timestamp: { header: "webhook-timestamp" },
},
timestamp: {
source: { header: "webhook-timestamp" },
toleranceSeconds: 300,
},
},
};

Standard webhooks also require provider-specific secret handling. For those, you'll need to remove whsec_ and base64-decode the suffix before using it as the HMAC key. Twilio form parameters and Discord Ed25519 are better custom adapters than forced templates.

Adding webhook verification for a new provider should require only a small configuration change and provider test vectors, not a new crypto implementation.

Step 3 – Write one verification core

You need one shared core that strictly parses each provider's inputs, builds the signed bytes, checks freshness where available, and compares signatures safely. Keep the crypto path small, fail closed, and test it once.

First, see what a one-off verifier gets wrong:

12
const expected = crypto.createHmac("sha256", secret).update(body).digest("hex");
if (expected === req.headers["x-hub-signature-256"]) processEvent(body);

Why? Well, it doesn't handle prefixes, constant-time comparison, multiple signatures, strict decoding, timestamps, or key rotation.

A shared core should look something like this:

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657
type VerifyResult =
| { ok: true }
| { ok: false; reason:
| "missing_header" | "malformed_header" | "missing_timestamp"
| "stale_timestamp" | "bad_encoding" | "mismatch" | "error" };
function verifySignature(
scheme: SignatureScheme,
headers: Record<string, string | string[] | undefined>,
rawBody: Buffer,
secrets: Buffer[],
nowMs = Date.now(),
): VerifyResult {
// Assumes Node-style lowercase header names; normalize otherwise.
if (secrets.length === 0) return { ok: false, reason: "error" };
const header = headers[scheme.signatureHeader.toLowerCase()];
if (typeof header !== "string") {
return { ok: false, reason: header === undefined
? "missing_header" : "malformed_header" };
}
const candidates = parseSignatures(scheme.signatureFormat, header);
if (candidates.length === 0) return { ok: false, reason: "malformed_header" };
const values = readSignedValues(scheme, headers, header);
if (!values.ok) return values.result;
const timestamp = values.values.timestamp ?? "";
if (scheme.timestamp) {
const seconds = parseTimestampStrict(timestamp);
if (seconds === null) return { ok: false, reason: "missing_timestamp" };
if (Math.abs(nowMs / 1000 - seconds) > scheme.timestamp.toleranceSeconds) {
return { ok: false, reason: "stale_timestamp" };
}
}
const prefix = renderSignedPrefix(scheme.signedPrefix, values.values);
const signedContent = Buffer.concat([Buffer.from(prefix, "utf8"), rawBody]);
const received = candidates.map((c) => decodeStrict(c, scheme.encoding));
if (received.every((value) => value === null)) {
return { ok: false, reason: "bad_encoding" };
}
for (const secret of secrets) {
const expected = crypto.createHmac(scheme.algorithm, secret)
.update(signedContent).digest();
for (const signature of received) {
if (signature && signature.length === expected.length &&
crypto.timingSafeEqual(signature, expected)) {
return { ok: true };
}
}
}
return { ok: false, reason: "mismatch" };
}

parseSignatures, readSignedValues, renderSignedPrefix, parseTimestampStrict, and decodeStrict should be small, strict, and tested once. Decode signatures to bytes; do not compare encoded strings. Check lengths before timingSafeEqual, and validate hex/base64 before decoding because permissive decoders may truncate invalid input.

Define a policy for malformed multi-signature headers. Rejecting duplicate headers or any malformed candidate is defensible, but that's a policy choice and not a universal protocol requirement.

Use HMAC, not sha256(secret + body). If verification fails because of a missing secret or invalid config, reject the request:

12345678
function verifyOrReject(...args: Parameters<typeof verifySignature>): VerifyResult {
try {
return verifySignature(...args);
} catch (err) {
logVerificationError(err);
return { ok: false, reason: "error" };
}
}

Step 4 – Check freshness, prevent replay, and scope secrets

You need to limit replay risk without confusing freshness with deduplication: enforce timestamp windows where available, deduplicate by the provider's documented identifier, and select only the relevant tenant's keys.

Let's start with timestamp checks.

For schemes with signed timestamps (such as Stripe, Slack, and standard webhooks) reject timestamps outside of a given window, often 5 minutes.

Freshness isn't deduplication. A request can arrive twice inside the window, so use the provider's event identifier as the idempotency key. GitHub's X-GitHub-Delivery identifies a delivery and remains the same on redelivery. Shopify documents distinct identifiers for individual deliveries and underlying events. An "event ID" can mean different things for different providers, so be certain you are using the right key.

For providers without timestamps in the common scheme, such as GitHub and Shopify, identifier semantics and deduplication are your main application-level replay defenses.

In a multi-tenant product, look up secrets by provider, tenant, and subscription. The route or trusted endpoint mapping should select the candidate key; do not trust unverified body content to authorize a tenant. Never loop over every secret you have: a match against another tenant's key can become a cross-tenant security problem.

Support old and new secrets for rotation, but then retire the old one (and never log either of them). Avoid logging complete payloads or signatures because together they may be replayable or include sensitive data. Log provider, tenant, correlation ID, and a reason such as missing_header, stale_timestamp, bad_encoding, or mismatch.

Step 5 – Use adapters for unusual schemes and official SDKs

You need to keep provider variances inside the same verification contract. Prefer a provider-maintained SDK when available; otherwise, use a small custom adapter rather than forcing an unusual protocol into the generic HMAC template.

These cases need a custom adapter:

  • Twilio: form POSTs sign the full URL plus alphabetically sorted form parameters. JSON requests use a separate body-validation path, so URL reconstruction and content type matter.
  • Discord: verify X-Signature-Timestamp + raw body with Ed25519 and the application's public key.
  • Handshakes: a subscription challenge is a protocol response, not ordinary signature verification.

Keep the same result contract and secret/key lookup:

123456789
type CustomScheme = {
custom: (input: {
headers: Record<string, string | undefined>;
rawBody: Buffer;
url: string;
keys: Buffer[];
nowMs: number;
}) => VerifyResult;
};

Use an official SDK whenever one is available. Stripe's verification helper, for example, can be wrapped so the provider handles the protocol details while your product keeps one logging and failure contract. For public keys, pin the key or fetch it from a trusted, provider-documented source, but never from a URL supplied by the webhook.

Step 6 – Test against provider vectors

You need to prove the implementation matches the provider's protocol, not your assumptions.

Start with provider-supplied vectors, then exercise malformed, replayed, rotated, and altered requests.

Use each provider's documented payload, secret, and expected signature. Then run some negative tests. Here's what that might look like:

TestExpected result
Valid signature and timestampok
One body byte changedmismatch
Wrong secretmismatch
Valid old secret during rotationok
Retired secretmismatch
Past or future timestamp outside tolerancestale_timestamp
Missingmissing_header
Invalid encoding or truncated signaturebad_encoding or mismatch, never an exception
Missing secret configurationerror, and reject the request
Duplicate deliveryVerification may pass; deduplication must stop processing

You'll also want to test empty bodies, non-ASCII content, unusual whitespace, non-JSON bodies, and proxy behavior. Return a 401 or 400 without explicitly naming the failure. Don't return 2xx for an unverified request to stop retries since doing so can mask a real event that you do want to know about.

After verification, acknowledge quickly and hand off to idempotent processing. A verified duplicate is still a duplicate.

Shortcuts that fail

The following are myths, but developers sometimes fall for them:

  • HTTPS authenticates the caller. It protects the connection and authenticates your server to the sender; it does not authenticate arbitrary callers.
  • An IP allowlist is enough. It is an extra layer, not a replacement for signatures.
  • The provider name in a header is enough. It only selects a verifier.
  • Skip verification in staging. Use test secrets so staging exercises raw-body handling.
  • Log the error and continue. Verification errors fail closed.

Where Prismatic fits in

Prismatic's built-in HMAC webhook trigger handles the common case where a provider hashes the request body with a string secret and sends the hash in a header.

The Slack trigger demonstrates the custom HMAC pattern: it combines the timestamp with the raw body and handles Slack's URL-verification challenge.

When a provider does not fit the built-in trigger, implement its logic in a custom component or trigger while keeping raw-body access, scoped secrets, fail-closed behavior, and provider test vectors.

The Prepare Your API for Integrations docs describe webhook registration and deregistration through lifecycle handlers. Prismatic event webhooks can be configured with an HMAC secret and send an x-webhook-signature header in the form sha256=<hex signature>.

Beyond that, you still need to know each provider's verification scheme, choose the timestamp tolerance, manage customer secrets and rotation, test real vectors, and pair verification with idempotent processing.

It's all about the pattern

The hard part is variation. Put that variation in data, and use a small adapter only when the protocol differs.

Assume the body can be re-serialized, a signature can be replayed, secrets can rotate, and customers can rotate theirs without warning. Build a single verifier that handles those cases, and let each provider be a short configuration entry with fixtures.

Once you've learned the patterns for webhook signatures, implementing a new provider should take a few minutes and a pull request.

Get a Demo

Ready to make your product extensible?

Join teams from Fortune 500s to high-growth startups that turned integrations into a growth driver and made their products the foundation that customers build on.