Picture this: you're building an app and you need to know the instant a payment succeeds on Stripe, a document gets signed on TurboSign, or a task changes status in your project management tool. You could write a loop that asks “anything new?” every five seconds. But that's like calling the pizza place every minute to ask if your order is ready Sure, it technically works, but they'll hate you and you're wasting everyone's time.
Enter webhooks: instead of asking, you give the pizza place your phone number and they call you when it's ready. In API terms, you give the service a URL, and they send a POST request to that URL whenever the event you care about fires. Simple, efficient, real-time.
But there's a catch. If anyone on the internet can send a POST request to your URL, how do you know it actually came from the service you trust? That's where HMAC comes in. It's not as scary as the acronym sounds. Let's break it down.
Part 1: What Even Is a Webhook?
A webhook is an HTTP callback. When an event happens in System A, it sends an HTTP POST request to a URL that System B controls. That's it. No magic, no special protocol Just a regular HTTP request triggered by an event.
The word “webhook” is just a fancy way of saying “a URL that receives automated HTTP requests.” If you've ever built an Express route that handles a POST request, congratulations. You already know 80% of what you need.
The Mental Model
Webhooks vs. Polling: The Full Picture
| Aspect | Polling | Webhooks |
|---|---|---|
| How it works | Your app asks 'anything new?' on a loop | The source app tells you when something happens |
| Latency | Depends on interval (5s? 30s? 5min?) | Near-instant (sub-second) |
| Efficiency | Wastes requests when nothing changed | Only fires when there's actual data |
| Complexity | Simple to build, expensive to run | Needs a public endpoint, but scales beautifully |
| Real-world analogy | Checking your mailbox every 5 minutes | Having a doorbell |
Real-World Webhook Examples
Webhooks are everywhere. Here are some you've probably used without thinking about it:
Part 2: The Problem. Anyone Can POST
Here's the uncomfortable truth: your webhook endpoint is just a URL. If someone discovers it (and they will, because URLs leak through logs, configs, Postman collections, and ChatGPT screenshots), they can send whatever they want to it.
Imagine your webhook handler processes a payment_succeeded event by marking an order as paid and shipping it. If an attacker can forge that webhook, they just got free stuff. Without verification, your webhook endpoint is basically an unlocked door with a sign that says “send me JSON and I'll do whatever it says.”
You need a way to answer two questions:
Did this request actually come from the service I registered with, or is someone spoofing it?
Is this the exact payload the sender sent, or did someone intercept and modify it in transit?
HMAC answers both questions with one mechanism. And it does it without transmitting any secrets over the wire.
Part 3: HMAC, the Bouncer for Your Webhook
HMAC stands for Hash-based Message Authentication Code. Let's unpack that name because each word matters:
- Hash-based: it uses a hash function (like SHA-256) under the hood
- Message: the thing you're verifying (the webhook payload)
- Authentication Code: a short string that proves who sent the message and that it wasn't tampered with
Think of it like a wax seal on a medieval letter. The seal proves who sent it (only the sender has that specific seal stamp), and if the letter was opened, the seal is broken. HMAC is the digital version of that wax seal.
How HMAC Works in 4 Steps
You and the sender agree on a secret key. This key never travels with the webhook. Both sides already have it.
Before sending, the source computes HMAC-SHA256(secret, payload) and attaches the result as a header (e.g., X-Signature).
When the webhook arrives, you run the same HMAC-SHA256(secret, rawBody) on your end.
If your hash matches the header, the payload is authentic and untampered. Use timingSafeEqual, never ===.
Inside the HMAC Black Box
You might wonder: why not just hash the secret and message together? Like SHA256(secret + message)? Because that's vulnerable to something called a length extension attack. An attacker can take a valid hash and append extra data to the message while producing a valid hash for the longer message, without knowing the secret. HMAC's double-hash construction prevents this:
// What HMAC actually does (simplified)function hmac(secret, message) {// 1. XOR the secret with two different padding constantsconst innerKey = xor(secret, 0x36363636...);const outerKey = xor(secret, 0x5c5c5c5c...);// 2. Hash the inner key + messageconst innerHash = sha256(innerKey + message);// 3. Hash the outer key + that resultreturn sha256(outerKey + innerHash);}// The double-hash is what makes HMAC stronger than// a naive hash(secret + message). It prevents// "length extension attacks" where an attacker appends// data to a message and forges a valid hash.
In practice, you never implement HMAC yourself because every language has a built-in. In Node.js, it's crypto.createHmac(). But understanding why it's a double hash helps you appreciate that HMAC isn't just “hash with a key.” It's specifically designed to be resistant to known cryptographic attacks.
Part 4: Timing Attacks, the Bug You Can't See
This is the part most tutorials mention in passing but don't really explain. It's also the part that separates code that works from code that's actually secure.
When you compare two strings with ===, JavaScript (and most languages) uses short-circuit evaluation: it stops comparing at the first byte that doesn't match. This means:
- Comparing
"aaaa"to"baaa"fails immediately (first byte differs) - Comparing
"aaaa"to"aaab"takes slightly longer (fails at the fourth byte)
An attacker can measure these tiny timing differences (we're talking microseconds) and guess the correct signature one byte at a time. Start with "00...", try every hex character for the first byte, keep the one that takes longest, move to the next byte. It's like cracking a combination lock by listening for clicks.
The fix is crypto.timingSafeEqual(). It always compares every byte, regardless of where (or if) there's a mismatch. Same input, same time. Always.
// BAD: vulnerable to timing attacksfunction insecureCompare(a: string, b: string): boolean {return a === b;// Returns false at the FIRST mismatched byte.// "aaaa" vs "abaa" → fails at index 1 (fast)// "aaaa" vs "aaab" → fails at index 3 (slow)// An attacker measures this difference to guess// the correct signature one byte at a time.}// GOOD: constant-time comparisonfunction secureCompare(a: string, b: string): boolean {return crypto.timingSafeEqual(Buffer.from(a, "hex"),Buffer.from(b, "hex"));// Always compares ALL bytes, even after a mismatch.// Takes the same time whether 0 or all bytes match.// No timing information leaks to an attacker.}
Part 5: Build a Verified Webhook Receiver
Let's put it all together. Here's a complete webhook receiver in Node.js/Express that verifies HMAC signatures properly. This is production-ready, not a toy example.
import crypto from "crypto";import express from "express";const app = express();// Critical: capture the raw body BEFORE parsingapp.use("/webhooks", express.raw({ type: "application/json" }));app.post("/webhooks/incoming", (req, res) => {const secret = process.env.WEBHOOK_SECRET;const signature = req.headers["x-signature"];if (!secret || typeof signature !== "string") {return res.status(400).json({ error: "Missing secret or signature" });}// Step 1: Recompute the HMAC from the raw bodyconst expectedSignature = crypto.createHmac("sha256", secret).update(req.body) // raw Buffer, not parsed JSON.digest("hex");// Step 2: Both must be the same byte length for timingSafeEqualconst expectedBuf = Buffer.from(expectedSignature, "hex");const providedBuf = Buffer.from(signature, "hex");if (expectedBuf.length !== providedBuf.length) {return res.status(401).json({ error: "Invalid signature" });}// Step 3: Compare using timingSafeEqual (NOT ===)const trusted = crypto.timingSafeEqual(expectedBuf, providedBuf);if (!trusted) {return res.status(401).json({ error: "Invalid signature" });}// Step 4: Now it's safe to parse and processconst payload = JSON.parse(req.body.toString());console.log("Verified webhook:", payload);// Respond 200 immediately, process async if neededres.status(200).json({ received: true });});
Line-by-Line Breakdown
express.raw()This is the most important line. By default, Express parses JSON bodies automatically, which can alter the byte representation. We need the exact raw bytes that were sent so our HMAC matches. This middleware gives us a Buffer, not a parsed object.- Guard clause for missing inputs. If the secret is not configured or the sender didn't include a signature header, bail early. Never call
createHmacwith an undefined key. createHmac("sha256", secret)Creates an HMAC instance using SHA-256 and our shared secret. SHA-256 is the industry standard; some services use SHA-1 (check their docs), but SHA-256 is what you should default to.- Length check before comparison.
timingSafeEqualthrows if the two buffers have different byte lengths. A valid SHA-256 hex digest is always 64 characters (32 bytes), so a length mismatch means the signature is definitely wrong. Reject it before comparing. timingSafeEqual()Constant-time comparison. Both buffers are the same length (verified above), so this safely compares every byte without leaking timing information.- Respond 200 immediately. Most webhook senders have a timeout (typically 5-30 seconds). If you don't respond in time, they'll retry, and you'll process the same event multiple times. Ack fast, process async.
Part 6: Mistakes That Will Ruin Your Weekend
Every one of these has bitten a developer in production. Most of them won't show up in your tests because your test payloads are well-formed and your test “attacker” is you.
Why it's dangerous: Vulnerable to timing attacks. An attacker can guess the hash one byte at a time
Why it's dangerous: JSON.parse() can alter whitespace/encoding, changing the hash
Why it's dangerous: Secrets in git repos get leaked. It's a matter of when, not if
Why it's dangerous: Most senders retry on timeout, causing duplicate processing
Part 7: Hardening Your Webhook in Production
HMAC verification is necessary but not sufficient. Here are the other layers a production webhook receiver needs:
Idempotency
Webhook senders retry on failure (and sometimes on success, if they didn't get your 200 fast enough). Your handler must produce the same result whether it processes an event once or five times. Store the event ID and skip duplicates.
Replay Protection
An attacker who intercepts a valid webhook can replay it later. Many senders include a timestamp header. Reject webhooks older than a few minutes. Stripe, for example, sends a t= timestamp in their signature header.
Rate Limiting
Even with HMAC, protect your endpoint from being overwhelmed. If your handler triggers expensive work (generating documents, sending emails), rate-limit the endpoint to prevent accidental or malicious floods.
Logging and Monitoring
Log every webhook: received, verified, failed verification, processed, errored. When things go wrong (and they will), your logs are your only diagnostic tool. Track HMAC failures specifically. A spike in verification failures could mean a compromised secret or a misconfigured sender.
Secret Rotation
Secrets don't last forever. Plan for rotation from day one: accept both the current and previous secret during a transition window. Most webhook platforms support generating a new secret before revoking the old one.
How TurboDocx does it: Our integration webhook system uses per-webhook API keys as the HMAC secret, automatic retry with exponential backoff, and idempotent document versioning. In some integrations, a single verified webhook triggers document generation, e-signatures, or PDF conversion and delivers the result back to the source system automatically.
HMAC Cheat Sheet
Pin this to your wall. Or your second monitor. Or wherever your sticky notes live.
| Concept | One-Liner |
|---|---|
| Webhook | An HTTP POST sent to your URL when an event occurs |
| HMAC | A keyed hash that proves authenticity + integrity |
| Shared Secret | A key both sides know; never sent with the webhook |
| Signature Header | The HMAC digest the sender attaches (e.g., X-Hub-Signature-256) |
| timingSafeEqual | Constant-time comparison that blocks timing attacks |
| express.raw() | Middleware to capture the unparsed body for HMAC computation |
| Idempotency | Processing the same event twice produces the same result |
| Replay Attack | Resending a valid old webhook; defend with timestamp checks |
Frequently Asked Questions
Build Webhook-Powered Automations with TurboDocx
TurboDocx uses HMAC-verified webhooks to trigger document generation, e-signatures, and PDF conversion from tools like Wrike, Salesforce, and more. No polling, no manual steps.
Keep Reading
Event sourcing, CQRS, and Kafka patterns for distributed systems.
How 100M weekly npm downloads got compromised and how to prevent it.
Lambda, edge computing, and WebAssembly patterns for scalable backends.
