Webhook tangguh tidak ditentukan oleh seberapa sering request berhasil pada percobaan pertama, melainkan oleh seberapa baik kontrak event menangani retry, duplikasi, payload lama, dan urutan event yang tidak selalu konsisten. Jika sistem Anda menganggap setiap webhook datang sekali, datang berurutan, dan selalu valid, masalah produksi hanya tinggal menunggu waktu.

Untuk integrasi backend, prinsip utamanya sederhana: jangan berharap perilaku jaringan selalu rapi. Yang harus rapi adalah kontraknya. Artinya, producer dan consumer perlu sepakat soal event ID, versi payload, tanda tangan, toleransi waktu, skema data, kebijakan retry, deduplikasi, serta arti respons 2xx, 4xx, dan 5xx. Sistem yang baik lahir dari kontrak yang jelas, bukan perilaku “kreatif” yang ambigu.

Mengapa webhook sering gagal di produksi

Banyak implementasi webhook terlihat benar saat diuji lokal, lalu mulai bermasalah ketika trafik, latensi, dan kegagalan parsial muncul. Penyebab umumnya bukan pada HTTP semata, tetapi pada asumsi yang salah:

  • Duplikasi dianggap bug langka, padahal retry otomatis hampir pasti menimbulkan event ganda.
  • Ordering dianggap terjamin, padahal request bisa sampai tidak berurutan karena antrean, retry, atau perbedaan latensi.
  • Payload dianggap stabil selamanya, padahal kontrak tanpa versioning cepat rapuh saat field berubah.
  • Signature diverifikasi setelah parsing bebas, sehingga raw body yang berubah format dapat membuat verifikasi gagal atau, lebih buruk, lolos dengan cara yang salah.
  • Status code tidak dibedakan maknanya, sehingga producer terus me-retry request yang sebenarnya tidak akan pernah valid.

Karena itu, desain webhook yang benar harus berangkat dari fakta bahwa delivery umumnya bersifat at least once, bukan exactly once.

Kontrak event untuk webhook tangguh

Berikut elemen kontrak yang sebaiknya dianggap wajib untuk webhook backend yang serius.

1. Event ID yang unik dan stabil

Setiap event harus punya event_id unik yang tidak berubah di setiap retry. Inilah kunci deduplikasi di sisi consumer. Jangan gunakan timestamp saja sebagai identitas, karena dua event berbeda bisa terjadi pada waktu yang sama atau terlalu berdekatan.

Karakteristik event ID yang baik:

  • Unik secara global atau setidaknya unik dalam namespace producer.
  • Tidak berubah antar retry.
  • Dikirim di header, payload, atau keduanya.
  • Dapat disimpan sebagai kunci deduplikasi.

2. Versioning payload

Webhook tanpa versioning akan menyulitkan evolusi schema. Tambahan field baru biasanya aman, tetapi perubahan tipe field, penghapusan field, atau perubahan struktur nested bisa mematahkan consumer lama.

Pola yang umum:

  • event_type: misalnya invoice.paid, subscription.canceled.
  • event_version: misalnya 2025-08-01 atau integer seperti 2.
  • schema evolution rule: field baru boleh ditambahkan, field lama didepresiasi bertahap, breaking change butuh versi baru.

Versi tidak harus rumit, tetapi harus eksplisit. Consumer sebaiknya menolak atau mengkarantina versi yang tidak dikenal, bukan memprosesnya diam-diam.

3. Signature verification

Webhook harus ditandatangani agar consumer bisa memastikan payload benar-benar berasal dari producer dan tidak diubah di tengah jalan. Praktik umum adalah mengirim signature berbasis HMAC atas raw request body menggunakan shared secret.

Hal penting yang sering keliru:

  • Verifikasi harus dilakukan terhadap raw body, bukan hasil JSON yang sudah diparsing lalu diserialisasi ulang.
  • Gunakan constant-time comparison saat membandingkan signature.
  • Dukung secret rotation dengan lebih dari satu secret aktif selama masa transisi.
  • Masukkan timestamp ke dalam material yang ditandatangani untuk membantu mencegah replay.

