Desain API tahan retry untuk sinkronisasi ke sistem legacy bertujuan mencegah duplikasi data, konflik status, dan bug integrasi saat request atau webhook dikirim ulang. Pada integrasi dengan sistem lama, masalah ini lebih sering muncul karena timeout pendek, koneksi tidak stabil, kemampuan autentikasi terbatas, dan klien yang tidak selalu mendukung fitur modern seperti header khusus atau verifikasi tanda tangan yang kompleks.

Solusi utamanya bukan sekadar “boleh retry”, tetapi kontrak API yang memang dirancang idempoten, aturan deduplikasi yang jelas, payload yang bisa dievolusikan tanpa merusak kompatibilitas, serta mekanisme fallback untuk klien lama. Artikel ini membahas desain endpoint, status code, skema webhook, strategi deduplication, dan anti-pattern yang sering memicu duplikasi atau inkonsistensi saat sinkronisasi ke sistem legacy.

Masalah nyata pada sinkronisasi ke sistem legacy

Pada sistem modern, kita cenderung mengasumsikan koneksi relatif stabil, dukungan TLS memadai, header custom tersedia, dan format payload bisa diperbarui cepat. Asumsi ini sering gagal pada sistem legacy atau perangkat lama. Beberapa masalah yang paling umum:

  • Retry memicu duplikasi: klien mengirim ulang request karena timeout, padahal server sebenarnya sudah memproses request pertama.
  • Urutan event tidak stabil: event “dibuat”, “diubah”, lalu “dibatalkan” bisa tiba dalam urutan yang berbeda.
  • Timeout ambigu: pengirim tidak tahu apakah request gagal total atau berhasil tetapi responsnya tidak sempat diterima.
  • Payload berubah dari waktu ke waktu: sistem lama sering tidak toleran terhadap field baru, perubahan tipe data, atau nested object tambahan.
  • Autentikasi terbatas: beberapa klien lama hanya mendukung shared secret statis, basic auth, IP allowlist, atau bahkan parameter query.

Karena itu, desain API untuk integrasi lama harus berangkat dari prinsip berikut: setiap operasi penting harus aman terhadap pengiriman ulang, dan setiap event harus bisa diproses tanpa bergantung penuh pada urutan kedatangannya.

Prinsip dasar desain API tahan retry

1. Pisahkan operasi create, update, dan event ingestion

Kesalahan umum adalah satu endpoint dipakai untuk banyak makna, misalnya POST /sync untuk membuat data baru, mengubah data, dan menerima retry tanpa identitas unik. Lebih aman jika kontraknya tegas:

  • PUT /devices/{external_id} untuk upsert berdasarkan ID yang stabil dari sisi klien.
  • POST /orders hanya jika klien bisa mengirim idempotency key.
  • POST /webhooks/events untuk menerima event append-only dengan event ID unik.

Semakin jelas semantik endpoint, semakin kecil risiko retry menyebabkan state ganda.

2. Gunakan identitas bisnis yang stabil

Jika sistem legacy punya kunci unik yang konsisten, lebih baik gunakan itu sebagai pengenal utama integrasi daripada membuat ID acak baru di setiap request. Contoh:

  • Baik: external_order_id, device_serial_number, legacy_customer_code
  • Buruk: ID acak baru yang dibuat klien pada setiap percobaan kirim

Dengan identitas yang stabil, server dapat menerapkan upsert, deteksi duplikasi, dan rekonsiliasi data dengan lebih akurat.

3. Bedakan idempotency untuk command dan deduplication untuk event

Dua konsep ini terkait tetapi tidak identik:

  • Idempotency key cocok untuk operasi bergaya command, misalnya “buat transaksi” atau “kirim instruksi”. Tujuannya: request yang sama tidak menghasilkan efek ganda.
  • Deduplication by event ID cocok untuk event atau webhook. Tujuannya: event yang terkirim ulang tidak diproses dua kali.

Menggabungkan keduanya tanpa aturan jelas sering menghasilkan bug. Misalnya, event status yang sama dikirim dua kali tetapi dengan timestamp berbeda: apakah itu duplikat atau update baru? Jawabannya harus ditentukan pada level kontrak.

Desain kontrak request/response yang aman terhadap retry

Endpoint command dengan idempotency key

Untuk operasi yang menciptakan efek samping, POST masih valid asalkan klien mengirim kunci idempoten yang stabil untuk satu niat operasi.

