# Verify webhook signatures in Node.js

> Verify Ikiform webhook signatures in Node.js: raw bodies in Express and Fastify, the SDK, the standardwebhooks package and a tested node:crypto version.

Published 2026-10-25 · https://ikiform.com/guides/webhook-signature-verification/nodejs

Get the request body as raw bytes before any JSON parser runs, then pass it with the headers and your `whsec_` secret to `verifyWebhook` from `@ikiform/sdk/webhooks`. In Express that means `express.raw()` on the webhook route; in Fastify, a string content-type parser scoped to that route. Without the SDK, `node:crypto`'s `createHmac` and `timingSafeEqual` do the same in about 20 lines.

This page covers long-running Node servers: Express and Fastify, plus a dependency-free version you can unit test. For route handlers in Next.js, Astro and other frameworks, see [the overview](https://ikiform.com/guides/webhook-signature-verification.md#implementation); for a Cloudflare Worker with storage, see [your own server](https://ikiform.com/blog/form-submissions-to-your-own-server.md).

## What's different in Node

Node frameworks are helpful with JSON. Register `express.json()` or use Fastify's defaults and every handler gets `req.body` as an object. By the time your webhook code runs, the bytes Ikiform signed are gone, and `JSON.stringify(req.body)` produces different ones: other spacing, other escaping of characters such as `/` or emoji. The fix is about where you get the body, not how you hash it.

Diagram: A webhook body passing through a global JSON parser compared with a raw parser on the route

1. With a global JSON parser registered first, the signed bytes are parsed into an object in middleware.
2. Serializing that object again gives a different string, so the signature can't match and the handler answers 400.
3. With a raw parser on the webhook route, the handler receives the exact bytes as a Buffer.
4. It verifies those bytes, parses them only afterwards and answers 204.

Order matters. Express runs middleware in the order you register it, so the webhook route goes first.

## Express

`server.js`:

```js
import express from "express";
import { verifyWebhook, WebhookVerificationError } from "@ikiform/sdk/webhooks";

const app = express();

app.post("/webhooks/ikiform", express.raw({ type: "application/json", limit: "300kb" }), async (req, res) => {
  try {
    const event = await verifyWebhook({
      payload: req.body.toString("utf8"),
      headers: req.headers,
      secret: process.env.IKIFORM_WEBHOOK_SECRET,
    });
    if (event.type === "submission.created") {
      console.info("submission", event.data.submission_id, event.data.fields);
    }
    res.sendStatus(204);
  } catch (error) {
    if (error instanceof WebhookVerificationError) return res.status(400).send(error.reason);
    throw error;
  }
});

app.use(express.json());

app.listen(3000);
```

- `express.raw({ type: "application/json" })` on this route only gives `req.body` as a `Buffer`. The global `express.json()` registered after it never sees this request, because the route has already answered.
- The `limit` sits above Ikiform's 256 KB payload cap. Express's default of 100 KB would refuse a large submission with `413`.
- `verifyWebhook` takes Node's `req.headers` as-is; it looks headers up case-insensitively and checks the timestamp, every `v1` signature and the JSON.
- In Express 5, an error thrown from an async handler goes to the error middleware, so the rethrow is safe.

## Fastify

`server.js`:

```js
import Fastify from "fastify";
import { verifyWebhook, WebhookVerificationError } from "@ikiform/sdk/webhooks";

const app = Fastify({ logger: true });

app.register(async (webhooks) => {
  webhooks.addContentTypeParser(
    "application/json",
    { parseAs: "string", bodyLimit: 300 * 1024 },
    (_request, body, done) => done(null, body),
  );

  webhooks.post("/webhooks/ikiform", async (request, reply) => {
    try {
      const event = await verifyWebhook({
        payload: request.body,
        headers: request.headers,
        secret: process.env.IKIFORM_WEBHOOK_SECRET,
      });
      request.log.info({ type: event.type, id: event.id }, "ikiform webhook");
      return reply.code(204).send();
    } catch (error) {
      if (error instanceof WebhookVerificationError) return reply.code(400).send(error.reason);
      throw error;
    }
  });
});

await app.listen({ port: 3000, host: "0.0.0.0" });
```

Fastify parses `application/json` itself. Registering a parser with `parseAs: "string"` inside `register` replaces it for routes in that plugin only, thanks to encapsulation, so the rest of your API still gets parsed objects. `request.body` is then the exact string Ikiform sent.

## With the standardwebhooks package

If you'd rather use the spec's reference library, `npm install standardwebhooks`. Its `Webhook` class strips the `whsec_` prefix itself and accepts a `Buffer` body. It's synchronous and throws `WebhookVerificationError` with a message:

`server.js`:

```js
import express from "express";
import { Webhook, WebhookVerificationError } from "standardwebhooks";

const webhook = new Webhook(process.env.IKIFORM_WEBHOOK_SECRET);
const app = express();

app.post("/webhooks/ikiform", express.raw({ type: "application/json" }), (req, res) => {
  try {
    const event = webhook.verify(req.body, req.headers);
    console.info(event.type, event.id);
    res.sendStatus(204);
  } catch (error) {
    if (error instanceof WebhookVerificationError) return res.status(400).send(error.message);
    throw error;
  }
});
```

> `@ikiform/sdk` uses Web Crypto only, so it needs Node 20 or later. The `standardwebhooks` package bundles its own SHA-256 and also runs on older versions.

## With node:crypto and a test

No dependency, and a return value that says why a request failed. It hashes the raw `Buffer` directly, so there's no question of string encoding:

`verify.ts`:

```ts
import { createHmac, timingSafeEqual } from "node:crypto";

export type VerifyFailure =
  | "missing_headers"
  | "invalid_timestamp"
  | "timestamp_too_old"
  | "timestamp_too_new"
  | "no_matching_signature";

type Headers = Record<string, string | string[] | undefined>;

const TOLERANCE_SECONDS = 5 * 60;

function header(headers: Headers, name: string) {
  const value = headers[name];
  return Array.isArray(value) ? value.join(" ") : value;
}

export function verifySignature(
  rawBody: Buffer | string,
  headers: Headers,
  secret: string,
  now = Date.now(),
): VerifyFailure | null {
  const id = header(headers, "webhook-id");
  const timestamp = header(headers, "webhook-timestamp");
  const signatures = header(headers, "webhook-signature");
  if (!id || !timestamp || !signatures) return "missing_headers";
  if (!/^\d{1,12}$/.test(timestamp)) return "invalid_timestamp";

  const age = Math.floor(now / 1000) - Number(timestamp);
  if (age > TOLERANCE_SECONDS) return "timestamp_too_old";
  if (age < -TOLERANCE_SECONDS) return "timestamp_too_new";

  const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
  const expected = createHmac("sha256", key).update(`${id}.${timestamp}.`).update(rawBody).digest();

  for (const entry of signatures.split(" ")) {
    const [version, value] = entry.split(",", 2);
    if (version !== "v1" || !value) continue;
    const received = Buffer.from(value, "base64");
    if (received.length === expected.length && timingSafeEqual(received, expected)) return null;
  }
  return "no_matching_signature";
}
```

- `timingSafeEqual` throws when the two buffers differ in length, so the length check comes first. A wrong-length signature is not a match.
- `createHmac(...).update()` twice hashes the prefix and the body as one stream, the same as hashing them joined.
- Node lowercases incoming header names, so `headers["webhook-id"]` works on `req.headers` directly.

And a test with the [test vector](https://ikiform.com/guides/webhook-signature-verification.md#test-vector). Run it with `node --test`; Node 22.18 and later run TypeScript files directly:

`verify.test.ts`:

```ts
import assert from "node:assert/strict";
import { test } from "node:test";
import { verifySignature } from "./verify.ts";

const secret = "whsec_Fg8c6hm1Gt41v5RSPKm72LggDx8S1mlWkbBYpHPAnpw=";
const rolled = "whsec_4YWiO+F83kohh/SGNr2zcgChkpVJ65ySVeV23Qabr2A=";
const body =
  '{"id":"dlv_01k7v2d4n8r2p6s0w3y5b7c9e1","type":"endpoint.test","timestamp":"2026-10-17T12:00:00.000Z","api_version":"2026-10-17","data":{"endpoint_id":"whk_01k7v2e1b3d5f7h9k1m3n5q7s9","message":"This is a test event from Ikiform."}}';
const headers = {
  "webhook-id": "dlv_01k7v2d4n8r2p6s0w3y5b7c9e1",
  "webhook-timestamp": "1792300000",
  "webhook-signature": "v1,Duq+Iuh0xbRZyS+C/TO5W1hcGLGwg+ifBiWiYkq15qA=",
};
const now = 1792300000 * 1000 + 30_000;

test("accepts the test vector", () => {
  assert.equal(verifySignature(Buffer.from(body), headers, secret, now), null);
});

test("accepts either secret during a roll", () => {
  const both = { ...headers, "webhook-signature": `${headers["webhook-signature"]} v1,gfHP3s7ANdIpnM7gewtWtSvTrHgmsbEFgCaeRRR0Eak=` };
  assert.equal(verifySignature(body, both, secret, now), null);
  assert.equal(verifySignature(body, both, rolled, now), null);
});

test("rejects a re-serialized body", () => {
  const pretty = JSON.stringify(JSON.parse(body), null, 2);
  assert.equal(verifySignature(pretty, headers, secret, now), "no_matching_signature");
});

test("rejects a stale timestamp", () => {
  assert.equal(verifySignature(body, headers, secret, now + 10 * 60_000), "timestamp_too_old");
});
```

The fixed `now` keeps the 2026 timestamp inside the 5-minute window. The pretty-printed case is the one that catches a parser sneaking in later, so keep it even when the others feel obvious.

## Skipping repeats

A verified delivery can still arrive twice: if your handler finishes but the answer is lost or takes longer than 15 seconds, Ikiform retries with the same `webhook-id`. In a Node service that already has Redis, one `SET` with the `NX` condition remembers each id. This uses the option names of node-redis 5 and later:

`deliveries.js`:

```js
import { createClient } from "redis";

const redis = await createClient({ url: process.env.REDIS_URL }).connect();

export async function firstDelivery(deliveryId) {
  const stored = await redis.set(`ikiform:delivery:${deliveryId}`, "1", {
    condition: "NX",
    expiration: { type: "EX", value: 4 * 24 * 60 * 60 },
  });
  return stored === "OK";
}
```

Call `await firstDelivery(event.id)` after verifying and before doing any work; if it returns `false`, answer `204` and stop. The 4-day expiry outlasts Ikiform's retry window of about three days, so a late retry is still recognized. Without Redis, a unique column in your database does the same job. Recording the id first means a crash halfway through skips the retry too; if you would rather retry, record it only after the work succeeds.

## Check it works

1. Run `node --test`. All four cases pass.
2. Expose the server on a public `https:` URL, for example with `cloudflared tunnel --url http://localhost:3000`, and add it under **Webhooks** in the dashboard.
3. Set `IKIFORM_WEBHOOK_SECRET` to the endpoint's `whsec_` secret and restart the server.
4. Choose **Send test**. The delivery log shows `204`.
5. Move `app.use(express.json())` above the webhook route and send another test. It fails with `400 no_matching_signature`, which proves the test would catch that mistake. Move it back.

## Gotchas

| Problem | Cause and fix |
| --- | --- |
| `req.body` is `undefined` or `{}` | The raw parser's `type` didn't match the request's `Content-Type`, so it skipped the body. Ikiform sends `application/json` |
| TypeScript says a `Buffer` isn't assignable to `string` | `verifyWebhook` takes the body as a string. Pass `req.body.toString("utf8")` |
| Works locally, fails on a serverless host | The platform parsed the body for you. On the Next.js Pages Router, set `bodyParser: false` in the route's `config` and read the stream yourself |
| `RangeError: Input buffers must have the same byte length` | `timingSafeEqual` with a malformed signature. Check lengths before comparing |
| `413 Payload Too Large` in the delivery log | The body parser's limit is below 256 KB. Raise it on the webhook route |

## Questions

### How do I get the raw body in Express for a webhook?

Add express.raw({ type: "application/json" }) as middleware on the webhook route, which gives req.body as a Buffer. Register the route before any global express.json(), or the JSON parser runs first.

### Why does crypto.timingSafeEqual throw?

It requires two buffers of the same byte length and throws a RangeError otherwise. Compare lengths first and treat a mismatch as a failed signature.

### Can I use the standardwebhooks npm package with Ikiform?

Yes. Ikiform follows Standard Webhooks, so new Webhook(secret).verify(body, headers) works with the whsec_ secret from the dashboard. It throws WebhookVerificationError when a check fails.

### Which Node versions does @ikiform/sdk support for webhooks?

Node 20 and later, plus Cloudflare Workers, Deno and Bun. verifyWebhook uses the Web Crypto API only.

## More guides

- [Verify webhook signatures](https://ikiform.com/guides/webhook-signature-verification.md): Check that a form webhook really came from Ikiform: the Standard Webhooks HMAC-SHA256 scheme, a test vector, raw bodies, timestamps and secret rotation.
- [Python](https://ikiform.com/guides/webhook-signature-verification/python.md): Verify Ikiform webhook signatures in Flask, FastAPI and Django, with the standardwebhooks package or the standard library, plus a pytest test.
- [Go net/http](https://ikiform.com/guides/webhook-signature-verification/go.md): Verify Ikiform webhook signatures in Go with net/http: read the body once, check it with crypto/hmac, test it, or use the Standard Webhooks library.
- [Ruby](https://ikiform.com/guides/webhook-signature-verification/ruby.md): Verify Ikiform webhook signatures in Sinatra with OpenSSL and a Minitest test, and in Rails with the standardwebhooks gem and request.raw_post.
- [PHP](https://ikiform.com/guides/webhook-signature-verification/php.md): Verify Ikiform webhook signatures in plain PHP with hash_hmac and hash_equals, and in Laravel with the Standard Webhooks library and a CSRF exception.
