Blog · 31 Agustus 2026
Webhook pembayaran yang aman: tanda tangan, dan empat hal setelahnya
Endpoint webhook adalah alamat HTTP publik. Tidak ada yang menghalangi siapa pun mengirim POST ke sana dengan badan pesan buatan sendiri, dan alamat itu bocor lebih mudah daripada yang dikira: dari riwayat DNS, dari berkas konfigurasi yang ikut terdorong ke repositori publik, dari catatan proyek, atau sekadar dari menebak /webhook di domain yang sudah diketahui. Karena itu handler yang menulis “pesanan lunas” hanya karena menerima badan pesan bertuliskan payment.paid memberikan barang gratis kepada siapa saja yang menemukan alamatnya.
Verifikasi tanda tangan menutup celah itu, dan hanya celah itu. Tulisan ini membahas cara memverifikasinya tanpa tiga kesalahan yang membuat verifikasi menjadi hiasan, lalu empat hal yang tetap harus diselesaikan setelah tanda tangannya cocok: mencocokkan pesanan, menangani pengiriman yang datang dua kali, membalas dengan status yang benar, dan menutup perubahan status yang tidak pernah dikirimkan.
Verifikasi tanda tangan, dan tiga kesalahan yang menggagalkannya
Setiap pengiriman membawa tanda tangan bertimestamp di header Kasera-Signature-V1, berbentuk t=<unix>,v1=<hex>: v1 adalah HMAC-SHA256 atas t + "." + rawBody, dan setelah rotasi secret ada dua entri v1 selama 24 jam. Id event ada di Kasera-Event-Id. Verifikasinya adalah menolak timestamp yang melenceng lebih dari lima menit, menghitung ulang HMAC dengan signing secret milik sendiri, lalu membandingkannya dengan tiap entri v1:
// Node.js, sebelum payload di-parse
const crypto = require("crypto");
// Kasera-Signature-V1: t=<unix>,v1=<hex>[,v1=<hex>]
function verify(rawBody, v1Header, secret, toleranceSeconds = 300) {
const parts = v1Header.split(",");
const t = Number(parts[0].slice(2)); // "t=<unix>"
if (!Number.isFinite(t)) return false;
if (Math.abs(Date.now() / 1000 - t) > toleranceSeconds) return false;
const expected = Buffer.from(
crypto.createHmac("sha256", secret).update(t + "." + rawBody).digest("hex")
);
return parts
.slice(1)
.filter((p) => p.startsWith("v1="))
.some((p) => {
const sig = Buffer.from(p.slice(3));
return sig.length === expected.length && crypto.timingSafeEqual(sig, expected);
});
}Tiga kesalahan membuat potongan kode di atas berhenti melindungi apa pun, dan ketiganya lolos pengujian karena pembayaran yang sah tetap masuk seperti biasa:
- Membandingkan hasil parse-ulang, bukan body mentah. Kerangka kerja web yang sudah mem-parse JSON menyerahkan objek, bukan byte aslinya. Menyusun ulang JSON dari objek itu mengubah urutan kunci, spasi, dan pelolosan karakter, sehingga HMAC-nya berbeda. Body mentah harus diambil sebelum middleware JSON menyentuhnya.
- Membandingkan dengan operator kesetaraan biasa. Perbandingan string yang berhenti pada byte pertama yang berbeda membocorkan berapa banyak byte awal yang sudah benar lewat selisih waktu, dan selisih itu bisa dipakai menebak tanda tangan yang benar byte demi byte. Perbandingan berwaktu tetap adalah bawaan pustaka kriptografi di hampir semua bahasa, jadi tidak ada alasan memakai yang lain.
- Memakai secret dari mode yang salah. Mode live dan mode tes adalah dua endpoint terpisah dengan signing secret masing-masing. Secret tes tidak pernah bisa memverifikasi payload live, dan sebaliknya. Kegagalan ini muncul persis pada saat go live, yaitu saat paling tidak nyaman untuk mendiagnosis.
Satu catatan tambahan yang sering menggigit di Node.js: perbandingan berwaktu tetap menuntut kedua buffer berpanjang sama, dan melempar galat kalau tidak. Header yang dipalsukan tentu bebas berpanjang berapa pun, jadi panjangnya perlu diperiksa lebih dulu dan ketidaksamaan panjang diperlakukan sebagai penolakan, bukan sebagai kerusakan server. Handler yang melempar galat pada tanda tangan palsu akan membalas 500, dan balasan itu mengundang pengiriman ulang untuk pesan yang memang seharusnya dibuang.
Referensi lengkap payload dan headernya ada di dokumentasi webhook, dan urutan pemasangannya dari kunci API sampai handler pertama ada di panduan integrasi QRIS di website.
Tanda tangan yang sah bukan berarti pesanan yang benar
Ini bagian yang paling sering dilewatkan, bahkan oleh integrasi yang verifikasinya sudah benar. Tanda tangan yang cocok membuktikan satu hal saja: pesan itu benar dikirim oleh pihak yang memegang secret. Pesan itu tidak membuktikan bahwa nominalnya sesuai dengan pesanan yang hendak dipenuhi.
Karena itu handler tidak boleh memakai angka dari payload sebagai dasar tindakan. Alurnya: ambil external_id atau payment_request_id dari payload, cari pesanan yang sesuai di basis data sendiri, lalu bandingkan nominal pesanan itu dengan amount yang datang. Kalau berbeda, hentikan pemenuhan dan tandai untuk diperiksa manusia. Pola yang sama berlaku untuk pesanan yang sudah dibatalkan, sudah kedaluwarsa di sisi sendiri, atau sudah dipenuhi sebelumnya. Basis data sendiri adalah acuannya; payload hanyalah kabar bahwa ada yang berubah.
Pengiriman bisa datang lebih dari sekali
Pengiriman bersifat at-least-once. Event yang sama bisa tiba lebih dari sekali, misalnya karena balasan sebelumnya tidak sampai kembali padahal pekerjaannya sudah selesai, dan setiap pengulangan membawa id yang sama. Konsekuensinya, penanganan ganda bukan kasus langka yang bisa diabaikan, melainkan kejadian normal yang harus dirancang.
Cara paling sederhana adalah menyimpan id event yang sudah selesai diproses pada kolom unik, lalu menjadikan penyisipan baris itu sebagai penentu: kalau penyisipannya ditolak karena bentrok, event tersebut sudah pernah dikerjakan dan sisanya cukup dilewati. Yang penting adalah penentunya berupa satu penulisan atomik, bukan pemeriksaan “sudah ada belum” yang diikuti penulisan terpisah, karena dua pengiriman yang tiba bersamaan sama-sama akan melihat kolom itu masih kosong. Persoalan yang sama, dengan arah berlawanan, dibahas di tulisan tentang tagihan ganda dan Idempotency-Key: yang satu mencegah satu pesanan menjadi dua tagihan, yang lain mencegah satu pembayaran dikirim dua kali.
Balas cepat, kerjakan belakangan, dan jangan menelan galat
Endpoint harus menjawab 2xx. Selain itu, pengiriman diulang dengan exponential backoff sampai tujuh percobaan dalam rentang sekitar 33 jam. Dua hal mengikuti dari angka itu.
Pertama, pekerjaan berat tidak boleh dilakukan sebelum membalas. Mengirim surel, menerbitkan berkas, memanggil layanan lain, dan mencetak dokumen semuanya bisa membuat balasan terlambat sehingga pengiriman dianggap gagal dan diulang tanpa perlu. Pola yang aman: verifikasi tanda tangan, catat event ke antrean, balas 2xx, lalu kerjakan sisanya di proses terpisah.
Kedua, membalas 200 saat pemrosesan gagal adalah cara paling mudah kehilangan pembayaran. Balasan 200 berarti pesan itu diterima dan tidak akan diulang, jadi galat basis data yang tertelan di dalam blok penangkap galat menghapus satu-satunya pemberitahuan yang akan pernah datang. Anggaran tujuh percobaan dalam 33 jam itu justru berguna kalau dipakai: kegagalan yang jujur dibalas dengan status galat, sehingga sistem mencoba lagi saat layanan sudah pulih.
Untuk endpoint yang mati lebih lama daripada rentang percobaan itu, jalan pulihnya bukan menunggu, melainkan menarik status sendiri lewat GET /v1/transactions/:id untuk pesanan yang masih menggantung. Cara yang sama juga menjadi jaring pengaman rutin bagi pesanan bernilai besar.
Sebelum sampai ke sana, ada baiknya memastikan lebih dulu bahwa eventnya memang pernah dikirim. Sebagian laporan webhook yang tidak tiba ternyata tidak pernah diposkan ke mana pun, dan cara membedakannya dibahas di tulisan tentang webhook yang tidak sampai.
Yang tidak dikirimkan lewat webhook
Hanya ada satu jenis event, yaitu payment.paid. Tidak ada event untuk kedaluwarsa, pembatalan, atau pengembalian dana. Ini pembatasan yang nyata dan pantas diketahui sebelum merancang, karena artinya semua perubahan status selain lunas harus dijadwalkan atau ditarik sendiri. Stok yang ditahan saat tagihan dibuat, misalnya, harus dilepas dari expires_at milik tagihan itu, bukan dari pemberitahuan yang tidak akan pernah datang. Rinciannya ada di tulisan tentang masa berlaku tagihan.
Satu hal lagi yang bukan bukti: kembalinya pembeli ke return_url. Alamat itu hanyalah navigasi, bisa dibuka siapa saja tanpa membayar, dan tidak pernah layak dijadikan penentu status.
Menguji penolakannya, bukan hanya penerimaannya
Handler webhook hampir selalu diuji dengan pembayaran yang berhasil, dan hampir tidak pernah diuji dengan tanda tangan yang salah. Padahal jalur kedua itulah yang menjadi satu-satunya pertahanan. Tiga pengujian yang layak dijalankan sekali sebelum go live, semuanya bisa dilakukan di mode tes:
- Kirim payload yang sah dengan header
Kasera-Signature-V1yang sengaja diubah satu karakter, lalu pastikan balasannya penolakan dan tidak ada pesanan yang berubah. - Kirim payload yang sah dua kali dengan id event yang sama, lalu pastikan pemenuhannya hanya terjadi sekali.
- Kirim payload yang tanda tangannya sah tetapi nominalnya tidak cocok dengan pesanan, lalu pastikan pemenuhan berhenti dan kejadiannya tercatat.
Terakhir, soal secret itu sendiri: tempatnya di variabel lingkungan, bukan di kode yang ikut masuk repositori. Memindahkan URL webhook ke domain baru tidak mengganti secret, sehingga migrasi tidak menuntut penulisan ulang kode verifikasi, dan secret bisa dibuat lebih dulu sebelum URL-nya diisi. Rotasi dilakukan saat ada dugaan kebocoran, dan setelah rotasi, pengiriman berikutnya ditandatangani dengan secret yang baru. Yang perlu disiapkan sebelum rotasi pertama adalah kemampuan menerima dua secret sekaligus untuk sementara, karena pengiriman yang sudah beredar masih dicoba ulang sampai sekitar 33 jam; urutannya dibahas di artikel menyimpan dan merotasi kunci API.