Webhook adalah cara server lain memberi kabar ke server-mu: begitu sesuatu terjadi di sana, dia langsung mengirim request POST ke alamat yang sudah kamu daftarkan. Bayangin pesan makanan lalu meninggalkan nomor HP di kasir: "nanti kami telepon kalau sudah siap". Kamu gak perlu menelepon restoran tiap lima menit untuk bertanya. Nomor HP itu URL webhook-mu, teleponnya adalah request POST, dan "oke, makasih" yang kamu ucapkan adalah jawaban 200.
🧪 Tebakan cepat: server Toko Buku memeriksa tanda tangan webhook dari
JSON.stringify(req.body). Payment gateway mengirim webhook yang asli, dengan body{"id": "evt_1001", ...}yang memakai spasi setelah titik dua dan koma. Diterima atau ditolak? Tebak dulu, jawabannya ada di bagian "Hitung dari raw body".
Polling vs webhook: tanya terus atau dikabari
Pembeli Toko Buku membayar pesanan 1001 (Laskar Pelangi, Rp89.000) lewat QRIS. Server Toko Buku perlu tahu kapan uangnya masuk. Ada dua cara:
| Polling (pull) | Webhook (push) | |
|---|---|---|
| Siapa yang mulai | server Toko Buku bertanya ke gateway | gateway mengabari server Toko Buku |
| Request | GET /payments/1001 tiap 10 detik: 360 request per jam, hampir semua dijawab "belum" |
satu POST saat pembayaran masuk |
| Seberapa cepat tahu | telat sampai 10 detik | begitu event terjadi |
| Syarat | cukup bisa mengirim request | server-mu harus punya URL publik |
Jadi webhook itu push: yang punya kabar yang mengirim. Polling itu pull: yang butuh kabar yang menarik, berulang-ulang. Contoh di dunia nyata: payment gateway seperti Midtrans, Xendit, dan Stripe mengabari pembayaran, GitHub mengabari tiap ada push.
Alur webhook pembayaran Toko Buku
- Di dashboard gateway, admin Toko Buku mendaftarkan URL
https://api.tokobuku.test/webhooks/paymentdan mendapat secret, kunci rahasia yang cuma diketahui gateway dan server Toko Buku. - Pembeli membayar. Gateway membuat event
evt_1001bertipepayment.succeeded. - Gateway mengirim request ini ke URL tadi:
POST /webhooks/payment HTTP/1.1
Host: api.tokobuku.test
Content-Type: application/json
X-Timestamp: 1791429300
X-Signature: fdcf334c24445140c3df0484ff565aca246dcfd280d46a5fb1a7c4ee351c6fa2
{"id": "evt_1001", "type": "payment.succeeded", "data": {"order_id": 1001, "book_id": 1, "amount": 89000}}
- Server Toko Buku memeriksa request-nya, menjawab
200, lalu menandai pesanan 1001 lunas dan mengurangi stok Laskar Pelangi dari 12 jadi 11.
X-Timestamp dan X-Signature dibahas di bawah. Tiap gateway menamai header-nya sendiri dan sedikit beda dalam apa yang ditandatangani, tapi idenya sama.
Signature HMAC: bukti kiriman asli
Siapa pun yang tahu URL-mu bisa mengirim POST /webhooks/payment berisi "pesanan 1001 sudah dibayar". Kalau server langsung percaya, buku dikirim tanpa ada uang masuk. Pemeriksanya HMAC (Hash-based Message Authentication Code):
- Gateway menghitung
HMAC-SHA256(secret, body). Hasilnya 64 karakter hex, dikirim di headerX-Signature. - Server menghitung ulang dengan secret yang sama, lalu membandingkan. Cocok berarti body-nya dibuat pemegang secret dan gak berubah satu byte pun di jalan. Gak cocok: tolak dengan
401.
Penyerang tanpa secret gak bisa membuat signature yang cocok, dan mengubah satu angka saja (89000 jadi 1000) bikin hasil HMAC-nya beda total. Ini bentuk dasarnya, dengan satu kesalahan yang dibahas di bagian berikut:
const crypto = require("crypto");
const WEBHOOK_SECRET = "webhook-secret";
app.post("/webhooks/payment", (req, res) => {
const signature = req.headers["x-signature"];
const payload = JSON.stringify(req.body); // belum benar, lihat bagian berikut
const expected = crypto.createHmac("sha256", WEBHOOK_SECRET).update(payload).digest("hex");
if (signature === expected) {
res.status(200).json({ received: true });
} else {
res.status(401).json({ error: "Invalid signature" });
}
});
req.headers["x-signature"]: Express menyimpan nama header dalam huruf kecil, jadiX-Signaturedibaca sebagai"x-signature".createHmac("sha256", WEBHOOK_SECRET): menyiapkan HMAC dengan algoritma SHA-256 dan secret-nya..update(payload): memasukkan teks yang ditandatangani..digest("hex"): menghasilkan signature sebagai teks hex, format yang sama dengan header.signature === expected: cocok dijawab200, gak cocok dijawab401 Unauthorized, status yang sama untuk request tanpa token di pelajaran Autentikasi.
Di server sungguhan, secret-nya disimpan di environment variable (process.env.WEBHOOK_SECRET), bukan ditulis di kode, seperti di pelajaran API Security.
Hitung dari raw body
HMAC menghitung byte, bukan makna. Gateway menandatangani body persis seperti yang dikirimnya: 106 byte, dengan spasi setelah titik dua dan koma. express.json() mengubah body itu jadi object, dan JSON.stringify(req.body) menyusunnya lagi tanpa spasi:
dikirim gateway (106 byte): {"id": "evt_1001", "type": "payment.succeeded", ...}
JSON.stringify (96 byte): {"id":"evt_1001","type":"payment.succeeded",...}
Isinya sama, byte-nya beda, jadi HMAC-nya beda: 84ddb095... lawan ce6291f7.... Webhook yang asli ditolak 401. Itu jawaban tebakannya.
Hitung dari raw body, byte apa adanya sebelum di-parse. Di Express, pasang express.raw() khusus di route webhook:
app.post("/webhooks/payment", express.raw({ type: "application/json" }), (req, res) => {
const rawBody = req.body.toString("utf8"); // Buffer berisi byte asli
const expected = crypto.createHmac("sha256", process.env.WEBHOOK_SECRET).update(rawBody).digest("hex");
const a = Buffer.from(req.headers["x-signature"] || "", "hex");
const b = Buffer.from(expected, "hex");
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
return res.status(401).json({ error: "Invalid signature" });
}
const event = JSON.parse(rawBody); // baru di-parse sesudah cocok
res.status(200).json({ received: true });
});
express.raw()membuatreq.bodyjadi Buffer berisi byte persis seperti yang dikirim..update()juga bisa langsung menerima Buffer itu:.update(req.body).crypto.timingSafeEqual(a, b)membandingkan dua Buffer dalam waktu yang sama, cocok atau gak. Panjangnya dicek dulu, karena dia melempar error kalau panjangnya beda.
Latihannya memakai bentuk ini. Tester-nya mengirim webhook dari tebakan tadi, persis dengan spasinya, jadi tanda tangannya cuma cocok kalau dihitung dari raw body.
Timestamp: tolak rekaman lama
Signature membuktikan request-nya asli, tapi gak membuktikan request-nya baru. Penyerang yang merekam satu webhook asli bisa mengirimnya lagi besok, dan signature-nya tetap cocok. Karena itu gateway menambahkan X-Timestamp (detik Unix saat dikirim) dan menandatangani timestamp + "." + rawBody. Server menolak kalau selisihnya lebih dari 300 detik (5 menit):
const timestamp = Number(req.headers["x-timestamp"]);
if (Math.abs(Date.now() / 1000 - timestamp) > 300) {
return res.status(401).json({ error: "Timestamp outside the tolerance zone" });
}
const expected = crypto
.createHmac("sha256", process.env.WEBHOOK_SECRET)
.update(`${timestamp}.${rawBody}`)
.digest("hex");
Timestamp-nya ikut ditandatangani, jadi penyerang gak bisa menggantinya. Tiap kiriman ulang dari gateway membawa timestamp dan signature yang baru.
Jawab cepat, proses belakangan
Gateway cuma menunggu sebentar, misalnya 10 detik (GitHub memakai batas itu). Kalau server Toko Buku membuat invoice PDF dan mengirim email dulu, 12 detik, baru menjawab, gateway menganggapnya gagal walau pesanannya sudah diproses. Jadi: periksa, jawab 200, baru proses (idealnya lewat queue).
Selain 2xx (misalnya 503 karena server down, timeout, atau 401), gateway menganggap kirimannya gagal dan mengirim ulang dengan jeda yang makin panjang, disebut exponential backoff: 1 menit, 2 menit, 4 menit, 8 menit. Stripe mengulang sampai 3 hari. Waktu server-mu pulih, event-nya tetap sampai.
Event yang sama bisa datang dua kali
Retry punya efek samping: kalau jawaban 200-mu hilang atau telat, event yang sudah kamu proses dikirim lagi. Gateway menjanjikan "minimal sekali", bukan "tepat sekali". Tanpa pengaman, evt_1001 diproses dua kali: stok Laskar Pelangi turun jadi 10 dan email kuitansi terkirim dua kali.
Pengamannya id event sebagai idempotency key: catat id yang sudah diproses, lewati yang datang lagi.
const processed = new Set(); // di aplikasi sungguhan: tabel database
if (processed.has(event.id)) {
return res.status(200).json({ received: true, duplicate: true });
}
processed.add(event.id);
Duplikatnya tetap dijawab 200, supaya gateway berhenti mengirim ulang. Jadi gunanya idempotency key: mencegah duplikat, event yang sama gak diproses dua kali. Pelajaran berikutnya, Idempotency, memakai ide yang sama untuk request dari client.
⚠️ Jebakan yang sering ketemu
- Menghitung dari
JSON.stringify(req.body)di server sungguhan. Byte-nya beda dari yang ditandatangani, jadi webhook asli ditolak. Pakai raw body. timingSafeEqualdengan panjang beda.crypto.timingSafeEquallebih aman dari===karena waktunya gak membocorkan berapa karakter yang cocok, tapi dia melemparRangeErrorkalau panjang kedua Buffer beda, dan server menjawab500. Cek panjangnya dulu.- Memproses dulu baru menjawab. Gateway kehabisan waktu, menganggap gagal, lalu mengirim ulang. Jawab
200dulu. - Duplikat dijawab error.
409atau500untuk event yang sudah diproses bikin gateway terus mengulang. Jawab200.
TL;DR: Webhook itu push: gateway mengirim POST ke URL-mu saat event terjadi, sedangkan polling itu pull, bertanya berulang-ulang. Periksa keasliannya dengan HMAC: hitung createHmac("sha256", secret).update(...).digest("hex"), bandingkan dengan header x-signature, dan jawab 401 kalau gak cocok. Hitung dari raw body, karena JSON.stringify(req.body) mengubah byte-nya. Tolak timestamp yang selisihnya lebih dari 5 menit supaya rekaman lama gak bisa dipakai ulang. Jawab 200 secepatnya, proses belakangan. Gateway mengirim ulang dengan backoff, jadi event bisa datang dua kali: catat id event (idempotency key) supaya gak diproses dua kali, dan tetap jawab 200 untuk duplikat.