Kontrak webhook yang tahan retry dan event duplikat tidak cukup hanya dengan mengirim HTTP POST ke endpoint partner. Dalam praktiknya, webhook bisa terkirim lebih dari sekali, datang tidak berurutan, terlambat, atau gagal di tengah jalan setelah sebagian proses di receiver sudah berjalan. Jika kontraknya lemah, integrasi mudah menghasilkan data ganda, status tidak sinkron, atau bug yang sulit direproduksi.

Solusinya adalah mendesain kontrak webhook sebagai delivery protocol yang eksplisit: setiap event punya identitas unik, payload cukup stabil untuk diproses aman, request bisa diverifikasi dengan signature/HMAC, receiver bisa melakukan deduplikasi, dan sender punya aturan retry yang jelas. Artikel ini fokus pada keputusan desain yang berguna untuk backend engineer yang membangun integrasi pihak ketiga.

Mengapa webhook sering gagal dengan cara yang tidak terlihat

Masalah paling umum pada webhook bukan hanya koneksi putus. Ada beberapa pola kegagalan yang harus dianggap normal:

  • Retry otomatis: sender mengirim ulang karena timeout atau menerima status non-2xx.
  • Event duplikat: event yang sama terkirim lebih dari sekali, baik karena retry maupun bug internal sender.
  • Out-of-order delivery: event status terbaru bisa tiba lebih dulu daripada event sebelumnya.
  • Kegagalan parsial: receiver sudah menyimpan perubahan, tetapi respons HTTP gagal dikirim kembali ke sender.
  • Reprocessing internal: receiver memproses ulang job queue sehingga efek samping terjadi dua kali.

Karena itu, webhook sebaiknya dianggap at-least-once delivery, bukan exactly-once delivery. Artinya, kontrak harus dirancang agar menerima kenyataan bahwa event dapat datang lebih dari sekali dan tetap aman diproses.

Komponen inti kontrak webhook yang tahan retry

1. Event ID unik dan stabil

Setiap event harus memiliki event ID unik yang tidak berubah antar retry. Ini adalah fondasi deduplikasi. Jika sender mengirim ulang event yang sama, nilai event_id harus tetap identik.

Contoh header dan payload yang umum dipakai:

POST /webhooks/order HTTP/1.1
Content-Type: application/json
X-Webhook-Event-Id: evt_01J8X9K7A2N4
X-Webhook-Timestamp: 1731485152
X-Webhook-Signature: sha256=4f4d...abcd
X-Webhook-Event-Type: order.paid

{
  "id": "evt_01J8X9K7A2N4",
  "type": "order.paid",
  "created_at": "2024-11-13T10:45:52Z",
  "data": {
    "order_id": "ord_12345",
    "status": "paid",
    "customer_id": "cus_789"
  }
}

Prinsip penting:

  • event_id mewakili kejadian yang dikirim, bukan request HTTP-nya.
  • Retry atas event yang sama tidak boleh menghasilkan ID baru.
  • ID harus cukup unik secara global dalam sistem sender.

2. Signature/HMAC untuk verifikasi integritas dan asal request

Webhook tidak boleh dipercaya hanya karena datang dari internet ke endpoint yang benar. Minimal, request perlu diverifikasi dengan HMAC signature menggunakan secret bersama antara sender dan receiver.

Pola yang umum:

  1. Sender membuat string yang akan ditandatangani, misalnya gabungan timestamp + '.' + raw_body.
  2. Sender menghitung HMAC-SHA256 menggunakan secret endpoint.
  3. Signature dikirim di header.
  4. Receiver menghitung ulang signature dari raw body dan membandingkannya dengan constant-time comparison.

Contoh pseudocode verifikasi:

signed_payload = timestamp + "." + raw_body
expected = HMAC_SHA256(secret, signed_payload)
if !constant_time_equal(expected, signature_from_header):
    return 401

Mengapa memakai raw body? Karena jika receiver mem-parse JSON lalu menyusunnya kembali, format whitespace atau urutan field bisa berubah dan signature tidak lagi cocok.

