Pada endpoint write seperti membuat pembayaran, order, invoice, atau transfer saldo, masalah paling berbahaya sering bukan request yang gagal total, melainkan request yang mungkin berhasil tetapi klien tidak menerima respons karena timeout, koneksi putus, atau gateway di tengah jalur. Jika klien lalu melakukan retry tanpa kontrak yang jelas, server bisa memproses operasi yang sama dua kali.

Idempotency key adalah kontrak sederhana untuk mencegah duplikasi efek samping pada request yang sama. Klien mengirim kunci unik untuk satu operasi logis, lalu server menyimpan hasil eksekusi pertama dan mengembalikan hasil yang sama untuk retry berikutnya dengan key yang sama. Untuk sistem besar, kontrak yang tegas seperti ini lebih berharga daripada logika implisit yang tersebar. Ini sejalan dengan pelajaran umum dari sistem berskala besar: kompleksitas harus ditekan lewat antarmuka yang sederhana, bukan ditutup dengan asumsi.

Artikel ini fokus pada desain praktis endpoint write API yang aman saat retry dan timeout: kapan idempotency key perlu dipakai, bagaimana bentuk header/body, bagaimana menentukan scope key, berapa TTL yang masuk akal, bagaimana menyimpan hasil, status code apa yang cocok, cara menghadapi race condition, serta implikasinya ke webhook dan pola outbox.

Kapan idempotency key diperlukan

Idempotency key paling relevan untuk operasi yang:

  • Mengubah state: membuat resource baru, memicu pembayaran, reservasi stok, membuat refund, mengirim instruksi transfer.
  • Punya efek samping eksternal: memanggil payment gateway, mengirim email penting, menerbitkan event, membuat webhook.
  • Bisa di-retry oleh klien atau infrastruktur: aplikasi mobile, reverse proxy, job worker, atau message consumer.
  • Tidak aman jika terduplikasi: dua order, dua charge, dua shipment, dua pencatatan audit yang memicu proses lanjutan.

Tidak semua endpoint memerlukannya. Untuk operasi yang memang sudah idempoten secara alami, misalnya PUT /users/{id} yang menimpa representasi lengkap, Anda mungkin cukup mengandalkan semantik resource dan kontrol konkurensi seperti ETag atau If-Match. Namun untuk endpoint bergaya action seperti POST /payments atau POST /orders, idempotency key biasanya lebih aman dan lebih mudah dipahami oleh klien.

Situasi nyata yang membutuhkan retry aman

  • Klien mengirim POST /payments, server sukses memproses, tetapi respons 201 tidak sampai ke klien.
  • Load balancer menutup koneksi saat upstream lambat, padahal transaksi database sudah commit.
  • Worker memproses pesan lalu crash setelah menulis ke database tetapi sebelum mengakui pesan.
  • Aplikasi mobile mengirim ulang request yang sama saat jaringan berpindah dari Wi-Fi ke seluler.

Dalam semua kasus ini, server perlu bisa menjawab pertanyaan: apakah operasi logis ini sudah pernah diproses? Idempotency key menyediakan jawabannya secara eksplisit.

Desain kontrak API: header, body, dan cakupan key

Letakkan key di header, bukan di payload bisnis

Pola yang umum dan rapi adalah mengirim idempotency key di header, misalnya:

POST /v1/payments HTTP/1.1
Idempotency-Key: 7e7d6e4b-0a0f-4d0a-8bbf-1c8f7a9c8c5b
Content-Type: application/json

{
  "customer_id": "cus_123",
  "amount": 150000,
  "currency": "IDR",
  "reference": "INV-2026-001"
}

Alasannya:

  • Memisahkan metadata transport dari payload domain.
  • Lebih mudah diterapkan lintas endpoint yang berbeda.
  • Lebih jelas bahwa key adalah kontrak retry, bukan bagian dari model bisnis.

Anda tetap bisa menerima key di body bila ada keterbatasan klien, tetapi sebaiknya pilih satu mekanisme utama agar dokumentasi dan validasi tetap konsisten.

Scope key: per endpoint, per actor, atau per resource?

Kesalahan desain yang sering terjadi adalah menganggap key unik secara global tanpa definisi scope yang jelas. Padahal arti “request yang sama” harus dibatasi. Scope yang umum dipakai:

  • Per actor + endpoint: misalnya unik untuk kombinasi account_id + POST /payments + Idempotency-Key. Ini biasanya pilihan aman.
  • Per actor + operasi bisnis: cocok bila beberapa endpoint internal mewakili operasi logis yang sama.
  • Per resource target: berguna jika request selalu terkait resource tertentu, misalnya POST /orders/{id}/capture.