4. Timestamp tolerance untuk mencegah replay

Selain signature, sertakan timestamp request, misalnya pada header. Consumer lalu memeriksa apakah timestamp masih berada dalam jendela toleransi yang wajar. Jika terlalu lama, request dapat ditolak sebagai potensi replay atau webhook tertahan lama di jalur pengiriman.

Trade-off penting:

  • Terlalu ketat: request valid bisa ditolak saat ada skew clock atau antrean jaringan.
  • Terlalu longgar: jendela replay menjadi terlalu besar.

Gunakan sinkronisasi waktu yang baik pada server producer dan consumer, lalu dokumentasikan toleransinya secara jelas.

5. Skema payload yang eksplisit

Jangan hanya mendokumentasikan contoh JSON; dokumentasikan juga field wajib, optional, nullable, dan aturan semantiknya. Consumer perlu tahu apakah suatu field boleh hilang, boleh null, atau selalu ada.

Minimal, payload sebaiknya memuat:

  • event_id
  • event_type
  • event_version
  • occurred_at
  • delivery_attempt jika ingin transparan soal retry
  • resource atau data domain yang relevan

6. Idempotency dan deduplication store

Di sisi consumer, event yang sama bisa datang lebih dari sekali. Karena itu, handler harus idempotent: memproses event yang sama dua kali tidak boleh menggandakan efek samping seperti pengiriman email, pengurangan stok, atau pembuatan invoice.

Dua lapisan yang biasanya dipakai bersama:

  1. Deduplication store berdasarkan event_id.
  2. Idempotent business operation berdasarkan identitas domain, misalnya payment_id atau order_id + status.

Ini penting karena deduplikasi di level event saja belum cukup. Bisa saja dua event berbeda secara ID menghasilkan efek domain yang seharusnya tidak diduplikasi.

7. Retry policy yang terdokumentasi

Producer harus jelas kapan ia retry, berapa lama, dan apakah menggunakan backoff. Consumer perlu tahu bahwa event bisa tiba beberapa menit atau beberapa jam setelah kejadian awal jika endpoint sempat gagal.

Dokumentasikan setidaknya:

  • Kondisi retry: timeout, koneksi gagal, respons 5xx, atau 429 jika didukung.
  • Kondisi tidak retry: 2xx dan umumnya 4xx permanen.
  • Batas maksimal percobaan atau masa hidup delivery.
  • Apakah ada dead-letter queue atau dashboard retry manual.

8. Ordering tidak dijamin

Asumsi terpenting: webhook tidak boleh mengandalkan ordering kecuali memang ada mekanisme khusus yang mendukungnya, dan itu pun sering sulit dipertahankan saat retry terjadi. Consumer harus mampu menerima event subscription.updated sebelum subscription.created, atau event status lama setelah status baru.

Solusi praktis:

  • Gunakan version number atau updated_at pada resource untuk menolak state yang lebih lama.
  • Jika payload tidak cukup, lakukan fetch latest state dari API producer sebelum menulis ke database.
  • Rancang handler sebagai state reconciliation, bukan sekadar append buta.

Contoh kontrak payload JSON

Berikut contoh payload yang cukup realistis untuk webhook pembayaran:

{
  "event_id": "evt_01JX9Q7M4Y7V2K8P3D4R5S6T7U",
  "event_type": "invoice.paid",
  "event_version": "2025-08-01",
  "occurred_at": "2025-08-20T10:15:30Z",
  "delivery_attempt": 3,
  "idempotency_key": "inv_93842_paid",
  "resource": {
    "invoice_id": "inv_93842",
    "customer_id": "cus_1288",
    "status": "paid",
    "amount": 150000,
    "currency": "IDR",
    "paid_at": "2025-08-20T10:15:10Z",
    "updated_at": "2025-08-20T10:15:10Z"
  }
}

Contoh header terkait:

POST /webhooks/provider-x HTTP/1.1
Content-Type: application/json
X-Webhook-Id: evt_01JX9Q7M4Y7V2K8P3D4R5S6T7U
X-Webhook-Timestamp: 1724148930
X-Webhook-Signature: v1=4b0f0c...
X-Webhook-Event-Type: invoice.paid

