Tunas Akara
Kembali ke Blog

Idempotency di Payment API: Selamat dari Retry Tanpa Double Charge

oleh RayhanDiperbarui 8 menit baca
paymentsidempotencyapi-designfintech
Idempotency di Payment API: Selamat dari Retry Tanpa Double Charge

Idempotency di Payment API: Selamat dari Retry Tanpa Double Charge

Di proyek payment-orchestration, satu bug ini paling sering muncul: client retry charge setelah timeout, dan customer kena tagih dua kali. Ini bukan edge case langka. Ini hasil default dari retry logic yang naif, dijalankan di jaringan yang sesekali putus tepat antara server selesai memproses dan responsnya sampai ke client.

Masalah retry

Satu payment request melewati beberapa hop — server sendiri, gateway, sering juga acquiring bank, lalu kembali. Tiap hop bisa timeout setelah charge-nya sebenarnya sudah berhasil di ujung sana. Bagi client, timeout dan kegagalan sungguhan terlihat identik: keduanya sama-sama tidak ada respons.

Bayangkan transfer lewat ATM, layarnya macet sebelum struk keluar. Tidak ada yang tahu apakah uangnya sudah terpotong atau belum — itulah persis posisi payment API saat timeout. Client tidak bisa membedakan "ini belum pernah terjadi" dari "ini sudah terjadi, saya cuma tidak dengar kabarnya."

Retry tetap naluri yang benar; menolak retry cuma membuat kegagalan sementara macet di spinner. Masalahnya adalah retry secara buta, seolah percobaan pertama pasti belum sampai. Celah antara "saya tidak tahu" dan "ini sudah terjadi" itulah sumber double charge.

Penjelasan Stripe soal kenapa idempotency dibangun ke dalam API-nya menyebut kegagalan ini langsung. Untuk endpoint yang mengenakan charge ke customer, "accidentally calling it twice would lead to the customer being double-charged, which is very bad".

Celah semantik HTTP

Masalah ini terus berulang sebagian karena kosakata HTTP sendiri tidak membantu. Definisi metode idempotent dari MDN: metode yang membuat beberapa request identik menghasilkan efek yang sama seperti satu request. MDN menyebut tegas bahwa "the POST and PATCH methods are not guaranteed to be idempotent."

Contohnya: POST /add_row yang diulang membuat baris baru setiap kali, bukan konvergen ke satu state. Endpoint charge adalah POST; tidak ada jaminan di HTTP bahwa memanggilnya dua kali itu aman. Draft standar dari IETF HTTPAPI working group menyatakan hal yang sama secara formal. POST dan PATCH tidak idempotent di bawah RFC 9110, berbeda dari GET, PUT, DELETE, HEAD, dan OPTIONS.

Itulah kenapa mekanisme khusus ini ada: header request Idempotency-Key yang disertakan client pada panggilan mutating. Server membaca retry sebagai retry, bukan instruksi baru untuk men-charge kartu lagi.

Bagaimana provider besar menerapkannya

Konsepnya sama; detailnya tidak.

ProviderHeaderFormat / batas keyTTL (masa berlaku — lewat itu key dianggap kedaluwarsa)Perilaku saat duplikat masih diproses
StripeIdempotency-KeyDisarankan V4 UUID, maks 255 karakterMin. 24 jam, lalu dihapusMengulang status code dan body yang tersimpan, termasuk 500 yang tersimpan
Adyenidempotency-keyDisarankan UUID, maks 64 karakterMin. 7 hariHTTP 409/422, error code 704: "request already processed or in progress"
MidtransIdempotency-KeyMaks 46 karakter (lebih dari itu diabaikan)5 menitHTTP 202 selagi request awal masih diproses
Xenditidempotency-keyString bebas dari clientDidokumentasikan per endpointDUPLICATE_ERROR jika key dipakai ulang dengan parameter berbeda

Stripe membatasi key idempotency hanya untuk POSTGET dan DELETE sudah idempotent by definition. Masa retensi minimum Adyen satu minggu, tujuh kali lipat batas Stripe. Jendela lima menit Midtrans cukup singkat untuk memengaruhi cara retry backoff dirancang, dibahas di bawah.

Membangun idempotency layer sendiri

Kalau tim sendiri yang menyediakan endpoint payment, keputusan desain yang sama harus dibuat — persis seperti yang sudah dibuat vendor-vendor di atas.

Simpan respons pertama. Idempotency key tidak sekadar memblokir duplikat — request duplikat dijawab dengan hasil yang sama seperti request pertama. Simpan body respons dan status berdasarkan nilai key, lalu kembalikan tanpa perubahan saat request itu diulang.

