# 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.

Publicado em 2026-10-25 · https://ikiform.com/pt-br/guides/webhook-signature-verification/nodejs

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](https://ikiform.com/pt-br/guides/webhook-signature-verification.md#implementation); para um Cloudflare Worker com armazenamento, veja [o seu próprio servidor](https://ikiform.com/blog/form-submissions-to-your-own-server.md).

## 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.

Diagrama: Um corpo de webhook passando por um parser de JSON global comparado com um parser bruto na rota

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`:

```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`:

```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`:

```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`:

```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](https://ikiform.com/pt-br/guides/webhook-signature-verification.md#test-vector). Rode com `node --test`; o Node 22.18 e posteriores rodam arquivos TypeScript diretamente:

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

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`:

```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

| 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

### Como pego o corpo bruto no Express para um webhook?

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.

### Por que o crypto.timingSafeEqual lança erro?

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.

### Posso usar o pacote npm standardwebhooks com o Ikiform?

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.

### Quais versões do Node o @ikiform/sdk suporta para webhooks?

Node 20 e posteriores, além de Cloudflare Workers, Deno e Bun. O verifyWebhook usa apenas a Web Crypto API.

## Mais guias

- [Verificar assinaturas de webhook](https://ikiform.com/pt-br/guides/webhook-signature-verification.md): Confira se um webhook de formulário veio mesmo do Ikiform: o esquema HMAC-SHA256 do Standard Webhooks, um vetor de teste, corpos brutos, timestamps e rotação de segredo.
- [Python](https://ikiform.com/pt-br/guides/webhook-signature-verification/python.md): Verifique assinaturas de webhook do Ikiform no Flask, FastAPI e Django, com o pacote standardwebhooks ou a biblioteca padrão, mais um teste com pytest.
- [Go net/http](https://ikiform.com/pt-br/guides/webhook-signature-verification/go.md): Verifique assinaturas de webhook do Ikiform em Go com net/http: leia o corpo uma vez, confira com crypto/hmac, teste, ou use a biblioteca do Standard Webhooks.
- [Ruby](https://ikiform.com/pt-br/guides/webhook-signature-verification/ruby.md): Verifique assinaturas de webhook do Ikiform no Sinatra com OpenSSL e um teste Minitest, e no Rails com a gem standardwebhooks e request.raw_post.
- [PHP](https://ikiform.com/pt-br/guides/webhook-signature-verification/php.md): Verifique assinaturas de webhook do Ikiform em PHP puro com hash_hmac e hash_equals, e no Laravel com a biblioteca do Standard Webhooks e uma exceção de CSRF.
