Verify webhook signatures6 min read
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.
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; for a Cloudflare Worker with storage, see your own server.
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.
- With a global JSON parser registered first, the signed bytes are parsed into an object in middleware.
- Serializing that object again gives a different string, so the signature can't match and the handler answers 400.
- With a raw parser on the webhook route, the handler receives the exact bytes as a Buffer.
- It verifies those bytes, parses them only afterwards and answers 204.
Express
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 givesreq.bodyas aBuffer. The globalexpress.json()registered after it never sees this request, because the route has already answered.- The
limitsits above Ikiform's 256 KB payload cap. Express's default of 100 KB would refuse a large submission with413. verifyWebhooktakes Node'sreq.headersas-is; it looks headers up case-insensitively and checks the timestamp, everyv1signature and the JSON.- In Express 5, an error thrown from an async handler goes to the error middleware, so the rethrow is safe.
Fastify
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:
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:
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";
}timingSafeEqualthrows 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 onreq.headersdirectly.
And a test with the test vector. Run it with node --test; Node 22.18 and later run TypeScript files directly:
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:
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
- Run
node --test. All four cases pass. - Expose the server on a public
https:URL, for example withcloudflared tunnel --url http://localhost:3000, and add it under Webhooks in the dashboard. - Set
IKIFORM_WEBHOOK_SECRETto the endpoint'swhsec_secret and restart the server. - Choose Send test. The delivery log shows
204. - Move
app.use(express.json())above the webhook route and send another test. It fails with400 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
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.
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.
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.
Node 20 and later, plus Cloudflare Workers, Deno and Bun. verifyWebhook uses the Web Crypto API only.
Stuck on a step, or want a guide for your situation? Email hello@ikiform.com.