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.succeeded diproses 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_id atau id sebagai identitas event.
  • event_type seperti invoice.paid atau user.updated.
  • occurred_at atau created_at sebagai waktu kejadian di sisi producer.
  • source_system untuk menandai asal event.
  • resource_id seperti 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.captured dengan event_id=evt_123 diterima 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

  1. Terima request dan baca raw body.
  2. Verifikasi signature dan validasi field minimum.
  3. Simpan catatan event secara atomik berdasarkan event_id.
  4. Jika event baru, tandai received lalu enqueue untuk diproses asynchronous.
  5. Jika event duplikat, balas sukses tanpa memproses ulang.
  6. Worker memproses event, memperbarui status menjadi processing, processed, atau failed.
  7. Jika gagal sementara, retry dengan backoff.
  8. 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:

  1. Menyimpan status invoice = paid.
  2. Membuat entri ledger.
  3. 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

  1. Source tagging: simpan asal perubahan, misalnya updated_by=integration_x.
  2. Causation ID dan correlation ID: teruskan identitas pemicu antar sistem.
  3. Ignore self-originated events: jika event berasal dari perubahan yang Anda buat sendiri, jangan kirim balik update yang identik.
  4. Change detection: kirim event hanya jika ada perubahan material, bukan setiap write.
  5. 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:

  1. Provider mengirim webhook dengan event_id=evt_9001.
  2. Endpoint memverifikasi signature pada raw body.
  3. Database mencoba insert evt_9001 dengan status received.
  4. Jika unique constraint bentrok, event dianggap duplikat dan endpoint mengembalikan sukses.
  5. Job dipush ke queue untuk memproses evt_9001.
  6. Worker memuat event dan mengunci row terkait.
  7. Worker mengecek apakah invoice sudah paid; jika ya, tidak membuat efek samping ganda.
  8. Worker membuat ledger entry dengan operation key unik.
  9. Worker mengirim notifikasi hanya jika belum pernah dikirim.
  10. Status event diubah ke processed.
  11. Jika API notifikasi eksternal timeout, worker menandai failed_retryable dan menjadwalkan retry dengan backoff.
  12. 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

SkenarioRisikoRespons yang disarankan
Signature tidak validWebhook palsu atau payload dimodifikasiTolak dengan 401/403, jangan enqueue, log audit seperlunya
Event ID sama, payload samaDuplikasi akibat retryBalas sukses, tandai ignored_duplicate atau perbarui last_received_at
Event ID sama, payload berbedaAnomali producer atau seranganTandai suspicious, jangan proses otomatis sampai diinvestigasi
Event valid tetapi datang terlambatState mundur atau tertimpa event usangBandingkan versi/waktu, validasi transisi, bisa tandai ignored_stale
Worker crash setelah efek samping parsialOperasi ganda saat retryGunakan idempotency key per operasi dan checkpoint status
Downstream 5xx/timeoutRetry storm dan antrean menumpukRetry dengan exponential backoff + jitter, aktifkan circuit breaker
Payload tidak lengkap untuk memutuskan stateKeputusan salah di sisi consumerGunakan webhook sebagai sinyal lalu fetch state terbaru dari sumber
Loop A → B → AStorm, data berulang, biaya tinggiSource tagging, correlation/causation ID, change detection, loop threshold
Retry melebihi batasEvent macet tanpa penyelesaianPindahkan 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.