Webhook retry aman berarti endpoint Anda tetap benar meskipun penyedia mengirim event yang sama berkali-kali, urutannya berubah, atau integrasi saling memicu tanpa henti. Solusinya bukan sekadar “cek apakah event pernah diproses”, tetapi merancang kontrak webhook, penyimpanan status, verifikasi keamanan, dan mekanisme kontrol retry sejak awal.
Dalam praktiknya, ada tiga sumber masalah yang paling sering: duplikasi karena retry otomatis, out-of-order delivery karena jaringan dan queue, serta loop integrasi ketika sistem A memicu B lalu B memicu A lagi. Artikel ini fokus pada pola backend API yang tahan terhadap kondisi tersebut, lengkap dengan alur, pseudo-code, tabel skenario gagal, checklist implementasi, dan anti-pattern yang perlu dihindari.
Mengapa webhook mudah rusak saat retry?
Kebanyakan penyedia webhook menganggap pengiriman berhasil hanya jika endpoint Anda merespons dalam jangka waktu tertentu dengan status HTTP sukses. Jika tidak, mereka akan melakukan retry. Dari sudut pandang penyedia, ini masuk akal. Dari sudut pandang penerima, efeknya bisa berbahaya bila endpoint tidak idempoten:
- Order dibuat dua kali karena event
payment.succeededdiproses ulang. - Status mundur karena event lama datang setelah event baru.
- Loop tak berujung karena setiap perubahan status mengirim webhook balik ke sistem asal.
- Storm pada infrastruktur karena event gagal terus di-retry tanpa pembatas.
Karena itu, desain webhook sebaiknya mengasumsikan kondisi berikut sebagai hal normal, bukan edge case:
- Event dapat dikirim lebih dari sekali.
- Event dapat datang tidak berurutan.
- Payload dapat valid tetapi sudah usang.
- Provider dan consumer sama-sama bisa melakukan retry.
- Integrasi multi-sistem dapat menghasilkan pola kejadian berulang yang mirip reaksi berantai.
Kontrak webhook yang perlu disepakati
Sebelum membahas kode, tentukan kontrak minimal agar endpoint punya cukup informasi untuk memutuskan apakah event perlu diterima, diabaikan, diulang, atau diblokir.
1. Event ID yang stabil
Setiap webhook sebaiknya memiliki event ID unik yang stabil di semua retry. Inilah kunci utama untuk deduplikasi. Jika provider mengirim payload yang sama tetapi ID berubah tiap retry, deduplikasi menjadi jauh lebih sulit.
Contoh field yang lazim dipakai:
event_idatauidsebagai identitas event.event_typesepertiinvoice.paidatauuser.updated.occurred_atataucreated_atsebagai waktu kejadian di sisi producer.source_systemuntuk menandai asal event.resource_idseperti ID order, invoice, atau user yang terdampak.
2. Signature verification
Jangan proses payload sebelum memverifikasi bahwa webhook benar berasal dari pengirim yang sah. Umumnya dilakukan dengan HMAC signature atas body mentah request. Ini penting bukan hanya untuk keamanan, tetapi juga untuk mencegah event palsu ikut masuk ke mekanisme retry dan queue Anda.
Catatan: Verifikasi signature hampir selalu membutuhkan raw request body, bukan hasil parsing JSON yang mungkin berubah formatnya. Simpan atau akses body mentah sebelum middleware mengubahnya.
3. Timestamp dan toleransi replay
Selain signature, banyak sistem menambahkan timestamp di header untuk membatasi replay attack. Event yang sangat lama dapat ditolak atau minimal diberi penanda “suspect”. Namun hati-hati: menolak event hanya karena keterlambatan juga bisa menyebabkan kehilangan data jika provider sedang mengalami backlog. Biasanya lebih aman membedakan antara:
- Terlambat tapi valid: simpan, lalu evaluasi terhadap status resource.
- Tidak valid secara kriptografis: tolak langsung.
4. Idempotency key di level operasi
Event ID dan idempotency key sering disamakan, padahal fungsinya berbeda. Event ID dipakai untuk mengenali satu kejadian webhook. Idempotency key dipakai untuk memastikan satu operasi bisnis tidak dieksekusi dua kali.
Contoh:
- Event
payment.captureddenganevent_id=evt_123diterima dua kali. - Operasi internal Anda adalah “buat entri ledger untuk pembayaran X”.
- Idempotency key operasi dapat berupa gabungan
payment_id + action.
Ini penting karena kadang dua event berbeda secara teknis tetap mengarah ke operasi bisnis yang sama.
Desain endpoint webhook yang tahan duplikasi
Pola yang paling aman adalah memisahkan fase penerimaan webhook dari fase pemrosesan bisnis.
Alur yang disarankan
- Terima request dan baca raw body.
- Verifikasi signature dan validasi field minimum.
- Simpan catatan event secara atomik berdasarkan
event_id. - Jika event baru, tandai
receivedlalu enqueue untuk diproses asynchronous. - Jika event duplikat, balas sukses tanpa memproses ulang.
- Worker memproses event, memperbarui status menjadi
processing,processed, ataufailed. - Jika gagal sementara, retry dengan backoff.
- Jika gagal permanen atau melewati batas, pindahkan ke dead-letter queue.
Respons HTTP cepat penting agar provider tidak menganggap webhook gagal hanya karena proses bisnis Anda lama. Biasanya endpoint penerimaan hanya melakukan verifikasi, deduplikasi, dan enqueue.
Status penyimpanan event
Simpan status event dalam tabel atau store yang dapat di-query dengan cepat. Minimal status yang berguna:
received: event valid dan sudah tercatat.processing: worker sedang menangani event.processed: selesai sukses.failed_retryable: gagal sementara, akan dicoba lagi.failed_terminal: gagal permanen, tidak akan diulang otomatis.dead_lettered: dipindah ke DLQ untuk investigasi/manual replay.ignored_stale: valid, tetapi diabaikan karena sudah usang.ignored_duplicate: event ID sudah pernah diterima.
Dengan status ini, tim operasional dapat membedakan event yang benar-benar hilang, yang masih antre, yang diabaikan sengaja, dan yang butuh intervensi.
Contoh skema penyimpanan
webhook_events
- event_id (unique)
- event_type
- source_system
- resource_id
- occurred_at
- payload_hash
- signature_valid
- status
- retry_count
- last_error
- first_received_at
- last_received_at
- processed_at
- causation_id (opsional)
- correlation_id (opsional)
payload_hash berguna untuk audit. Jika event_id sama tetapi payload berbeda, Anda bisa langsung menandainya sebagai kondisi anomali.
Pseudo-code endpoint penerimaan
function handleWebhook(request):
rawBody = request.rawBody
signature = request.headers["X-Signature"]
timestamp = request.headers["X-Timestamp"]
if not verifySignature(rawBody, signature, timestamp):
return HTTP 401
event = parseJson(rawBody)
if not hasRequiredFields(event):
return HTTP 400
result = insertEventIfAbsent(
event_id=event.id,
event_type=event.type,
source_system=event.source,
resource_id=event.resource_id,
occurred_at=event.occurred_at,
payload_hash=sha256(rawBody),
status="received",
signature_valid=true
)
if result == "duplicate":
markLastReceivedAt(event.id)
return HTTP 200
enqueue("process_webhook_event", event.id)
return HTTP 202
Fungsi penting di sini adalah insertEventIfAbsent. Implementasinya harus atomik, misalnya memakai unique constraint pada event_id. Jangan mengandalkan pola “cek dulu, lalu insert” tanpa proteksi database karena race condition bisa tetap menghasilkan duplikasi.
Idempotency di level worker, bukan hanya endpoint
Banyak implementasi berhenti di deduplikasi event masuk. Itu belum cukup. Worker yang memproses event juga harus idempoten, karena queue dapat mengirim job dua kali, worker bisa crash setelah menulis ke database, atau timeout bisa membuat job dianggap gagal padahal operasi eksternal sudah berjalan.
Contoh masalah nyata
Misalkan worker menerima event invoice.paid, lalu:
- Menyimpan status invoice = paid.
- Membuat entri ledger.
- Mengirim email.
Jika worker crash setelah langkah 2 tetapi sebelum menandai event selesai, retry berikutnya bisa membuat entri ledger ganda. Karena itu, setiap efek samping penting perlu punya kunci idempoten sendiri.
Pseudo-code worker idempoten
function processWebhookEvent(eventId):
event = loadEventForUpdate(eventId)
if event.status == "processed":
return
setStatus(eventId, "processing")
try:
if isStaleEvent(event):
setStatus(eventId, "ignored_stale")
return
operationKey = "ledger:" + event.resource_id + ":paid"
createLedgerEntryIfAbsent(operationKey, event)
updateInvoiceStateSafely(event.resource_id, expectedTransition="unpaid->paid")
sendEmailIfAbsent("email:" + event.resource_id + ":paid")
setStatus(eventId, "processed")
except TemporaryError as err:
scheduleRetryWithBackoff(eventId)
setFailure(eventId, "failed_retryable", err)
except PermanentError as err:
setFailure(eventId, "failed_terminal", err)
Dua prinsip penting terlihat di atas:
- State transition yang aman: jangan update status resource secara buta; validasi transisi yang diizinkan.
- Efek samping idempoten: ledger, email, atau panggilan API keluar perlu diberi kunci operasi unik.
Menangani out-of-order delivery tanpa merusak state
Webhook tidak selalu datang sesuai urutan kejadian. Event order.shipped bisa tiba sebelum order.paid, atau event pembaruan lama datang setelah versi data terbaru. Jika Anda hanya “menimpa status terakhir”, data mudah korup.
Pendekatan yang umum dipakai
- Bandingkan versi atau sequence number jika producer menyediakannya.
- Gunakan occurred_at sebagai petunjuk, tetapi jangan anggap sempurna jika clock antar sistem bisa berbeda.
- Validasi state transition di sisi consumer.
- Ambil ulang state terbaru dari producer bila event hanya berfungsi sebagai notifikasi perubahan.
Kapan fetch state terbaru lebih aman?
Jika payload webhook tidak lengkap atau urutan event sering bermasalah, gunakan webhook sebagai sinyal untuk melakukan reconciliation fetch. Misalnya, saat menerima customer.updated, endpoint tidak langsung menimpa seluruh profil lokal. Sebaliknya, ia menjadwalkan sinkronisasi dari API sumber lalu menerapkan perubahan berdasarkan versi terbaru yang didapat.
Trade-off-nya:
- Pro: lebih tahan terhadap event usang dan payload parsial.
- Kontra: menambah latensi, beban API sumber, dan ketergantungan pada ketersediaan upstream.
Deteksi loop integrasi yang praktis
Loop terjadi ketika satu event memicu perubahan di sistem lain, lalu perubahan itu menghasilkan webhook balik yang memicu perubahan yang sama lagi. Polanya mirip reaksi berulang: kejadian yang tampak valid terus muncul dalam bentuk sangat serupa sampai sistem kewalahan.
Tanda-tanda loop
- Resource yang sama berubah bolak-balik dalam waktu singkat.
- Event type yang sama berulang dengan korelasi kuat antar dua sistem.
- Volume retry naik bersamaan di dua arah integrasi.
- Payload berubah sangat sedikit, tetapi tetap memicu update penuh.
Strategi pencegahan loop
- Source tagging: simpan asal perubahan, misalnya
updated_by=integration_x. - Causation ID dan correlation ID: teruskan identitas pemicu antar sistem.
- Ignore self-originated events: jika event berasal dari perubahan yang Anda buat sendiri, jangan kirim balik update yang identik.
- Change detection: kirim event hanya jika ada perubahan material, bukan setiap write.
- Loop threshold: blokir sementara jika pola kejadian yang sama melebihi ambang dalam jendela waktu tertentu.
Contoh aturan deteksi loop
function shouldBlockAsLoop(event):
fingerprint = hash(
event.source_system,
event.event_type,
event.resource_id,
normalizeImportantFields(event.payload)
)
count = incrementCounter(fingerprint, window=5 minutes)
if count > LOOP_THRESHOLD:
return true
return false
Fingerprint tidak perlu memakai seluruh payload. Fokuskan pada field yang merepresentasikan perubahan bisnis utama. Jika seluruh payload dipakai mentah, perbedaan kecil yang tidak material bisa membuat loop tidak terdeteksi.
Prinsip praktis: jangan hanya mengandalkan satu mekanisme. Deduplikasi event ID mencegah retry identik, tetapi loop sering melibatkan event berbeda dengan makna bisnis yang sama. Karena itu perlu kombinasi event ID, source tagging, dan fingerprint perubahan.
Retry yang sehat: exponential backoff, dead-letter, dan circuit breaker
Retry memang perlu, tetapi retry yang tidak dikendalikan justru menciptakan storm. Saat downstream sedang rusak, ribuan event yang gagal bersamaan bisa memenuhi queue, membebani database, dan memperparah outage.
Exponential backoff dengan jitter
Retry sebaiknya menggunakan jeda yang makin panjang, bukan interval tetap. Tambahkan jitter agar banyak worker tidak menyerbu lagi pada detik yang sama.
Contoh urutan yang wajar secara umum:
- Retry 1: beberapa detik
- Retry 2: puluhan detik
- Retry 3: beberapa menit
- Retry berikutnya: makin jarang hingga batas maksimum
Jangan mengunci diri pada angka tertentu jika kebutuhan sistem belum jelas. Yang penting adalah pola meningkat dan ada batas atas.
Bedakan error sementara dan permanen
Retry hanya masuk akal untuk kegagalan sementara, misalnya timeout jaringan, koneksi database sesaat, atau upstream 5xx. Untuk kegagalan permanen seperti payload tidak valid, signature salah, atau resource referensi tidak akan pernah ada, retry otomatis hanya membuang sumber daya.
Dead-letter queue
Setelah melewati jumlah retry maksimum, pindahkan event ke dead-letter queue atau status terminal yang mudah diaudit. DLQ penting agar event bermasalah tidak hilang diam-diam dan tidak ikut menyumbat jalur utama.
Yang sebaiknya tersedia untuk DLQ:
- Payload asli atau referensinya.
- Riwayat error terakhir.
- Jumlah retry.
- Waktu pertama dan terakhir gagal.
- Mekanisme replay manual yang aman.
Circuit breaker untuk mencegah storm
Jika downstream tertentu sedang gagal total, worker sebaiknya tidak terus mencoba operasi yang hampir pasti gagal. Circuit breaker membantu dengan cara:
- Closed: request berjalan normal.
- Open: request baru ditolak cepat untuk sementara waktu.
- Half-open: sebagian kecil request diuji untuk melihat apakah downstream pulih.
Dalam konteks webhook, circuit breaker mencegah seluruh worker terus memukul layanan yang sedang down. Event bisa ditunda, diantre ulang, atau dialihkan ke jalur degradasi yang lebih aman.
Contoh alur end-to-end
Berikut contoh alur untuk event payment.succeeded:
- Provider mengirim webhook dengan
event_id=evt_9001. - Endpoint memverifikasi signature pada raw body.
- Database mencoba insert
evt_9001dengan statusreceived. - Jika unique constraint bentrok, event dianggap duplikat dan endpoint mengembalikan sukses.
- Job dipush ke queue untuk memproses
evt_9001. - Worker memuat event dan mengunci row terkait.
- Worker mengecek apakah invoice sudah paid; jika ya, tidak membuat efek samping ganda.
- Worker membuat ledger entry dengan operation key unik.
- Worker mengirim notifikasi hanya jika belum pernah dikirim.
- Status event diubah ke
processed. - Jika API notifikasi eksternal timeout, worker menandai
failed_retryabledan menjadwalkan retry dengan backoff. - Jika pola event yang sama muncul terlalu sering dari dua sistem yang saling memicu, guard loop mengaktifkan blok sementara dan mengirim alert.
Tabel skenario gagal dan respons yang disarankan
| Skenario | Risiko | Respons yang disarankan |
|---|---|---|
| Signature tidak valid | Webhook palsu atau payload dimodifikasi | Tolak dengan 401/403, jangan enqueue, log audit seperlunya |
| Event ID sama, payload sama | Duplikasi akibat retry | Balas sukses, tandai ignored_duplicate atau perbarui last_received_at |
| Event ID sama, payload berbeda | Anomali producer atau serangan | Tandai suspicious, jangan proses otomatis sampai diinvestigasi |
| Event valid tetapi datang terlambat | State mundur atau tertimpa event usang | Bandingkan versi/waktu, validasi transisi, bisa tandai ignored_stale |
| Worker crash setelah efek samping parsial | Operasi ganda saat retry | Gunakan idempotency key per operasi dan checkpoint status |
| Downstream 5xx/timeout | Retry storm dan antrean menumpuk | Retry dengan exponential backoff + jitter, aktifkan circuit breaker |
| Payload tidak lengkap untuk memutuskan state | Keputusan salah di sisi consumer | Gunakan webhook sebagai sinyal lalu fetch state terbaru dari sumber |
| Loop A → B → A | Storm, data berulang, biaya tinggi | Source tagging, correlation/causation ID, change detection, loop threshold |
| Retry melebihi batas | Event macet tanpa penyelesaian | Pindahkan ke DLQ, sediakan replay manual dan alert |
Anti-pattern yang sering terjadi
1. Memproses event sebelum verifikasi
Ini kesalahan yang paling berbahaya. Jika parsing dan pemrosesan dilakukan sebelum signature diverifikasi, Anda membuka jalan bagi event palsu untuk memicu write ke database atau queue.
2. Cek duplikasi hanya di memori
Cache in-memory dapat membantu performa, tetapi jangan jadikan satu-satunya sumber kebenaran. Restart service atau scale-out instance akan membuat deduplikasi bocor. Tetap perlukan store persisten dengan constraint unik.
3. Retry tanpa batas
Retry tak terbatas untuk error permanen hanya menambah biaya dan noise. Selalu tentukan batas percobaan dan jalur terminal seperti DLQ.
4. Menimpa state tanpa validasi transisi
Jika setiap event boleh langsung menulis status terbaru, event terlambat dapat merusak data. Gunakan aturan transisi atau versi resource.
5. Menganggap HTTP 200 berarti bisnis sukses
Pada banyak desain yang sehat, endpoint mengembalikan sukses hanya untuk menandakan webhook diterima dan valid, bukan seluruh proses bisnis selesai. Ini bukan masalah, asalkan status pemrosesan bisa dilacak secara internal.
6. Tidak menyimpan konteks debugging
Jika hanya menyimpan “gagal” tanpa payload hash, waktu kejadian, retry count, atau error terakhir, investigasi akan lambat. Simpan metadata yang cukup sejak awal.
Tips debugging dan observabilitas
- Log dengan event_id, resource_id, correlation_id, dan status.
- Buat metrik untuk jumlah event masuk, duplicate rate, retry rate, DLQ rate, dan loop-block count.
- Pantau distribusi latensi antara penerimaan webhook dan selesai diproses.
- Alert jika duplicate rate atau retry rate melonjak tajam.
- Sediakan endpoint internal atau dashboard untuk menelusuri status satu event dari awal sampai akhir.
Jika terjadi anomali, pertanyaan debug yang berguna antara lain:
- Apakah event ini benar duplikat atau event berbeda dengan efek bisnis sama?
- Apakah event diproses sebelum signature diverifikasi?
- Apakah retry berasal dari provider, queue internal, atau keduanya?
- Apakah state resource berubah karena event usang?
- Apakah dua sistem sedang saling memantulkan perubahan yang identik?
Checklist implementasi webhook retry aman
- Verifikasi signature menggunakan raw body sebelum parsing lanjutan.
- Validasi field minimum: event ID, type, source, timestamp/resource ID sesuai kebutuhan.
- Simpan event dengan unique constraint pada event ID.
- Balas cepat setelah verifikasi dan deduplikasi; proses bisnis di queue.
- Gunakan status event yang eksplisit: received, processing, processed, failed, dead-lettered, dan lain-lain.
- Terapkan idempotency key pada operasi bisnis penting, bukan hanya di endpoint.
- Validasi state transition untuk mencegah event usang menimpa data baru.
- Tentukan strategi out-of-order: sequence, timestamp, atau fetch state terbaru.
- Tambahkan source tagging, correlation ID, dan fingerprint untuk deteksi loop.
- Gunakan exponential backoff dengan jitter untuk retry sementara.
- Bedakan error retryable dan terminal.
- Sediakan dead-letter queue dan replay manual yang aman.
- Pasang circuit breaker untuk downstream yang gagal terus-menerus.
- Tambahkan metrik, logging terstruktur, dan alert operasional.
- Uji skenario duplikasi, keterlambatan, crash di tengah proses, dan loop integrasi.
Penutup
Webhook retry aman tidak dibangun dengan satu trik tunggal. Anda membutuhkan kombinasi kontrak event yang baik, verifikasi signature, penyimpanan status yang jelas, idempotency di level event dan operasi bisnis, penanganan out-of-order, serta kontrol retry yang disiplin. Untuk loop integrasi, deduplikasi saja tidak cukup; perlu deteksi pola kejadian berulang berdasarkan sumber, korelasi, dan fingerprint perubahan.
Jika harus memulai dari versi minimum yang efektif, lakukan empat hal ini dulu: verifikasi signature sebelum memproses apa pun, simpan event ID secara atomik, enqueue pemrosesan asynchronous, dan pastikan operasi bisnis idempoten. Setelah itu, tambahkan backoff, DLQ, circuit breaker, dan guard loop agar sistem tetap stabil saat kondisi buruk benar-benar terjadi.
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!