Hindari key global tanpa namespace, karena key yang sama dari tenant berbeda bisa saling bertabrakan. Untuk sistem multi-tenant, minimal sertakan identitas tenant atau principal dalam key record.

Aturan praktis: satu idempotency key harus merepresentasikan satu niat bisnis, bukan sekadar satu paket HTTP.

Haruskah payload yang sama wajib punya key yang sama?

Tidak. Umumnya klien membuat key acak per operasi lalu memakai key itu untuk semua retry dari operasi yang sama. Server tidak perlu menebak kesamaan niat hanya dari payload, karena dua request dengan payload identik bisa saja memang dimaksudkan sebagai dua operasi berbeda, misalnya membuat dua order dengan item yang sama.

Namun server perlu memastikan bahwa key yang sama tidak dipakai untuk payload yang berbeda. Caranya dengan menyimpan request fingerprint, misalnya hash dari metode, path yang relevan, actor/tenant, dan payload yang sudah dinormalisasi. Jika ada retry dengan key sama tetapi fingerprint berbeda, kembalikan error konflik.

Alur request yang benar dengan idempotency key

Alur sukses pertama kali

  1. Klien membuat idempotency key unik untuk satu operasi logis.
  2. Server menerima request dan memeriksa apakah key sudah pernah dipakai dalam scope yang sama.
  3. Jika belum ada, server membuat record idempotensi dalam status in_progress atau melakukan reservasi atomik.
  4. Server memproses operasi bisnis di dalam transaksi yang aman.
  5. Server menyimpan hasil akhir yang akan dipakai untuk replay, misalnya status code, body respons, dan referensi resource.
  6. Server menandai record idempotensi sebagai completed.
  7. Respons dikirim ke klien.

Alur retry setelah timeout

  1. Klien tidak yakin request sebelumnya berhasil, lalu mengirim ulang request dengan key yang sama.
  2. Server menemukan record key yang sudah completed.
  3. Server mengembalikan hasil yang sama seperti permintaan pertama, tanpa mengeksekusi efek samping lagi.

Alur concurrent retry

  1. Dua request dengan key sama masuk hampir bersamaan.
  2. Hanya satu yang boleh memenangkan reservasi key.
  3. Request lain harus menunggu hasil, mendapat status sedang diproses, atau menerima hasil replay jika sudah selesai.

Di sinilah idempotency key bukan sekadar cache respons. Ia juga mekanisme koordinasi agar dua eksekusi paralel tidak memicu duplikasi.

Apa yang perlu disimpan

Minimal: metadata key dan fingerprint

Penyimpanan minimal biasanya mencakup:

  • scope atau namespace: tenant, actor, endpoint, atau operasi.
  • idempotency_key.
  • request_fingerprint: hash dari request yang dinormalisasi.
  • status: in_progress, completed, mungkin failed.
  • created_at dan expires_at.

Ideal: hasil yang bisa direplay

Untuk pengalaman klien yang konsisten, simpan juga:

  • http_status.
  • response_body yang relevan.
  • response_headers jika ada header penting.
  • resource_id atau referensi entitas yang dibuat.
  • error_type jika eksekusi gagal dengan cara yang perlu dibedakan.

Mengapa perlu menyimpan hasil? Karena setelah timeout, klien tidak hanya perlu tahu bahwa operasi pernah dijalankan, tetapi juga perlu menerima hasil yang sama. Jika hanya menyimpan “sudah pernah diproses” tanpa hasilnya, klien tetap bingung harus bertindak apa.

Simpan hasil sukses saja atau termasuk error?

Trade-off-nya sebagai berikut:

  • Simpan hasil sukses saja: lebih sederhana, tetapi request yang gagal di tengah bisa diproses ulang dengan konsekuensi yang tidak selalu aman.
  • Simpan hasil terminal termasuk error tertentu: lebih konsisten untuk klien, tetapi Anda harus membedakan error yang aman direplay dan error yang seharusnya boleh dicoba lagi.

