Webhook sering gagal bukan karena jaringan semata, tetapi karena kontrak event berubah diam-diam, payload tidak konsisten antar event, atau konsumer dipaksa menebak apakah sebuah request perlu di-retry. Jika memakai analogi Every Frame Perfect, maka setiap event webhook harus terasa seperti “frame” yang presisi: bentuk payload stabil, field penting selalu ada di tempat yang dapat diprediksi, dan perilaku error dapat diandalkan.

Prinsip utama API webhook yang konsisten per event adalah sederhana: setiap event harus punya kontrak yang jelas, identitas unik, jaminan verifikasi, aturan retry yang eksplisit, dan mekanisme idempotensi. Dengan itu, integrasi tidak rapuh saat ada duplikat, event terlambat, urutan tidak sesuai, atau sebagian proses downstream gagal.

Mengapa konsistensi per event lebih penting daripada payload yang “lengkap”

Kesalahan umum dalam desain webhook adalah mencoba mengirim semua data yang mungkin dibutuhkan konsumer, tetapi mengabaikan konsistensi antar event. Hasilnya, satu event memakai id sebagai string, event lain memakai integer; satu event menaruh waktu kejadian di created_at, event lain di timestamp; satu event ditandai berhasil dengan 200, event lain menganggap 202 sebagai kegagalan.

Dalam praktik, konsumer webhook lebih mudah dibangun dan dioperasikan jika hal-hal berikut konsisten:

  • Envelope event: field pembungkus seperti event_id, event_type, occurred_at, delivery_id, api_version selalu tersedia.
  • Penamaan event: pola nama event seragam, misalnya order.created, order.paid, invoice.failed.
  • Bentuk payload utama: field inti seperti data.object.id selalu berada di jalur yang sama.
  • Aturan status code: penerima tahu kapan harus mengembalikan 2xx, kapan 4xx, dan kapan 5xx.
  • Perilaku retry: pengirim punya aturan retry yang dapat diprediksi, bukan campuran logika per endpoint.

Konsistensi tidak berarti semua event harus berisi field yang sama persis. Artinya, struktur dasar, nama field penting, dan semantik error harus seragam sehingga integrasi bisa diprogram, diuji, dan di-debug dengan mudah.

Kontrak webhook yang stabil: envelope, versioning, dan penamaan event

1. Gunakan envelope yang seragam

Hindari payload yang bentuknya berubah total per event. Pakai envelope bersama untuk semua event, lalu letakkan data domain di dalam data. Contoh:

{
  "event_id": "evt_01J2Z6Y3A6F4Q8M9N0P1R2S3T4",
  "delivery_id": "dlv_01J2Z6Y7K4L8M1N2P3Q4R5S6T7",
  "event_type": "order.paid",
  "api_version": "2025-01-01",
  "occurred_at": "2025-06-22T10:15:30Z",
  "attempt": 1,
  "source": "billing-service",
  "idempotency_key": "order.paid:ord_123:v1",
  "data": {
    "object": {
      "id": "ord_123",
      "status": "paid",
      "customer_id": "cus_456",
      "amount": 150000,
      "currency": "IDR",
      "paid_at": "2025-06-22T10:15:28Z"
    }
  }
}

Keuntungan envelope seperti ini:

  • event_id mengidentifikasi kejadian bisnis.
  • delivery_id mengidentifikasi percobaan pengiriman. Satu event bisa punya banyak delivery jika di-retry.
  • attempt memudahkan observabilitas.
  • api_version membantu migrasi kontrak.
  • idempotency_key memudahkan konsumer melakukan deduplikasi atau pemrosesan aman.

2. Bedakan ID event dan ID resource

Jangan gunakan ID resource sebagai pengganti ID event. Misalnya, satu order yang sama bisa menghasilkan event order.created, order.paid, dan order.refunded. Masing-masing perlu event_id unik. Jika tidak, konsumer sulit membedakan apakah sebuah request adalah event baru atau duplikat dari event lama.

3. Terapkan versioning kontrak dengan disiplin

Versioning penting ketika Anda perlu mengubah struktur payload atau semantik field. Pendekatan yang umum:

  • Version di payload lewat field seperti api_version.
  • Version per subscription, sehingga tiap consumer bisa tetap menerima versi lama sampai siap migrasi.
  • Versioned event schema, didokumentasikan per event.

Prinsip praktisnya:

  • Tambahan field baru biasanya aman jika konsumer diharapkan mengabaikan field yang tidak dikenal.
  • Penghapusan field, perubahan tipe data, atau perubahan semantik perlu versi baru.
  • Jangan mengubah arti field lama tanpa versi baru, meskipun nama field tetap sama.

