Blog · 29 Agustus 2026
Tagihan ganda dan Idempotency-Key: satu header yang menentukan
Dua permintaan pembuatan pembayaran yang isinya identik adalah dua tagihan, kecuali keduanya membawa header Idempotency-Key yang sama. Tidak ada field lain di dalam body yang menutup celah itu: bukan nomor pesanan, bukan referensi internal, bukan kombinasi nominal dan waktu. Satu header itu yang menentukan, dan sifatnya opsional, jadi integrasi yang tidak mengirimnya memang tidak terlindungi.
Dari mana tagihan ganda benar-benar datang
Hampir tidak pernah dari pembeli yang sengaja membayar dua kali. Bentuknya selalu sama: server sudah mengerjakan permintaan, tetapi pihak yang meminta tidak pernah tahu hasilnya, lalu meminta lagi.
- Tombol bayar ditekan dua kali. Respons pertama lambat, jempol tidak sabar, dan dua permintaan berangkat sebelum layar sempat berubah.
- Jaringan seluler memutus respons. Permintaan sampai dan diproses, tetapi jawabannya tidak pernah kembali. Dari sisi aplikasi, kejadian ini tidak bisa dibedakan dari permintaan yang tidak pernah sampai.
- Antrean pekerjaan mengulang tugas. Worker mati setelah memanggil API tetapi sebelum menandai tugas selesai, dan tugas itu diambil lagi oleh worker berikutnya.
- Proxy atau load balancer mengulang sendiri. Sebagian klien HTTP dan perantara mencoba ulang pada gangguan koneksi, tanpa pemberitahuan ke kode pemanggil.
- Halaman hasil POST dimuat ulang. Dialog konfirmasi kirim ulang formulir ditekan, dan permintaan yang sama berangkat sekali lagi.
Kelimanya menghasilkan permintaan yang tidak bisa dibedakan dari permintaan sah untuk pembayaran kedua yang memang berbeda. Server tidak punya cara menebaknya, dan tidak seharusnya menebak: satu pesanan yang sama bisa saja wajar dibayar dua kali, misalnya pelunasan setelah uang muka. Yang dibutuhkan bukan tebakan, melainkan pernyataan dari pemanggil bahwa percobaan ini adalah percobaan ulang dari niat yang sama.
Yang dilakukan header itu, persisnya
Header Idempotency-Key pada endpoint pembuatan permintaan pembayaran adalah pernyataan tersebut. Perilakunya bisa diringkas dalam lima aturan, dan semuanya punya konsekuensi langsung pada kode penanganan respons.
| Kejadian | Hasil | Artinya bagi kode |
|---|---|---|
| Key baru, body valid | 201, permintaan dibuat | Jalur normal |
| Key sama, body sama | 200, permintaan yang asli dikembalikan | Sukses, tidak ada yang dibuat kedua kali |
| Key sama, body berbeda | 409 idempotency_conflict | Bug di sisi pemanggil, bukan untuk diulang |
| Percobaan pertama ditolak 422 | Tidak ada yang tersimpan | Key bebas dipakai lagi |
| Key dikirim sebulan kemudian | Tetap dikenali | Key disimpan permanen, tidak kedaluwarsa |
Tiga detail yang mudah terlewat. Pertama, 200 dan 201 sama-sama berarti berhasil, dan kode yang hanya menerima 201 akan menganggap retry yang sukses sebagai kegagalan lalu mengulangnya lagi. Kedua, yang dibandingkan adalah hash dari byte yang benar-benar dikirim, bukan objek hasil parse, sehingga urutan field yang bertukar atau spasi yang berubah terhitung body berbeda dan berujung 409: body untuk satu percobaan disusun sekali, lalu byte itu yang dipakai ulang. Ketiga, batasnya 255 byte, bukan 255 karakter, jadi key yang disusun dari teks non-ASCII habis lebih cepat, sementara UUID tidak terpengaruh. Kontrak lengkapnya ada di dokumentasi idempotency.
Tiga tambalan yang tampak bekerja, tetapi tidak
Ketiganya muncul berulang kali pada integrasi yang dibangun tanpa membaca bagian ini, dan ketiganya gagal pada kasus yang justru paling mahal.
Menganggap nomor pesanan sebagai pengaman
external_id dan merchant_ref disimpan, dikembalikan di setiap respons, dan bisa dipakai memfilter daftar transaksi. Keduanya label, bukan klaim bahwa dua permintaan adalah satu pembayaran. Dua create dengan merchant_ref yang sama adalah dua permintaan pembayaran, dan itu memang perilaku yang benar: satu pesanan wajar saja dibayar dua kali, dan penyedia pembayaran tidak berhak memutuskan sebaliknya atas nama penjual.
Membuat key baru setiap percobaan
Bentuk yang paling berbahaya, karena tampak benar di setiap review kode: header terpasang, namanya benar, nilainya UUID yang sah. Yang salah adalah tempat pembuatannya. UUID yang dibuat di dalam fungsi pengirim akan berbeda pada setiap percobaan, sehingga setiap retry membawa key baru dan dianggap pembayaran baru. Perlindungannya lolos tanpa satu pun error, dan yang muncul kemudian adalah dua tagihan untuk satu pesanan.
Menyaring berdasarkan nominal dan jendela waktu
Menolak permintaan dengan nominal sama dalam tiga menit terakhir terasa aman sampai dua pembeli berbeda membeli barang berharga sama pada menit yang sama, lalu salah satunya ditolak tanpa alasan yang bisa dijelaskan. Aturan seperti ini menukar duplikat yang kelihatan dengan penolakan yang tidak kelihatan.
Memilih nilai key
Syaratnya satu: nilai itu harus sudah ada sebelum percobaan pertama, dan harus bisa ditemukan kembali oleh percobaan kedua. Karena itu tempat penyimpanannya lebih penting daripada bentuknya.
| Sumber nilai | Aman | Alasan |
|---|---|---|
| UUID disimpan di baris pesanan sebelum panggilan pertama | Ya | Percobaan kedua membaca nilai yang sama dari basis data |
| Nomor pesanan sendiri, unik per permintaan pembayaran | Ya | Sudah ada sebelum pembayaran dibuat |
| UUID dibuat di dalam fungsi pengirim | Tidak | Berubah pada setiap retry |
| Waktu saat ini atau angka acak per permintaan | Tidak | Tidak pernah sama dua kali |
| Nomor pesanan yang boleh dibayar berkali-kali | Tidak | Cicilan kedua akan dikira duplikat dan tidak pernah terbuat |
// Key dibuat sekali, ikut disimpan bersama pesanan,
// lalu dipakai ulang oleh setiap percobaan.
const key = order.idempotency_key ?? crypto.randomUUID();
await orders.saveKey(order.id, key);
const res = await fetch("https://pay.kasera.id/v1/transactions", {
method: "POST",
headers: {
Authorization: "Bearer kp_live_...",
"Idempotency-Key": key,
"Content-Type": "application/json",
},
body: order.payloadBytes, // byte yang sama persis di tiap percobaan
});
// 201 baru dibuat, 200 sudah ada sebelumnya. Keduanya sukses.
if (res.status === 201 || res.status === 200) return res.json();Kasus terakhir di tabel itu yang paling sering terlewat pada model berlangganan dan cicilan: key melekat pada satu niat membayar, bukan pada pesanan. Kalau satu pesanan bisa menghasilkan beberapa pembayaran, maka setiap pembayaran perlu key sendiri, misalnya nomor pesanan digabung nomor angsuran.
Menangani respons dengan benar
- Tidak ada respons sama sekali. Ulangi dengan key yang sama. Inilah satu kejadian yang seluruh mekanisme ini dibuat untuk menanganinya.
- Menerima
200. Perlakukan persis seperti201. Permintaan pembayaran yang dikembalikan adalah yang asli, lengkap dengancheckout_urlyang sama. - Menerima
409. Jangan diulang, dan jangan diakali dengan key baru. Artinya key itu sudah menempel pada permintaan lain: ambil permintaan aslinya, atau perbaiki body yang berubah di tengah jalan. - Menerima
422. Perbaiki body-nya. Tidak ada yang tersimpan, dan key yang sama boleh dipakai lagi setelah perbaikan.
Sisi lain yang juga perlu idempoten
Melindungi pembuatan tagihan tidak otomatis melindungi sisi penerimaannya. Pengiriman webhook bersifat at-least-once: event yang sama bisa tiba lebih dari sekali dan selalu membawa id yang sama, jadi handler harus melakukan dedupe berdasarkan id event sebelum menandai pesanan lunas. Tanpa itu, satu pembayaran bisa menghasilkan dua email, dua label pengiriman, atau dua kali penambahan saldo, meskipun tagihannya hanya satu. Cara memasang keduanya dalam satu integrasi ada di panduan integrasi QRIS di website.
Kapan header ini tidak dibutuhkan
Tautan pembayaran yang dibuat manual lewat dashboard tidak memerlukannya, karena tidak ada retry otomatis yang bisa mengulangnya; kalau tautan terlanjur dibuat dua kali, yang satu dibiarkan kedaluwarsa. Sisanya, setiap create yang berasal dari kode, sebaiknya selalu membawanya. Biayanya satu kolom di basis data dan satu baris header, dan yang ditukar dengan itu adalah satu-satunya kelas kesalahan pada jalur pembayaran yang paling merusak kepercayaan: pembeli yang ditagih dua kali untuk satu barang.