Catatan: signature memverifikasi bahwa payload tidak berubah dan berasal dari pihak yang memegang secret. Ini tidak otomatis mencegah replay attack. Untuk itu, gunakan timestamp dan validasi jendela waktu.

3. Timestamp untuk membatasi replay

Tambahkan timestamp pada header atau payload yang ikut ditandatangani. Receiver memeriksa apakah request masih berada dalam jendela waktu yang diterima, misalnya beberapa menit.

Manfaatnya:

  • Mengurangi risiko request lama diputar ulang oleh pihak yang tidak sah.
  • Membantu debugging urutan dan umur request.
  • Memberi konteks apakah keterlambatan berasal dari jaringan atau antrian internal sender.

Trade-off-nya, receiver dan sender perlu jam sistem yang cukup sinkron. Karena itu, jangan membuat jendela terlalu ketat jika lingkungan deploy beragam.

4. Payload minimal namun stabil

Webhook yang baik bukan payload terbesar, melainkan payload yang cukup untuk diproses dan stabil dalam jangka panjang. Hindari memasukkan terlalu banyak field yang mudah berubah formatnya jika receiver sebenarnya hanya butuh sebagian kecil data.

Struktur yang biasanya aman:

  • id: ID event unik
  • type: jenis event, misalnya invoice.paid
  • created_at: waktu event dibuat di sender
  • data: objek inti yang relevan
  • resource_id atau ID domain utama di dalam data
  • version bila Anda memang mengelola evolusi skema secara eksplisit

Hindari beberapa kesalahan umum:

  • Mengirim seluruh snapshot objek besar tanpa kebutuhan jelas.
  • Mengubah nama field lama tanpa periode kompatibilitas.
  • Mengandalkan urutan field JSON.
  • Mencampur metadata delivery dengan data bisnis tanpa pemisahan jelas.

Jika resource lengkap bisa diambil lewat API, pendekatan yang sering efektif adalah mengirim payload ringkas plus ID resource. Ini membuat kontrak webhook lebih stabil, walau trade-off-nya receiver perlu melakukan fetch tambahan bila butuh detail lebih banyak.

Status code respons: apa arti 2xx, 4xx, dan 5xx untuk sender

Kontrak webhook harus mendefinisikan dengan jelas bagaimana sender menafsirkan respons receiver.

Respons yang sebaiknya dianggap sukses

Secara umum, 2xx berarti event diterima. Untuk webhook, sering kali yang paling aman adalah receiver merespons cepat setelah validasi dasar dan pencatatan event, lalu memproses sisanya secara asynchronous.

  • 200 OK: event diterima dan boleh dianggap sukses.
  • 202 Accepted: event diterima untuk diproses async. Ini sangat cocok jika receiver mengantrikan job internal.
  • 204 No Content: event diterima, tidak ada body respons.

Pilih satu pola dan dokumentasikan. Dari sisi sender, semua 2xx biasanya perlu diperlakukan sebagai terminal success, artinya jangan retry lagi.

Kapan sender perlu retry

Biasanya sender perlu retry jika menerima:

  • 5xx: receiver mengalami error sementara.
  • 429: receiver sedang melakukan rate limit, jika kontrak mengizinkan retry.
  • timeout atau koneksi gagal.

Untuk 4xx, banyak kasus sebaiknya tidak di-retry otomatis karena ini menandakan masalah permanen, misalnya signature salah, endpoint tidak valid, atau payload ditolak. Namun ada pengecualian tergantung kontrak. Yang penting: dokumentasikan keputusan ini dengan tegas.

Prinsip praktis untuk receiver

Receiver jangan mengembalikan 2xx jika event belum disimpan sama sekali dan berisiko hilang. Minimum yang aman biasanya:

  1. Verifikasi signature dan timestamp.
  2. Cek deduplikasi awal berdasarkan event ID.
  3. Simpan jejak penerimaan event.
  4. Masukkan ke queue atau transaksi lokal yang menjamin event tidak hilang.
  5. Baru kembalikan 2xx.

Aturan retry sender yang sehat