POST /api/legacy/orders HTTP/1.1
Content-Type: application/json
Idempotency-Key: 5b8d8c71-legacy-00042
Authorization: Basic base64(user:pass)

{
  "external_order_id": "ORD-00042",
  "customer_code": "CUST-19",
  "amount": 150000,
  "currency": "IDR",
  "requested_at": "2026-08-19T10:15:00Z"
}

Respons pertama yang berhasil diproses:

HTTP/1.1 201 Created
Content-Type: application/json

{
  "status": "accepted",
  "resource_id": "a9f2c3",
  "external_order_id": "ORD-00042",
  "idempotency_key": "5b8d8c71-legacy-00042"
}

Jika request yang sama dikirim ulang karena timeout, server sebaiknya mengembalikan hasil yang konsisten, bukan membuat data baru:

HTTP/1.1 200 OK
Content-Type: application/json

{
  "status": "duplicate",
  "resource_id": "a9f2c3",
  "external_order_id": "ORD-00042",
  "idempotency_key": "5b8d8c71-legacy-00042"
}

Beberapa tim memilih selalu mengembalikan 201 untuk replay yang identik, beberapa memilih 200. Yang penting adalah konsisten dan terdokumentasi. Untuk integrasi legacy, respons yang stabil sering lebih penting daripada “kemurnian” semantik.

Aturan penting idempotency key

  • Harus unik untuk satu niat operasi, bukan unik per HTTP request.
  • Harus disimpan bersama hash payload atau representasi request yang relevan.
  • Jika kunci yang sama dipakai dengan payload berbeda, kembalikan error konflik.

Contoh respons saat key sama dipakai dengan isi berbeda:

HTTP/1.1 409 Conflict
Content-Type: application/json

{
  "error": "idempotency_key_reused_with_different_payload",
  "message": "Idempotency-Key sudah pernah dipakai untuk request lain"
}

Ini penting untuk mencegah bug halus ketika perangkat lama selalu mengirim key statis yang sama untuk semua transaksi.

Endpoint upsert berbasis identifier stabil

Jika klien tidak mampu mengirim header Idempotency-Key, pendekatan paling praktis adalah menggunakan PUT dengan identifier stabil pada path.

PUT /api/legacy/devices/SN-884422
Content-Type: application/json
Authorization: Basic base64(user:pass)

{
  "status": "active",
  "firmware_version": "1.0.7",
  "reported_at": "2026-08-19T10:20:00Z"
}

Dengan pola ini, retry terhadap request yang sama cenderung aman karena target resource-nya eksplisit. Namun, tetap ada trade-off: jika update harus bersifat append-only atau perlu audit setiap perubahan, PUT saja tidak cukup; Anda tetap perlu event log atau revisioning.

Strategi deduplication untuk webhook dan event

Gunakan event_id yang unik dan persisten

Pada webhook, pengirim harus menyertakan event_id yang unik secara global atau setidaknya unik dalam namespace pengirim.

POST /integrations/webhooks/legacy
Content-Type: application/json
X-Signature: sha256=...
X-Event-Id: evt-20260819-000987
X-Event-Type: order.updated
X-Event-Timestamp: 2026-08-19T10:25:12Z

{
  "event_version": "1",
  "event_id": "evt-20260819-000987",
  "event_type": "order.updated",
  "occurred_at": "2026-08-19T10:24:59Z",
  "data": {
    "external_order_id": "ORD-00042",
    "status": "paid",
    "sequence": 17
  }
}

Penerima webhook sebaiknya menyimpan event_id di tabel deduplication dengan status pemrosesan. Jika event yang sama masuk lagi, server dapat mengembalikan 200 OK atau 202 Accepted tanpa memproses ulang efek sampingnya.

Simpan status pemrosesan event

Deduplication yang baik tidak cukup hanya menyimpan “sudah pernah lihat event ini”. Simpan juga status seperti:

  • received
  • processing
  • processed
  • failed

Alasannya, retry bisa terjadi tepat ketika worker sedang memproses event pertama. Tanpa status transisional, dua worker dapat mengeksekusi efek samping yang sama.

Implementasi umum:

  1. Insert event_id dengan constraint unik.
  2. Jika insert berhasil, tandai received lalu proses.
  3. Jika insert gagal karena duplicate key, cek status sebelumnya.
  4. Jika status processed, balas sukses tanpa memproses ulang.
  5. Jika status processing, balas sukses atau 202 sesuai kontrak, jangan jalankan duplikat paralel.