4. Gunakan penamaan event yang eksplisit

Pilih satu pola nama event dan pertahankan. Bentuk seperti domain.action atau resource.verb_past paling mudah dibaca. Contoh:

  • order.created
  • order.paid
  • invoice.payment_failed

Hindari nama event yang ambigu seperti order.updated untuk semua perubahan. Event yang terlalu generik memaksa konsumer membandingkan state sebelum dan sesudah, yang memperumit logika dan debugging.

Signature verification dan timestamp tolerance

Webhook tanpa verifikasi tanda tangan rentan dipalsukan. Praktik umum adalah pengirim membuat signature berbasis secret bersama dan isi payload mentah, lalu penerima memverifikasinya sebelum memproses event.

Pola header yang umum

Anda bisa memakai header seperti:

  • X-Webhook-Signature: hasil HMAC dari payload mentah.
  • X-Webhook-Timestamp: waktu saat signature dibuat.
  • X-Webhook-Event-Id: ID event untuk korelasi log.

Contoh pseudocode verifikasi HMAC:

signed_payload = timestamp + "." + raw_body
expected_signature = HMAC_SHA256(secret, signed_payload)
secure_compare(expected_signature, header_signature)

Beberapa catatan penting:

  • Gunakan raw body, bukan hasil parsing JSON yang sudah di-format ulang. Perubahan whitespace atau urutan serialisasi bisa membuat signature tidak cocok.
  • Pakai perbandingan konstan waktu untuk mengurangi risiko timing attack.
  • Sertakan timestamp agar signature tidak bisa dipakai ulang tanpa batas waktu.

Terapkan timestamp tolerance

Penerima sebaiknya menolak request dengan timestamp yang terlalu lama atau terlalu jauh di masa depan. Ini membantu mencegah replay attack. Nilai toleransi bergantung pada kebutuhan sistem dan sinkronisasi jam, tetapi idenya adalah memberi jendela yang cukup untuk keterlambatan jaringan tanpa membuka celah replay terlalu besar.

Jika request gagal hanya karena timestamp di luar toleransi, log-kan alasan secara jelas. Masalah ini sering terjadi bukan karena serangan, tetapi karena jam server pengirim atau penerima tidak sinkron.

Idempotensi, deduplikasi, dan event duplikat

Dalam webhook, duplikat adalah kondisi normal, bukan edge case langka. Retry karena timeout, connection reset, atau respons 5xx akan menghasilkan pengiriman ulang. Karena itu, endpoint penerima harus idempotent atau setidaknya punya mekanisme deduplikasi.

Idempotensi vs deduplikasi

  • Idempotensi: memproses request yang sama berkali-kali menghasilkan efek akhir yang sama.
  • Deduplikasi: sistem mendeteksi bahwa event sudah pernah diproses dan melewati pemrosesan ulang.

Keduanya sering dipakai bersama. Deduplikasi mencegah kerja ganda; idempotensi melindungi dari kondisi balapan, retry, atau bug dedupe.

Strategi yang disarankan

  1. Simpan event_id atau idempotency_key di penyimpanan yang punya jaminan keunikan.
  2. Lakukan pengecekan dan penandaan “sedang diproses” atau “sudah diproses” secara atomik.
  3. Pastikan operasi bisnis utama juga aman jika dijalankan dua kali.

Contoh tabel sederhana:

CREATE TABLE processed_webhook_events (
  event_id VARCHAR(64) PRIMARY KEY,
  event_type VARCHAR(100) NOT NULL,
  processed_at TIMESTAMP NOT NULL,
  status VARCHAR(20) NOT NULL
);

Alur sederhananya:

1. Verifikasi signature.
2. Coba insert event_id ke tabel dedupe.
3. Jika insert gagal karena duplicate key, kembalikan 200/204 dan hentikan.
4. Jika berhasil, proses event.
5. Update status hasil pemrosesan.

Mengapa duplikat sebaiknya tetap dibalas 2xx? Karena dari sudut pandang pengirim, event itu sudah diterima dan tidak perlu di-retry lagi. Mengembalikan 409 atau 500 untuk duplikat justru bisa memperpanjang siklus retry tanpa manfaat.

Kapan memakai idempotency key?

event_id cukup untuk dedupe level event, tetapi idempotency_key berguna jika satu efek bisnis dapat dipicu dari lebih dari satu jalur. Misalnya, Anda ingin menjamin bahwa pembuatan invoice downstream tidak terjadi dua kali meskipun ada dua event berbeda yang akhirnya mengarah ke tindakan yang sama.

