Guides

How to verify signed webhooks in Node.js (and keep the signed PDF)

Verify an HMAC-signed webhook in Node.js and Next.js without the usual traps: check the raw body, compare in constant time, reject replays, ignore duplicate deliveries, then fetch the signed PDF with its audit trail. Tested code included.

Akbar Ali · 7 October 2026

Your e-signature provider POSTs to a URL on your server when a document is signed. Anyone who finds that URL can POST to it too. So before you mark a contract as completed in your database, you want proof that the request really came from the provider and that nobody changed it on the way.

That proof is a signature header. Verifying it is about fifteen lines. Getting those fifteen lines right is where people lose an afternoon, so this guide goes trap by trap. Every snippet below ran against a real Express server and a Next.js-style route handler before I put it here. I'm using PutMySign webhooks because that's what I build, but the same shape works for any HMAC-signed webhook.

Panik Kalm Panik meme. Panik: "Webhook handler returns 200 on everything". Kalm: "Added a signature check". Panik again: "It fails on every real delivery".
Spoiler: it's trap 1.

What you're verifying

Every delivery carries an X-Signature header that looks like this:

X-Signature: t=1789000000,v1=9c4f...

t is a Unix timestamp. v1 is an HMAC-SHA256 of the string ${t}.${rawBody}, using the signing secret you got when you added the endpoint. Same secret, same bytes in, same hex out. If your result doesn't match v1, don't trust the request. The docs have the full event list and the payload shape.

Trap 1: you checked the parsed body, not the raw one

This is the big one. Your framework parses the JSON for you, and you turn it back into a string to check it. The string you get is not the string that was signed. Key order, spaces and escaping can all differ, and one changed byte is a different HMAC.

So the handler needs the raw bytes. In Express, that means express.raw on this route, not express.json:

import express from "express";
import { verify } from "./verify.js";

const secret = process.env.PUTMYSIGN_WEBHOOK_SECRET;
const seen = new Set(); // use a database table or Redis in production

export const app = express();

// express.raw, not express.json: we need the exact bytes that were signed.
app.post(
  "/webhooks/putmysign",
  express.raw({ type: "application/json" }),
  (req, res) => {
    const rawBody = req.body.toString("utf8");

    if (!verify(rawBody, req.get("X-Signature"), secret)) {
      return res.sendStatus(400);
    }

    const event = JSON.parse(rawBody);

    // Retries mean the same event can arrive twice.
    if (seen.has(event.id)) return res.sendStatus(200);
    seen.add(event.id);

    if (event.event === "document.completed") {
      // Queue the real work (download the PDF, update your DB) and return fast.
      console.log("completed", event.data.document.id);
    }

    res.sendStatus(200);
  }
);

If you have a global app.use(express.json()) above this, it runs first and eats the body. Register this route before it, or skip the global parser for this path.

In a Next.js App Router route handler it's even simpler, as long as you ask for text first:

// app/api/webhooks/putmysign/route.js
import { verify } from "@/lib/verify";

export async function POST(request) {
  // text() gives you the raw body. Don't call request.json() first.
  const rawBody = await request.text();

  if (!verify(rawBody, request.headers.get("x-signature"), process.env.PUTMYSIGN_WEBHOOK_SECRET)) {
    return new Response("bad signature", { status: 400 });
  }

  const event = JSON.parse(rawBody);
  // ...same idempotency check and handling as above
  return new Response("ok");
}

I tested both with a signed body (accepted) and the same JSON re-serialised with indentation (rejected, as it should be). It's also the most common reason a "correct" check fails in production.

Trap 2: === and the crash that hides in the safe version

Comparing signatures with === leaks timing information, so the standard advice is crypto.timingSafeEqual. Good advice, with a catch: it throws if the two buffers have different lengths. A request with a short or garbage v1 then turns into an unhandled error and a 500, which is a strange thing to hand to anyone who is poking at your endpoint.