Pendekatan praktis:

  • Jangan reserve key untuk request invalid seperti JSON salah atau field wajib hilang. Kembalikan 4xx biasa, klien boleh memperbaiki dan mengirim ulang.
  • Reserve key setelah request lolos validasi dasar.
  • Simpan hasil terminal untuk operasi yang sudah mulai menimbulkan efek samping atau sudah masuk transaksi bisnis.

Yang penting, aturan ini terdokumentasi jelas agar klien tahu kapan harus memakai key yang sama dan kapan harus membuat key baru.

TTL: berapa lama key disimpan?

Tidak ada angka universal. TTL harus mengikuti jendela retry yang realistis dan risiko bisnis duplikasi. Pertimbangkan:

  • Berapa lama klien atau infrastruktur dapat melakukan retry otomatis.
  • Berapa lama proses downstream bisa menyelesaikan transaksi atau webhook terkait.
  • Seberapa mahal konsekuensi duplikasi jika key kedaluwarsa terlalu cepat.
  • Biaya penyimpanan record idempotensi.

Untuk operasi sensitif seperti pembayaran, TTL cenderung lebih panjang dibanding operasi ringan. Yang penting bukan angka pastinya, tetapi prinsipnya: TTL harus lebih lama daripada jendela ketidakpastian retry yang Anda izinkan.

Gunakan pembersihan berkala untuk record kedaluwarsa. Jika beban tinggi, simpan payload respons secara efisien atau simpan referensi ke resource lalu bangun respons replay dari sumber yang stabil.

Status code yang masuk akal

Tidak ada satu standar tunggal yang wajib dipakai, tetapi pilih perilaku yang konsisten:

  • Request pertama sukses membuat resource: 201 Created.
  • Retry dengan key sama, hasil sudah ada: sering kali tetap kembalikan 200 OK atau 201 Created dengan body yang sama. Konsistensi lebih penting daripada variasi.
  • Key sama, payload berbeda: 409 Conflict adalah pilihan yang jelas.
  • Request dengan key sama sedang diproses: bisa 409 Conflict, 425 Too Early, atau 202 Accepted tergantung kontrak API. Jika ragu, 409 dengan pesan eksplisit sering paling mudah dipahami.
  • Validasi input gagal sebelum operasi dimulai: 400 Bad Request atau 422 Unprocessable Entity.

Yang paling penting adalah dokumentasi: klien harus tahu apakah retry dengan key yang sama akan mendapatkan body yang identik, status yang identik, atau setidaknya referensi ke hasil yang sama.

Race condition dan deduplikasi di backend

Masalah inti: check-then-insert tidak cukup

Pola naif seperti “cek apakah key ada, jika tidak ada lalu proses” rentan balapan. Dua request paralel bisa sama-sama melihat key belum ada, lalu keduanya mengeksekusi operasi.

Anda memerlukan reservasi atomik. Implementasinya bisa memakai database relasional, key-value store dengan operasi atomik, atau kombinasi keduanya. Prinsipnya:

  • Harus ada constraint unik pada scope + idempotency key.
  • Hanya satu request yang boleh berhasil membuat record awal.
  • Request lain yang kalah harus tahu bahwa operasi sudah/masih diproses.

Contoh struktur tabel

idempotency_records (
  scope                text,
  idempotency_key      text,
  request_fingerprint  text,
  status               text,
  http_status          integer,
  response_body        text,
  resource_id          text,
  created_at           timestamp,
  expires_at           timestamp,
  primary key (scope, idempotency_key)
)

Constraint unik pada (scope, idempotency_key) adalah fondasi deduplikasi.

Pseudo-code backend

function handleCreatePayment(request, actor):
    validateBasic(request)

    key = request.headers["Idempotency-Key"]
    if key is missing:
        return 400

    scope = actor.tenant_id + ":POST:/v1/payments"
    fingerprint = hash(normalize(request.body), actor.id, "/v1/payments", "POST")

    record = idempotencyStore.get(scope, key)
    if record exists:
        if record.request_fingerprint != fingerprint:
            return 409, {"error": "idempotency_key_reused_with_different_payload"}

        if record.status == "completed":
            return record.http_status, record.response_body

        if record.status == "in_progress":
            return 409, {"error": "request_still_processing"}

    reserved = idempotencyStore.tryInsert(scope, key, {
        request_fingerprint: fingerprint,
        status: "in_progress",
        expires_at: now() + ttl
    })

    if not reserved:
        // request lain menang balapan, baca ulang record
        record = idempotencyStore.get(scope, key)
        if record.request_fingerprint != fingerprint:
            return 409
        if record.status == "completed":
            return record.http_status, record.response_body
        return 409, {"error": "request_still_processing"}

    try:
        begin transaction

        payment = createPaymentInDatabase(request.body, actor)
        enqueueOutboxEvent("payment.created", payment.id)

        response = {
            "id": payment.id,
            "status": payment.status,
            "amount": payment.amount
        }

        commit transaction

        idempotencyStore.markCompleted(scope, key, {
            http_status: 201,
            response_body: response,
            resource_id: payment.id
        })

        return 201, response

    catch err:
        rollback transaction

        if isTerminalBusinessError(err):
            idempotencyStore.markCompleted(scope, key, {
                http_status: mapError(err),
                response_body: {"error": err.code}
            })
            return mapError(err), {"error": err.code}

        idempotencyStore.releaseOrMarkFailed(scope, key)
        return 500