Beberapa catatan desain:

  • event_id dipakai untuk deduplikasi transport-level.
  • idempotency_key dapat mewakili operasi domain-level, misalnya status invoice menjadi paid.
  • updated_at membantu consumer memutuskan apakah payload ini lebih baru daripada data lokal.
  • delivery_attempt berguna untuk observability, tetapi jangan dijadikan dasar logika bisnis.

Implementasi producer: bagaimana mengirim webhook yang benar

Bangun event dari perubahan domain, bukan dari controller HTTP

Producer yang baik menerbitkan event saat perubahan domain sudah commit di database. Jangan kirim webhook sebelum transaksi utama selesai; jika transaksi rollback tetapi webhook sudah terkirim, consumer akan menerima kenyataan yang tidak pernah benar-benar terjadi.

Pola yang umum dipakai adalah outbox pattern:

  1. Transaksi bisnis menyimpan perubahan domain.
  2. Dalam transaksi yang sama, tulis record event ke tabel outbox.
  3. Worker terpisah membaca outbox dan mengirim webhook.
  4. Status delivery dan retry dicatat terpisah.

Pendekatan ini mengurangi risiko inkonsistensi antara database internal dan delivery webhook.

Tandatangani payload dari raw body final

Jika signature dibuat sebelum payload final dibentuk, atau jika body masih bisa diubah oleh middleware, verifikasi di consumer bisa gagal. Pastikan urutannya jelas:

  1. Serialisasi payload final.
  2. Bangun string yang ditandatangani, misalnya timestamp + "." + raw_body.
  3. Hitung HMAC dengan secret aktif.
  4. Kirim header timestamp dan signature.

Gunakan retry dengan backoff dan observability

Retry agresif tanpa backoff bisa memperparah outage consumer. Sebaliknya, retry terlalu jarang membuat sinkronisasi data menjadi lambat. Pilih kebijakan yang masuk akal, lalu tampilkan status delivery agar pengguna integrasi bisa mendiagnosis masalah tanpa menebak-nebak.

Simpan setidaknya:

  • waktu pengiriman terakhir,
  • jumlah percobaan,
  • status code terakhir,
  • ringkasan error jaringan,
  • request ID internal untuk pelacakan.

Implementasi consumer: verifikasi, simpan, akui, lalu proses

Di sisi consumer, tujuan utamanya adalah menerima webhook secara aman dan cepat, lalu memindahkan pekerjaan berat ke antrean internal. Endpoint webhook sebaiknya sesederhana mungkin.

Urutan handler yang direkomendasikan

  1. Ambil raw request body.
  2. Validasi header wajib: event ID, timestamp, signature, content type jika diperlukan.
  3. Verifikasi timestamp tolerance.
  4. Verifikasi signature.
  5. Parse JSON dan validasi skema minimal.
  6. Cek deduplication store berdasarkan event_id.
  7. Simpan event mentah atau enqueue untuk proses async.
  8. Balas 2xx secepat mungkin jika event sudah diterima dengan aman.

Alasan urutan ini penting: Anda ingin mencegah payload palsu masuk ke sistem internal, tetapi juga tidak ingin endpoint webhook menjalankan logika bisnis berat sebelum acknowledgment dikirim.

Contoh pseudo-code consumer

async function handleWebhook(req, res) {
  const rawBody = req.rawBody;
  const signature = req.headers['x-webhook-signature'];
  const timestamp = req.headers['x-webhook-timestamp'];
  const eventId = req.headers['x-webhook-id'];

  if (!signature || !timestamp || !eventId) {
    return res.status(400).send('missing required headers');
  }

  if (!isTimestampWithinTolerance(timestamp)) {
    return res.status(400).send('timestamp outside tolerance');
  }

  if (!verifySignature({ rawBody, timestamp, signature, secrets })) {
    return res.status(401).send('invalid signature');
  }

  let event;
  try {
    event = JSON.parse(rawBody);
  } catch {
    return res.status(400).send('invalid json');
  }

  if (!event.event_id || !event.event_type || !event.event_version) {
    return res.status(400).send('invalid event schema');
  }

  const inserted = await dedupeStore.insertIfAbsent(event.event_id, {
    received_at: new Date().toISOString(),
    event_type: event.event_type
  });

  if (!inserted) {
    return res.status(200).send('duplicate ignored');
  }

  await queue.publish('webhook-events', event);
  return res.status(202).send('accepted');
}

