Webhook sering gagal bukan karena kasus normal, tetapi karena input yang tidak sesuai ekspektasi: event type baru yang belum dikenal, field tambahan, urutan event terbalik, replay dari penyedia, atau payload lama yang baru tiba setelah sistem lokal sudah berubah. Konteks seperti IOCCC 2025 mengingatkan bahwa input eksternal bisa sulit ditebak, tetapi solusi nyata tetap ada: desain consumer webhook yang menganggap setiap request sebagai data dari luar yang untrusted, bisa terlambat, bisa duplikat, dan bisa berubah.

Jika Anda ingin desain webhook tahan event aneh, prinsip utamanya sederhana: verifikasi asal request, validasi seperlunya, toleran terhadap perubahan yang aman, simpan jejak event untuk idempotensi, proses secara asinkron, dan pastikan observability cukup untuk membedakan bug internal dari perilaku normal provider. Artikel ini fokus pada implementasi praktis, bukan teori umum.

Masalah nyata pada consumer webhook

Consumer webhook menerima input dari sistem yang tidak Anda kontrol penuh. Walaupun dokumentasi provider baik, perilaku lapangan tetap bisa berbeda karena retry, deployment bertahap, bug provider, atau perubahan kontrak yang belum Anda adopsi.

  • Unknown event type: provider menambah tipe event baru sebelum consumer diperbarui.
  • Field tambahan: payload berisi properti baru yang tidak ada saat integrasi awal.
  • Perubahan skema minor: field opsional menjadi hadir, enum bertambah, nested object berubah kecil.
  • Replay: event lama dikirim ulang, sengaja atau akibat retry.
  • Duplikasi: event yang sama dikirim lebih dari sekali dengan ID sama atau payload identik.
  • Payload lama datang terlambat: event sah, tetapi status lokal sudah bergerak ke kondisi lebih baru.
  • Out-of-order delivery: event B datang sebelum event A.

Kesalahan umum adalah memperlakukan webhook seperti RPC sinkron: satu request dianggap selalu valid, berurutan, dan final. Ini hampir selalu rapuh.

Prinsip arsitektur: terima cepat, verifikasi, antrikan, lalu proses

Pola yang paling aman biasanya terdiri dari empat tahap:

  1. Ingress endpoint: menerima HTTP request, membaca body mentah, verifikasi signature, dan melakukan validasi dasar.
  2. Persist raw event: simpan body mentah, header penting, waktu terima, dan metadata deduplikasi.
  3. Queue/worker: pemrosesan utama dilakukan async agar endpoint cepat merespons dan retry provider tidak memperparah beban.
  4. Business projector/handler: logika domain yang memutuskan apakah event diterapkan, diabaikan, ditunda, atau masuk DLQ.

Kenapa pola ini efektif? Karena ia memisahkan masalah transport dari masalah bisnis. Endpoint hanya memastikan request asli dan dapat dilacak. Worker menangani ketidakpastian seperti urutan event, idempotensi, dan retry internal.

Contract-first design: definisikan kontrak minimal yang benar-benar dibutuhkan

Contract-first design untuk webhook bukan berarti Anda harus menolak semua field yang tidak dikenal. Justru sebaliknya: definisikan kontrak minimum yang wajib ada, lalu abaikan sisanya selama tidak merusak keamanan atau logika.

Apa yang sebaiknya masuk kontrak minimum

  • event_id atau identifier unik lain untuk idempotensi.
  • event_type untuk routing.
  • occurred_at atau timestamp sumber untuk ordering heuristik.
  • resource_id atau entity identifier yang terkena dampak.
  • schema_version bila provider menyediakannya.
  • signature headers untuk autentikasi dan anti-replay.

Field lain sebaiknya diperlakukan sebagai optional input. Bila suatu field tidak kritis, jangan jadikan syarat keras. Ini membuat consumer tahan terhadap penambahan properti baru.

Contoh payload yang sehat untuk consumer toleran

{
  "id": "evt_01JXYZ8A4K9",
  "type": "payment.succeeded",
  "schema_version": "2025-01",
  "occurred_at": "2025-02-14T10:15:00Z",
  "account_id": "acct_123",
  "data": {
    "payment_id": "pay_456",
    "order_id": "ord_789",
    "amount": 125000,
    "currency": "IDR",
    "status": "succeeded",
    "customer": {
      "id": "cus_123",
      "email": "[email protected]"
    }
  },
  "meta": {
    "delivery_attempt": 2,
    "trace_id": "trc_abc"
  },
  "new_field_from_provider": {
    "unexpected_but_valid": true
  }
}

Consumer yang rapuh akan gagal karena new_field_from_provider tidak dikenal. Consumer yang sehat hanya memeriksa field wajib, lalu mengabaikan tambahan yang tidak relevan.