Sender perlu mempunyai kebijakan retry yang eksplisit. Tanpa itu, receiver sulit memprediksi pola traffic dan menyiapkan deduplikasi yang benar.

Rekomendasi umum:

  • Gunakan exponential backoff dengan jitter agar retry tidak menumpuk serempak.
  • Batasi total durasi retry atau jumlah percobaan.
  • Jangan buat retry terlalu agresif untuk semua kegagalan.
  • Gunakan event ID yang sama di semua percobaan retry.
  • Catat setiap percobaan pengiriman untuk audit dan debugging.

Contoh urutan retry yang masuk akal bisa berupa jeda yang makin panjang. Tidak perlu mendokumentasikan angka terlalu presisi jika belum stabil, tetapi Anda perlu mendokumentasikan prinsipnya: kapan retry dilakukan, kapan dihentikan, dan apakah ada dashboard atau dead-letter handling untuk delivery yang gagal total.

Deduplikasi di receiver: fondasi agar event duplikat tidak merusak data

Strategi minimum yang sebaiknya ada

Receiver perlu menyimpan jejak event yang pernah diterima. Setidaknya simpan:

  • event_id
  • event_type
  • waktu diterima
  • status pemrosesan, misalnya received, processing, processed, failed
  • hash payload atau raw payload bila diperlukan untuk audit
  • response/result internal terakhir

Pola implementasi umum:

  1. Terima request.
  2. Verifikasi signature dan timestamp.
  3. Insert record event berdasarkan unique key pada event_id.
  4. Jika insert gagal karena duplicate key, anggap event sudah pernah diterima.
  5. Kembalikan 2xx jika event lama memang sudah atau sedang diproses dengan aman.
  6. Jika insert berhasil, lanjutkan enqueue job processing.

Contoh skema tabel sederhana:

CREATE TABLE webhook_events (
  event_id        VARCHAR(64) PRIMARY KEY,
  event_type      VARCHAR(100) NOT NULL,
  received_at     TIMESTAMP NOT NULL,
  status          VARCHAR(20) NOT NULL,
  payload_hash    VARCHAR(128),
  resource_id     VARCHAR(64),
  last_error      TEXT
);

Kunci utamanya bukan struktur tabel tertentu, melainkan adanya constraint unik pada event ID. Tanpa itu, dua request paralel bisa lolos cek deduplikasi dan diproses dua kali.

Deduplikasi transport vs idempotensi bisnis

Deduplikasi berdasarkan event ID melindungi dari pengiriman ulang event yang sama. Namun itu belum tentu cukup jika dua event berbeda bisa menghasilkan efek bisnis yang sama.

Contoh:

  • payment.captured dengan event_id berbeda tetapi merujuk ke transaksi pembayaran yang sama karena bug sender.
  • Dua event berbeda yang sama-sama mencoba menandai order sebagai paid.

Karena itu, selain deduplikasi transport, receiver sering tetap perlu idempotensi pada level domain, misalnya dengan aturan: jika order sudah paid, jangan buat invoice baru lagi.

Kapan memakai event ID, kapan memakai idempotency key

Keduanya sering tercampur, padahal fungsinya berbeda.

Pakai event ID jika Anda ingin mengidentifikasi satu webhook event

Event ID dipakai untuk:

  • Deduplikasi pengiriman webhook yang sama
  • Audit log delivery
  • Melacak retry per event
  • Mengikat semua percobaan HTTP ke satu kejadian logis

Jika sender me-retry event yang sama, event ID harus tetap sama.

Pakai idempotency key jika Anda ingin mengendalikan efek dari suatu operasi

Idempotency key lebih cocok untuk menjamin bahwa sebuah operasi bisnis atau API mutation tidak dieksekusi lebih dari sekali. Ini umum pada endpoint POST /payments, POST /payouts, atau callback yang memicu side effect tertentu.

Contoh perbedaan:

  • Event ID: “ini event webhook yang sama seperti percobaan kemarin”.
  • Idempotency key: “jalankan operasi bisnis ini paling banyak sekali”.

