Webhooks — API

Webhook adalah mekanisme di mana server mengirim data ke URL yang kamu tentukan saat event tertentu terjadi. Push vs Poll: Pendekatan Cara Kerja Kelebihan…

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

  1. Di dashboard gateway, admin Toko Buku mendaftarkan URL https://api.tokobuku.test/webhooks/payment dan mendapat secret, kunci rahasia yang cuma diketahui gateway dan server Toko Buku.
  2. Pembeli membayar. Gateway membuat event evt_1001 bertipe payment.succeeded.
  3. 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}}
  1. 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):

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

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

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

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.

Yang akan kamu pelajari