Cara Verifikasi Signature Webhook (Node.js, PHP, Go, Python)

Endpoint webhook Anda adalah URL publik yang bisa di-POST siapa saja. Begini cara kerja verifikasi signature yang sebenarnya, dalam empat bahasa, dan apa yang harus dicek saat signature tidak pernah cocok.

Untuk verifikasi signature webhook, hitung HMAC-SHA256 dari "<timestamp>.<raw body request>" memakai signing secret Anda, bandingkan dengan signature di header request memakai fungsi constant-time, dan tolak timestamp yang selisihnya lebih dari lima menit. Lewatkan salah satu langkah itu, dan endpoint Anda tetap menjadi API tulis yang terbuka untuk publik.

Ringkasan

  • Tanda tangani dan periksa byte mentah yang Anda terima, jangan JSON yang sudah di-parse lalu diserialisasi ulang.
  • Bandingkan dengan timingSafeEqual, hmac.compare_digest, hash_equals, atau hmac.Equal, jangan pernah dengan ==.
  • Tolak timestamp yang berselisih lebih dari 5 menit dari jam server Anda; itulah yang menghentikan serangan replay.
  • Balas dengan 2xx dalam 10 detik dan lakukan de-duplikasi berdasarkan id peristiwa, karena retry dan kirim ulang pasti terjadi.
  • SealReply menandatangani setiap pengiriman; webhook tersedia mulai paket Starter.

Artikel ini membahas kenapa pemeriksaan ini penting, bagaimana signature dibentuk, empat implementasi yang bisa langsung Anda tempel ke proyek hari ini, dan sejumlah alasan kenapa implementasi yang tampak benar tetap menghasilkan invalid.

An envelope with a wax seal arriving at a server, next to a magnifying glass holding a matching stamp

Apa yang terjadi jika verifikasi signature webhook dilewati?

Siapa pun yang menemukan URL-nya bisa mengirim peristiwa palsu ke Anda. Endpoint webhook harus berupa URL HTTPS publik, karena memang begitulah pengiriman sampai ke Anda. Artinya, siapa pun juga bisa mengirim POST ke sana. Dua hal bisa salah:

Pemalsuan. Seseorang membaca dokumentasi publik penyedia layanan, menyusun JSON yang sama secara manual, lalu mengirimkannya ke Anda. Jika peristiwa itu membuat pesanan, memberi saldo, atau menulis baris "deal menang" ke CRM Anda, berarti Anda sudah membuka endpoint tulis untuk umum. Dan URL endpoint lebih mudah bocor daripada yang dikira orang: bundle frontend, repo publik, screenshot tab network.

Replay. Request yang asli pun bisa menjadi masalah jika ditangkap lalu dikirim ulang berkali-kali. Karena itulah timestamp ikut ditandatangani, dan karena itu pula Anda wajib memeriksanya.

Dua hal yang terasa seperti perlindungan, padahal bukan:

  • HTTPS saja. HTTPS membuktikan payload tidak dibaca atau diubah selama perjalanan. HTTPS tidak mengatakan apa pun tentang siapa pengirimnya.
  • Rahasia di URL, misalnya /hooks?key=abc123. URL lengkap tercatat di access log, log proxy, dan dashboard monitoring, dan menggantinya berarti mengubah endpoint di semua tempat.

Signature menjawab pertanyaan yang tidak bisa dijawab keduanya: apakah request ini dikirim oleh pihak yang memegang rahasia, dan apakah body-nya sama persis, byte demi byte, dengan yang mereka kirim?

Seperti apa bentuk signature webhook?

Signature webhook adalah kode pendek yang dihitung pengirim dari request dan sebuah rahasia yang hanya diketahui kedua pihak, sehingga penerima bisa membuktikan siapa pengirimnya dan bahwa tidak ada yang berubah di perjalanan. SealReply mengirim header ini di setiap pengiriman:

X-Signature: t=1793525957,v1=413882683ef3f416e353fe8c13a8358135e80f7630f92ef02b1e5edd37e655fd
  • t adalah Unix timestamp pengiriman, dalam detik.
  • v1 adalah HMAC-SHA256 dalam hex huruf kecil dari "<t>.<raw body>", dengan signing secret Anda (whsec_…) sebagai kunci.

