- 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):
| Provider | Signature header | Signed content | Algorithm | Encoding | Timestamp |
|---|---|---|---|---|---|
| GitHub | X-Hub-Signature-256 (sha256=) | Raw body | HMAC-SHA256 | Hex | None in the common scheme |
| Stripe | Stripe-Signature (t=…, v1=…) | {timestamp}.{raw body} | HMAC-SHA256 | Hex | In the signature header |
| Slack | X-Slack-Signature (v0=) | v0:{timestamp}:{raw body} | HMAC-SHA256 | Hex | Separate header |
| Shopify | X-Shopify-Hmac-SHA256 | Raw HTTPS request body | HMAC-SHA256 | Base64 | None in the common HTTPS scheme |
| Standard webhooks | webhook-signature (v1,<base64>) | {webhook-id}.{webhook-timestamp}.{raw body} | HMAC-SHA256 | Base64 | Separate headers |
| Twilio | X-Twilio-Signature | Full URL plus sorted form parameters for form POSTs | HMAC-SHA1 | Base64 | None in the common form scheme |
| Discord | X-Signature-Ed25519 | {timestamp}{raw body} | Ed25519 | Hex | Separate 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
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
And here are some examples:
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152
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
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
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
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 bodywith 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
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:
| Test | Expected result |
|---|---|
| Valid signature and timestamp | ok |
| One body byte changed | mismatch |
| Wrong secret | mismatch |
| Valid old secret during rotation | ok |
| Retired secret | mismatch |
| Past or future timestamp outside tolerance | stale_timestamp |
| Missing | missing_header |
| Invalid encoding or truncated signature | bad_encoding or mismatch, never an exception |
| Missing secret configuration | error, and reject the request |
| Duplicate delivery | Verification 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.


