Blog · Terbit
Verifikasi callback payment gateway: token statis, hash atas beberapa kolom, atau HMAC atas body mentah, dan apa yang sebenarnya dibuktikan masing-masing
Jawaban singkatnya: ada tiga bentuk bukti keaslian callback yang dipakai payment gateway di Indonesia, dan ketiganya membuktikan hal yang berbeda. Token statis membuktikan siapa pengirimnya. Hash atas beberapa kolom membuktikan kolom-kolom itu saja. HMAC atas body mentah membuktikan seluruh isi pesan, dan bila timestamp ikut ditandatangani, juga kapan pesan itu dibuat. Kode verifikasi yang ditulis untuk satu bentuk tidak bisa dipakai untuk bentuk lain, dan kesalahan paling mahal terjadi saat integrasi dipindahkan tanpa menyadari bentuknya ikut berubah.
Semua keterangan tentang gateway lain di bawah ini dibaca dari dokumentasi publik masing-masing pada 23 September 2026. Dokumentasi bisa berubah, jadi periksa ulang di sumbernya sebelum menulis kode produksi.
Tiga bentuk, dan siapa memakai yang mana
| Gateway | Letak bukti | Yang ditandatangani | Perlindungan replay |
|---|---|---|---|
| Xendit | Header x-callback-token | Tidak ada; token yang sama di setiap kiriman | Tidak ada |
| Midtrans | Kolom signature_key di body | SHA512 atas order_id, status_code, gross_amount, dan server key | Tidak ada |
| TriPay | Header X-Callback-Signature | HMAC-SHA256 atas body JSON dengan private key | Tidak ada |
| Kasera Pay | Header Kasera-Signature-V1 | HMAC-SHA256 atas timestamp, satu titik, dan body mentah dengan signing secret | Timestamp ditolak bila melenceng lebih dari 5 menit |
Token statis: membuktikan pengirim, bukan isi
Dokumentasi Xendit menjelaskan bahwa setiap event membawa token di header x-callback-token, dan token itu diambil dari pengaturan webhook di dasbor. Verifikasinya satu perbandingan string. Kelemahannya juga ada di kesederhanaan itu: token yang sama muncul di setiap kiriman, sehingga siapa pun yang pernah melihat satu kiriman utuh memegang kemampuan memalsukan kiriman berikutnya dengan body apa pun. Tempat token seperti itu bocor biasanya bukan serangan canggih, melainkan log akses yang mencatat seluruh header, alat penerowongan yang dipakai saat pengembangan, atau layanan pencatat request yang dipasang sementara lalu terlupa.
Aturan yang mengikuti: perlakukan token itu seperti kata sandi, jangan pernah mencatatnya ke log, dan jangan menjalankan pengiriman barang hanya dari isi callback. Ambil id pembayaran dari callback, lalu baca status dan nominalnya dari API gateway dengan kunci milik server sendiri.
Hash atas beberapa kolom: kolom lain tidak ikut terlindungi
Midtrans menandatangani notifikasinya dengan signature_key, yaitu SHA512 dari order_id, status_code, gross_amount, dan server key yang dirangkai menjadi satu string. Karena server key ikut dirangkai, pihak luar tidak bisa membuat hash yang cocok untuk kombinasi baru. Tetapi hanya empat nilai itu yang terikat. Kolom lain di body, termasuk transaction_status dan payment_type, tidak ikut dihitung. Tidak ada timestamp di dalam rangkaiannya, sehingga notifikasi sah yang pernah terkirim tetap sah bila dikirim ulang.
Dua akibat praktisnya. Pertama, keputusan uang hanya boleh bersandar pada kolom yang ditandatangani, atau pada status yang dibaca ulang lewat GET status seperti yang disarankan dokumentasi Midtrans sendiri. Kedua, rangkai nilainya persis seperti yang diterima. gross_amount di contoh notifikasinya berbentuk string seperti “10000.00”, dan kode yang mengubahnya menjadi angka lebih dulu akan menghasilkan hash yang tidak pernah cocok.
HMAC atas body mentah: seluruh isi terikat, dengan satu syarat teknis
TriPay mengirim X-Callback-Signature, HMAC-SHA256 atas body JSON dengan private key merchant. Kasera Pay mengirim Kasera-Signature-V1 berbentuk t=<unix>,v1=<hex>, dengan v1 adalah HMAC-SHA256 atas timestamp, satu titik, dan body mentah. Pada kedua bentuk, mengubah satu karakter di body membuat tanda tangannya tidak cocok, jadi status dan nominal di dalam pesan yang lolos verifikasi memang yang dikirim gateway.
Syarat teknisnya adalah body mentah, byte demi byte seperti yang diterima. Contoh PHP di dokumentasi TriPay membacanya dari php://input, dan itu benar. Contoh Node.js di halaman yang sama menghitung HMAC dari JSON.stringify(request.body) setelah express.json(), yang hanya cocok selama penyusunan ulang JSON menghasilkan byte yang persis sama dengan aslinya. Spasi, urutan kunci, dan pelolosan karakter tidak dijamin bertahan, jadi yang aman adalah mengambil buffer mentah sebelum parser JSON menyentuhnya. Contoh PHP yang sama juga membandingkan dengan !==; hash_equals adalah perbandingan yang tidak membocorkan informasi lewat selisih waktu.
HMAC tanpa timestamp masih bisa diputar ulang: kiriman yang pernah sah tetap sah. Karena itu Kasera-Signature-V1 ikut menandatangani t dan kiriman yang timestampnya melenceng lebih dari lima menit ditolak, lalu sisa jendelanya ditutup dengan dedupe atas id event. Rinciannya ada di dokumentasi webhook Kasera Pay, dan tiga kesalahan yang membuat verifikasi HMAC diam-diam berhenti melindungi dibahas di tulisan tentang webhook pembayaran yang aman.
Tiga verifier berdampingan
// Node.js. Tiga bentuk, tiga fungsi yang tidak bisa saling menggantikan.
const crypto = require("crypto");
function sama(a, b) {
const x = Buffer.from(a);
const y = Buffer.from(b);
return x.length === y.length && crypto.timingSafeEqual(x, y);
}
// 1. Token statis: cocokkan header dengan token dari dasbor.
function verifyToken(headers, token) {
return sama(headers["x-callback-token"] ?? "", token);
}
// 2. Hash atas beberapa kolom: gross_amount dipakai sebagai string apa adanya,
// misalnya "10000.00". Mengubahnya menjadi angka dulu menghasilkan "10000".
function verifyFieldHash(n, serverKey) {
const expected = crypto
.createHash("sha512")
.update(n.order_id + n.status_code + n.gross_amount + serverKey)
.digest("hex");
return sama(n.signature_key ?? "", expected);
}
// 3. HMAC atas body mentah bertimestamp (Kasera-Signature-V1: t=...,v1=...).
function verifyHmac(rawBody, header, secret) {
const [tPart, ...sigs] = (header ?? "").split(",");
const t = tPart?.startsWith("t=") ? tPart.slice(2) : "";
if (!Number.isFinite(Number(t)) || t === "") return false;
if (Math.abs(Date.now() / 1000 - Number(t)) > 300) return false;
const expected = crypto
.createHmac("sha256", secret)
.update(t + "." + rawBody)
.digest("hex");
return sigs.some((s) => s.startsWith("v1=") && sama(s.slice(3), expected));
}Ketiganya memakai perbandingan berwaktu tetap dengan pemeriksaan panjang lebih dulu, karena timingSafeEqual melempar galat pada buffer berpanjang beda dan galat itu akan terbaca sebagai 500 yang mengundang pengiriman ulang untuk pesan palsu.
Jatah pengiriman ulang ikut menentukan desainnya
Bentuk tanda tangan menentukan apa yang bisa dipercaya. Jatah pengiriman ulang menentukan berapa lama server boleh mati tanpa kehilangan notifikasi. TriPay mengulang dengan jeda 2 menit hingga maksimal 3 kali, jadi server yang mati sepuluh menit saat pemeliharaan sudah cukup untuk membuat satu pembayaran tidak pernah tercatat lewat callback. Xendit mengulang sampai enam kali dengan exponential backoff. Midtrans mengulang menurut kode balasan: 500 sekali, 503 empat kali, 400 dan 404 dua kali, kode lain lima kali, dan tidak mengulang sama sekali untuk 301, 302, dan 303. Kasera Pay mengulang sampai 7 kali dalam kisaran 33 jam.
Pada gateway berjatah pendek, pencocokan berkala dari API bukan cadangan, melainkan bagian utama desainnya. Timbangannya dibahas di perbandingan webhook dengan menanyakan status berkala.
Saat integrasi berpindah bentuk
Perpindahan dari token ke HMAC paling sering gagal di satu tempat: route callback lama dipasang setelah middleware JSON global, dan itu tidak masalah selama yang diperiksa hanya header. Begitu bentuknya HMAC, body mentah sudah habis dibaca sebelum handler berjalan, dan seluruh kiriman sah ditolak. Urutan yang aman:
- Buat route terpisah per gateway, masing-masing dengan secret dan verifier sendiri. Jangan memakai satu route yang menebak bentuk dari header yang kebetulan ada.
- Pasang pembacaan body mentah khusus di route HMAC, sebelum parser JSON mana pun.
- Satukan dedupe di satu tabel dengan kunci yang menyebut gatewaynya, karena selama masa tumpang tindih kedua gateway sama-sama masih mengirim ulang.
- Uji penolakan, bukan hanya penerimaan: body yang diubah satu karakter dan timestamp yang terlalu tua harus sama-sama ditolak.
Susunan lengkap masa peralihannya, termasuk dua webhook yang hidup bersamaan, ada di panduan migrasi dari gateway lain tanpa downtime, dan contoh route yang membaca body mentah di Java ada di panduan integrasi payment gateway di Spring Boot.
Pertanyaan yang sering muncul
Apakah token statis di header callback sama amannya dengan tanda tangan HMAC?
Tidak setara. Token membuktikan bahwa pengirimnya mengetahui token, tetapi tidak mengikat isi pesannya: siapa pun yang pernah melihat satu kiriman, misalnya dari log server, alat penerowongan, atau proxy, bisa menempelkan token yang sama pada body apa pun sampai token itu dirotasi. HMAC atas body mentah mengikat setiap byte isi pesan, sehingga mengubah satu karakter nominal membuat tanda tangannya tidak cocok. Token tetap layak dipakai bila memang itu yang disediakan gateway, asalkan dibandingkan dengan perbandingan berwaktu tetap dan status akhirnya dibaca ulang dari API gateway sebelum barang dikirim.
Kenapa signature_key Midtrans tidak cocok padahal server key-nya benar?
Penyebab yang paling sering adalah gross_amount yang sudah berubah bentuk sebelum dirangkai. Contoh notifikasi di dokumentasi Midtrans menulisnya sebagai string dengan dua angka desimal, misalnya "10000.00". Kode yang lebih dulu mengubahnya menjadi angka lalu kembali menjadi string menghasilkan "10000", dan hash SHA512-nya berbeda sama sekali. Rangkai order_id, status_code, gross_amount, dan server key persis seperti nilai yang diterima.
Kalau tanda tangan callback sudah cocok, apakah statusnya masih perlu dicek ulang lewat API?
Bergantung pada apa yang ditandatangani. Pada HMAC atas seluruh body, isi pesan yang cocok adalah isi yang dikirim gateway, jadi status di dalamnya bisa dipercaya dan pembacaan ulang hanya perlu saat urutan event penting. Pada token statis dan pada hash atas beberapa kolom, ada bagian pesan yang tidak terikat oleh bukti apa pun, dan Midtrans sendiri menyarankan verifikasi lewat GET status. Pada kedua bentuk itu pembacaan ulang dari server adalah bagian dari verifikasi, bukan tambahan.
Berapa kali callback dikirim ulang kalau server sedang mati?
Berbeda jauh antar gateway, menurut dokumentasi masing-masing yang dibaca pada 23 September 2026. TriPay mengulang dengan jeda 2 menit hingga maksimal 3 kali. Xendit mengulang sampai enam kali dengan exponential backoff. Midtrans bergantung pada kode balasan: 500 diulang sekali, 503 empat kali, 400 dan 404 dua kali, kode lain lima kali, dan kode redirect 301, 302, 303 tidak diulang. Kasera Pay mengulang dengan exponential backoff sampai 7 kali dalam kisaran 33 jam. Jatah yang pendek berarti pemeliharaan server beberapa menit sudah cukup untuk kehilangan notifikasi, sehingga pencocokan berkala dari API menjadi wajib.