Hal yang perlu diperhatikan:

  • 200 untuk duplikat sering lebih baik daripada error, karena producer tidak perlu retry lagi.
  • 202 Accepted cocok jika Anda hanya mengakui bahwa event diterima dan akan diproses async.
  • dedupeStore.insertIfAbsent idealnya atomik agar dua request paralel dengan event sama tidak sama-sama lolos.

Pilihan deduplication store

Tidak ada satu jawaban untuk semua sistem. Pilih berdasarkan kebutuhan retensi, throughput, dan kesederhanaan operasional.

  • Database relasional: baik jika Anda butuh konsistensi kuat dan bisa memakai unique constraint pada event_id.
  • Redis: cocok untuk deduplikasi cepat berbasis TTL, tetapi perlu dipikirkan persistensi dan risiko kehilangan state saat restart atau failover.
  • Kombinasi: Redis untuk hot path, database untuk audit jangka lebih panjang.

TTL deduplikasi tidak harus selamanya. Namun, retensi terlalu pendek bisa membuat event lama yang di-retry kembali dianggap baru. Sesuaikan dengan kebijakan retry producer dan kebutuhan audit.

Arti respons 2xx, 4xx, dan 5xx pada webhook

Status code adalah bagian dari kontrak. Producer dan consumer harus membaca maknanya dengan konsisten.

2xx: diterima, jangan retry

Gunakan 2xx hanya jika request memang sudah diterima dengan aman. Pada model async, itu berarti event sudah lolos verifikasi minimal dan berhasil dicatat atau dimasukkan ke queue internal.

Kesalahan umum: membalas 200 sebelum event disimpan ke storage atau queue. Jika proses berikutnya gagal, producer mengira delivery sukses padahal event hilang.

4xx: masalah pada request, umumnya jangan retry

Gunakan 4xx untuk kesalahan yang tidak akan membaik dengan pengiriman ulang yang sama, misalnya:

  • signature tidak valid,
  • timestamp di luar toleransi,
  • JSON rusak,
  • schema wajib tidak terpenuhi,
  • versi event tidak didukung.

Producer umumnya sebaiknya tidak me-retry 4xx permanen. Jika ada 429, dokumentasikan apakah producer harus mencoba lagi nanti.

5xx: kegagalan sementara, boleh retry

Gunakan 5xx jika consumer mengalami gangguan sementara, misalnya database down, queue internal tidak tersedia, atau timeout dependency internal. Producer dapat menganggap ini sebagai sinyal retry.

Jangan menyamarkan bug validasi sebagai 500. Akibatnya, producer akan mengirim ulang payload yang sama berkali-kali tanpa peluang sukses.

Edge case nyata yang sering mematahkan integrasi

Event duplicate setelah timeout

Producer mengirim webhook, consumer sebenarnya berhasil memproses, tetapi koneksi putus sebelum respons diterima producer. Producer menganggap gagal lalu retry. Tanpa deduplikasi, pembayaran bisa tercatat dua kali.

Solusi: dedupe berdasarkan event_id dan pastikan operasi bisnis juga idempotent.

Event lama datang setelah event baru

Misalnya order.shipped tiba lebih dulu, lalu order.processing datang belakangan karena retry tertunda. Jika consumer hanya overwrite status, state menjadi mundur.

Solusi: simpan versi status atau bandingkan updated_at. Tolak update yang lebih lama.

Secret dirotasi, sebagian request masih memakai secret lama

Saat rotasi credential, request yang sedang dalam antrean delivery mungkin masih ditandatangani dengan secret lama.