Gunakan database constraint sebagai lapisan terakhir

Cache seperti Redis berguna untuk deduplication cepat, tetapi jangan bergantung penuh padanya jika integrasinya kritis. Constraint unik di database tetap penting sebagai source of truth. Kombinasi yang umum:

  • Redis untuk menahan burst retry jangka pendek
  • Database unique index untuk jaminan final

Jika hanya mengandalkan cache dengan TTL pendek, event yang datang terlambat bisa lolos sebagai request baru.

Menangani timeout dan respons asinkron

Jangan paksa pemrosesan berat di jalur request

Pada integrasi dengan perangkat atau sistem lama, timeout klien sering lebih pendek dari waktu proses bisnis Anda. Jika endpoint memerlukan validasi, transformasi, dan sinkronisasi ke beberapa backend, lebih aman gunakan pola accept fast, process async.

HTTP/1.1 202 Accepted
Content-Type: application/json

{
  "status": "accepted",
  "tracking_id": "trk-778812",
  "external_order_id": "ORD-00042"
}

Dengan pola ini:

  • Server hanya memvalidasi dasar, menyimpan request, lalu mengantrikan pekerjaan.
  • Klien bisa melakukan polling ke endpoint status jika webhook callback tidak tersedia.
  • Retry dari klien tetap aman jika dikombinasikan dengan idempotency key.

Tambahkan endpoint status untuk fallback

Jika webhook tidak dapat diandalkan atau perangkat tidak bisa menerima callback, sediakan endpoint status sederhana:

GET /api/legacy/requests/trk-778812
Authorization: Basic base64(user:pass)

Respons:

HTTP/1.1 200 OK
Content-Type: application/json

{
  "tracking_id": "trk-778812",
  "status": "processed",
  "resource_id": "a9f2c3",
  "last_updated_at": "2026-08-19T10:26:05Z"
}

Ini sangat membantu untuk klien lama yang tidak bisa memverifikasi tanda tangan webhook, tidak punya listener publik, atau berjalan di jaringan tertutup.

Urutan event tidak stabil: jangan bergantung pada arrival order

Salah satu sumber bug integrasi paling sulit dilacak adalah asumsi bahwa event datang sesuai urutan kejadian. Dalam praktiknya, event bisa tertunda, diulang, atau tiba terbalik.

Gunakan sequence number atau version per aggregate

Jika memungkinkan, sertakan sequence, revision, atau version per entitas bisnis, misalnya per order atau per device. Penerima dapat mengabaikan event yang versinya lebih tua dari state terakhir yang sudah diterapkan.

Contoh logika:

  • State order saat ini revision 17.
  • Event baru datang dengan revision 16.
  • Jangan overwrite state terbaru dengan data lama.

Jika sequence tidak tersedia, gunakan occurred_at dengan hati-hati. Timestamp rentan terhadap jam sistem yang tidak sinkron. Untuk sistem legacy, timestamp sebaiknya dianggap hint, bukan jaminan urutan absolut.

Bedakan event snapshot dan event delta

  • Snapshot event: membawa status terbaru lengkap; lebih mudah diproses out-of-order, tetapi payload lebih besar.
  • Delta event: hanya membawa perubahan; lebih hemat, tetapi lebih rentan rusak jika event hilang atau urutannya kacau.

Untuk integrasi ke platform lama, snapshot parsial yang ringkas sering lebih aman daripada delta murni.

Payload versioning yang kompatibel dengan klien lama

Jangan merusak parser lama dengan perubahan agresif

Banyak sistem legacy memiliki parser JSON yang rapuh: ada yang gagal jika menemukan field baru, ada yang sensitif pada null, ada yang tidak mendukung nested object yang lebih dalam. Karena itu, versioning payload harus eksplisit.

Pendekatan praktis:

  • Sertakan event_version atau schema_version di payload.
  • Pertahankan field lama selama masa transisi.
  • Hindari mengubah arti field yang sudah ada.
  • Tambahkan field baru sebagai opsional bila klien memang toleran.

Pilih strategi versioning yang sesuai

Pilihan yang umum:

  • Version di payload: cocok untuk webhook dan integrasi lama karena mudah diinspeksi tanpa negosiasi header.
  • Version di path seperti /v1/: mudah dipahami, tetapi bisa menggandakan endpoint terlalu cepat.
  • Version via header: rapi untuk API modern, tetapi kurang cocok jika klien lama sulit mengatur header custom.