Tolerant reader: ketat pada yang wajib, longgar pada yang aman

Pola tolerant reader sangat cocok untuk webhook. Intinya, parser Anda tidak boleh terlalu bergantung pada seluruh bentuk payload jika bisnis hanya memerlukan sebagian kecil darinya.

Praktik yang dianjurkan

  • Validasi keberadaan field wajib dan tipe dasarnya.
  • Izinkan field tambahan tanpa gagal parse.
  • Jangan mengandalkan urutan properti JSON.
  • Hindari strict enum rejection untuk field yang tidak menentukan keamanan.
  • Simpan raw payload agar perubahan skema bisa dianalisis ulang tanpa kehilangan data.

Hal yang tetap harus ketat

  • Signature harus valid.
  • Timestamp replay window harus masuk batas yang diizinkan.
  • Identifier penting tidak boleh kosong atau ambigu.
  • Jika event type dikenal, field minimum untuk handler event itu harus tersedia.

Trade-off-nya jelas: makin toleran parser Anda, makin penting logging dan monitoring supaya perubahan provider tidak diam-diam mengubah perilaku bisnis. Toleran bukan berarti membiarkan data buruk masuk tanpa jejak.

Signature verification dan anti-replay

Webhook tanpa verifikasi signature pada dasarnya membuka endpoint publik yang bisa dipanggil siapa saja. Minimal, verifikasi harus dilakukan terhadap raw request body, bukan JSON yang sudah diparse ulang, karena perubahan whitespace atau serialisasi ulang dapat mengubah hasil hash.

Prinsip verifikasi yang benar

  • Ambil body mentah persis seperti diterima.
  • Baca header signature dan timestamp dari provider.
  • Bangun string yang ditandatangani sesuai spesifikasi provider.
  • Hitung HMAC dengan secret yang benar.
  • Bandingkan menggunakan constant-time comparison.
  • Tolak request yang timestamp-nya terlalu lama atau terlalu jauh ke depan.

Pseudocode verifikasi

rawBody = readRawRequestBody()
signature = request.header("X-Signature")
timestamp = request.header("X-Timestamp")

if !signature or !timestamp:
  return 401

if abs(now() - parseTime(timestamp)) > allowedSkew:
  return 401

signedPayload = timestamp + "." + rawBody
expected = hmac_sha256(secret, signedPayload)

if !constantTimeEquals(expected, signature):
  return 401

Anti-replay tidak cukup hanya dengan timestamp. Request yang valid masih bisa diputar ulang dalam jendela waktu yang sama. Karena itu, simpan kombinasi seperti signature + timestamp atau event_id dalam store jangka pendek untuk mendeteksi replay yang identik.

Idempotency store: fondasi menghadapi duplikasi dan retry

Provider webhook hampir selalu menerapkan retry. Artinya, duplikasi adalah perilaku normal, bukan anomali. Karena itu, handler harus idempotent: menjalankan event yang sama dua kali tidak boleh menghasilkan efek samping ganda.

Apa yang disimpan

  • event_id sebagai kunci utama bila tersedia.
  • content hash untuk fallback bila provider tidak memberi ID unik.
  • status pemrosesan: received, processing, processed, failed, dead-lettered.
  • processed_at dan alasan kegagalan.
  • resource_id untuk analisis ordering dan korelasi.

Pola implementasi

  1. Saat request lolos verifikasi, lakukan upsert event record berdasarkan kunci idempotensi.
  2. Jika status sudah processed, balas sukses tanpa memproses ulang.
  3. Jika status processing, hindari race condition dengan lock atau compare-and-set.
  4. Jika status failed, tentukan apakah boleh retry otomatis atau masuk DLQ.

Penyimpanan bisa memakai database relasional, key-value store, atau kombinasi keduanya. Jika perlu transaksi kuat dengan perubahan bisnis, database relasional sering lebih mudah. Jika prioritasnya throughput tinggi dengan TTL, key-value store bisa membantu. Pilih sesuai kebutuhan konsistensi.

Pseudocode alur handler end-to-end

function webhookHandler(request):
  rawBody = request.rawBody
  verifySignatureOrReject(request.headers, rawBody)

  envelope = parseJson(rawBody)
  validateMinimumEnvelope(envelope)

  key = envelope.id ?? hash(rawBody)

  record = idempotencyStore.find(key)
  if record.status == "processed":
    return 200

  if !idempotencyStore.tryMarkProcessing(key, rawBody, envelope):
    return 200

  enqueue({ key: key })
  return 202

