Verificar assinaturas de webhook6 min de leitura
Verificar assinaturas de webhook em Node.js
Verifique assinaturas de webhook do Ikiform em Node.js: corpos brutos no Express e no Fastify, o SDK, o pacote standardwebhooks e uma versão testada com node:crypto.
Pegue o corpo da requisição como bytes brutos antes que qualquer parser de JSON rode e passe-o, com os headers e o seu segredo whsec_, para verifyWebhook de @ikiform/sdk/webhooks. No Express, isso significa express.raw() na rota do webhook; no Fastify, um parser de content-type em string restrito a essa rota. Sem o SDK, createHmac e timingSafeEqual do node:crypto fazem o mesmo em cerca de 20 linhas.
Esta página cobre servidores Node de longa duração: Express e Fastify, mais uma versão sem dependências que você pode testar com testes unitários. Para route handlers no Next.js, Astro e outros frameworks, veja a visão geral; para um Cloudflare Worker com armazenamento, veja o seu próprio servidor.
O que muda no Node
Os frameworks Node são prestativos com JSON. Registre express.json() ou use os padrões do Fastify e todo handler recebe req.body como objeto. Quando o código do seu webhook roda, os bytes que o Ikiform assinou já se foram, e JSON.stringify(req.body) produz outros: outro espaçamento, outro escape de caracteres como / ou emoji. A correção está em onde você pega o corpo, não em como calcula o hash.
- Com um parser de JSON global registrado primeiro, os bytes assinados viram um objeto no middleware.
- Serializar esse objeto de novo gera uma string diferente, então a assinatura não tem como bater e o handler responde 400.
- Com um parser bruto na rota do webhook, o handler recebe os bytes exatos como um Buffer.
- Ele verifica esses bytes, só depois faz o parse e 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" })só nesta rota entregareq.bodycomoBuffer. Oexpress.json()global registrado depois nunca vê esta requisição, porque a rota já respondeu.- O
limitfica acima do teto de 256 KB de payload do Ikiform. O padrão de 100 KB do Express recusaria um envio grande com413. verifyWebhookaceita oreq.headersdo Node como está; ele busca os headers sem diferenciar maiúsculas e confere o timestamp, cada assinaturav1e o JSON.- No Express 5, um erro lançado de um handler async vai para o middleware de erro, então relançar é 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" });O Fastify faz o parse de application/json sozinho. Registrar um parser com parseAs: "string" dentro de register o substitui apenas nas rotas desse plugin, graças ao encapsulamento, então o resto da sua API continua recebendo objetos parseados. request.body passa a ser a string exata que o Ikiform enviou.
Com o pacote standardwebhooks
Se preferir usar a biblioteca de referência da especificação, npm install standardwebhooks. A classe Webhook dela tira o prefixo whsec_ sozinha e aceita um corpo Buffer. Ela é síncrona e lança WebhookVerificationError com uma mensagem:
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 apenas Web Crypto, então precisa do Node 20 ou posterior. O pacote standardwebhooks traz o próprio SHA-256 e roda também em versões mais antigas.Com node:crypto e um teste
Sem dependências, e com um valor de retorno que diz por que uma requisição falhou. Ele calcula o hash do Buffer bruto diretamente, então não há dúvida de codificação de string:
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";
}timingSafeEquallança erro quando os dois buffers têm tamanhos diferentes, então a verificação de tamanho vem primeiro. Uma assinatura com tamanho errado não bate.- Chamar
createHmac(...).update()duas vezes calcula o hash do prefixo e do corpo como um único fluxo, o mesmo que calcular o hash deles unidos. - O Node coloca os nomes dos headers recebidos em minúsculas, então
headers["webhook-id"]funciona direto emreq.headers.
E um teste com o vetor de teste. Rode com node --test; o Node 22.18 e posteriores rodam arquivos TypeScript diretamente:
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");
});O now fixo mantém o timestamp de 2026 dentro da janela de 5 minutos. O caso formatado é o que pega um parser que se infiltra depois, então mantenha-o mesmo quando os outros parecerem óbvios.
Ignorando repetições
Uma entrega verificada ainda pode chegar duas vezes: se o seu handler termina mas a resposta se perde ou demora mais de 15 segundos, o Ikiform tenta de novo com o mesmo webhook-id. Num serviço Node que já tem Redis, um SET com a condição NX lembra cada id. Isto usa os nomes de opção do node-redis 5 e 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";
}Chame await firstDelivery(event.id) depois de verificar e antes de fazer qualquer trabalho; se devolver false, responda 204 e pare. A expiração de 4 dias dura mais que a janela de novas tentativas do Ikiform, de cerca de três dias, então uma nova tentativa tardia ainda é reconhecida. Sem Redis, uma coluna única no seu banco de dados faz o mesmo trabalho. Registrar o id primeiro significa que uma falha no meio do caminho também ignora a nova tentativa; se você prefere tentar de novo, registre-o só depois que o trabalho der certo.
Confira se funciona
- Rode
node --test. Os quatro casos passam. - Exponha o servidor numa URL
https:pública, por exemplo comcloudflared tunnel --url http://localhost:3000, e adicione-a em Webhooks no painel. - Defina
IKIFORM_WEBHOOK_SECRETcom o segredowhsec_do endpoint e reinicie o servidor. - Escolha Send test. O log de entregas mostra
204. - Mova
app.use(express.json())para cima da rota do webhook e envie outro teste. Ele falha com400 no_matching_signature, o que prova que o teste pegaria esse erro. Mova de volta.
Pegadinhas
| Problema | Causa e correção |
|---|---|
req.body é undefined ou {} | O type do parser bruto não bateu com o Content-Type da requisição, então ele pulou o corpo. O Ikiform envia application/json |
O TypeScript diz que um Buffer não pode ser atribuído a string | verifyWebhook recebe o corpo como string. Passe req.body.toString("utf8") |
| Funciona localmente, falha num host serverless | A plataforma fez o parse do corpo por você. No Pages Router do Next.js, defina bodyParser: false no config da rota e leia o stream você mesmo |
RangeError: Input buffers must have the same byte length | timingSafeEqual com uma assinatura malformada. Confira os tamanhos antes de comparar |
413 Payload Too Large no log de entregas | O limite do body parser está abaixo de 256 KB. Aumente-o na rota do webhook |
Perguntas
Adicione express.raw({ type: "application/json" }) como middleware na rota do webhook, o que entrega req.body como Buffer. Registre a rota antes de qualquer express.json() global, ou o parser de JSON roda primeiro.
Ele exige dois buffers com o mesmo tamanho em bytes e lança um RangeError caso contrário. Compare os tamanhos antes e trate uma diferença como assinatura inválida.
Sim. O Ikiform segue o Standard Webhooks, então new Webhook(secret).verify(body, headers) funciona com o segredo whsec_ do painel. Ele lança WebhookVerificationError quando uma verificação falha.
Node 20 e posteriores, além de Cloudflare Workers, Deno e Bun. O verifyWebhook usa apenas a Web Crypto API.
Travou em algum passo ou quer um guia para o seu caso? Envie um e-mail para hello@ikiform.com.