Backend Architecture

How to Webhook: HMAC & Secure Integrations

Webhooks are how modern apps talk to each other. But without verification, anyone can fake a request. This is HMAC, explained so a sophomore CS student can implement it before lunch.

Nicolas Fry
Nicolas FryFounder & CEO
May 7, 202615 min read

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

// Without webhooks (polling):
Your App → "Any new orders?" → Stripe
Your App → "How about now?" → Stripe
Your App → "...now?" → Stripe
Your App → "NOW?" → Stripe
// (999 empty responses later...)
Stripe → "Yes, one order." → Your App
// With webhooks:
Your App → "Here's my URL, ping me when something happens." → Stripe
// (silence... your app does other things)
Stripe → POST /your-webhook-url { "event": "payment_succeeded" } → Your App

Webhooks vs. Polling: The Full Picture

AspectPollingWebhooks
How it worksYour app asks 'anything new?' on a loopThe source app tells you when something happens
LatencyDepends on interval (5s? 30s? 5min?)Near-instant (sub-second)
EfficiencyWastes requests when nothing changedOnly fires when there's actual data
ComplexitySimple to build, expensive to runNeeds a public endpoint, but scales beautifully
Real-world analogyChecking your mailbox every 5 minutesHaving a doorbell

Real-World Webhook Examples

Webhooks are everywhere. Here are some you've probably used without thinking about it:

  • Stripe sends a webhook when a payment succeeds or fails
  • GitHub notifies your CI/CD pipeline on every push
  • Slack fires a webhook when a message mentions your bot
  • TurboSign notifies your system when all signers have signed a document
  • Wrike / Jira / Asana trigger automations when a task status changes

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:

Authenticity

Did this request actually come from the service I registered with, or is someone spoofing it?

Integrity

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

Step 1
Share a secret

You and the sender agree on a secret key. This key never travels with the webhook. Both sides already have it.

Step 2
Sender hashes the payload

Before sending, the source computes HMAC-SHA256(secret, payload) and attaches the result as a header (e.g., X-Signature).

Step 3
You recompute the hash

When the webhook arrives, you run the same HMAC-SHA256(secret, rawBody) on your end.

Step 4
Compare securely

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 constants
const innerKey = xor(secret, 0x36363636...);
const outerKey = xor(secret, 0x5c5c5c5c...);
// 2. Hash the inner key + message
const innerHash = sha256(innerKey + message);
// 3. Hash the outer key + that result
return 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 attacks
function 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 comparison
function 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 parsing
app.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 body
const 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 timingSafeEqual
const 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 process
const payload = JSON.parse(req.body.toString());
console.log("Verified webhook:", payload);
// Respond 200 immediately, process async if needed
res.status(200).json({ received: true });
});

Line-by-Line Breakdown

  1. 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.
  2. Guard clause for missing inputs. If the secret is not configured or the sender didn't include a signature header, bail early. Never call createHmac with an undefined key.
  3. 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.
  4. Length check before comparison. timingSafeEqual throws 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.
  5. timingSafeEqual() Constant-time comparison. Both buffers are the same length (verified above), so this safely compares every byte without leaking timing information.
  6. 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.
Want to go deeper? Webhooks are just one piece of the distributed systems puzzle. See our event-driven architecture guide for patterns on message brokers, sagas, and resilient service-to-service communication.

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.

Using === to compare signatures

Why it's dangerous: Vulnerable to timing attacks. An attacker can guess the hash one byte at a time

Always use crypto.timingSafeEqual()
Parsing the body before verifying

Why it's dangerous: JSON.parse() can alter whitespace/encoding, changing the hash

Verify the raw body buffer first, then parse
Hardcoding the secret in source code

Why it's dangerous: Secrets in git repos get leaked. It's a matter of when, not if

Use environment variables or a secrets manager
Not responding 200 quickly

Why it's dangerous: Most senders retry on timeout, causing duplicate processing

Respond 200 immediately, process async

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.

ConceptOne-Liner
WebhookAn HTTP POST sent to your URL when an event occurs
HMACA keyed hash that proves authenticity + integrity
Shared SecretA key both sides know; never sent with the webhook
Signature HeaderThe HMAC digest the sender attaches (e.g., X-Hub-Signature-256)
timingSafeEqualConstant-time comparison that blocks timing attacks
express.raw()Middleware to capture the unparsed body for HMAC computation
IdempotencyProcessing the same event twice produces the same result
Replay AttackResending 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