Blog · Terbit · Diperbarui

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.

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.

KejadianHasilArtinya bagi kode
Key baru, body valid201, permintaan dibuatJalur normal
Key sama, body sama200, permintaan yang asli dikembalikanSukses, tidak ada yang dibuat kedua kali
Key sama, body berbeda409 idempotency_conflictBug di sisi pemanggil, bukan untuk diulang
Percobaan pertama ditolak 422Tidak ada yang tersimpanKey bebas dipakai lagi
Key dikirim sebulan kemudianTetap dikenaliKey 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 nilaiAmanAlasan
UUID disimpan di baris pesanan sebelum panggilan pertamaYaPercobaan kedua membaca nilai yang sama dari basis data
Nomor pesanan sendiri, unik per permintaan pembayaranYaSudah ada sebelum pembayaran dibuat
UUID dibuat di dalam fungsi pengirimTidakBerubah pada setiap retry
Waktu saat ini atau angka acak per permintaanTidakTidak pernah sama dua kali
Nomor pesanan yang boleh dibayar berkali-kaliTidakCicilan kedua akan dikira duplikat dan tidak pernah terbuat
// Key dibuat sekali, ikut disimpan bersama pesanan,
// lalu dipakai ulang oleh setiap percobaan.
// Satu pernyataan atomik: dua klik bersamaan mendapat key yang sama.
const { rows } = await db.query(
  `UPDATE orders SET idempotency_key = COALESCE(idempotency_key, $2)
    WHERE id = $1 RETURNING idempotency_key`,
  [order.id, crypto.randomUUID()],
);
const key = rows[0].idempotency_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

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. Di toko yang mengirim barang digital otomatis, penambahan ganda itu langsung memakan saldo pemasok; urutan penjaganya untuk kasus itu ada di tulisan tentang toko top up game dan voucher.

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.

Mulai sekarang

Terima pembayaran pertama minggu ini.

Daftar gratis, buat payment link atau pakai API, dan lihat statusnya berubah otomatis. Tanpa biaya bulanan, biaya transaksi hanya untuk pembayaran yang berhasil.

Dapatkan di Google PlayDownload di App Store