Untuk konteks legacy, version di payload atau path biasanya lebih praktis daripada mengandalkan header khusus.

Autentikasi webhook dan API saat fitur modern tidak tersedia

Prioritas utama: integritas dan autentikasi minimum yang realistis

Idealnya webhook ditandatangani menggunakan HMAC berbasis shared secret. Namun, beberapa sistem lama tidak mendukung header tertentu, algoritma modern yang fleksibel, atau rotasi secret otomatis. Dalam kondisi ini, desain harus mengutamakan solusi yang bisa dijalankan secara konsisten.

Skema tanda tangan webhook yang sederhana dan stabil

Contoh skema yang praktis:

  • Shared secret per integrasi
  • String yang ditandatangani: timestamp + "." + raw_body
  • Header: X-Signature dan X-Timestamp
X-Timestamp: 2026-08-19T10:25:12Z
X-Signature: sha256=3b1f...

Verifikasi di server penerima:

  1. Ambil raw body asli, jangan JSON yang sudah di-parse ulang.
  2. Bangun string kanonik sesuai kontrak.
  3. Hitung HMAC dengan shared secret.
  4. Bandingkan dengan constant-time comparison.
  5. Tolak request jika timestamp terlalu jauh dari waktu server, kecuali memang ada toleransi lebih longgar untuk perangkat yang jamnya buruk.

Kesalahan umum adalah menandatangani JSON setelah diubah formatter atau serializer. Tanda tangan harus dihitung dari representasi yang sama persis antara pengirim dan penerima.

Fallback jika klien tidak mendukung header custom

Jika klien atau perangkat lama tidak bisa mengirim header custom, beberapa fallback yang masih masuk akal adalah:

  • Signature di query parameter untuk jaringan internal atau kanal yang benar-benar terkendali
  • Basic Auth ditambah allowlist IP
  • Shared token statis pada path terpisah khusus integrasi tertentu

Semua fallback ini lebih lemah daripada HMAC di header, tetapi kadang perlu demi kompatibilitas. Jika terpaksa memakainya:

  • Batasi scope token per klien
  • Rotasi secret secara berkala bila memungkinkan
  • Pisahkan endpoint per partner agar insiden tidak meluas
  • Tambahkan rate limit dan audit log yang detail

Contoh kontrak API dan webhook yang disarankan

Contoh kontrak command aman retry

POST /api/legacy/payments
Content-Type: application/json
Idempotency-Key: pay-legacy-20260819-001
Authorization: Basic base64(user:pass)

{
  "external_payment_id": "PAY-001",
  "external_order_id": "ORD-00042",
  "amount": 150000,
  "currency": "IDR",
  "paid_at": "2026-08-19T10:30:00Z",
  "schema_version": 1
}

Respons sukses:

HTTP/1.1 202 Accepted
Content-Type: application/json

{
  "status": "accepted",
  "tracking_id": "trk-pay-001",
  "idempotency_key": "pay-legacy-20260819-001"
}

Contoh kontrak webhook aman retry

POST /webhooks/legacy/payment-status
Content-Type: application/json
X-Webhook-Id: wh-00012001
X-Timestamp: 2026-08-19T10:31:00Z
X-Signature: sha256=ab12cd34...

{
  "event_version": 1,
  "event_id": "evt-pay-00012001",
  "event_type": "payment.processed",
  "occurred_at": "2026-08-19T10:30:58Z",
  "data": {
    "external_payment_id": "PAY-001",
    "status": "processed",
    "sequence": 8
  }
}

Respons penerima:

HTTP/1.1 200 OK
Content-Type: application/json

{
  "received": true,
  "event_id": "evt-pay-00012001"
}

Untuk webhook, respons minimal dan cepat lebih baik daripada memproses seluruh bisnis flow sebelum membalas.

Status code yang sebaiknya dipakai

  • 200 OK: request replay berhasil dikenali, atau webhook duplikat diterima tanpa error.
  • 201 Created: resource baru benar-benar dibuat sinkron pada request pertama.
  • 202 Accepted: request diterima untuk diproses asinkron.
  • 400 Bad Request: payload tidak valid secara sintaks atau field wajib hilang.
  • 401 Unauthorized: autentikasi gagal.
  • 403 Forbidden: kredensial valid tetapi tidak diizinkan.
  • 409 Conflict: idempotency key dipakai ulang dengan payload berbeda, atau conflict revision.
  • 422 Unprocessable Entity: payload valid secara format tetapi tidak lolos aturan bisnis.
  • 429 Too Many Requests: retry terlalu agresif.
  • 500/502/503: gangguan server atau dependency; klien boleh retry sesuai kebijakan.