Sebagian besar penyedia memakai variasi dari cara ini; urutan dan nama header-nya berbeda, idenya sama. Menandatangani timestamp bersama body itulah yang membuat pemeriksaan kesegaran ada artinya: ubah t, maka v1 tidak lagi cocok, sehingga penyerang tidak bisa mengganti tanggal request lama yang mereka tangkap.

A key, a clock dial and a sheet of paper flowing through a funnel and coming out as a single padlock

HMAC bukan enkripsi. HMAC adalah keyed digest yang dispesifikasikan di RFC 2104: rahasia yang sama atas byte yang sama selalu menghasilkan output yang sama, selisih satu byte menghasilkan output yang sama sekali berbeda, dan melihat output-nya tidak membantu siapa pun menemukan rahasianya.

Apa saja yang harus benar di kode verifikasi?

Tiga hal: raw body, perbandingan constant-time, dan pemeriksaan timestamp. Fungsinya hanya lima belas baris, tetapi setiap baris punya alasan.

  1. Hash raw body, bukan JSON yang sudah di-parse. Ini kesalahan yang dilakukan hampir semua orang; ada bagian khusus tentangnya di bawah.
  2. Bandingkan dalam waktu konstan. == berhenti di byte pertama yang berbeda, dan perbedaan waktunya cukup untuk menebak signature byte demi byte. Setiap runtime menyediakan fungsi untuk ini: crypto.timingSafeEqual, hmac.compare_digest, hash_equals, hmac.Equal.
  3. Periksa timestamp, dengan toleransi. Lima menit adalah default yang bagus: cukup lebar untuk selisih jam dan retry, cukup sempit agar request yang tertangkap cepat tidak berlaku lagi.

Perbandingan constant-time juga punya jebakan

Dokumentasi hmac Python dan package crypto/hmac Go sama-sama menyuruh Anda memakai fungsi pembanding mereka, bukan ==. Masih ada dua hal yang sering salah: timingSafeEqual di Node melempar error jika panjangnya berbeda, jadi cek panjangnya dulu; dan kedua sisi harus dalam bentuk yang sama, string hex dengan string hex, bukan hex dengan byte digest mentah.

Bagaimana cara verifikasi signature webhook di Node, Python, PHP, dan Go?

Salin salah satu dari empat fungsi di bawah dan panggil sebelum Anda mem-parse body. Ini contoh dari dokumentasi SealReply. Keempatnya hanya memakai standard library, tanpa dependensi. Semuanya sudah dijalankan saat artikel ini ditulis: tanda tangani sebuah body, keempatnya mencetak ok; ubah satu byte signature, keempatnya mencetak invalid.

Node.js

import crypto from "node:crypto";

const TOLERANCE_SECONDS = 5 * 60;

// header: the X-Signature request header. rawBody: the body as received (Buffer or string).
export function verifySignature(secret, header, rawBody, now = Date.now() / 1000) {
  const parts = Object.fromEntries((header ?? "").split(",").map((p) => p.split("=", 2)));
  const t = Number(parts.t);
  if (!parts.v1 || !Number.isInteger(t) || Math.abs(now - t) > TOLERANCE_SECONDS) return false;
  const expected = crypto.createHmac("sha256", secret).update(`${parts.t}.`).update(rawBody).digest("hex");
  const a = Buffer.from(expected);
  const b = Buffer.from(parts.v1);
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

timingSafeEqual melempar error jika panjang kedua buffer berbeda, jadi pemeriksaan panjang dilakukan lebih dulu.

Di Express, route-nya butuh raw body, artinya express.raw dipasang khusus di route tersebut:

app.post("/hooks", express.raw({ type: "application/json" }), (req, res) => {
  if (!verifySignature(process.env.SEALREPLY_WEBHOOK_SECRET, req.get("X-Signature"), req.body)) {
    return res.sendStatus(401);
  }
  const event = JSON.parse(req.body.toString("utf8"));
  res.sendStatus(200); // acknowledge first, work after
});

Python

import hashlib, hmac, time

TOLERANCE_SECONDS = 5 * 60

def verify_signature(secret: str, header: str, raw_body: bytes, now: float | None = None) -> bool:
    parts = dict(p.split("=", 1) for p in (header or "").split(",") if "=" in p)
    try:
        t = int(parts["t"])
        signature = parts["v1"]
    except (KeyError, ValueError):
        return False
    if abs((now or time.time()) - t) > TOLERANCE_SECONDS:
        return False
    expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, signature)