Solusi: izinkan beberapa secret aktif selama masa transisi dan catat key identifier bila perlu.

Payload valid secara sintaks, salah secara semantik

JSON bisa lolos parsing tetapi data domain tidak masuk akal, misalnya amount negatif untuk event pembayaran sukses, atau currency kosong.

Solusi: pisahkan validasi transport, schema, dan aturan domain. Ketiganya berbeda.

Consumer memproses lambat dan producer terus retry

Jika endpoint webhook menunggu proses berat selesai, timeout dapat memicu retry beruntun.

Solusi: verifikasi, simpan, enqueue, balas cepat. Kerjakan proses berat di worker async.

Anti-pattern yang sebaiknya dihindari

  • Mengandalkan ordering tanpa metadata versi atau waktu update.
  • Menggunakan timestamp sebagai ID event dan berharap unik.
  • Mengakui 200 terlalu dini sebelum event durably persisted.
  • Memproses sinkron di endpoint hingga melewati timeout producer.
  • Verifikasi signature dari JSON yang sudah di-format ulang.
  • Tidak menyimpan event mentah, sehingga debugging insiden menjadi sulit.
  • Menganggap deduplikasi sama dengan idempotensi; keduanya terkait tetapi tidak identik.
  • Memperlakukan semua non-2xx sebagai retryable tanpa membedakan 4xx permanen dan 5xx sementara.

Checklist implementasi producer dan consumer

Checklist producer

  • Setiap event memiliki event_id unik dan stabil antar retry.
  • Payload memiliki event_type, event_version, dan occurred_at.
  • Webhook dikirim hanya setelah perubahan domain benar-benar commit.
  • Gunakan outbox atau mekanisme setara untuk menghindari kehilangan event.
  • Signature dihitung dari raw body final plus timestamp.
  • Retry policy terdokumentasi dan memakai backoff.
  • Status delivery dan attempt tercatat untuk observability.
  • Dukungan rotasi secret tersedia.

Checklist consumer

  • Dapat mengakses raw request body.
  • Memverifikasi signature sebelum memproses lebih jauh.
  • Memeriksa timestamp tolerance untuk mitigasi replay.
  • Memvalidasi skema minimum dan versi event.
  • Memiliki deduplication store atomik berdasarkan event_id.
  • Logika bisnis bersifat idempotent di level domain.
  • Proses berat dipindah ke queue internal.
  • Menyimpan payload mentah dan metadata penting untuk audit.
  • Tidak mengasumsikan ordering event.
  • Respons 2xx, 4xx, dan 5xx digunakan secara konsisten.

Debugging dan observability yang benar-benar membantu

Saat webhook gagal, yang dibutuhkan tim bukan tebakan, tetapi jejak yang bisa ditelusuri. Simpan dan tampilkan metadata berikut di kedua sisi jika memungkinkan:

  • event_id
  • delivery attempt
  • timestamp pengiriman dan penerimaan
  • status code dan body respons secukupnya
  • hasil verifikasi signature tanpa membocorkan secret
  • reason code internal seperti duplicate, invalid_signature, schema_error, queued

Jika memungkinkan, berikan endpoint atau dashboard untuk menelusuri histori delivery per event. Ini sering lebih berguna daripada sekadar log mentah karena pengguna integrasi dapat melihat apakah masalah ada pada request, autentikasi, atau sistem penerima.

Penutup

Webhook tangguh: kontrak event yang tahan retry dan duplikasi bukan soal menambahkan banyak fitur, tetapi soal memperjelas aturan main. Ketika event ID, versioning, signature verification, timestamp tolerance, skema payload, idempotency, retry policy, deduplikasi, dan makna status code dirancang dengan benar, integrasi menjadi jauh lebih mudah diprediksi.

Jika Anda hanya mengambil satu prinsip dari artikel ini, ambil yang ini: anggap setiap webhook bisa datang lebih dari sekali, bisa datang terlambat, dan bisa datang tidak berurutan. Setelah itu, rancang kontraknya agar sistem tetap benar dalam semua kondisi tersebut.