Verificar assinaturas de webhook

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.

Preet SutharFundador, Ikiform

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.

Um corpo de webhook passando por um parser de JSON global comparado com um parser bruto na rotaexpress.json() global primeiroexpress.raw() na rota do webhookbytesbrutoso que oIkiformassinouJSON.parseno middlewareJSON.stringifybytesdiferentes400no_matching_signaturebytesbrutoso que oIkiformassinouBufferintactoverifica,depois faz oparse204Um corpo de webhook passando por um parser de JSON global comparado com um parser bruto na rotaexpress.json() global primeiroexpress.raw() na rota do webhookbytes brutoso que o Ikiform assinouJSON.parseno middlewareJSON.stringifybytes diferentes400no_matching_signaturebytes brutoso que o Ikiform assinouBufferintactoverifica, depois faz oparse204
  1. Com um parser de JSON global registrado primeiro, os bytes assinados viram um objeto no middleware.
  2. Serializar esse objeto de novo gera uma string diferente, então a assinatura não tem como bater e o handler responde 400.
  3. Com um parser bruto na rota do webhook, o handler recebe os bytes exatos como um Buffer.
  4. Ele verifica esses bytes, só depois faz o parse e responde 204.
A ordem importa. O Express roda os middlewares na ordem em que você os registra, então a rota do webhook vem primeiro.

Express

server.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" }) só nesta rota entrega req.body como Buffer. O express.json() global registrado depois nunca vê esta requisição, porque a rota já respondeu.
  • O limit fica acima do teto de 256 KB de payload do Ikiform. O padrão de 100 KB do Express recusaria um envio grande com 413.
  • verifyWebhook aceita o req.headers do Node como está; ele busca os headers sem diferenciar maiúsculas e confere o timestamp, cada assinatura v1 e 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

server.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" });

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:

server.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;
  }
});
O @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:

verify.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 lanç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 em req.headers.

E um teste com o vetor de teste. Rode com node --test; o Node 22.18 e posteriores rodam arquivos TypeScript diretamente:

verify.test.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");
});

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:

deliveries.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";
}

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

  1. Rode node --test. Os quatro casos passam.
  2. Exponha o servidor numa URL https: pública, por exemplo com cloudflared tunnel --url http://localhost:3000, e adicione-a em Webhooks no painel.
  3. Defina IKIFORM_WEBHOOK_SECRET com o segredo whsec_ do endpoint e reinicie o servidor.
  4. Escolha Send test. O log de entregas mostra 204.
  5. Mova app.use(express.json()) para cima da rota do webhook e envie outro teste. Ele falha com 400 no_matching_signature, o que prova que o teste pegaria esse erro. Mova de volta.

Pegadinhas

ProblemaCausa 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 stringverifyWebhook recebe o corpo como string. Passe req.body.toString("utf8")
Funciona localmente, falha num host serverlessA 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 lengthtimingSafeEqual com uma assinatura malformada. Confira os tamanhos antes de comparar
413 Payload Too Large no log de entregasO limite do body parser está abaixo de 256 KB. Aumente-o na rota do webhook

Perguntas

Travou em algum passo ou quer um guia para o seu caso? Envie um e-mail para hello@ikiform.com.