# Vérifier les signatures de webhook en Node.js

> Vérifiez les signatures des webhooks Ikiform en Node.js : corps bruts dans Express et Fastify, le SDK, le package standardwebhooks et une version node:crypto testée.

Publié le 2026-10-25 · https://ikiform.com/fr/guides/webhook-signature-verification/nodejs

Récupérez le corps de la requête en octets bruts avant l'exécution de tout parser JSON, puis passez-le avec les en-têtes et votre secret `whsec_` à `verifyWebhook` de `@ikiform/sdk/webhooks`. Dans Express, cela signifie `express.raw()` sur la route du webhook ; dans Fastify, un parser de content-type en chaîne limité à cette route. Sans le SDK, `createHmac` et `timingSafeEqual` de `node:crypto` font la même chose en une vingtaine de lignes.

Cette page couvre les serveurs Node de longue durée : Express et Fastify, plus une version sans dépendance que vous pouvez tester unitairement. Pour les route handlers de Next.js, Astro et d'autres frameworks, consultez [la vue d'ensemble](https://ikiform.com/fr/guides/webhook-signature-verification.md#implementation) ; pour un Cloudflare Worker avec stockage, consultez [votre propre serveur](https://ikiform.com/blog/form-submissions-to-your-own-server.md).

## Ce qui diffère dans Node

Les frameworks Node sont serviables avec le JSON. Enregistrez `express.json()` ou utilisez les réglages par défaut de Fastify et chaque gestionnaire reçoit `req.body` sous forme d'objet. Au moment où votre code de webhook s'exécute, les octets signés par Ikiform ont disparu, et `JSON.stringify(req.body)` en produit d'autres : espacement différent, échappement différent de caractères comme `/` ou les emoji. La solution porte sur l'endroit où vous récupérez le corps, pas sur la façon de le hacher.

Schéma : Un corps de webhook qui passe par un parser JSON global comparé à un parser brut sur la route

1. Avec un parser JSON global enregistré en premier, les octets signés sont analysés en objet dans le middleware.
2. Resérialiser cet objet donne une chaîne différente, donc la signature ne peut pas correspondre et le gestionnaire répond 400.
3. Avec un parser brut sur la route du webhook, le gestionnaire reçoit les octets exacts sous forme de Buffer.
4. Il vérifie ces octets, ne les analyse qu'ensuite et répond 204.

L'ordre compte. Express exécute les middlewares dans l'ordre d'enregistrement, donc la route du webhook passe en premier.

## 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" })` sur cette seule route donne `req.body` sous forme de `Buffer`. Le `express.json()` global enregistré après ne voit jamais cette requête, car la route a déjà répondu.
- La `limit` est au-dessus du plafond de payload de 256 Ko d'Ikiform. La valeur par défaut d'Express, 100 Ko, refuserait une grosse soumission avec `413`.
- `verifyWebhook` prend les `req.headers` de Node tels quels ; il recherche les en-têtes sans tenir compte de la casse et vérifie le timestamp, chaque signature `v1` et le JSON.
- Dans Express 5, une erreur levée depuis un gestionnaire async va au middleware d'erreur, donc la relance est sans risque.

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

Fastify analyse lui-même `application/json`. Enregistrer un parser avec `parseAs: "string"` dans `register` le remplace uniquement pour les routes de ce plugin, grâce à l'encapsulation, donc le reste de votre API reçoit toujours des objets analysés. `request.body` est alors la chaîne exacte envoyée par Ikiform.

## Avec le package standardwebhooks

Si vous préférez utiliser la bibliothèque de référence de la spécification, `npm install standardwebhooks`. Sa classe `Webhook` retire elle-même le préfixe `whsec_` et accepte un corps `Buffer`. Elle est synchrone et lève `WebhookVerificationError` avec un message :

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

> `@ikiform/sdk` utilise uniquement Web Crypto, donc il nécessite Node 20 ou plus récent. Le package `standardwebhooks` embarque sa propre implémentation de SHA-256 et fonctionne aussi sur des versions plus anciennes.

## Avec node:crypto et un test

Aucune dépendance, et une valeur de retour qui indique pourquoi une requête a échoué. Le code hache directement le `Buffer` brut, donc la question de l'encodage des chaînes ne se pose pas :

`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` lève une exception quand les deux buffers ont des longueurs différentes, donc la vérification de longueur vient en premier. Une signature de mauvaise longueur ne correspond pas.
- `createHmac(...).update()` appelé deux fois hache le préfixe et le corps comme un seul flux, comme si on les hachait joints.
- Node met en minuscules les noms d'en-têtes entrants, donc `headers["webhook-id"]` fonctionne directement sur `req.headers`.

Et un test avec le [vecteur de test](https://ikiform.com/fr/guides/webhook-signature-verification.md#test-vector). Lancez-le avec `node --test` ; Node 22.18 et plus récent exécutent directement les fichiers TypeScript :

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

Le `now` fixe garde le timestamp de 2026 dans la fenêtre de 5 minutes. Le cas du corps mis en forme est celui qui détecte un parser qui s'immiscerait plus tard, donc gardez-le même quand les autres semblent évidents.

## Ignorer les répétitions

Une livraison vérifiée peut tout de même arriver deux fois : si votre gestionnaire termine mais que la réponse est perdue ou prend plus de 15 secondes, Ikiform réessaie avec le même `webhook-id`. Dans un service Node qui a déjà Redis, un seul `SET` avec la condition `NX` mémorise chaque identifiant. Ce code utilise les noms d'options de node-redis 5 et plus récent :

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

Appelez `await firstDelivery(event.id)` après la vérification et avant tout travail ; s'il renvoie `false`, répondez `204` et arrêtez. L'expiration de 4 jours dépasse la fenêtre de nouvelles tentatives d'Ikiform d'environ trois jours, donc une nouvelle tentative tardive est toujours reconnue. Sans Redis, une colonne unique dans votre base de données fait le même travail. Enregistrer l'identifiant en premier signifie qu'un plantage à mi-chemin ignore aussi la nouvelle tentative ; si vous préférez réessayer, enregistrez-le seulement après la réussite du travail.

## Vérifiez que tout fonctionne

1. Lancez `node --test`. Les quatre cas passent.
2. Exposez le serveur sur une URL `https:` publique, par exemple avec `cloudflared tunnel --url http://localhost:3000`, et ajoutez-la sous **Webhooks** dans le tableau de bord.
3. Définissez `IKIFORM_WEBHOOK_SECRET` avec le secret `whsec_` de l'endpoint et redémarrez le serveur.
4. Choisissez **Send test**. Le journal de livraison affiche `204`.
5. Déplacez `app.use(express.json())` au-dessus de la route du webhook et envoyez un autre test. Il échoue avec `400 no_matching_signature`, ce qui prouve que le test détecterait cette erreur. Remettez-le à sa place.

## Pièges

| Problème | Cause et solution |
| --- | --- |
| `req.body` vaut `undefined` ou `{}` | Le `type` du parser brut ne correspondait pas au `Content-Type` de la requête, donc il a ignoré le corps. Ikiform envoie `application/json` |
| TypeScript indique qu'un `Buffer` n'est pas assignable à `string` | `verifyWebhook` prend le corps sous forme de chaîne. Passez `req.body.toString("utf8")` |
| Fonctionne en local, échoue sur un hébergeur serverless | La plateforme a analysé le corps à votre place. Avec le Pages Router de Next.js, définissez `bodyParser: false` dans la `config` de la route et lisez le flux vous-même |
| `RangeError: Input buffers must have the same byte length` | `timingSafeEqual` avec une signature malformée. Vérifiez les longueurs avant de comparer |
| `413 Payload Too Large` dans le journal de livraison | La limite du parser de corps est inférieure à 256 Ko. Augmentez-la sur la route du webhook |

## Questions

### Comment obtenir le corps brut dans Express pour un webhook ?

Ajoutez express.raw({ type: "application/json" }) comme middleware sur la route du webhook, ce qui donne req.body sous forme de Buffer. Enregistrez la route avant tout express.json() global, sinon le parser JSON s'exécute en premier.

### Pourquoi crypto.timingSafeEqual lève-t-il une exception ?

Il exige deux buffers de même longueur en octets et lève une RangeError sinon. Comparez d'abord les longueurs et traitez une différence comme une signature invalide.

### Puis-je utiliser le package npm standardwebhooks avec Ikiform ?

Oui. Ikiform suit Standard Webhooks, donc new Webhook(secret).verify(body, headers) fonctionne avec le secret whsec_ du tableau de bord. Il lève WebhookVerificationError quand une vérification échoue.

### Quelles versions de Node @ikiform/sdk prend-il en charge pour les webhooks ?

Node 20 et plus récent, ainsi que Cloudflare Workers, Deno et Bun. verifyWebhook utilise uniquement l'API Web Crypto.

## Autres guides

- [Vérifier les signatures de webhook](https://ikiform.com/fr/guides/webhook-signature-verification.md): Vérifiez qu'un webhook de formulaire vient bien d'Ikiform : le schéma HMAC-SHA256 de Standard Webhooks, un vecteur de test, les corps bruts, les timestamps et la rotation du secret.
- [Python](https://ikiform.com/fr/guides/webhook-signature-verification/python.md): Vérifiez les signatures des webhooks Ikiform dans Flask, FastAPI et Django, avec le package standardwebhooks ou la bibliothèque standard, plus un test pytest.
- [Go net/http](https://ikiform.com/fr/guides/webhook-signature-verification/go.md): Vérifiez les signatures des webhooks Ikiform en Go avec net/http : lisez le corps une seule fois, vérifiez-le avec crypto/hmac, testez-le, ou utilisez la bibliothèque Standard Webhooks.
- [Ruby](https://ikiform.com/fr/guides/webhook-signature-verification/ruby.md): Vérifiez les signatures des webhooks Ikiform dans Sinatra avec OpenSSL et un test Minitest, et dans Rails avec la gem standardwebhooks et request.raw_post.
- [PHP](https://ikiform.com/fr/guides/webhook-signature-verification/php.md): Vérifiez les signatures des webhooks Ikiform en PHP simple avec hash_hmac et hash_equals, et dans Laravel avec la bibliothèque Standard Webhooks et une exception CSRF.
