# Verifikasi tanda tangan webhook di Node.js

> Verifikasi tanda tangan webhook Ikiform di Node.js: body mentah di Express dan Fastify, SDK, paket standardwebhooks, dan versi node:crypto yang sudah diuji.

Terbit 2026-10-25 · https://ikiform.com/id/guides/webhook-signature-verification/nodejs

Dapatkan body request sebagai byte mentah sebelum parser JSON apa pun berjalan, lalu berikan bersama header dan secret `whsec_` Anda ke `verifyWebhook` dari `@ikiform/sdk/webhooks`. Di Express itu berarti `express.raw()` di route webhook; di Fastify, parser content-type string yang dibatasi pada route itu. Tanpa SDK, `createHmac` dan `timingSafeEqual` dari `node:crypto` melakukan hal yang sama dalam sekitar 20 baris.

Halaman ini membahas server Node yang berjalan lama: Express dan Fastify, ditambah versi tanpa dependency yang bisa Anda uji dengan unit test. Untuk route handler di Next.js, Astro, dan framework lain, lihat [ringkasan](https://ikiform.com/id/guides/webhook-signature-verification.md#implementation); untuk Cloudflare Worker dengan penyimpanan, lihat [server Anda sendiri](https://ikiform.com/blog/form-submissions-to-your-own-server.md).

## Apa yang berbeda di Node

Framework Node sangat membantu soal JSON. Daftarkan `express.json()` atau pakai default Fastify dan setiap handler mendapat `req.body` sebagai objek. Saat kode webhook Anda berjalan, byte yang ditandatangani Ikiform sudah hilang, dan `JSON.stringify(req.body)` menghasilkan byte yang berbeda: spasi lain, escaping lain untuk karakter seperti `/` atau emoji. Solusinya ada pada di mana Anda mendapatkan body, bukan cara Anda meng-hash-nya.

Diagram: Body webhook yang melewati parser JSON global dibandingkan dengan parser mentah di route

1. Dengan parser JSON global yang didaftarkan lebih dulu, byte yang ditandatangani di-parse menjadi objek di middleware.
2. Menserialisasi ulang objek itu menghasilkan string berbeda, jadi tanda tangan tidak bisa cocok dan handler menjawab 400.
3. Dengan parser mentah di route webhook, handler menerima byte persisnya sebagai Buffer.
4. Handler memverifikasi byte itu, baru mem-parsing-nya sesudahnya, dan menjawab 204.

Urutan itu penting. Express menjalankan middleware sesuai urutan pendaftaran, jadi route webhook didaftarkan lebih dulu.

## 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" })` hanya di route ini memberi `req.body` sebagai `Buffer`. `express.json()` global yang didaftarkan sesudahnya tidak pernah melihat request ini, karena route sudah menjawab.
- `limit` diatur di atas batas payload Ikiform 256 KB. Default Express 100 KB akan menolak kiriman besar dengan `413`.
- `verifyWebhook` menerima `req.headers` Node apa adanya; fungsi ini mencari header tanpa memedulikan huruf besar-kecil dan memeriksa timestamp, setiap tanda tangan `v1`, dan JSON-nya.
- Di Express 5, error yang dilempar dari handler async diteruskan ke error middleware, jadi melempar ulang aman.

## 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 mem-parsing `application/json` sendiri. Mendaftarkan parser dengan `parseAs: "string"` di dalam `register` menggantinya hanya untuk route di plugin itu, berkat enkapsulasi, jadi bagian lain API Anda tetap mendapat objek yang sudah di-parse. `request.body` lalu menjadi string persis yang dikirim Ikiform.

## Dengan paket standardwebhooks

Jika Anda lebih suka memakai library referensi spesifikasinya, jalankan `npm install standardwebhooks`. Class `Webhook`-nya menghapus awalan `whsec_` sendiri dan menerima body `Buffer`. Class ini sinkron dan melempar `WebhookVerificationError` beserta pesannya:

`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` hanya memakai Web Crypto, jadi butuh Node 20 atau lebih baru. Paket `standardwebhooks` membawa SHA-256 sendiri dan juga berjalan di versi lama.

## Dengan node:crypto dan test

Tanpa dependency, dan dengan nilai kembalian yang menjelaskan mengapa request gagal. Kode meng-hash `Buffer` mentah secara langsung, jadi tidak ada persoalan encoding 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` melempar error saat kedua buffer berbeda panjang, jadi pemeriksaan panjang dilakukan lebih dulu. Tanda tangan dengan panjang yang salah bukanlah kecocokan.
- `createHmac(...).update()` dua kali meng-hash awalan dan body sebagai satu stream, sama seperti meng-hash keduanya setelah digabung.
- Node mengubah nama header yang masuk menjadi huruf kecil, jadi `headers["webhook-id"]` langsung berfungsi di `req.headers`.

Dan test dengan [test vector](https://ikiform.com/id/guides/webhook-signature-verification.md#test-vector). Jalankan dengan `node --test`; Node 22.18 dan yang lebih baru menjalankan file TypeScript secara langsung:

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

`now` yang tetap menjaga timestamp 2026 tetap di dalam jendela 5 menit. Kasus yang diformat rapi adalah yang akan menangkap parser yang diam-diam ditambahkan nanti, jadi pertahankan meski kasus lain terasa jelas.

## Melewati pengulangan

Pengiriman yang terverifikasi tetap bisa tiba dua kali: jika handler Anda selesai tetapi jawabannya hilang atau butuh lebih dari 15 detik, Ikiform mencoba ulang dengan `webhook-id` yang sama. Di layanan Node yang sudah punya Redis, satu `SET` dengan kondisi `NX` mengingat setiap id. Ini memakai nama opsi node-redis 5 dan yang lebih baru:

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

Panggil `await firstDelivery(event.id)` setelah verifikasi dan sebelum mengerjakan apa pun; jika mengembalikan `false`, jawab `204` dan berhenti. Masa kedaluwarsa 4 hari melampaui jendela percobaan ulang Ikiform yang sekitar tiga hari, jadi percobaan ulang yang terlambat tetap dikenali. Tanpa Redis, kolom unik di database Anda melakukan tugas yang sama. Mencatat id lebih dulu berarti crash di tengah jalan juga akan melewati percobaan ulang; jika Anda lebih suka mencoba ulang, catat id hanya setelah pekerjaan berhasil.

## Pastikan berfungsi

1. Jalankan `node --test`. Keempat kasus lolos.
2. Ekspos server di URL `https:` publik, misalnya dengan `cloudflared tunnel --url http://localhost:3000`, dan tambahkan di **Webhooks** di dashboard.
3. Atur `IKIFORM_WEBHOOK_SECRET` ke secret `whsec_` milik endpoint dan restart server.
4. Pilih **Send test**. Log pengiriman menampilkan `204`.
5. Pindahkan `app.use(express.json())` ke atas route webhook dan kirim uji lagi. Uji gagal dengan `400 no_matching_signature`, yang membuktikan test akan menangkap kesalahan itu. Kembalikan ke posisinya.

## Hal yang sering menjebak

| Masalah | Penyebab dan solusi |
| --- | --- |
| `req.body` bernilai `undefined` atau `{}` | `type` parser mentah tidak cocok dengan `Content-Type` request, jadi body dilewati. Ikiform mengirim `application/json` |
| TypeScript menyatakan `Buffer` tidak bisa di-assign ke `string` | `verifyWebhook` menerima body sebagai string. Berikan `req.body.toString("utf8")` |
| Berhasil secara lokal, gagal di host serverless | Platform mem-parsing body untuk Anda. Di Pages Router Next.js, atur `bodyParser: false` di `config` route dan baca stream-nya sendiri |
| `RangeError: Input buffers must have the same byte length` | `timingSafeEqual` dengan tanda tangan yang cacat. Periksa panjang sebelum membandingkan |
| `413 Payload Too Large` di log pengiriman | Batas body parser di bawah 256 KB. Naikkan di route webhook |

## Pertanyaan

### Bagaimana cara mendapatkan body mentah di Express untuk webhook?

Tambahkan express.raw({ type: "application/json" }) sebagai middleware di route webhook, yang memberi req.body sebagai Buffer. Daftarkan route sebelum express.json() global, atau parser JSON berjalan lebih dulu.

### Mengapa crypto.timingSafeEqual melempar error?

Fungsi itu butuh dua buffer dengan panjang byte yang sama dan melempar RangeError jika tidak. Bandingkan panjang lebih dulu dan anggap ketidakcocokan sebagai tanda tangan yang gagal.

### Bisakah saya memakai paket npm standardwebhooks dengan Ikiform?

Bisa. Ikiform mengikuti Standard Webhooks, jadi new Webhook(secret).verify(body, headers) berfungsi dengan secret whsec_ dari dashboard. Fungsi itu melempar WebhookVerificationError saat pemeriksaan gagal.

### Versi Node mana yang didukung @ikiform/sdk untuk webhook?

Node 20 dan yang lebih baru, ditambah Cloudflare Workers, Deno, dan Bun. verifyWebhook hanya memakai Web Crypto API.

## Panduan lainnya

- [Verifikasi tanda tangan webhook](https://ikiform.com/id/guides/webhook-signature-verification.md): Pastikan webhook formulir benar-benar berasal dari Ikiform: skema HMAC-SHA256 Standard Webhooks, test vector, body mentah, timestamp, dan rotasi secret.
- [Python](https://ikiform.com/id/guides/webhook-signature-verification/python.md): Verifikasi tanda tangan webhook Ikiform di Flask, FastAPI, dan Django, dengan paket standardwebhooks atau standard library, ditambah test pytest.
- [Go net/http](https://ikiform.com/id/guides/webhook-signature-verification/go.md): Verifikasi tanda tangan webhook Ikiform di Go dengan net/http: baca body sekali, periksa dengan crypto/hmac, uji, atau gunakan library Standard Webhooks.
- [Ruby](https://ikiform.com/id/guides/webhook-signature-verification/ruby.md): Verifikasi tanda tangan webhook Ikiform di Sinatra dengan OpenSSL dan test Minitest, dan di Rails dengan gem standardwebhooks dan request.raw_post.
- [PHP](https://ikiform.com/id/guides/webhook-signature-verification/php.md): Verifikasi tanda tangan webhook Ikiform di PHP biasa dengan hash_hmac dan hash_equals, dan di Laravel dengan library Standard Webhooks dan pengecualian CSRF.