Raw body-nya adalah request.body di Django, request.get_data() di Flask (bukan request.json), dan await request.body() di FastAPI.

PHP

const SEALREPLY_TOLERANCE_SECONDS = 300;

function sealreply_verify_signature(string $secret, string $header, string $rawBody, ?int $now = null): bool
{
    $parts = [];
    foreach (explode(',', $header) as $part) {
        $pair = explode('=', $part, 2);
        if (count($pair) === 2) {
            $parts[$pair[0]] = $pair[1];
        }
    }
    if (!isset($parts['t'], $parts['v1']) || !ctype_digit($parts['t'])) {
        return false;
    }
    if (abs(($now ?? time()) - (int) $parts['t']) > SEALREPLY_TOLERANCE_SECONDS) {
        return false;
    }
    $expected = hash_hmac('sha256', $parts['t'] . '.' . $rawBody, $secret);
    return hash_equals($expected, $parts['v1']);
}

Baca body dengan file_get_contents('php://input') dan header dari $_SERVER['HTTP_X_SIGNATURE']. Di Laravel, gunakan $request->getContent() dan $request->header('X-Signature'), dan route-nya harus dikecualikan dari verifikasi CSRF.

Go

const tolerance = 5 * time.Minute

func VerifySignature(secret, header string, rawBody []byte, now time.Time) bool {
	parts := map[string]string{}
	for _, p := range strings.Split(header, ",") {
		if k, v, ok := strings.Cut(p, "="); ok {
			parts[k] = v
		}
	}
	t, err := strconv.ParseInt(parts["t"], 10, 64)
	if err != nil || parts["v1"] == "" {
		return false
	}
	if d := now.Sub(time.Unix(t, 0)); d > tolerance || d < -tolerance {
		return false
	}
	mac := hmac.New(sha256.New, []byte(secret))
	mac.Write([]byte(parts["t"] + "."))
	mac.Write(rawBody)
	return hmac.Equal([]byte(hex.EncodeToString(mac.Sum(nil))), []byte(parts["v1"]))
}

r.Body adalah stream yang hanya bisa dibaca sekali, jadi jalankan body, _ := io.ReadAll(r.Body) di awal, verifikasi dengan body, lalu json.Unmarshal(body, &event). Sama halnya di Gin, dengan c.GetRawData().

Di mana raw body di setiap framework?

Raw body dan fungsi pembanding adalah dua baris yang paling sering salah saat orang menyesuaikan kode, jadi berikut perbandingannya:

Framework Raw body Pembanding constant-time
Express express.raw({ type: "application/json" }) di route, lalu req.body crypto.timingSafeEqual
Django request.body hmac.compare_digest
Flask request.get_data() hmac.compare_digest
FastAPI await request.body() hmac.compare_digest
PHP biasa file_get_contents('php://input') hash_equals
Laravel $request->getContent() (route dikecualikan dari CSRF) hash_equals
Go net/http io.ReadAll(r.Body) hmac.Equal
Gin c.GetRawData() hmac.Equal

Kenapa signature webhook tidak pernah cocok?

Biasanya karena body yang Anda hash bukan byte yang persis sama dengan yang dikirim. Berikut lima penyebabnya, dari yang paling umum:

Gejala Penyebab Solusi
Semua request gagal, termasuk peristiwa uji Body di-parse dan diserialisasi ulang sebelum di-hash Hash byte mentah; pakai raw parser khusus di route webhook
Gagal hanya di produksi Proxy, gateway, atau middleware logging mengubah atau menghabiskan body Catat panjang byte di tahap verifikasi dan bandingkan dengan log pengiriman
Gagal sejak deploy atau ganti rahasia Rahasia salah, terpotong, atau versi lama, atau ada baris baru di akhir variabel Tempel ulang rahasia secara lengkap; setelah Rahasia baru, perbarui server
Gagal di satu server, berhasil di server lain Jam server selisih lebih dari 5 menit Pastikan NTP tersinkron; catat hasil pemeriksaan timestamp secara terpisah
Dilaporkan "invalid" tanpa detail Hex dibandingkan dengan byte, beda huruf besar-kecil, atau error panjang yang tertelan Bandingkan string hex dengan string hex; cek panjang sebelum timingSafeEqual