Check the length first:

import crypto from "node:crypto";

const TOLERANCE_SECONDS = 300;

export function verify(rawBody, header, secret) {
  // "t=1789...,v1=9c4f..." -> { t, v1 }
  const parts = Object.fromEntries(
    String(header ?? "")
      .split(",")
      .map((p) => p.trim().split("="))
  );
  if (!parts.t || !parts.v1) return false;

  // Too old (or from the future)? Treat it as a replay.
  if (Math.abs(Date.now() / 1000 - Number(parts.t)) > TOLERANCE_SECONDS) {
    return false;
  }

  const expected = crypto
    .createHmac("sha256", secret)
    .update(`${parts.t}.${rawBody}`)
    .digest();
  const received = Buffer.from(parts.v1, "hex");

  // timingSafeEqual throws if the lengths differ, so check first.
  return received.length === expected.length && crypto.timingSafeEqual(received, expected);
}
Is This a Pigeon? meme. The butterfly: "v1=ab". The man: "timingSafeEqual". The question: "Valid signature?".
It throws RangeError. Node would like a word about your lengths.

I ran the short-v1 case on purpose: with the length check it returns 400, and without it, it throws ERR_CRYPTO_TIMING_SAFE_EQUAL_LENGTH.

Trap 3: a valid signature from last week

An attacker who records one real delivery can send it to you again later. The signature is still valid, because nothing in it has changed. That's why the timestamp is part of what's signed: you can refuse anything that's too old.

The code above rejects a timestamp more than five minutes away from your clock. Your server clock needs to be roughly right for this to work (NTP on, and it usually is). Don't make the window much tighter than five minutes, or a slow clock will reject real deliveries.

Trap 4: the same event, twice

Your endpoint has to answer with a 2xx quickly. If it doesn't, the delivery is retried, five times over roughly a day, with longer waits each time. That's what you want. But it means the same event can reach you twice, for example when your handler did the work and then timed out before answering.

Every event has an id. Store the ones you've handled and return 200 for repeats without doing the work again. In the example above that's a Set, which is fine for a demo and wrong for production: use a unique column in your database, so two parallel deliveries can't both win.

Do the slow part after you answer. Download the PDF in a job, not inside the request.

Trap 5: trusting the wrong event

Events arrive per recipient. recipient.signed means one person signed. A document stays "sent" while anyone still has to sign, so don't mark a contract as done on that one. document.completed is the one that means everyone has signed.

That's also the point to fetch the file. GET /v1/documents/:id/file returns the finished PDF with the signatures, the signing certificate and the audit trail already inside it. There's no second file to download and stitch together.

// inside your job, after document.completed
const res = await fetch(`https://putmysign.com/v1/documents/${documentId}/file`, {
  headers: { Authorization: `Bearer ${process.env.PUTMYSIGN_KEY}` },
});
const pdf = Buffer.from(await res.arrayBuffer());
// store pdf somewhere you control

Store that file. Don't just keep a link to it.

How I tested it

I ran nine cases against the Express server and the Next-style handler: a valid delivery, a duplicate event id, a re-serialised body, a tampered body, a timestamp from 1970, a short v1, a missing header, and the Next handler with a good and a bad body. Nine of nine behave as above. The PDF download snippet is not in that run; it's the call from the API docs, so check it against your own key.

The short version

  • Verify the raw bytes. Never a re-serialised object.
  • Check the length before timingSafeEqual.
  • Reject timestamps more than a few minutes old.
  • Store event ids and ignore repeats.
  • Act on document.completed, and keep the signed PDF.

The API reference has every field. If you haven't built the signing side yet, there's a guide for Next.js and one for React, and if you're still choosing a vendor, the DocuSign alternatives guide shows what to ask. Want to try it? Create a free account and use a test key: it works like a live one and sends no email.

What does your handler do when the work behind a webhook fails halfway: return 500 and take the retry, or answer 200 and fix it from a queue?