Retry yang benar: status code, backoff, dan kapan berhenti

Retry yang baik harus mempertimbangkan apakah kegagalan bersifat sementara atau permanen. Aturan ini perlu konsisten dan terdokumentasi.

Status code yang disarankan untuk penerima webhook

  • 2xx: event diterima. Gunakan jika request valid dan sudah diterima untuk diproses, termasuk ketika event adalah duplikat yang aman diabaikan.
  • 400: payload tidak valid secara sintaks atau field wajib tidak ada. Biasanya jangan retry otomatis, karena masalahnya tidak akan pulih sendiri.
  • 401/403: signature atau otorisasi gagal. Biasanya jangan retry tanpa perubahan konfigurasi.
  • 409: bisa dipakai untuk konflik bisnis tertentu, tetapi untuk webhook sering lebih aman memproses secara idempotent dan tetap membalas 2xx untuk duplikat.
  • 422: payload valid secara sintaks, tetapi tidak dapat diproses karena aturan domain. Biasanya perlu keputusan eksplisit apakah retry masuk akal.
  • 429: rate limited. Pengirim sebaiknya retry dengan jeda lebih panjang.
  • 5xx: kegagalan sementara di sisi penerima. Aman untuk di-retry.

Jika endpoint Anda mendorong event ke queue internal, membalas 202 Accepted masuk akal selama event benar-benar sudah diterima secara durabel. Jangan membalas 202 jika event belum disimpan di mana pun dan masih bisa hilang bila proses mati.

Backoff dan jitter

Retry tanpa backoff akan memperburuk gangguan. Gunakan exponential backoff dengan jitter agar lonjakan retry tidak terjadi serempak. Secara konsep:

delay = random_between(0, base * 2^attempt)

Jitter penting saat banyak webhook gagal bersamaan, misalnya karena penerima sedang deploy atau database mengalami gangguan sesaat.

Berapa lama retry?

Tidak ada angka universal. Yang penting adalah:

  • beda antara kegagalan sementara dan permanen,
  • punya batas jumlah percobaan atau batas waktu total,
  • punya visibilitas saat event menyerah di-retry.

Jika setelah beberapa percobaan event tetap gagal, pindahkan ke dead-letter queue atau status gagal permanen agar dapat dianalisis dan diproses manual bila perlu.

Menangani edge case: terlambat, duplikat, out-of-order, dan partial failure

1. Event terlambat

Event bisa datang terlambat karena antrean, retry panjang, atau masalah jaringan. Karena itu, jangan mengasumsikan bahwa occurred_at dekat dengan waktu penerimaan. Simpan dua waktu secara terpisah:

  • occurred_at: kapan kejadian bisnis terjadi.
  • received_at: kapan webhook diterima.

Ini penting untuk audit dan debugging. Sistem downstream juga sebaiknya tidak menolak event hanya karena “sudah lewat”, kecuali memang ada aturan bisnis yang jelas.

2. Event duplikat

Duplikat harus diperlakukan sebagai hal biasa. Jika Anda sudah punya dedupe berdasarkan event_id, respons terbaik biasanya 2xx dan log sebagai duplicate hit.

3. Event out-of-order

Urutan pengiriman tidak selalu sama dengan urutan kejadian. Misalnya order.paid bisa tiba sebelum order.created di sistem konsumer karena retry dan keterlambatan berbeda. Ada beberapa pendekatan:

  • Fetch latest state: setelah menerima event, konsumer mengambil state terbaru dari API sumber. Ini paling aman bila sumber adalah otoritas utama.
  • Gunakan sequence number atau version: setiap resource punya nomor versi, sehingga konsumer tahu apakah event lebih lama dari state yang sudah dimiliki.
  • Tahan sementara event yang belum bisa diproses: simpan event yang bergantung pada state sebelumnya, lalu coba lagi setelah event pendahulu datang atau setelah sinkronisasi data.

Untuk banyak integrasi, strategi paling robust adalah menganggap webhook sebagai sinyal perubahan, lalu melakukan sinkronisasi state jika urutan benar-benar penting.

4. Partial failure

Partial failure terjadi saat endpoint sudah menerima event, tetapi sebagian proses downstream gagal. Contoh: event tersimpan di database, tetapi publikasi ke queue internal gagal. Solusi yang lebih aman:

  • terima dan simpan event lebih dulu secara durabel,
  • balas 2xx hanya setelah penyimpanan aman,
  • proses langkah lanjutan secara asynchronous dari queue internal,
  • pakai outbox pattern jika perlu konsistensi antara database dan publisher internal.

