Double charge biasanya terjadi bukan karena user menekan tombol dua kali, tetapi karena sistem menghadapi kondisi yang sangat umum: request timeout, koneksi terputus setelah server memproses pembayaran, atau client melakukan retry otomatis tanpa tahu status request sebelumnya. Solusi yang paling umum dan praktis untuk kasus ini adalah idempotency key.
Dengan idempotency key untuk API payment, server dapat mengenali bahwa dua request sebenarnya adalah operasi yang sama. Request pertama diproses seperti biasa, sedangkan request duplikat akan menerima hasil yang sama seperti sebelumnya, atau ditolak jika payload-nya berbeda. Ini penting untuk mencegah charge ganda tanpa mengorbankan kemampuan client melakukan retry.
Apa itu Idempotency Key?
Idempotency key adalah nilai unik yang dikirim client bersama request untuk menandai satu operasi bisnis tertentu, misalnya "buat pembayaran untuk order 123". Jika request yang sama dikirim ulang dengan key yang sama, server tidak boleh membuat pembayaran baru.
Secara praktis, idempotency key bukan sekadar deduplikasi HTTP, tetapi mekanisme konsistensi operasi. Tujuannya adalah memastikan bahwa satu aksi bisnis hanya dieksekusi sekali walaupun request diterima berkali-kali.
Kapan dibutuhkan?
- Client retry karena timeout.
- Koneksi putus setelah server menerima request.
- Mobile app berpindah jaringan lalu mengirim ulang request.
- Load balancer atau proxy melakukan retry.
- User klik tombol bayar dua kali dalam waktu dekat.
Format header yang umum
Format yang paling lazim adalah header HTTP khusus, misalnya:
POST /payments
Idempotency-Key: 8c5d5f4a-9d11-4f1a-a5f2-2f8d4a9a3b71
Content-Type: application/jsonGunakan nilai yang benar-benar unik per operasi. UUID atau nilai acak dengan entropi tinggi adalah pilihan yang aman.
Catatan: Jangan membentuk idempotency key dari timestamp sederhana atau nomor urut yang mudah ditebak. Key yang mudah ditebak bisa memicu benturan antar request atau menjadi celah abuse pada API publik.
Alur Request Pertama vs Request Duplikat
Request pertama
- Server menerima
Idempotency-Key. - Server memeriksa apakah key sudah pernah dipakai dalam scope yang benar.
- Jika belum ada, server mencatat key sebagai request baru.
- Server memproses payment ke provider.
- Server menyimpan hasil akhir agar retry berikutnya bisa mengembalikan respons yang konsisten.
Request duplikat dengan payload sama
- Server menemukan key yang sama.
- Server memverifikasi bahwa payload identik atau setara secara bisnis.
- Jika request sebelumnya sudah selesai, server mengembalikan respons lama, bukan membuat charge baru.
- Jika request pertama masih diproses, server bisa mengembalikan status bahwa request sedang berjalan.
Request duplikat dengan payload berbeda
Ini harus dianggap sebagai konflik. Misalnya key yang sama dipakai untuk nominal berbeda atau order berbeda. Dalam kasus ini, server sebaiknya tidak memproses request baru dan mengembalikan error konflik.
Alasannya sederhana: satu idempotency key merepresentasikan satu operasi. Jika payload berbeda, artinya client mencoba memakai identitas operasi lama untuk operasi lain.
Prinsip Desain yang Aman untuk Endpoint Payment
1. Tentukan scope idempotency key
Key tidak berdiri sendiri. Anda perlu menentukan scope-nya. Scope yang umum:
- Per user/account + key: aman untuk API publik multi-tenant.
- Per merchant + key: cocok jika merchant adalah aktor utama.
- Per operasi bisnis + key: misalnya hanya untuk endpoint create payment.
Jangan memakai key global tanpa scope pada sistem multi-tenant. Dua user berbeda bisa saja kebetulan mengirim key yang sama. Karena itu, identitas unik biasanya berupa kombinasi seperti:
(actor_id, operation_name, idempotency_key)2. Simpan fingerprint payload
Selain key, simpan juga fingerprint payload untuk membedakan retry yang valid dari konflik. Fingerprint bisa berupa hash dari field-field penting, misalnya:
- order_id
- amount
- currency
- payment_method
Jangan asal hash raw JSON mentah jika urutan field, spasi, atau field non-esensial bisa berubah. Lebih aman membangun representasi kanonik dari field yang memang menentukan operasi bisnis.
fingerprint = hash(
user_id + ":" +
order_id + ":" +
amount + ":" +
currency + ":" +
payment_method
)Jika key sama tetapi fingerprint berbeda, kembalikan konflik.
3. Simpan status request
Record idempotency sebaiknya memiliki status yang jelas, misalnya:
processing: request sedang diprosessucceeded: request selesai suksesfailed: request selesai gagal final
Status ini penting agar server tahu apakah harus menunggu, mengembalikan hasil lama, atau menyuruh client retry nanti.
4. Simpan referensi hasil bisnis, bukan hanya flag
Kesalahan umum adalah hanya menyimpan "key sudah dipakai". Ini tidak cukup. Untuk payment, Anda perlu setidaknya menyimpan:
- status request
- fingerprint payload
- payment_id internal
- provider_transaction_id jika sudah ada
- HTTP status terakhir yang akan dikembalikan
- respons body ringkas atau referensi ke response snapshot
Dengan begitu, request duplikat bisa menerima respons yang konsisten.
Skema Tabel yang Umum
Berikut contoh skema tabel yang cukup praktis untuk database relasional:
CREATE TABLE payment_idempotency (
id BIGINT PRIMARY KEY,
actor_id VARCHAR(64) NOT NULL,
operation VARCHAR(64) NOT NULL,
idempotency_key VARCHAR(128) NOT NULL,
request_fingerprint VARCHAR(128) NOT NULL,
status VARCHAR(32) NOT NULL,
payment_id VARCHAR(64) NULL,
provider_tx_id VARCHAR(128) NULL,
response_status_code INT NULL,
response_body TEXT NULL,
error_code VARCHAR(64) NULL,
locked_until TIMESTAMP NULL,
expires_at TIMESTAMP NOT NULL,
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL,
UNIQUE (actor_id, operation, idempotency_key)
);Catatan desain:
UNIQUE (actor_id, operation, idempotency_key)mencegah insert ganda secara atomik.request_fingerprintdipakai untuk validasi retry.response_bodyboleh diganti dengan pointer ke tabel lain jika respons besar.expires_atdigunakan untuk TTL dan housekeeping.
Kapan memilih database?
Database relasional cocok jika Anda butuh:
- transaksi kuat
- integritas unik yang jelas
- hubungan erat dengan record payment
- audit yang mudah
Untuk endpoint payment, database sering menjadi pilihan default yang lebih aman dibanding hanya mengandalkan cache.
Database vs Redis untuk Penyimpanan Key
Opsi 1: Database relasional
Kelebihan:
- Konsistensi kuat dengan unique constraint.
- Mudah diaudit.
- Cocok untuk data penting seperti payment.
Kekurangan:
- Latency umumnya lebih tinggi daripada cache in-memory.
- Perlu desain transaksi yang hati-hati agar tidak menahan lock terlalu lama.
Opsi 2: Redis
Kelebihan:
- Cepat untuk lookup dan lock pendek.
- Mudah menerapkan TTL otomatis.
- Cocok untuk mencegah retry paralel jangka pendek.
Kekurangan:
- Jika hanya disimpan di Redis tanpa persistensi yang memadai, record bisa hilang saat restart atau failover.
- Kurang ideal sebagai satu-satunya sumber kebenaran untuk operasi finansial.
Pola yang sering dipakai
Pendekatan yang lebih aman adalah:
- Database sebagai source of truth
- Redis sebagai pelengkap untuk lock atau deduplikasi cepat
Misalnya, Redis dipakai untuk menahan request paralel dengan key yang sama selama beberapa detik, sementara hasil akhir tetap disimpan di database.
TTL: Berapa Lama Idempotency Key Disimpan?
Tidak ada angka universal. TTL harus mengikuti karakter bisnis dan pola retry client. Untuk payment, TTL biasanya harus cukup lama untuk menutupi:
- retry otomatis dari client
- delay jaringan
- replay karena user mengulangi aksi setelah aplikasi hang
- callback provider yang datang terlambat
Pertimbangan praktis:
- Jika TTL terlalu pendek, request duplikat yang datang belakangan bisa dianggap request baru dan memicu charge ganda.
- Jika TTL terlalu panjang, tabel akan membesar dan key lama bisa mengganggu reuse yang seharusnya sah.
Pola yang umum adalah menyimpan key selama periode yang cukup untuk siklus retry normal, lalu menghapusnya secara berkala dengan job housekeeping. Untuk payment, lebih aman memilih TTL yang konservatif daripada terlalu agresif.
Prinsip: TTL idempotency sebaiknya lebih panjang daripada timeout client, retry window, dan kemungkinan keterlambatan webhook yang masih relevan terhadap keputusan charge.
Kapan Mengembalikan Respons Lama vs Konflik?
Kembalikan respons lama jika:
- Key sama
- Scope sama
- Fingerprint payload sama
- Request sebelumnya sudah selesai
Dalam kondisi ini, respons lama justru perilaku yang diinginkan. Client mendapat hasil deterministik meskipun request dikirim ulang.
Kembalikan konflik jika:
- Key sama tetapi payload berbeda
- Key sama dipakai untuk order atau amount lain
- Key sama dipakai di operasi berbeda dalam scope yang seharusnya terpisah
Status code yang masuk akal untuk konflik adalah 409 Conflict.
Kembalikan status sedang diproses jika:
- Request pertama belum selesai
- Retry datang sangat cepat atau paralel
Dalam kasus ini, beberapa opsi yang umum:
409 Conflictdengan pesan bahwa request sedang diproses202 Acceptedjika arsitektur Anda memang asynchronous425 Too Earlysecara semantik bisa relevan, tetapi tidak selalu nyaman untuk semua client dan gateway
Yang paling penting adalah konsisten dan terdokumentasi dengan jelas di kontrak API.
Contoh Alur Middleware dan Handler
Berikut pseudo-code yang menggambarkan pendekatan praktis:
function handleCreatePayment(request) {
actorId = authenticatedUserId(request)
key = request.headers["Idempotency-Key"]
operation = "create_payment"
if (!key) {
return response(400, { error: "missing_idempotency_key" })
}
fingerprint = buildFingerprint({
actor_id: actorId,
order_id: request.body.order_id,
amount: request.body.amount,
currency: request.body.currency,
payment_method: request.body.payment_method
})
beginTransaction()
record = findIdempotencyRecordForUpdate(actorId, operation, key)
if (record exists) {
if (record.request_fingerprint != fingerprint) {
rollback()
return response(409, { error: "idempotency_key_reused_with_different_payload" })
}
if (record.status == "succeeded" || record.status == "failed") {
rollback()
return response(record.response_status_code, record.response_body)
}
if (record.status == "processing") {
rollback()
return response(409, { error: "request_in_progress" })
}
}
if (!record exists) {
insertIdempotencyRecord({
actor_id: actorId,
operation: operation,
idempotency_key: key,
request_fingerprint: fingerprint,
status: "processing",
expires_at: nowPlusTTL()
})
}
commit()
try {
payment = createLocalPaymentPending(request.body)
providerResult = chargeProvider(payment)
finalResponse = {
payment_id: payment.id,
status: providerResult.status,
provider_reference: providerResult.reference
}
updateIdempotencyRecord({
actor_id: actorId,
operation: operation,
idempotency_key: key,
status: "succeeded",
payment_id: payment.id,
provider_tx_id: providerResult.reference,
response_status_code: 201,
response_body: finalResponse
})
return response(201, finalResponse)
} catch (err) {
failure = mapError(err)
updateIdempotencyRecord({
actor_id: actorId,
operation: operation,
idempotency_key: key,
status: failure.final ? "failed" : "processing",
response_status_code: failure.httpStatus,
response_body: failure.body,
error_code: failure.code
})
return response(failure.httpStatus, failure.body)
}
}Poin penting dari pseudo-code di atas:
- Pemeriksaan key dilakukan sebelum memproses provider.
- Ada validasi fingerprint untuk mencegah reuse yang salah.
- Unique constraint atau lock memastikan hanya satu request yang masuk ke jalur eksekusi utama.
- Hasil akhir disimpan agar retry menerima respons konsisten.
Race Condition dan Retry Paralel
Masalah yang sering terjadi
Dua request dengan key yang sama bisa tiba hampir bersamaan. Jika Anda hanya melakukan:
- cek apakah key ada
- jika tidak ada, proses payment
maka keduanya bisa lolos sebelum salah satu sempat menyimpan record. Inilah race condition klasik yang berujung double charge.
Cara mencegahnya
- Gunakan unique constraint di database pada kombinasi scope + key.
- Gunakan transaction dan, jika perlu,
SELECT ... FOR UPDATEpada record yang sudah ada. - Untuk proteksi tambahan, gunakan lock terdistribusi jangka pendek di Redis, tetapi jangan jadikan satu-satunya mekanisme final.
Pola insert-first lebih aman
Daripada cek lalu insert, lebih aman mencoba membuat record processing lebih dulu secara atomik. Jika insert gagal karena unique constraint, artinya request lain sudah lebih dulu memegang key tersebut.
Dengan pola ini, hanya satu request yang boleh lanjut ke proses charge provider.
Partial Failure Setelah Provider Berhasil
Ini edge case paling penting dalam payment. Skenarionya:
- Server berhasil men-charge provider.
- Sebelum sempat menyimpan hasil ke database, server crash atau timeout.
- Client retry dengan idempotency key yang sama.
Jika sistem Anda tidak punya strategi pemulihan, retry bisa melakukan charge kedua.
Cara menghadapinya
Ada beberapa lapisan mitigasi:
- Simpan payment lokal lebih dulu dengan status awal, misalnya
pending. - Kirim reference internal ke provider jika provider mendukung field merchant reference atau external reference.
- Rekonsiliasi ke provider saat status lokal belum pasti.
- Gunakan webhook/callback untuk memulihkan status transaksi yang berhasil tetapi belum tercatat penuh.
Jika retry datang dan record idempotency masih processing atau status lokal belum final, jangan langsung charge ulang. Lebih aman:
- cek apakah payment lokal dengan reference tersebut sudah ada
- cek status ke provider bila memungkinkan
- selesaikan rekonsiliasi lebih dulu
Prinsip penting: idempotency key mencegah duplikasi dari sisi API Anda, tetapi charge ganda tetap bisa terjadi jika interaksi dengan provider tidak punya reference yang bisa direkonsiliasi.
API Internal vs API Publik
API publik
Pada API publik, idempotency key hampir selalu perlu diekspos ke client karena server tidak bisa mengontrol retry dari luar. Pertimbangan tambahannya:
- validasi format header
- scope per tenant atau per user
- batas panjang key
- rate limiting untuk mencegah abuse
- dokumentasi status code dan perilaku retry
API internal
Pada API internal antar layanan, Anda punya lebih banyak kontrol. Kadang idempotency bisa dibangun dari:
- event ID dari message bus
- payment intent ID internal
- order ID yang memang unik untuk operasi tertentu
Namun, jangan menganggap API internal pasti aman. Retry dari job runner, queue consumer, atau circuit breaker tetap bisa memicu request berulang. Prinsip dasarnya sama: satu operasi bisnis harus punya identitas yang stabil.
Perbedaan utama
- API publik: kontrak harus eksplisit, header biasanya wajib, validasi lebih ketat.
- API internal: bisa memakai identifier internal yang lebih terstruktur, tetapi tetap butuh deduplikasi atomik.
Status Code yang Masuk Akal
Tidak ada satu standar tunggal yang wajib, tetapi berikut pilihan yang praktis:
201 Created: payment baru berhasil dibuat.200 OK: mengembalikan hasil lama untuk request duplikat yang sukses.409 Conflict: key sama tetapi payload berbeda, atau request serupa masih diproses.400 Bad Request: header idempotency wajib tetapi tidak ada atau format tidak valid.202 Accepted: jika proses payment memang asynchronous dan hasil final belum tersedia.
Yang lebih penting daripada memilih kode tertentu adalah memastikan perilakunya konsisten dan terdokumentasi. Client harus tahu kapan aman melakukan retry dengan key yang sama.
Kesalahan Umum yang Memicu Charge Ganda
- Hanya mengecek key di memory aplikasi, sehingga gagal saat ada banyak instance server.
- Tidak ada unique constraint, sehingga dua request paralel lolos bersamaan.
- Menghapus key terlalu cepat, padahal client masih bisa retry.
- Tidak menyimpan fingerprint payload, sehingga key yang sama bisa dipakai untuk nominal berbeda.
- Hanya menyimpan flag processed tanpa response snapshot atau referensi transaksi.
- Langsung retry charge ke provider saat status lokal ambigu, tanpa rekonsiliasi.
- Menggunakan order ID sebagai satu-satunya mekanisme padahal satu order bisa memiliki beberapa percobaan pembayaran yang sah.
- Tidak mempertimbangkan webhook terlambat yang memperbarui transaksi setelah client sudah retry.
Checklist Implementasi
- Tentukan endpoint mana yang wajib memakai
Idempotency-Key. - Tentukan scope key: per user, per merchant, atau per operasi.
- Buat unique constraint pada kombinasi scope + key.
- Simpan fingerprint dari field bisnis yang relevan.
- Simpan status request:
processing,succeeded,failed. - Simpan respons terakhir atau referensi ke hasil transaksi.
- Tetapkan TTL yang cukup untuk jendela retry nyata.
- Tangani request paralel dengan insert atomik, transaksi, atau lock.
- Pastikan provider charge punya reference yang bisa direkonsiliasi.
- Dokumentasikan kapan API mengembalikan respons lama, konflik, atau status in-progress.
- Tambahkan observability: log key, fingerprint, payment_id, provider reference, dan hasil rekonsiliasi.
- Uji dengan skenario timeout, retry paralel, crash setelah provider sukses, dan webhook terlambat.
Debugging dan Pengujian yang Perlu Dilakukan
Skenario uji minimum
- Kirim request yang sama dua kali berurutan dengan key yang sama.
- Kirim dua request paralel dengan key yang sama.
- Kirim key yang sama dengan amount berbeda.
- Simulasikan timeout setelah provider sukses tetapi sebelum DB update selesai.
- Simulasikan restart service saat status idempotency masih
processing.
Log yang sebaiknya ada
- idempotency_key
- actor_id atau tenant_id
- operation
- fingerprint
- payment_id internal
- provider_tx_id
- transisi status record idempotency
Tanpa log ini, investigasi double charge biasanya berubah menjadi tebakan.
Penutup
Idempotency key bukan fitur tambahan, melainkan kontrol inti untuk endpoint payment yang harus tahan terhadap retry, timeout, dan kegagalan jaringan. Implementasi yang benar tidak berhenti pada menerima header saja, tetapi mencakup scope yang tepat, fingerprint payload, penyimpanan hasil, unique constraint, TTL, dan strategi menghadapi partial failure.
Jika Anda merancang API payment, targetnya bukan sekadar "request yang sama tidak diproses dua kali", tetapi satu operasi bisnis tidak menimbulkan charge ganda meskipun sistem berada dalam kondisi gagal parsial. Di situlah idempotency key benar-benar memberi nilai.
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!