1. Insight
Insight
The problem this article addresses and why it matters.
Webhook verification is the boring part everyone gets wrong
A webhook arrives at your endpoint. The provider — Stripe, GitHub, Twilio, Shopify, Slack — promises that the request came from them and not from an attacker spraying your endpoint with fake events. The proof is a signature header (different name per provider) carrying an HMAC of the request body computed with a shared secret you and the provider both know. Your job is to recompute the HMAC and reject any request where it doesn't match.
The verification is twelve lines of code. The twelve lines are different at every provider. Stripe signs timestamp + "." + payload with SHA-256 and expects the header to contain both. GitHub signs the raw body and expects a sha256= prefix on the header. Twilio signs the URL plus sorted POST parameters with SHA-1 (yes, SHA-1, still). Shopify signs the raw body and base64-encodes the result. Slack prepends v0: and a timestamp, then signs with SHA-256.
Most teams write the verification once per provider and don't audit it again. The classic failure modes — wrong digest (hex vs base64), wrong delimiter ("." vs ":"), wrong cipher (SHA-1 vs SHA-256), missing timing-safe compare — show up in penetration tests, not in normal operation.
Why a verifier with provider templates beats a generic HMAC tool
A generic HMAC tool ("hash this with that key") is fine if you remember every provider's exact signing scheme. Nobody does. The tool in this article ships templates for the five most common providers, each encoding the provider's specific quirks: which header to read, what string to sign, which cipher, what encoding the signature uses, whether a timestamp is in scope.
The result: verifying a webhook becomes one tool call where you pass the body, the headers, and the provider name. The tool returns verified: true|false plus the exact string that was signed (for debugging) and the signing scheme documentation (for new joiners).
What this article delivers
End-to-end webhook verification for Stripe and GitHub, the generate-mode use cases (signing requests to an upstream that requires HMAC auth), and the diagnostics: when a signature fails, the tool surfaces the canonical signed payload so the developer can see what differed from what the provider expects.
2. Intent
Intent
What you will be able to do after reading.
By the end of this article you will be able to:
- Verify incoming webhook signatures from Stripe, GitHub, Twilio, Shopify, and Slack with one tool call per provider
- Read the
webhookDetailsoutput to debug a failed verification — see the exact canonical string that was signed - Generate HMAC signatures for outbound requests when an upstream API requires HMAC-signed payloads
- Choose between SHA-256, SHA-384, and SHA-512 based on what the receiving system documents
- Recognise the cases where signature verification needs supplementary checks — replay-window enforcement, idempotency keys, and IP allow-listing
The Examples section walks through three real verifications (Stripe, GitHub, Slack) and one generation case (signing a request to an internal HMAC-authenticated API).
3. Examples
Examples
Annotated code and worked scenarios.
Before / after: verifying a Stripe webhook
Stripe's signing scheme combines a timestamp with the raw payload, separated by a literal period, and signs the result with HMAC-SHA-256. The signature lands in the Stripe-Signature header alongside the timestamp:
Before — hand-rolled verification:
const sig = req.headers['stripe-signature']; // "t=1747929892,v1=abc..."
const parts = sig.split(',').map((p) => p.split('='));
const t = parts.find((p) => p[0] === 't')?.[1];
const v1 = parts.find((p) => p[0] === 'v1')?.[1];
const signed = `${t}.${rawBody}`;
const hmac = crypto.createHmac('sha256', process.env.STRIPE_WEBHOOK_SECRET)
.update(signed)
.digest('hex');
// timing-safe compare ...
if (crypto.timingSafeEqual(Buffer.from(hmac), Buffer.from(v1))) { /* OK */ }20+ lines including the timestamp window check ("did this fire within the last 5 minutes?"), the timing-safe compare, the error paths. Each line is an opportunity for a subtle bug.
After:
hmacGenerator({
mode: 'verify',
message: rawBody,
key: process.env.STRIPE_WEBHOOK_SECRET,
algorithm: 'SHA-256',
signature: req.headers['stripe-signature'],
webhookTemplate: 'stripe',
webhookTimestamp: extractStripeTimestamp(req.headers['stripe-signature']),
});
// verified: true
// webhookDetails: {
// provider: 'stripe',
// signingScheme: 'HMAC-SHA-256 of "{timestamp}.{payload}", compared against the v1=... value in Stripe-Signature',
// headerName: 'Stripe-Signature',
// signedPayload: '1747929892.{"id":"evt_...","type":"invoice.paid",...}',
// }The verification is one call. The webhookDetails.signedPayload is the diagnostic: when verification fails, the field shows the exact string the tool fed to HMAC, so a developer can compare it against what they thought Stripe was signing.
Before / after: GitHub webhook
GitHub signs the raw request body with SHA-256 and ships the signature in X-Hub-Signature-256 prefixed with sha256=:
hmacGenerator({
mode: 'verify',
message: rawBody,
key: process.env.GITHUB_WEBHOOK_SECRET,
algorithm: 'SHA-256',
signature: req.headers['x-hub-signature-256'],
webhookTemplate: 'github',
});
// verified: true
// webhookDetails: {
// provider: 'github',
// signingScheme: 'HMAC-SHA-256 of raw request body, compared against the "sha256=..." prefix-stripped value of X-Hub-Signature-256',
// headerName: 'X-Hub-Signature-256',
// signedPayload: '{"action":"opened","issue":{"id":1234,...}}',
// }Note the absence of a timestamp parameter — GitHub's scheme doesn't include one in the signed string. The template knows that and ignores webhookTimestamp for the github provider.
Before / after: signing an outbound request
You're calling an internal service that requires HMAC-signed requests with SHA-512:
hmacGenerator({
mode: 'generate',
message: JSON.stringify(payload),
key: process.env.INTERNAL_SERVICE_HMAC_KEY,
algorithm: 'SHA-512',
});
// hex: 'a3f2c81b9d4e2c5...',
// base64: 'o/LIG51M4sV...',Add the chosen encoding to an X-Signature header on the outbound request. The receiving service runs the same algorithm and rejects mismatches. The hex and base64 fields are returned in parallel so the caller picks whichever encoding the receiver expects without a second call.
Before / after: failing verification with diagnostic output
When verification fails, the tool returns verified: false plus the same webhookDetails.signedPayload field. That's the debugging seam:
hmacGenerator({
mode: 'verify',
message: rawBody,
key: process.env.STRIPE_WEBHOOK_SECRET,
algorithm: 'SHA-256',
signature: req.headers['stripe-signature'],
webhookTemplate: 'stripe',
webhookTimestamp: '1747929892',
});
// verified: false
// webhookDetails: {
// provider: 'stripe',
// signingScheme: '...',
// signedPayload: '1747929892.{"id":"evt_...","type":"invoice.paid",...}',
// }The developer compares signedPayload against what they thought was being signed — usually the answer is "the timestamp was extracted from the wrong header field" or "the body was JSON-parsed-and-restringified somewhere in the middleware chain, changing whitespace and breaking the signature." Both are common; both surface in the diagnostic.
When humans use this
Most teams set up webhook verification once per provider and don't touch it again until a verification failure floods the error tracker. When that happens, paste the failing webhook into the tool with the provider template selected and signedPayload tells you what went wrong — usually within 30 seconds. The other common use is the initial integration: set up the verification, run a test webhook through, confirm verified: true, ship.
When agents use this
Three production patterns:
- Webhook handler scaffolding. An agent integrating with a new provider's webhook system reads the provider's docs, identifies the signing scheme (or selects from the template list), and generates the verification call inline in the handler. The agent doesn't re-derive the signing scheme; the template encapsulates it.
- Cross-provider integration testing. A pipeline that fires test webhooks across multiple providers uses generate mode to produce the signatures the receiving handlers expect. Each test case generates a webhook payload with a valid signature for the provider being tested.
- Forensic auditing. When investigating whether a request actually came from a provider (post-incident), the agent runs verify mode with the captured headers + body + secret. The
webhookDetails.signedPayloadbecomes the audit-trail entry.
Edge cases
Twilio's SHA-1 signature
Twilio still signs with SHA-1 as of 2026. The template uses SHA-1 for the twilio provider despite the global drift toward SHA-256. The tool surfaces this with no warning — it's documented Twilio behaviour, not a bug.
Body-parser interference
Most Node.js webhook bugs come from a body-parser middleware that re-stringifies the JSON before the verification handler runs. The verification then runs against the re-stringified body, which doesn't match what the provider signed. Fix at the framework layer: capture the raw body before parsing (express.raw({ type: 'application/json' }) etc.), pass that string to the tool.
Replay-window enforcement
The Stripe and Slack templates compute the HMAC; they don't reject old timestamps. A valid signature from an hour ago is still a valid signature. Most teams enforce a 5-minute replay window in the handler around the verifier call — that's outside the tool's scope.
Multi-secret rotation
When you rotate a webhook secret, both the old and new secret are typically valid for a transition window. The tool verifies against one secret per call; for rotation periods, call the verifier with the new secret first, then the old secret on failure. The provider templates don't change between secrets, just the key parameter.
4. Documentation
Documentation
Reference signatures, edge cases, and lookup tables.
Input parameters
Field | Type | Required | Default | Description |
|---|---|---|---|---|
|
| ✓ | — | Workflow selector |
|
| ✓ | — | The body being signed or verified |
|
| ✓ | — | The HMAC secret |
|
| ✓ | — | Digest algorithm. Templates override ( |
|
| for verify mode | — | The signature to verify against |
|
| ✗ |
| Apply provider-specific signing scheme |
|
| required for | — | Timestamp from the provider's header — used to construct the canonical signed string |
Output shape
{
hmac: string; // hex digest
hex: string; // alias for hmac (explicit naming)
base64: string; // base64 digest
verified?: boolean; // when mode: 'verify'
webhookDetails?: {
provider: string;
signingScheme: string; // human-readable scheme explanation
headerName: string; // which header to check at runtime
signedPayload: string; // the exact string fed to HMAC — diagnostic
};
}Provider templates
Template | Cipher | Signed string | Header |
|---|---|---|---|
| SHA-256 |
|
|
| SHA-256 |
|
|
| SHA-1 |
|
|
| SHA-256 |
|
|
| SHA-256 |
|
|
| as |
| none |
Error codes
Code | When it fires | Recovery |
|---|---|---|
|
| Provide both |
| Verify mode with malformed signature (not hex / not base64) | Verify the provider's header parsing |
| Template requires | Add the timestamp parameter |
| Algorithm not in | Use a supported algorithm; Twilio's SHA-1 is automatic via template |
When NOT to use this tool
For asymmetric verification (the provider signs with RSA / ECDSA and you verify with a public key — used by Apple App Store, Google Pay), this tool is the wrong layer. Use the runtime's crypto.verify or a JWT library; HMAC is symmetric and the schemes are different.
For high-throughput verification (thousands of webhooks per second), the per-call overhead of the tool's HTTP layer is wasted. Inline crypto.createHmac directly in your handler — the templates' canonical signing scheme is documented well enough to copy.
For verification of structured signatures (JWS, COSE), use a dedicated library. HMAC is one of several signing methods; the tool covers only the HMAC variant.
Performance notes
Generate-mode single call: under 1ms for messages below 100KB. Verify-mode single call: under 2ms including the canonical-string construction. The tool is deterministic — same inputs always produce the same HMAC — so generate-mode REST responses are Edge-Cache eligible. Verify-mode responses are Cache-Control: no-store because the verification result depends on the runtime secret and shouldn't be cached.
The provider templates are pure transforms over the canonical input; updates to the provider's documented scheme (rare) require a tool release. Pin the tool version in CI workflows if your build needs exact reproducibility across releases.