function worker(job):
  record = idempotencyStore.get(job.key)
  event = record.envelope

  if isUnknownEventType(event.type):
    storeForInspection(record)
    markProcessedNoop(record, reason="unknown_type")
    return

  if isReplayOutsidePolicy(event):
    markProcessedNoop(record, reason="stale_replay")
    return

  with resourceLock(event.resource_id):
    currentState = loadCurrentState(event.resource_id)

    if isOlderThanCurrent(event, currentState):
      markProcessedNoop(record, reason="outdated_event")
      return

    applyBusinessChange(event, currentState)
    updateResourceCheckpoint(event.resource_id, event.occurred_at, event.id)
    markProcessed(record)

Penting: status processed tidak selalu berarti event mengubah data. Event yang diabaikan karena replay, unknown type, atau outdated bisa tetap ditandai selesai agar tidak diproses berulang.

Versioning dan perubahan skema minor

Perubahan skema tidak selalu perlu memutus kompatibilitas. Dalam banyak kasus, consumer cukup tahan terhadap perubahan minor jika kontrak minimumnya stabil.

Strategi versioning yang masuk akal

  • Envelope versioning: versi pada level event untuk memisahkan struktur pembungkus dari isi data.
  • Per-event handler compatibility: handler mengetahui versi minimum yang didukung.
  • Dual parsing: sementara waktu dukung versi lama dan baru bila migrasi bertahap.
  • Schema registry internal: dokumentasikan bentuk payload yang benar-benar Anda konsumsi, bukan seluruh payload provider.

Jika provider menambah field baru, tolerant reader cukup aman. Jika provider mengubah makna field lama, itu perubahan semantik dan tidak bisa diselesaikan hanya dengan parser toleran. Anda perlu deteksi versi dan jalur migrasi yang eksplisit.

Catatan: jangan memetakan seluruh payload provider ke model domain internal satu banding satu. Lebih aman gunakan anti-corruption layer: parse event eksternal menjadi command atau event internal yang bentuknya stabil dan sempit.

Unknown event type: abaikan, simpan, observasi

Unknown event type tidak selalu error. Bisa jadi provider menambah kapabilitas baru yang belum relevan bagi Anda. Jika signature valid dan envelope dasar sah, pola paling aman biasanya:

  1. Simpan raw event.
  2. Catat bahwa type belum dikenal.
  3. Tandai selesai sebagai no-op atau kirim ke jalur inspeksi ringan.
  4. Buat alert hanya jika frekuensinya signifikan atau memengaruhi proses penting.

Kesalahan umum adalah merespons 4xx/5xx untuk unknown type. Akibatnya provider retry terus, antrian membengkak, dan Anda mengubah event yang seharusnya netral menjadi insiden operasional.

Event ordering dan payload lama yang datang terlambat

Urutan pengiriman webhook tidak bisa diasumsikan. Bahkan jika provider mengirim berurutan, jaringan dan retry dapat mengubah urutan tiba. Maka, desain handler harus memutuskan berdasarkan freshness policy, bukan urutan penerimaan.

Pendekatan yang umum

  • Per-resource checkpoint: simpan timestamp atau sequence terakhir yang sudah diterapkan untuk setiap resource.
  • Last-write-wins berbasis occurred_at: cukup jika timestamp sumber dapat dipercaya.
  • Sequence number: paling kuat jika provider menyediakan nomor urut per resource.
  • Fetch-latest-state: untuk event sensitif, gunakan webhook hanya sebagai pemicu lalu ambil kondisi terbaru dari API provider.

Kapan fetch-latest-state lebih aman

Jika event merepresentasikan status final yang mudah basi, misalnya status pembayaran, langganan, atau pengiriman, polling state terbaru setelah menerima webhook sering lebih aman daripada mengandalkan payload lama. Trade-off-nya adalah tambahan latensi, dependency ke API provider, dan kebutuhan rate limiting.

Retry policy internal dan Dead Letter Queue

Setelah event masuk sistem Anda, retry juga perlu diatur secara internal. Tidak semua error sama.

Klasifikasi retry yang berguna

  • Transient: timeout database, koneksi antar layanan, lock contention. Layak retry dengan backoff.
  • Permanent: schema invalid, resource_id hilang, event type tidak didukung untuk diproses. Jangan retry buta.
  • Conditional: dependency belum siap, ordering belum terpenuhi. Bisa ditunda dengan retry terbatas.

Prinsip retry

  • Gunakan exponential backoff dengan jitter agar tidak menimbulkan lonjakan serentak.
  • Batasi jumlah percobaan.
  • Setelah batas tercapai, pindahkan ke DLQ beserta payload mentah dan alasan gagal.
  • Sediakan tooling replay dari DLQ yang aman dan tetap idempotent.

DLQ bukan tempat membuang masalah. Ia adalah mekanisme untuk karantina event yang butuh investigasi tanpa menghambat alur utama.

Observability: Anda tidak bisa memperbaiki yang tidak terlihat

Webhook yang tahan event aneh perlu observability yang tajam. Tanpa itu, unknown type dan replay akan terlihat seperti bug acak.