Pada receiver webhook, Anda bisa memakai keduanya:

  • Gunakan event_id untuk deduplikasi ingress event.
  • Gunakan idempotency key internal saat memanggil service downstream yang berisiko menciptakan efek samping ganda.

Misalnya, webhook order.paid yang sama jangan hanya dideduplikasi di layer HTTP, tetapi juga saat memanggil service pembuatan invoice atau pengiriman email.

Menangani out-of-order delivery tanpa membuat status mundur

Webhook tidak selalu tiba sesuai urutan kejadian. Karena itu, receiver tidak boleh mengasumsikan event terbaru datang belakangan.

Ada beberapa strategi:

1. Gunakan versi atau urutan resource jika tersedia

Jika sender bisa menyertakan resource_version, receiver dapat menolak event yang versinya lebih lama daripada yang sudah diproses.

2. Gunakan aturan transisi status yang aman

Jika tidak ada versi eksplisit, bangun state machine domain. Contoh:

  • paid tidak boleh ditimpa kembali menjadi pending.
  • cancelled mungkin terminal dan tidak boleh dibalik tanpa event kompensasi khusus.

Pendekatan ini lebih aman daripada sekadar “event terbaru berdasarkan timestamp menang”, karena timestamp delivery belum tentu merepresentasikan urutan bisnis sebenarnya.

3. Fetch state sumber jika event hanya notifikasi

Jika konsistensi sangat penting, sender dapat mendesain webhook sebagai sinyal perubahan dan receiver mengambil state terbaru dari API sumber sebelum memutuskan update. Ini menambah latensi dan beban jaringan, tetapi mengurangi risiko state lokal menjadi usang.

Menangani kegagalan parsial dan timeout setelah proses sukses

Edge case yang sangat sering terjadi adalah:

  1. Receiver menerima request.
  2. Receiver berhasil menyimpan perubahan atau menjalankan side effect.
  3. Koneksi putus atau respons 2xx tidak pernah sampai ke sender.
  4. Sender menganggap gagal lalu me-retry event yang sama.

Jika receiver tidak idempotent, efek samping akan terjadi dua kali.

Solusi praktisnya:

  • Simpan jejak event sebelum side effect berat.
  • Pastikan side effect utama punya proteksi idempotensi domain.
  • Jika event yang sama datang lagi dan statusnya sudah processed, balas 2xx tanpa mengulang efek samping.
  • Jika statusnya processing, balas sesuai strategi sistem Anda; banyak sistem memilih tetap 2xx jika ada jaminan worker akan menyelesaikan proses.

Contoh alur yang relatif aman:

1. Verify signature + timestamp
2. INSERT webhook_events(event_id, status='received')
3. If duplicate event_id:
     - return 200/204 because event already accepted
4. Enqueue job with event_id
5. return 202

Worker:
6. Mark status='processing'
7. Apply business logic idempotently
8. Mark status='processed'

Keuntungan pola ini adalah timeout pada fase worker tidak mengubah fakta bahwa event sudah diterima secara aman oleh receiver.

Contoh desain kontrak yang praktis

Header yang berguna

  • X-Webhook-Event-Id: ID unik event
  • X-Webhook-Event-Type: tipe event
  • X-Webhook-Timestamp: unix timestamp atau format waktu yang disepakati
  • X-Webhook-Signature: hasil HMAC

Payload yang ringkas namun cukup

{
  "id": "evt_01J8X9K7A2N4",
  "type": "subscription.renewed",
  "created_at": "2024-11-13T10:45:52Z",
  "data": {
    "subscription_id": "sub_123",
    "customer_id": "cus_789",
    "period_start": "2024-11-13T00:00:00Z",
    "period_end": "2024-12-13T00:00:00Z",
    "status": "active"
  }
}

Jika skema perlu berevolusi, tambahkan field baru secara kompatibel. Hindari menghapus atau mengganti arti field lama tanpa versi atau masa transisi yang jelas.

Penyimpanan jejak event: berapa lama dan apa yang perlu disimpan?