Pseudo-code di atas menunjukkan tiga hal penting:

  • Validasi dasar dilakukan sebelum reservasi bila error tersebut tidak perlu di-idempotenkan.
  • Reservasi key harus atomik.
  • Efek samping lanjutan, seperti event untuk webhook, sebaiknya dipicu lewat outbox dalam transaksi yang sama.

Idempotency key bukan pengganti constraint bisnis

Idempotency key mencegah retry yang sama diproses dua kali, tetapi tidak otomatis melindungi dari duplikasi karena sumber lain. Anda tetap butuh constraint bisnis yang sesuai, misalnya:

  • Nomor invoice unik.
  • Satu capture untuk satu authorization.
  • Satu refund penuh per payment tertentu.
  • Satu shipment untuk satu order line tertentu jika memang aturannya demikian.

Anggap idempotency key sebagai lapisan perlindungan di batas API, bukan satu-satunya mekanisme integritas data.

Dampak ke webhook dan pola outbox

Masalah umum: API idempoten, tetapi webhook dobel

Banyak implementasi berhenti setelah endpoint aman dari duplikasi, padahal efek samping asinkron masih bisa terbit dua kali. Misalnya request POST /payments sudah dideduplikasi, tetapi event payment.created diterbitkan dua kali karena retry di publisher atau crash di tengah proses.

Solusinya adalah memisahkan commit state bisnis dari pengiriman event dengan pola transactional outbox:

  • Di transaksi yang sama saat membuat payment, tulis juga record outbox.
  • Worker terpisah membaca outbox lalu mengirim event/webhook.
  • Worker menandai outbox sebagai terkirim setelah sukses.

Dengan pola ini, request yang sama tidak akan membuat dua payment berbeda, dan penerbitan event menjadi dapat diulang dengan aman.

Webhook juga perlu idempotensi di sisi penerima

Meskipun pengirim sudah hati-hati, webhook pada dasarnya sering bersifat at-least-once delivery. Artinya penerima webhook juga harus melakukan deduplikasi menggunakan event ID atau delivery ID. Jadi ada dua lapis:

  • Idempotency key di API masuk untuk melindungi operasi write dari retry klien.
  • Event/webhook deduplication untuk melindungi konsumen dari pengiriman berulang.

Keduanya saling melengkapi, bukan saling menggantikan.

Kesalahan umum yang sering terjadi

1. Menyimpan key setelah operasi selesai

Jika record idempotensi baru dibuat setelah transaksi bisnis selesai, dua request paralel masih bisa sama-sama mengeksekusi operasi. Record harus dibuat atau di-reserve sebelum efek samping dijalankan.

2. Tidak memeriksa payload mismatch

Key yang sama untuk payload berbeda harus dianggap error. Jika tidak, klien bisa tidak sengaja mengaitkan dua niat bisnis berbeda ke record lama dan menerima hasil yang menyesatkan.

3. Scope terlalu luas atau terlalu sempit

Jika terlalu luas, tenant berbeda bisa bentrok. Jika terlalu sempit, endpoint yang seharusnya berbagi definisi operasi malah menghasilkan duplikasi. Definisikan scope berdasarkan niat bisnis dan identitas actor.

4. Mengandalkan hash payload sebagai satu-satunya key

Dua request yang payload-nya identik belum tentu operasi yang sama. Biarkan klien mengirim key eksplisit; hash payload hanya alat verifikasi bahwa retry memang cocok dengan key tersebut.

5. TTL terlalu pendek

