Verifica las firmas de webhook6 min de lectura
Verifica firmas de webhook en Node.js
Verifica las firmas de los webhooks de Ikiform en Node.js: cuerpos sin procesar en Express y Fastify, el SDK, el paquete standardwebhooks y una versión probada con node:crypto.
Obtén el cuerpo de la petición como bytes sin procesar antes de que se ejecute cualquier parser de JSON y pásalo, junto con las cabeceras y tu secreto whsec_, a verifyWebhook de @ikiform/sdk/webhooks. En Express eso significa express.raw() en la ruta del webhook; en Fastify, un parser de content type de cadena limitado a esa ruta. Sin el SDK, createHmac y timingSafeEqual de node:crypto hacen lo mismo en unas 20 líneas.
Esta página trata los servidores Node de larga duración: Express y Fastify, más una versión sin dependencias que puedes probar con tests unitarios. Para route handlers en Next.js, Astro y otros frameworks, consulta la visión general; para un Cloudflare Worker con almacenamiento, consulta tu propio servidor.
Qué cambia en Node
Los frameworks de Node te ayudan con el JSON. Registra express.json() o usa los valores por defecto de Fastify y cada handler recibe req.body como objeto. Cuando se ejecuta tu código del webhook, los bytes que firmó Ikiform ya no están, y JSON.stringify(req.body) produce otros: otros espacios, otro escapado de caracteres como / o los emojis. La solución está en dónde obtienes el cuerpo, no en cómo calculas el hash.
- Con un parser global de JSON registrado primero, los bytes firmados se convierten en un objeto en el middleware.
- Al volver a serializar ese objeto sale una cadena distinta, así que la firma no puede coincidir y el handler responde 400.
- Con un parser raw en la ruta del webhook, el handler recibe los bytes exactos como Buffer.
- Verifica esos bytes, los analiza solo después y responde 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" })solo en esta ruta te dareq.bodycomoBuffer. Elexpress.json()global registrado después nunca ve esta petición, porque la ruta ya ha respondido.- El
limitestá por encima del tope de 256 KB del payload de Ikiform. El valor por defecto de Express, 100 KB, rechazaría un envío grande con413. verifyWebhookaceptareq.headersde Node tal cual; busca las cabeceras sin distinguir mayúsculas y comprueba la marca de tiempo, cada firmav1y el JSON.- En Express 5, un error lanzado desde un handler async va al middleware de errores, así que volver a lanzarlo es seguro.
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 analiza application/json por su cuenta. Registrar un parser con parseAs: "string" dentro de register lo sustituye solo para las rutas de ese plugin, gracias al encapsulamiento, así que el resto de tu API sigue recibiendo objetos analizados. request.body es entonces la cadena exacta que envió Ikiform.
Con el paquete standardwebhooks
Si prefieres usar la librería de referencia de la especificación, npm install standardwebhooks. Su clase Webhook quita el prefijo whsec_ por sí sola y acepta un cuerpo Buffer. Es síncrona y lanza WebhookVerificationError con un mensaje:
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 usa solo Web Crypto, así que necesita Node 20 o posterior. El paquete standardwebhooks incluye su propio SHA-256 y también funciona en versiones anteriores.Con node:crypto y un test
Sin dependencias, y con un valor de retorno que dice por qué falló una petición. Calcula el hash del Buffer sin procesar directamente, así que no hay dudas de codificación de cadenas:
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";
}timingSafeEquallanza un error cuando los dos buffers tienen longitudes distintas, así que la comprobación de longitud va primero. Una firma de longitud incorrecta no es una coincidencia.- Llamar dos veces a
createHmac(...).update()calcula el hash del prefijo y el cuerpo como un solo flujo, igual que si los unieras. - Node pasa a minúsculas los nombres de las cabeceras entrantes, así que
headers["webhook-id"]funciona directamente sobrereq.headers.
Y un test con el vector de prueba. Ejecútalo con node --test; Node 22.18 y posteriores ejecutan archivos TypeScript directamente:
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");
});El now fijo mantiene la marca de tiempo de 2026 dentro de la ventana de 5 minutos. El caso formateado es el que detecta un parser que se cuele más adelante, así que mantenlo aunque los demás parezcan obvios.
Saltar las repeticiones
Una entrega verificada aún puede llegar dos veces: si tu handler termina pero la respuesta se pierde o tarda más de 15 segundos, Ikiform reintenta con el mismo webhook-id. En un servicio Node que ya tenga Redis, un SET con la condición NX recuerda cada id. Esto usa los nombres de opción de node-redis 5 y posteriores:
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";
}Llama a await firstDelivery(event.id) después de verificar y antes de hacer cualquier trabajo; si devuelve false, responde 204 y para. La caducidad de 4 días supera la ventana de reintentos de Ikiform, de unos tres días, así que un reintento tardío se sigue reconociendo. Sin Redis, una columna única en tu base de datos hace el mismo trabajo. Registrar el id primero significa que, si algo se cae a mitad, el reintento también se salta; si prefieres reintentar, regístralo solo cuando el trabajo salga bien.
Comprueba que funciona
- Ejecuta
node --test. Los cuatro casos pasan. - Expón el servidor en una URL
https:pública, por ejemplo concloudflared tunnel --url http://localhost:3000, y añádela en Webhooks en el panel. - Pon en
IKIFORM_WEBHOOK_SECRETel secretowhsec_del endpoint y reinicia el servidor. - Elige Send test. El registro de entregas muestra
204. - Mueve
app.use(express.json())por encima de la ruta del webhook y envía otra prueba. Falla con400 no_matching_signature, lo que demuestra que el test detectaría ese error. Vuelve a dejarlo como estaba.
Errores comunes
| Problema | Causa y solución |
|---|---|
req.body es undefined o {} | El type del parser raw no coincidía con el Content-Type de la petición, así que se saltó el cuerpo. Ikiform envía application/json |
TypeScript dice que un Buffer no se puede asignar a string | verifyWebhook recibe el cuerpo como cadena. Pasa req.body.toString("utf8") |
| Funciona en local, falla en un hosting serverless | La plataforma analizó el cuerpo por ti. En el Pages Router de Next.js, pon bodyParser: false en el config de la ruta y lee el stream tú mismo |
RangeError: Input buffers must have the same byte length | timingSafeEqual con una firma mal formada. Comprueba las longitudes antes de comparar |
413 Payload Too Large en el registro de entregas | El límite del body parser está por debajo de 256 KB. Súbelo en la ruta del webhook |
Preguntas
Añade express.raw({ type: "application/json" }) como middleware en la ruta del webhook, que te da req.body como Buffer. Registra la ruta antes de cualquier express.json() global, o el parser de JSON se ejecutará primero.
Necesita dos buffers con la misma longitud en bytes y, si no, lanza un RangeError. Compara primero las longitudes y trata una diferencia como una firma fallida.
Sí. Ikiform sigue Standard Webhooks, así que new Webhook(secret).verify(body, headers) funciona con el secreto whsec_ del panel. Lanza WebhookVerificationError cuando falla una comprobación.
Node 20 y posteriores, además de Cloudflare Workers, Deno y Bun. verifyWebhook usa solo la Web Crypto API.
¿Te atascaste en un paso o quieres una guía para tu caso? Escribe a hello@ikiform.com.