Jangan menjalankan terlalu banyak operasi sinkron di dalam handler webhook. Semakin panjang jalur sinkron, semakin besar peluang timeout dan retry duplikat.

Contoh desain endpoint penerima webhook

POST /webhooks/provider-x

Langkah handler:
1. Ambil raw body.
2. Ambil header signature dan timestamp.
3. Verifikasi signature dan tolerance timestamp.
4. Parse JSON.
5. Validasi field wajib: event_id, event_type, occurred_at, data.object.id.
6. Simpan event ke inbox table / queue secara atomik dengan kunci unik event_id.
7. Jika duplicate key, return 204.
8. Jika tersimpan, return 202 atau 204.
9. Worker asynchronous memproses event bisnis.
10. Jika worker gagal berulang, pindahkan ke DLQ.

Pola ini sering disebut inbox pattern di sisi penerima: endpoint HTTP hanya bertugas menerima secara aman, sedangkan logika bisnis berat dijalankan oleh worker.

Anti-pattern umum pada desain webhook

  • Mengubah struktur payload tanpa versi baru. Ini sumber regresi integrasi paling sering.
  • Menandatangani JSON hasil parsing ulang alih-alih raw body. Signature akan sulit diverifikasi konsisten.
  • Menganggap webhook exactly-once. Dalam praktik, yang realistis adalah at least once.
  • Menggunakan retry agresif tanpa backoff. Ini dapat memicu retry storm.
  • Menaruh logika bisnis berat langsung di request handler. Risiko timeout naik, lalu event dikirim ulang.
  • Tidak membedakan event_id dan delivery_id. Sulit menganalisis retry versus event unik.
  • Nama event terlalu generik seperti updated tanpa konteks perubahan.
  • Tidak punya observabilitas: tidak ada log korelasi per event_id, status retry, atau alasan kegagalan.

Kapan perlu dead-letter queue

Dead-letter queue (DLQ) diperlukan ketika Anda tidak ingin event gagal hilang diam-diam setelah retry habis. DLQ berguna jika:

  • event bernilai bisnis tinggi dan harus bisa diinvestigasi,
  • gagal permanen mungkin terjadi karena bug parser, data korup, atau aturan domain yang belum tertangani,
  • Anda butuh replay manual setelah perbaikan kode atau data,
  • pemrosesan melibatkan banyak layanan downstream yang bisa gagal berbeda-beda.

DLQ bukan alasan untuk menunda penanganan error. Event yang masuk DLQ harus punya metadata cukup: event_id, payload asli, jumlah percobaan, error terakhir, waktu gagal, dan layanan yang gagal memproses.

Checklist implementasi API webhook yang konsisten per event

  1. Tetapkan envelope standar untuk semua event.
  2. Gunakan event_id unik dan pisahkan dari delivery_id.
  3. Pilih skema penamaan event yang konsisten dan eksplisit.
  4. Dokumentasikan versi kontrak dan strategi kompatibilitas.
  5. Tandatangani request menggunakan HMAC atas raw body + timestamp.
  6. Verifikasi signature dengan secure compare.
  7. Terapkan timestamp tolerance untuk mencegah replay.
  8. Anggap delivery bersifat at least once dan siapkan deduplikasi.
  9. Simpan event secara durabel sebelum menjalankan proses berat.
  10. Buat handler HTTP sesingkat mungkin; teruskan ke queue internal.
  11. Gunakan backoff dengan jitter untuk retry.
  12. Bedakan error permanen dan sementara melalui status code yang konsisten.
  13. Siapkan strategi out-of-order: fetch latest state, version number, atau hold-and-retry.
  14. Catat log dan metrik per event_id, delivery_id, attempt, dan hasil verifikasi.
  15. Gunakan DLQ jika event gagal tidak boleh hilang.

Penutup

Desain webhook yang kuat tidak bergantung pada asumsi bahwa jaringan stabil atau event selalu datang sekali dan berurutan. Yang membuat integrasi tahan lama adalah kontrak yang presisi dan konsisten per event: payload punya bentuk yang dapat diprediksi, verifikasi aman, retry jelas, dan pemrosesan idempotent.

Jika setiap event diperlakukan seperti “frame” yang harus presisi, konsumer tidak perlu menebak-nebak arti field, status code, atau apakah sebuah request aman diproses ulang. Itulah inti API webhook yang konsisten per event: integrasi lebih mudah dibangun, lebih aman dioperasikan, dan jauh lebih mudah di-debug ketika hal yang tidak ideal benar-benar terjadi.