Jika key kedaluwarsa sebelum jendela retry berakhir, request ulang bisa diproses sebagai operasi baru. Ini berbahaya untuk operasi bernilai tinggi.

6. Tidak memikirkan respons replay

Mengembalikan hasil yang berbeda pada retry dengan key sama membuat klien sulit membangun logika yang andal. Simpan hasil yang cukup untuk replay yang konsisten.

7. Menganggap retry selalu berasal dari klien

Proxy, load balancer, worker, broker pesan, dan operator juga dapat menyebabkan eksekusi ulang. Desain idempoten harus mempertimbangkan seluruh jalur sistem.

Debugging dan observability

Implementasi idempotency key akan jauh lebih mudah dioperasikan jika Anda menambahkan observability sejak awal:

  • Log terstruktur yang memuat idempotency_key, scope, fingerprint, dan status record.
  • Metric jumlah key baru, replay, conflict karena payload mismatch, dan request in-progress.
  • Tracing yang mengaitkan request API, transaksi database, outbox event, dan webhook.
  • Dashboard untuk melihat rasio replay terhadap request pertama, agar timeout dan retry yang terlalu sering bisa terdeteksi.

Jika ada laporan “order terbuat dua kali”, pertanyaan investigasi biasanya:

  1. Apakah dua request memakai key yang sama?
  2. Apakah ada mismatch payload?
  3. Apakah reservasi key atomik benar-benar bekerja?
  4. Apakah duplikasi terjadi di API utama atau di event/webhook turunannya?
  5. Apakah TTL sudah kedaluwarsa saat retry terjadi?

Kapan tidak cukup memakai idempotency key saja

Ada situasi di mana Anda tetap memerlukan kontrol tambahan:

  • Optimistic concurrency control untuk mencegah update menimpa perubahan orang lain.
  • Constraint unik bisnis untuk menjaga aturan domain.
  • Distributed lock pada operasi langka yang benar-benar harus serial, meski ini biasanya opsi terakhir karena kompleksitas dan risiko deadlock.
  • Saga/compensation bila operasi menyentuh banyak sistem dan tidak bisa dibuat atomik.

Idempotency key menyelesaikan satu masalah spesifik dengan sangat baik: retry dari operasi yang sama. Jangan membebankan semua persoalan konsistensi padanya.

Checklist implementasi produksi

  • Tentukan endpoint mana yang wajib memakai Idempotency-Key.
  • Definisikan scope key secara eksplisit: tenant/actor + endpoint atau operasi bisnis.
  • Wajibkan key unik per operasi logis, bukan per percobaan kirim.
  • Lakukan validasi dasar sebelum reservasi jika error tersebut tidak perlu disimpan.
  • Simpan request fingerprint dan tolak key yang sama dengan payload berbeda.
  • Gunakan insert/reservasi atomik dengan constraint unik.
  • Sediakan status in_progress untuk menangani retry paralel.
  • Simpan hasil terminal yang cukup untuk replay respons secara konsisten.
  • Tentukan TTL berdasarkan jendela retry dan risiko bisnis, bukan angka arbitrer.
  • Tambahkan cleanup untuk record kedaluwarsa.
  • Jangan andalkan idempotency key tanpa constraint domain yang relevan.
  • Gunakan outbox untuk event/webhook agar efek samping asinkron tidak dobel.
  • Buat konsumen webhook juga idempoten.
  • Tambahkan logging, metrics, tracing, dan alert untuk replay/conflict yang tidak normal.
  • Dokumentasikan perilaku status code dan kapan klien harus memakai key yang sama atau membuat key baru.

Penutup

Idempotency key adalah salah satu kontrak API paling sederhana tetapi paling bernilai untuk sistem besar: klien diberi cara tegas untuk mengatakan, “ini masih operasi yang sama, tolong jangan jalankan dua kali.” Saat timeout, koneksi putus, atau respons tidak pasti, kontrak ini menjaga endpoint write tetap aman tanpa memaksa klien menebak-nebak status operasi.

Jika Anda merancang POST yang memicu perubahan state penting, pikirkan idempotensi sejak awal, bukan setelah duplikasi pertama terjadi di produksi. Mulailah dari scope yang jelas, reservasi atomik, fingerprint payload, penyimpanan hasil replay, TTL yang realistis, dan outbox untuk efek samping lanjutan. Kontrak sederhana yang tegas hampir selalu lebih tahan lama daripada logika retry yang ambigu.