---
title: "Webhook Security for E-Signature API Integrations | TurboDocx"
description: "How to secure webhooks for e-signature and document API integrations with HMAC. Verify signatures in Node.js with timingSafeEqual and SHA-256."
url: "https://www.turbodocx.com/blog/how-to-webhook-hmac-guide"
---

# Webhook Security for E-Signature API Integrations

> Webhooks are how e-signature and document APIs tell your app that a contract was signed. But without verification, anyone can fake that event. This is HMAC webhook security, explained so you can implement it before lunch.

By Nicolas Fry (Founder & CEO, TurboDocx). Published May 7, 2026. 15 min read. Category: Backend Architecture.

If you integrate an e-signature or document API, you need to know the instant a payment succeeds on Stripe, a document gets signed on [TurboSign](https://www.turbodocx.com/products/turbosign.md), 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

| 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:

- **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](https://www.turbodocx.com/products/turbosign.md)** notifies your system when all signers have signed a document
- **[Wrike](https://www.turbodocx.com/newsroom/turbodocx-wrike-partnership.md) / 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

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.
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).
3. **You recompute the hash.** When the webhook arrives, you run the same HMAC-SHA256(secret, rawBody) on your end.
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:

```typescript
// 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.

```typescript
// 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.

```typescript
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](https://www.turbodocx.com/blog/microservices-event-driven-architecture.md) 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. Fix: Always use crypto.timingSafeEqual().
- **Parsing the body before verifying.** Why it's dangerous: JSON.parse() can alter whitespace/encoding, changing the hash. Fix: 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. Fix: Use environment variables or a secrets manager.
- **Not responding 200 quickly.** Why it's dangerous: Most senders retry on timeout, causing duplicate processing. Fix: 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](https://www.turbodocx.com/products/api-and-sdk.md) 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

### What is a webhook in simple terms?

A webhook is a way for one application to send real-time data to another application the moment something happens. Instead of constantly asking 'any updates?' (polling), the source app pushes a notification to a URL you provide. Think of it like a doorbell instead of peeking through the window every 5 seconds.

### What does HMAC stand for and why is it used with webhooks?

HMAC stands for Hash-based Message Authentication Code. It's used with webhooks to verify that the payload actually came from the expected sender and wasn't tampered with in transit. The sender computes a hash of the payload using a shared secret key, and the receiver recomputes it to confirm a match.

### How do I verify a webhook signature in Node.js?

Use Node.js's built-in crypto module: compute an HMAC-SHA256 of the raw request body using your shared secret, then compare it to the signature header sent with the webhook using crypto.timingSafeEqual() to prevent timing attacks.

### What is the difference between webhooks and polling?

Polling means your application repeatedly sends requests to check for updates (e.g., every 30 seconds), wasting bandwidth when nothing has changed. Webhooks flip the model: the source sends you a POST request the instant something happens, making them real-time, efficient, and scalable.

### Why should I use timingSafeEqual instead of === for HMAC comparison?

A regular === comparison returns false as soon as it finds the first mismatched byte, and an attacker can measure this timing difference to guess the correct signature one byte at a time. crypto.timingSafeEqual() always takes the same amount of time regardless of where the mismatch occurs, blocking timing attacks.

## 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.

[Explore the API](https://www.turbodocx.com/products/api-and-sdk.md) or [see the Wrike integration](https://www.turbodocx.com/use-cases/wrike-document-automation.md).

## Keep Reading

- [Microservices & Event-Driven Architecture](https://www.turbodocx.com/blog/microservices-event-driven-architecture.md): Event sourcing, CQRS, and Kafka patterns for distributed systems.
- [Serverless Backend Architecture](https://www.turbodocx.com/blog/serverless-backend-architecture.md): Lambda, edge computing, and WebAssembly patterns for scalable backends.