Tidak ada satu angka yang selalu benar, tetapi prinsipnya:

  • Simpan cukup lama untuk mencakup jendela retry sender.
  • Pertimbangkan kebutuhan audit, investigasi insiden, dan dispute bisnis.
  • Jika payload sensitif, simpan hash atau subset yang diperlukan alih-alih seluruh body mentah.

Praktik yang sering berguna:

  • Tabel event ingress untuk deduplikasi dan status proses.
  • Log delivery sender untuk melacak percobaan kirim dan respons yang diterima.
  • Correlation ID atau trace ID agar debugging lintas sistem lebih mudah.

Jika volume webhook tinggi, Anda bisa memisahkan penyimpanan antara data deduplikasi jangka pendek dan arsip audit jangka panjang.

Checklist implementasi

  1. Tetapkan bahwa webhook bersifat at-least-once, bukan exactly-once.
  2. Berikan event_id unik dan stabil pada setiap event.
  3. Tambahkan signature berbasis HMAC pada raw body yang mencakup timestamp.
  4. Validasi timestamp untuk membatasi replay.
  5. Gunakan payload minimal, stabil, dan terdokumentasi jelas.
  6. Definisikan arti status code respons dan kapan sender melakukan retry.
  7. Terapkan exponential backoff dengan jitter di sender.
  8. Simpan event masuk dengan unique constraint pada event_id.
  9. Balas 2xx hanya setelah event aman diterima atau diantrekan.
  10. Buat business logic receiver idempotent, bukan hanya layer HTTP-nya.
  11. Siapkan strategi menghadapi out-of-order delivery.
  12. Catat status pemrosesan untuk debugging: received, processing, processed, failed.
  13. Uji kasus timeout, duplicate delivery, dan retry paralel.

Kesalahan umum yang sering merusak integrasi

  • Menggunakan request ID sebagai event ID, lalu menghasilkan ID baru saat retry.
  • Tidak menyimpan raw body atau memverifikasi signature dari JSON yang sudah diparse ulang.
  • Menganggap 200 berarti seluruh proses bisnis selesai, padahal event belum aman disimpan.
  • Tidak ada unique constraint pada event store sehingga race condition menghasilkan duplikasi.
  • Mengandalkan timestamp delivery untuk urutan bisnis tanpa state machine atau versi resource.
  • Mengulang side effect downstream karena hanya deduplikasi di ingress HTTP.
  • Mengirim payload terlalu besar sehingga kontrak rapuh saat field berubah.
  • Tidak mendokumentasikan retry policy sehingga partner menebak-nebak arti error.

Tips debugging saat integrasi webhook bermasalah

  • Log event_id, event type, timestamp, hasil verifikasi signature, dan status pemrosesan.
  • Bedakan log untuk delivery attempt dan business processing.
  • Jika muncul data ganda, periksa apakah duplikasi terjadi di level event ingress atau di side effect downstream.
  • Jika signature sering gagal, pastikan verifikasi memakai raw body yang sama persis dengan yang dikirim sender.
  • Jika event terlihat hilang, cek apakah receiver mengembalikan 2xx sebelum event benar-benar disimpan.
  • Jika state mundur, audit apakah ada out-of-order delivery dan apakah aturan transisi status sudah benar.

Penutup

Kontrak webhook yang tahan retry dan event duplikat dibangun dari keputusan kecil yang disiplin: event ID yang stabil, signature/HMAC dengan timestamp, payload yang minimal namun stabil, semantik status code yang jelas, retry sender yang terkendali, dan deduplikasi receiver yang didukung penyimpanan event yang benar. Di atas itu semua, Anda tetap perlu idempotensi pada level bisnis agar kegagalan parsial dan timeout tidak menghasilkan efek samping ganda.

Jika harus merangkum dalam satu prinsip: anggap setiap webhook bisa datang lebih dari sekali, bisa datang terlambat, dan bisa tiba setelah proses sebelumnya sebenarnya sudah sukses. Desain yang menerima kenyataan ini biasanya jauh lebih kuat daripada desain yang mengandalkan kondisi ideal.