Receiving a webhook is easy. Knowing it actually came from Stripe and not someone who guessed your endpoint URL is the hard part. Here is how I built provider-based signature verification for HookScope.
Why Signature Verification Matters
HookScope endpoints are public URLs. Anyone who knows your endpoint token can POST to it. Without verification, a malicious actor could send a fake payment_intent.succeeded event and trigger whatever your server does on payment success.
Every major webhook provider solves this with HMAC signatures. They sign the request body with a shared secret, send the signature in a header, and expect you to verify it. The concept is identical across providers — the implementation details are not.
The Provider Interface
Stripe, GitHub, Shopify, Clerk, and Przelewy24 all use HMAC-SHA256 but with different header names, different payload formats, and different signature encodings. Writing one function to handle all of them would be a mess.
Instead I defined a common interface and gave each provider its own isolated class:
interface IWebhookProvider {
verify(payload: Buffer, signature: string, secret: string): boolean
}Adding a new provider is a single file. Nothing else changes. Stripe parses its t=timestamp,v1=signature format internally. GitHub strips the sha256= prefix internally. The guard that calls them does not need to know any of this.
The Part Everyone Gets Wrong: Raw Body
HMAC verification requires the exact raw bytes of the request body. The moment Express parses JSON, those bytes are gone and replaced with a JavaScript object. Serializing it back to a string produces a different byte sequence — verification fails every time.
The fix is a middleware that captures the raw buffer before any parsing happens:
export class RawBodyMiddleware implements NestMiddleware {
use(req: Request, res: Response, next: NextFunction) {
rawBody(req, res, { type: '*/*' }, (err, req) => {
if (err) return next(err)
next()
})
}
}This runs before every route that handles incoming webhooks. Without it, no amount of HMAC logic will produce a valid signature.
Encrypted Secrets
Users paste their provider secret keys into HookScope so it can verify incoming requests on their behalf. Those secrets need to be stored securely — not in plaintext, not reversibly hashed.
I used AES-256-GCM with a random 12-byte IV generated per encryption. The authentication tag detects any tampering with the ciphertext. The decrypted secret exists in memory only during verification and is never logged or persisted anywhere else.
The Guard
A NestJS guard orchestrates the full flow on every incoming webhook request:
POST /webhooks/:endpointId
→ load endpoint from DB
→ decrypt secret
→ detect provider from headers
→ provider.verify(rawBody, signature, secret)
→ 401 if invalid
→ continue if validAn invalid signature returns 401 immediately. The request is never saved, never forwarded, never processed. The user sees a signature_failed alert in their dashboard with the provider name and the header that failed.
What I Learned
The raw body problem is not obvious until you hit it. Every webhook integration tutorial shows you how to compute the HMAC — almost none of them mention that you need to preserve the raw buffer before your framework touches it. I lost an hour to this on the first provider I integrated.
The provider interface pattern paid off immediately. I added Przelewy24 support in about twenty minutes because the structure was already in place. The guard did not change at all.
What's Next
Signature verification is live in HookScope now. The next planned features are schema drift detection — alerting you when a provider silently changes their payload structure — and team workspaces with per-member access control.
If you spend time debugging webhooks in development, HookScope handles the verification layer so you do not have to implement it yourself for every project.