Two envelopes at a shield in front of a server, the one with an intact seal passing through and the cracked one bounced back

1. Body di-parse lalu diserialisasi ulang. Delapan dari sepuluh kegagalan. JSON.parse lalu JSON.stringify bisa mengubah spasi, urutan key, escape Unicode, dan format angka: secara makna sama, secara kriptografi berbeda. app.use(express.json()) global adalah biang klasiknya; begitu pula framework PHP yang sudah membaca input stream sebelum Anda mengambil php://input.

2. Sesuatu di depan aplikasi Anda menulis ulang body. Reverse proxy atau API gateway yang menulis ulang body, mengompres ulang, atau mengonversi charset, atau middleware logging Anda sendiri yang menghabiskan stream. Catat panjang byte dan beberapa puluh byte pertama di titik verifikasi, lalu bandingkan dengan apa yang tercatat di log pengiriman penyedia.

3. Rahasianya salah. Rahasia hanya ditampilkan sekali saat Anda membuat endpoint, sehingga salinan yang terpotong sering terjadi, dan menggantinya langsung membatalkan yang lama. Periksa juga baris baru di akhir atau tanda kutip yang nyasar di environment variable: whsec_abc\n adalah rahasia yang berbeda.

4. Jam server meleset. Dengan toleransi lima menit, selisih beberapa menit saja membuat semua request ditolak. Pastikan NTP benar-benar tersinkron. Saat debugging, catat hasil pemeriksaan timestamp terpisah dari pemeriksaan digest supaya Anda tahu mana yang gagal.

5. Perbandingannya sendiri. Membandingkan string hex dengan byte digest mentah, beda huruf besar-kecil, atau panjang yang berbeda sehingga timingSafeEqual melempar error, lalu error itu tertelan oleh try di luar dan dilaporkan sebagai "invalid signature".

Cara tercepat menelusuri kelimanya: simpan raw body dari satu pengiriman sungguhan ke file, taruh X-Signature-nya di environment variable, lalu jalankan fungsi Anda secara lokal terhadap keduanya. Jika lolos di lokal, masalahnya ada di pipeline request Anda, bukan di kriptografinya.

Apa yang dilakukan setelah signature lolos?

Balas cepat, lakukan de-duplikasi, dan abaikan yang tidak Anda kenal. Verifikasi menjawab "bisakah saya memercayai request ini". Konsumen webhook yang sehat butuh tiga hal lagi:

  • Balas dulu, kerjakan belakangan. SealReply menunggu 2xx apa pun dalam 10 detik; selain itu dihitung gagal dan dicoba ulang setelah 1 menit, 5 menit, 30 menit, 2 jam, 6 jam, dan 24 jam, dan endpoint yang gagal tiga hari berturut-turut akan dimatikan. Masukkan peristiwa ke antrean, kembalikan 200, kerjakan bagian yang lambat di background.
  • De-duplikasi berdasarkan ID peristiwa. Peristiwa yang sama sesekali datang dua kali, karena retry atau kirim ulang manual, dan id-nya tetap sama di keduanya (juga ada di header X-SealReply-Event-Id). Gunakan sebagai idempotency key. Peristiwa juga bisa datang tidak berurutan, jadi baca status dari created_at dan status di payload, bukan dari urutan kedatangan.
  • Abaikan jenis peristiwa yang tidak Anda kenal. Kembalikan 200 dan lanjutkan, supaya jenis peristiwa baru yang ditambahkan nanti tidak memicu error di handler Anda.

Bagaimana mendapatkan signing secret di SealReply?

Anda mendapatkannya sekali, saat menambahkan endpoint. SealReply menandatangani setiap pengiriman webhook dengan cara ini secara default; tidak ada yang perlu diaktifkan. Buat endpoint di Developer → Webhook → Tambah endpoint:

  1. Masukkan URL-nya. Harus HTTPS di internet publik; rentang alamat privat dan localhost ditolak.
  2. Pilih peristiwa yang Anda inginkan. Ada sepuluh: conversation.created, conversation.assigned, conversation.closed, message.created, message.updated, message.deleted, offline_message.created, visitor.identified, lead.updated, dan visitor.deleted.
  3. Salin rahasia penandatanganan (whsec_…). Rahasia ini hanya ditampilkan sekali.