Pilih jendela expiry dengan sengaja. Terlalu pendek, dan client yang retry setelah percobaan pertama lambat-tapi-berhasil justru membuat charge kedua yang sungguhan. Terlalu panjang, dan payload tersimpan tanpa batas untuk key yang tidak pernah dipakai ulang. Batas 24 jam Stripe dan 7 hari Adyen mencerminkan asumsi berbeda soal seberapa lama client mungkin menunggu sebelum menyerah.

Tangani race saat masih diproses secara eksplisit. Key yang sama bisa datang dua kali selagi request pertama masih berjalan. Mengantrekannya diam-diam, atau membiarkan keduanya jalan, adalah cara double charge terjadi bahkan ketika key sudah dipasang. Draft IETF merekomendasikan respons 409 conflict di sini; 409/422 milik Adyen dan 202 milik Midtrans adalah dua jawaban produksi untuk masalah yang sama:

Loading diagram…

Scoping key yang benar

Satu namespace global untuk idempotency key adalah kesalahan yang berulang kali saya temui. Di platform multi-tenant, dua merchant bisa menghasilkan key yang sama, dan retry milik satu tenant mendarat di request milik tenant lain. Panduan draft IETF: bangun key lookup cache sebagai komposit — key dari client ditambah atribut yang hanya diketahui server, seperti identitas akun atau merchant.

Disiplin yang sama berlaku untuk body request. Stripe dan Xendit sama-sama memperlakukan key yang dipakai ulang dengan parameter berbeda sebagai error, bukan overwrite diam-diam. Stripe menolak ketidakcocokan itu, dan Xendit mengembalikan DUPLICATE_ERROR yang menyuruh caller membuat key baru. Amount atau penerima yang berubah di bawah key yang sama lebih mungkin bug di sisi caller ketimbang retry yang disengaja.

Kejanggalan khas rel lokal Indonesia

Rel pembayaran lokal menambah batasan yang tidak dimiliki provider global. Jendela 5 menit Midtrans pendek dibanding batas seminggu Adyen, dan tidak didukung untuk Permata Virtual Account, CIMB Clicks, KlikBCA, dan Indomaret. Jaring pengaman retry punya lubang per channel, bukan hanya soal waktu. Kalau checkout menawarkan metode-metode itu, referensi order idempotent sendiri harus menutup celah itu.

Panduan payout Xendit sederhana: pakai ulang nilai key yang sama pada retry mana pun untuk payout yang gagal atau timeout. Layak diuji sebelum go-live.

Alur QRIS dan virtual account memperumit ini, karena konfirmasi akhirnya biasanya datang secara asinkron lewat callback — jauh setelah panggilan API awal kembali. Idempotency key gateway melindungi request awal itu, tapi tidak berbuat apa-apa untuk callback-nya. Order ID idempotent sendiri tetap dibutuhkan di lapisan merchant, constraint yang membuat pemrosesan satu callback dua kali jadi no-op. Disiplin yang sama mencegah hotel menempatkan dua tamu di satu kamar saat dua konfirmasi booking bertabrakan.

Yang harus Anda minta dari gateway sebelum integrasi

Minta empat hal dari vendor payment mana pun: idempotency di setiap endpoint mutating, bukan cuma charge, dan kebijakan TTL serta scope key yang dipublikasikan. Minta juga respons yang deterministik untuk retry saat masih diproses, dan visibilitas ke conflict rate 409 milik sistem sendiri. Lalu jalankan checklist yang sama ke stack yang dipakai:

  1. Pastikan setiap endpoint payment mutating yang dipanggil atau disediakan menerima idempotency key.
  2. Cek retry logic-nya: memakai ulang key yang sama tiap percobaan, atau membuat key baru tiap panggilan — yang justru meniadakan mekanismenya?
  3. Pastikan scope key menyertakan identitas merchant atau akun, bukan cuma string mentah dari client.
  4. Untuk channel mana pun yang dikecualikan gateway, pastikan ada order ID idempotent sendiri di lapisan merchant.
  5. Tarik conflict rate 409/422 selama 30 hari terakhir — angka mendekati nol di sistem yang tinggi retry-nya justru layak diselidiki.

Idempotency key hanyalah bagian kecil dari permukaan API. Salah menanganinya adalah salah satu kesalahan termahal yang bisa terjadi di integrasi payment. Dan salah satu yang paling murah untuk dicegah.

Artikel Terkait

Mau bangun hal serupa?

Jasa Pengembangan Backend IoT & Integrasi Multi-Protokol

Backend yang menyerap telemetri perangkat lintas MQTT, WebSocket, Modbus, dan BLE, lalu menyatukannya jadi dashboard real-time yang andal.

Lihat cara saya bisa bantu