Untuk integrasi legacy, dokumentasikan dengan jelas status mana yang aman untuk di-retry, dan mana yang harus dianggap final.

Checklist implementasi

  1. Tentukan operasi mana yang butuh idempotency key dan mana yang cukup dengan upsert.
  2. Pastikan ada identifier bisnis yang stabil dari sistem legacy.
  3. Simpan rekam jejak idempotency key beserta payload hash dan hasil respons.
  4. Gunakan unique constraint di database untuk event ID dan kunci bisnis utama.
  5. Simpan status pemrosesan event: received, processing, processed, failed.
  6. Rancang endpoint agar bisa membalas cepat lalu memproses asinkron jika perlu.
  7. Sediakan endpoint polling status sebagai fallback.
  8. Sertakan event_version atau schema_version yang eksplisit.
  9. Jangan bergantung pada urutan arrival; dukung sequence atau revision.
  10. Verifikasi tanda tangan webhook dari raw body.
  11. Siapkan fallback auth untuk klien lama, tetapi batasi risikonya dengan allowlist, rotasi secret, dan audit log.
  12. Dokumentasikan kebijakan retry: timeout, backoff, maximum attempts, dan status code yang boleh diulang.

Anti-pattern yang sering memicu bug integrasi

1. Menganggap timeout berarti request gagal

Ini salah paling klasik. Timeout hanya berarti klien tidak menerima kepastian. Bisa jadi server sudah sukses memproses. Tanpa idempotency, retry berikutnya berpotensi membuat duplikasi.

2. Menggunakan timestamp sebagai satu-satunya kunci deduplikasi

Clock skew pada perangkat lama bisa besar. Dua event berbeda bisa punya timestamp sama, atau event lama bisa tampak lebih baru.

3. Menaruh seluruh logika deduplikasi di cache

Cache membantu, tetapi tidak cukup untuk jaminan akhir. Restart, eviction, atau TTL habis dapat membuka celah duplikasi.

4. Mengubah payload tanpa versioning

Menambah field terlihat aman dari sisi server modern, tetapi bisa mematahkan parser lama. Perubahan skema harus eksplisit dan bertahap.

5. Mengembalikan error untuk webhook duplikat

Jika event yang sama diterima lagi, respons error hanya memicu retry tambahan dari pengirim. Lebih baik akui penerimaan dan abaikan efek sampingnya.

6. Memproses webhook langsung ke operasi bisnis tanpa inbox table

Webhook yang diproses langsung ke state final lebih rentan race condition. Pola inbox table atau event intake table jauh lebih aman untuk deduplikasi dan audit.

Debugging dan observability yang membantu

Integrasi retry sulit di-debug jika Anda tidak punya korelasi yang jelas. Minimal, log berikut perlu tersedia:

  • idempotency_key
  • event_id
  • external_id
  • tracking_id
  • payload_hash
  • processing_status
  • first_seen_at dan last_seen_at

Tambahkan metrik seperti jumlah replay request, webhook duplicate rate, conflict rate pada idempotency key, dan jumlah event out-of-order. Metrik ini sering lebih berguna daripada sekadar error rate, karena banyak bug integrasi terjadi walaupun respons HTTP tampak sukses.

Penutup

Desain API tahan retry untuk sinkronisasi ke sistem legacy bukan soal menambahkan satu header lalu selesai. Yang dibutuhkan adalah kontrak yang jelas: bagaimana klien mengidentifikasi niat operasi, bagaimana server mendeteksi duplikasi, bagaimana event diproses saat urutannya kacau, bagaimana payload berevolusi, dan bagaimana autentikasi tetap masuk akal saat fitur modern tidak tersedia.

Jika harus memilih prioritas, urutannya biasanya seperti ini: identifier stabil, idempotency atau deduplication yang terdokumentasi, pemrosesan asinkron dengan status yang bisa dipolling, versioning payload, lalu mekanisme autentikasi terbaik yang benar-benar didukung klien. Dengan pendekatan ini, integrasi ke platform lama tetap bisa andal meskipun lingkungan teknisnya jauh dari ideal.