Lalu klik Kirim peristiwa uji untuk mengirim ping, dan buka Log pengiriman untuk melihat hasilnya: status, kode respons, waktu yang dibutuhkan, percobaan berikutnya, dan body persis yang dikirim, disimpan selama 30 hari.

Uji de-duplikasi Anda gratis

Kirim ulang di log pengiriman mengirim sebuah peristiwa lagi dengan id yang sama. Kirim ulang peristiwa yang sudah Anda tangani: jika sistem Anda membuat catatan kedua, berarti pemeriksaan idempotensi belum berfungsi.

Paket

Webhook dan API tersedia mulai paket Starter. Setiap ruang kerja bisa menambahkan hingga 10 endpoint.

Pemasangan yang umum: message.created ke channel chat tim (script lengkapnya ada di cara kirim notifikasi live chat ke Slack dan Telegram, juga Feishu dan DingTalk), offline_message.created ke sistem tiket, lead.updated ke CRM Anda sendiri. Saat visitor.deleted datang, hapus salinan data pengunjung itu di sistem Anda; itulah permintaan yang diajukan pengunjung saat menggunakan hak penghapusan datanya, dan privasi live chat membahas di mana data itu disimpan dan siapa yang bisa mengaksesnya.

Pertanyaan umum

Kenapa verifikasi signature webhook gagal padahal rahasianya benar?

Hampir selalu karena body sudah berubah sebelum Anda meng-hash-nya. Parser JSON, proxy, atau middleware logging mengubah byte menjadi sesuatu yang setara tetapi tidak identik, dan HMAC menyadari setiap byte. Hash raw body request, baru kemudian parse.

Bolehkah membandingkan signature dengan == ?

Tidak. Perbandingan string biasa berhenti di karakter pertama yang berbeda, dan lamanya waktu bisa membocorkan seberapa banyak bagian signature tebakan yang benar. Gunakan fungsi constant-time di bahasa Anda: crypto.timingSafeEqual, hmac.compare_digest, hash_equals, atau hmac.Equal.

Apakah webhook yang di-retry gagal di pemeriksaan timestamp 5 menit?

Tidak. SealReply menandatangani ulang setiap percobaan dengan waktu saat dikirim, jadi retry 24 jam kemudian membawa t dan v1 yang baru. Hanya request tangkapan yang di-replay oleh orang lain yang membawa timestamp lama.

Apakah signature webhook sama dengan enkripsi?

Tidak. Signature membuktikan siapa yang mengirim body dan bahwa body tidak diubah; isi body-nya tetap bisa dibaca. Menjaga kerahasiaannya selama perjalanan adalah tugas HTTPS, karena itu SealReply hanya mengirim ke endpoint HTTPS.

Bisakah menguji verifikasi signature webhook di localhost?

Tidak lewat pengiriman langsung: SealReply menolak localhost dan alamat privat. Catat raw body dan header X-Signature dari satu pengiriman sungguhan di server publik, lalu jalankan fungsi Anda terhadap keduanya secara lokal, atau buka akses ke mesin Anda lewat tunnel yang memberinya alamat HTTPS publik.

Apakah webhook SealReply tersedia di paket Gratis?

Tidak. Webhook dan API tersedia mulai paket Starter. Jika yang Anda butuhkan hanya kabar cepat saat ada chat baru, notifikasi push sudah cukup tanpa kode apa pun.

Langkah selanjutnya

Webhook dipakai saat Anda punya sistem sendiri yang perlu diberi data. Jika Anda hanya ingin cepat tahu bahwa ada yang bertanya, Anda tidak butuh semua ini: aktifkan notifikasi push dan atur jam operasional, seperti di live chat saat Anda sedang offline.

HMAC-SHA256 yang sama juga muncul di satu tempat lain yang perlu diketahui: menandatangani pengguna Anda yang sudah login agar agen tahu siapa lawan bicaranya, dibahas di cara identifikasi pengguna yang login di live chat.

Jika chat belum terpasang, mulai dari sana: mulai menggunakan SealReply hanya butuh sekitar lima menit, dan live chat untuk bisnis kecil membahas seluruh jalur dari pemasangan sampai penjualan berhasil.