obfus.link
Generators

Webhook signature verification across Stripe, GitHub, Twilio, Shopify, Slack

Verify incoming webhook signatures with provider-specific templates that encode each service's exact signing scheme. Generate HMAC signatures for outbound HMAC-authenticated calls in one tool call per provider.

The HMAC Generator verifies webhook signatures from Stripe, GitHub, Twilio, Shopify, and Slack with provider-specific templates that encode each service's exact signing scheme. Generate mode produces HMAC-SHA-256, -384, or -512 signatures for outbound requests. Returns the canonical signed payload for debugging failed verifications.

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 webhookDetails output 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.signedPayload becomes 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

mode

'generate' | 'verify'

Workflow selector

message

string

The body being signed or verified

key

string

The HMAC secret

algorithm

'SHA-256' | 'SHA-384' | 'SHA-512'

Digest algorithm. Templates override (twilio forces SHA-1)

signature

string

for verify mode

The signature to verify against

webhookTemplate

'stripe' | 'github' | 'twilio' | 'shopify' | 'slack' | 'custom'

'custom'

Apply provider-specific signing scheme

webhookTimestamp

string

required for stripe + slack templates

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

stripe

SHA-256

{timestamp}.{rawBody}

Stripe-Signature (parses v1=...)

github

SHA-256

{rawBody}

X-Hub-Signature-256 (strips sha256=)

twilio

SHA-1

{url}{sorted-form-params}

X-Twilio-Signature

shopify

SHA-256

{rawBody}, base64 output

X-Shopify-Hmac-SHA256

slack

SHA-256

v0:{timestamp}:{rawBody}

X-Slack-Signature (strips v0=)

custom

as algorithm

{message}

none

Error codes

Code

When it fires

Recovery

INPUT_EMPTY

message or key empty

Provide both

INPUT_MALFORMED

Verify mode with malformed signature (not hex / not base64)

Verify the provider's header parsing

INPUT_INVALID_TYPE

Template requires webhookTimestamp and none provided (stripe, slack)

Add the timestamp parameter

UNSUPPORTED_FORMAT

Algorithm not in {SHA-1, SHA-256, SHA-384, SHA-512}

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.

Try it now

HMAC Generator

Generate and verify HMAC signatures with Stripe, GitHub, Twilio, Shopify, Slack templates

FAQ

Frequently asked questions

Which provider templates are included?

Stripe (SHA-256, timestamp + period + payload), GitHub (SHA-256, raw body, sha256= prefix), Twilio (SHA-1, URL + sorted form params), Shopify (SHA-256, raw body, base64), Slack (SHA-256, v0: + timestamp + : + body). Plus a "custom" template for in-house APIs.

Why does my verification keep failing?

Almost always one of three causes: the raw body was JSON-parsed-and-restringified by a middleware (whitespace changes break the signature); the wrong header was used (Stripe has v0= and v1= variants); the timestamp was extracted from the wrong place. The webhookDetails.signedPayload output shows what the tool fed to HMAC — compare against the provider's expectation.

Does the tool enforce replay-window checking?

No. The tool verifies the signature; replay-window enforcement is a separate concern (rejecting requests older than 5 minutes) that belongs in the handler. The webhookTimestamp parameter is used to construct the canonical signed string, not to enforce freshness.

Can I use generate mode for outbound API calls?

Yes. Set mode: generate, pass the message and key, get hex and base64 in the response. Use whichever encoding the receiving service expects. The hex and base64 are returned in parallel so the caller picks without a second call.