Minimal yang harus dicatat

  • Request ID internal dan event_id provider.
  • event_type, resource_id, dan schema_version.
  • Hasil verifikasi signature.
  • Status idempotensi: first-seen, duplicate, replay, stale, processed.
  • Latency endpoint dan latency worker.
  • Jumlah retry dan alasan masuk DLQ.

Metrik yang berguna

  • Rate unknown event type.
  • Persentase duplicate/replay.
  • Persentase signature verification failure.
  • Queue lag dan worker failure rate.
  • Jumlah stale events yang diabaikan karena kalah oleh state baru.

Pastikan log tidak membocorkan secret, signature, atau data sensitif penuh. Untuk payload, pertimbangkan masking atau penyimpanan terenkripsi.

Tabel failure mode vs mitigasi

Failure modeDampakMitigasi utama
Unknown event typeHandler gagal atau provider retry terusTandai no-op, simpan raw payload, observability per type
Field tambahanParser ketat mematahkan kompatibilitasTolerant reader, validasi hanya field wajib
Perubahan skema minorMapping internal rusakContract minimum, versioning, anti-corruption layer
Replay request validEfek samping gandaTimestamp window, store replay, idempotency key
Duplikasi eventInsert/charge/update gandaIdempotency store, unique constraint, status processed
Out-of-order deliveryState mundur ke versi lamaCheckpoint per resource, sequence atau occurred_at
Payload lama datang terlambatOverwrite state terbaruFreshness policy, stale-event detection, fetch latest state
Transient dependency errorEvent hilang jika gagal sekaliQueue, retry dengan backoff dan jitter
Poison messageRetry tak berujung, queue tersumbatRetry limit, DLQ, tooling replay
Signature invalidRisiko request palsuHMAC verification terhadap raw body, secret rotation

Kesalahan umum integrasi webhook

  • Mem-parse JSON sebelum verifikasi signature, lalu memverifikasi hasil serialisasi ulang.
  • Menganggap 200 berarti aman diproses sinkron. Endpoint lambat justru memicu retry provider.
  • Tidak menyimpan raw payload, sehingga debugging perubahan skema menjadi sulit.
  • Menolak field tambahan padahal perubahan itu kompatibel.
  • Tidak punya idempotency store, lalu berharap queue menjamin exactly-once.
  • Mengandalkan urutan kedatangan sebagai urutan kebenaran.
  • Menganggap unknown type sebagai error fatal.
  • Tidak memisahkan transport dan logika domain, sehingga retry HTTP memicu side effect berulang.
  • Tidak punya DLQ, menyebabkan poison message menghambat alur lain.
  • Logging berlebihan hingga membocorkan data sensitif.

Checklist implementasi desain webhook tahan event aneh

  1. Verifikasi signature menggunakan raw request body.
  2. Terapkan batas waktu replay berdasarkan timestamp dan skew yang masuk akal.
  3. Definisikan kontrak minimum: event_id, type, occurred_at, resource_id, schema_version bila ada.
  4. Izinkan field tambahan yang tidak relevan bagi logika inti.
  5. Simpan raw payload, header penting, dan metadata penerimaan.
  6. Gunakan idempotency store dengan status lifecycle event.
  7. Respon cepat dari endpoint; pindahkan kerja berat ke queue.
  8. Terapkan handler unknown type sebagai no-op terobservasi, bukan error otomatis.
  9. Gunakan checkpoint per resource untuk mencegah state mundur.
  10. Tentukan kebijakan stale event: ignore, fetch-latest-state, atau re-evaluate.
  11. Bedakan error transient vs permanent untuk retry internal.
  12. Gunakan DLQ untuk event yang gagal berulang.
  13. Tambahkan metrik duplicate, replay, unknown type, signature failure, dan queue lag.
  14. Sediakan prosedur replay manual yang tetap idempotent.
  15. Uji dengan payload tak dikenal, field ekstra, urutan terbalik, dan event lama.

Penutup

Desain webhook tahan event aneh bukan soal membuat parser yang menerima semua hal, tetapi membangun sistem yang bisa membedakan mana yang harus ditolak, mana yang aman diabaikan, dan mana yang perlu diproses sekali saja. Contract-first design memberi batas minimum, tolerant reader menjaga kompatibilitas, signature verification melindungi asal request, dan idempotency store mencegah side effect ganda. Sisanya ditopang oleh ordering policy, retry yang sehat, DLQ, dan observability.

Jika Anda sedang membangun consumer webhook hari ini, targetkan sifat berikut: aman terhadap input palsu, tahan terhadap duplikasi, tidak panik saat melihat event type baru, dan tidak membiarkan payload lama menimpa state yang lebih baru. Itulah baseline engineering yang realistis untuk integrasi webhook di produksi.