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/json

Gunakan 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

  1. Server menerima Idempotency-Key.
  2. Server memeriksa apakah key sudah pernah dipakai dalam scope yang benar.
  3. Jika belum ada, server mencatat key sebagai request baru.
  4. Server memproses payment ke provider.
  5. Server menyimpan hasil akhir agar retry berikutnya bisa mengembalikan respons yang konsisten.

Request duplikat dengan payload sama

  1. Server menemukan key yang sama.
  2. Server memverifikasi bahwa payload identik atau setara secara bisnis.
  3. Jika request sebelumnya sudah selesai, server mengembalikan respons lama, bukan membuat charge baru.
  4. 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 diproses
  • succeeded: request selesai sukses
  • failed: 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_fingerprint dipakai untuk validasi retry.
  • response_body boleh diganti dengan pointer ke tabel lain jika respons besar.
  • expires_at digunakan 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 Conflict dengan pesan bahwa request sedang diproses
  • 202 Accepted jika arsitektur Anda memang asynchronous
  • 425 Too Early secara 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:

  1. cek apakah key ada
  2. 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 UPDATE pada 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:

  1. Server berhasil men-charge provider.
  2. Sebelum sempat menyimpan hasil ke database, server crash atau timeout.
  3. 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

  1. Tentukan endpoint mana yang wajib memakai Idempotency-Key.
  2. Tentukan scope key: per user, per merchant, atau per operasi.
  3. Buat unique constraint pada kombinasi scope + key.
  4. Simpan fingerprint dari field bisnis yang relevan.
  5. Simpan status request: processing, succeeded, failed.
  6. Simpan respons terakhir atau referensi ke hasil transaksi.
  7. Tetapkan TTL yang cukup untuk jendela retry nyata.
  8. Tangani request paralel dengan insert atomik, transaksi, atau lock.
  9. Pastikan provider charge punya reference yang bisa direkonsiliasi.
  10. Dokumentasikan kapan API mengembalikan respons lama, konflik, atau status in-progress.
  11. Tambahkan observability: log key, fingerprint, payment_id, provider reference, dan hasil rekonsiliasi.
  12. 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.