Webhook API yang dapat diulang tanpa efek ganda berarti pengirim boleh mengirim ulang event yang sama, tetapi penerima tidak boleh mengeksekusi efek bisnis lebih dari sekali. Ini penting karena kegagalan jaringan, timeout, restart worker, dan race condition memang membuat retry dan duplikasi menjadi kondisi normal, bukan pengecualian.
Jika sistem Anda memicu job, sinkronisasi hasil, atau callback lintas layanan—misalnya pada tool atau riset berbasis Python yang perlu koordinasi antarlayanan—kontrak webhook harus dirancang sejak awal agar tahan terhadap retry, replay, dan event duplikat. Solusinya bukan hanya “cek apakah ID sudah pernah diproses”, tetapi kombinasi desain payload, verifikasi signature/HMAC, timestamp, status code yang tepat, penyimpanan dedup, dan observability yang memadai.
Mengapa webhook mudah menghasilkan efek ganda
Webhook umumnya bekerja dengan model at-least-once delivery. Artinya, pengirim berusaha memastikan event sampai, walaupun konsekuensinya event yang sama bisa dikirim lebih dari sekali. Penyebab umumnya:
- Pengirim tidak menerima respons karena timeout jaringan.
- Penerima sebenarnya sudah memproses event, tetapi koneksi putus sebelum respons terkirim.
- Worker pengirim restart dan mengulang pengiriman dari antrean.
- Penerima mengembalikan status 5xx atau 429 sehingga event dijadwalkan ulang.
- Load balancer, proxy, atau job runner melakukan retry otomatis.
Karena itu, asumsi yang aman adalah: event bisa datang lebih dari sekali, bisa datang terlambat, dan urutannya tidak selalu terjaga.
Desain kontrak payload untuk webhook API yang dapat diulang tanpa efek ganda
1. Pisahkan event envelope dari business payload
Jangan kirim hanya objek bisnis mentah. Gunakan envelope yang menyimpan metadata pengiriman dan identitas event.
{
"event_id": "evt_01J9X7Y...",
"event_type": "job.completed",
"event_version": "2025-01-01",
"occurred_at": "2025-01-10T14:32:11Z",
"delivery_attempt": 3,
"source": "processor-service",
"data": {
"job_id": "job_123",
"status": "completed",
"result_ref": "s3://bucket/results/job_123.json"
}
}Field minimal yang umumnya berguna:
- event_id: identitas unik event, stabil di semua retry.
- event_type: jenis event, misalnya
job.completed. - event_version: versi skema event, bukan versi API global.
- occurred_at: waktu event terjadi di sisi producer.
- delivery_attempt: opsional, membantu debugging retry.
- source: layanan pengirim.
- data: payload bisnis.
Mengapa ini bekerja: penerima dapat membuat keputusan secara deterministik berdasarkan metadata event tanpa harus menebak konteks bisnis dari payload mentah.
2. Gunakan versioning event secara eksplisit
Jangan mengandalkan perubahan diam-diam pada struktur JSON. Saat skema berubah, penerima perlu tahu cara parsing yang benar.
Pola yang aman:
- Simpan event_version per event.
- Tambah field baru dengan cara kompatibel jika memungkinkan.
- Hindari mengubah makna field lama tanpa menaikkan versi.
- Dokumentasikan kontrak per
event_typedanevent_version.
Jika consumer belum mendukung versi tertentu, lebih baik ia menolak secara eksplisit dan tercatat, daripada diam-diam memproses salah.
3. Bedakan event_id dan idempotency key
Ini kesalahan desain yang sering terjadi.
- event_id mengidentifikasi satu event yang diterbitkan producer. Retry untuk event yang sama harus memakai
event_idyang sama. - idempotency key biasanya mengidentifikasi satu permintaan yang ingin dijalankan paling banyak sekali, sering dipakai pada API sinkron seperti pembuatan pembayaran atau job.
Pada webhook, event_id biasanya cukup untuk deduplikasi pengiriman event. Namun, idempotency key masih berguna jika consumer meneruskan efek ke sistem lain. Contohnya:
- Webhook
job.completedditerima dua kali. - Consumer harus membuat invoice di layanan lain.
- Gunakan
event_idsebagai dasar atau komponen downstream idempotency key agar invoice tidak dibuat ganda.
Aturan praktis:
event_iduntuk identitas event pada lapisan transport/integrasi.idempotency keyuntuk mencegah efek samping ganda pada operasi target.
Keamanan: signature/HMAC, timestamp, dan pencegahan replay
1. Verifikasi signature atas raw body
Webhook tidak cukup diamankan dengan HTTPS saja. Penerima perlu memastikan payload memang dikirim oleh producer yang sah dan tidak diubah di tengah jalan.
Pola umum:
- Producer dan consumer berbagi secret.
- Producer menghitung HMAC dari raw request body atau gabungan timestamp + raw body.
- Signature dikirim lewat header, misalnya
X-Signature. - Consumer menghitung ulang dan membandingkan dengan constant-time comparison.
Jangan menghitung signature dari JSON yang sudah di-parse lalu di-serialize ulang, karena urutan key dan whitespace bisa berubah.
2. Tambahkan timestamp dalam material yang ditandatangani
Signature saja belum mencegah replay. Penyerang yang mendapatkan request valid bisa mengirim ulang request yang sama. Karena itu, sertakan timestamp dalam header dan ikut ditandatangani.
Contoh pola header:
X-Webhook-Timestamp: 1736519531
X-Webhook-Signature: v1=abcdef123456...Material yang ditandatangani bisa berupa:
{timestamp}.{raw_body}Penerima lalu:
- Memeriksa timestamp masih dalam toleransi, misalnya beberapa menit.
- Memverifikasi HMAC.
- Menolak request yang terlalu lama atau terlalu jauh dari waktu server.
Mengapa ini penting: request lama yang valid secara kriptografis tidak boleh bebas diputar ulang tanpa batas waktu.
3. Replay protection bukan hanya timestamp
Timestamp mengurangi jendela replay, tetapi tidak selalu cukup. Jika request direplay dalam jendela toleransi, consumer tetap perlu menahan efek ganda dengan dedup berbasis event_id. Jadi:
- Signature + timestamp melindungi keaslian dan membatasi replay.
- Dedup store mencegah eksekusi berulang untuk event yang sama.
Status code penerima dan retry policy producer
1. Respons consumer harus sederhana dan deterministik
Target utama endpoint webhook adalah menerima event dengan aman, bukan menjalankan seluruh proses berat secara sinkron. Praktik yang aman:
- Verifikasi signature dan validasi minimum.
- Simpan event ke penyimpanan atau antrean internal.
- Kembalikan respons cepat.
Respons yang umum:
- 2xx: event diterima. Producer tidak perlu retry.
- 400: payload salah format atau field wajib hilang. Biasanya non-retryable.
- 401/403: signature tidak valid atau secret salah. Umumnya non-retryable sampai konfigurasi diperbaiki.
- 409: bisa dipakai untuk konflik semantik, tetapi untuk event duplikat biasanya lebih aman tetap kembalikan
200atau204agar producer berhenti retry. - 429: penerima overload sementara. Producer boleh retry dengan backoff.
- 5xx: kegagalan sementara di sisi consumer. Producer harus retry.
Tip: jika event duplikat sudah dikenali dan aman diabaikan, sering kali respons terbaik adalah
200 OKatau204 No Content, bukan error.
2. Retry policy producer harus eksplisit
Producer jangan mengandalkan retry tanpa aturan. Dokumentasikan:
- Jenis status code yang memicu retry.
- Backoff, idealnya exponential backoff with jitter.
- Batas maksimal percobaan atau durasi retry.
- Apakah ada dead-letter queue atau antrean gagal.
- Apakah consumer dapat meminta penundaan lewat
Retry-After.
Contoh kebijakan yang masuk akal secara umum:
- Retry untuk timeout, koneksi gagal, 429, dan 5xx.
- Jangan retry untuk 2xx, 400, 401, 403, 404 jika endpoint memang permanen salah.
- Gunakan jitter agar banyak worker tidak menembak ulang secara bersamaan.
Angka spesifik retry bergantung pada kebutuhan bisnis dan toleransi latensi. Yang penting, perilakunya terdokumentasi dan dapat diprediksi.
Penyimpanan dedup dan idempotensi di sisi consumer
1. Simpan jejak event yang sudah diproses
Dedup paling dasar adalah tabel atau key-value store yang menyimpan event_id. Namun implementasinya harus memperhatikan race condition.
Informasi yang berguna untuk disimpan:
event_idevent_typereceived_atprocessing_statussepertireceived,processing,processed,failedpayload_hashopsional untuk auditresponse_codeatau error ringkas
2. Gunakan operasi atomik, bukan cek lalu insert terpisah
Pola yang salah:
if not exists(event_id):
process()
insert(event_id)Dua request paralel bisa sama-sama lolos pengecekan lalu memproses dua kali.
Pola yang lebih aman:
- Buat unique constraint pada
event_id. - Lakukan
insert if not existsatau operasi atomik setara. - Hanya request yang berhasil memperoleh lock/log pertama yang boleh melanjutkan proses.
Jika pemrosesan berat dilakukan setelah insert awal, simpan status transisi agar kegagalan di tengah tidak membuat event hilang tanpa jejak.
3. Tentukan TTL dedup dengan sadar
Jika memakai Redis atau cache serupa, TTL terlalu pendek bisa membuat event lama diproses ulang saat retry terlambat datang. TTL terlalu panjang meningkatkan penggunaan memori. Pilih berdasarkan:
- Maksimal jangka retry producer.
- Kemungkinan replay terlambat.
- Kebutuhan audit dan forensik.
Untuk event penting, database persisten sering lebih aman daripada cache murni. Redis cocok untuk volume tinggi, tetapi pertimbangkan persistensi dan recovery saat restart.
Ordering tidak terjamin: jangan bergantung pada urutan webhook
Banyak integrasi gagal karena berasumsi event datang berurutan. Pada praktiknya, job.started bisa datang setelah job.completed, atau event update lebih baru tiba lebih dulu daripada yang lama.
Strategi menghadapi out-of-order event
- Gunakan versi objek atau sequence number jika producer bisa menyediakannya.
- Buat update idempoten dan monotonic, misalnya jangan ubah status dari
completedkembali kerunningjika status final sudah tersimpan. - Lakukan fetch state terbaru dari source-of-truth jika event hanya berperan sebagai notifikasi.
- Simpan event lalu rekonsiliasi bila urutan memengaruhi hasil bisnis.
Untuk banyak kasus, webhook paling aman diperlakukan sebagai signal bahwa sesuatu berubah, bukan satu-satunya sumber kebenaran. Misalnya event memberi tahu bahwa hasil job siap, lalu consumer mengambil hasil final dari endpoint atau object storage yang stabil.
Contoh alur implementasi producer dan consumer
Pseudocode producer
def send_webhook(url, secret, event):
raw_body = json_encode(event) # hasil serialisasi final yang dikirim
timestamp = current_unix_timestamp()
message = f"{timestamp}.{raw_body}"
signature = hmac_sha256(secret, message)
headers = {
"Content-Type": "application/json",
"X-Webhook-Timestamp": str(timestamp),
"X-Webhook-Signature": f"v1={signature}"
}
for attempt in retry_schedule_with_jitter():
response = http_post(url, headers=headers, body=raw_body, timeout_seconds=5)
if response.network_error:
continue
if 200 <= response.status_code < 300:
mark_delivered(event["event_id"])
return
if response.status_code in [429, 500, 502, 503, 504]:
continue
mark_permanent_failure(event["event_id"], response.status_code)
returnPseudocode consumer
def handle_webhook(request):
raw_body = request.raw_body
ts_header = request.headers.get("X-Webhook-Timestamp")
sig_header = request.headers.get("X-Webhook-Signature")
if not ts_header or not sig_header:
return http_response(401)
if timestamp_too_old(ts_header, tolerance_seconds=300):
return http_response(401)
expected = hmac_sha256(WEBHOOK_SECRET, f"{ts_header}.{raw_body}")
provided = parse_signature(sig_header, version="v1")
if not constant_time_equals(expected, provided):
return http_response(401)
event = json_decode(raw_body)
required_fields = ["event_id", "event_type", "event_version", "occurred_at", "data"]
if not has_required_fields(event, required_fields):
return http_response(400)
inserted = dedup_store.insert_if_absent(
key=event["event_id"],
value={"status": "received", "received_at": now()}
)
if not inserted:
# Event sudah pernah diterima; ack agar producer berhenti retry
return http_response(200)
try:
enqueue_internal_job(event)
dedup_store.update(event["event_id"], {"status": "queued"})
return http_response(202)
except TemporaryError:
dedup_store.delete(event["event_id"])
return http_response(503)Catatan penting pada contoh di atas:
- Consumer memverifikasi raw body, bukan JSON hasil parse ulang.
- Dedup dilakukan sebelum menjalankan efek bisnis.
- Event yang sudah pernah diterima tetap di-ack dengan 2xx.
- Jika gagal sebelum event aman masuk antrean internal, consumer dapat mengembalikan 5xx agar producer retry.
Edge case nyata yang sering terlewat
1. Event sama, payload berbeda
Jika event_id sama tetapi payload berbeda, itu indikasi bug producer atau potensi manipulasi. Jangan diam-diam menerima keduanya sebagai event yang sama. Simpan payload_hash saat pertama menerima. Jika duplikat berikutnya memiliki hash berbeda:
- Catat sebagai anomali.
- Jangan proses ulang otomatis.
- Naikkan alert untuk investigasi.
2. Consumer crash setelah efek bisnis, sebelum menyimpan status
Misalnya consumer sudah membuat record di database lain, lalu proses mati sebelum menandai event sebagai processed. Solusinya:
- Buat operasi bisnis target juga idempoten dengan kunci yang stabil.
- Atau simpan status dedup dan efek bisnis dalam transaksi yang konsisten jika berada dalam boundary yang sama.
3. Timeout respons tetapi event sebenarnya berhasil diproses
Ini kasus klasik. Producer retry karena tidak melihat 2xx, padahal consumer sukses. Karena itu, consumer wajib punya dedup dan operasi bisnis idempoten.
4. Secret dirotasi
Rotasi secret sering memutus integrasi jika consumer hanya menerima satu secret. Praktik yang aman:
- Dukung dua secret aktif sementara selama masa transisi.
- Tambahkan identitas key atau versi signature jika perlu.
- Monitor kegagalan verifikasi setelah rotasi.
5. Event lama datang setelah state baru tersimpan
Jika status objek sudah final, jangan turunkan ke status lama hanya karena event terlambat datang. Terapkan aturan transisi state yang jelas.
Observability dan debugging
Webhook sulit di-debug jika hanya mengandalkan log aplikasi biasa. Siapkan observability minimal berikut:
- Correlation identifiers: log
event_id,event_type, source, dan attempt. - Metrics: jumlah diterima, diverifikasi gagal, duplikat, sukses diproses, gagal sementara, gagal permanen.
- Latency: waktu dari diterima sampai diproses selesai.
- Retry visibility: berapa attempt rata-rata per endpoint consumer.
- Dead-letter visibility: event yang tidak pernah berhasil terkirim atau diproses.
Untuk debugging lapangan, simpan secukupnya:
- Header penting seperti timestamp dan signature version.
- Hash payload, bukan selalu payload penuh, jika ada data sensitif.
- Hasil keputusan dedup dan alasan reject.
Hindari menulis secret, signature mentah yang sensitif, atau payload penuh berisi data pribadi tanpa kontrol akses dan kebijakan retensi yang jelas.
Checklist kontrak API producer-consumer
Checklist producer
- Setiap event punya
event_idunik dan stabil di semua retry. - Payload dibungkus dalam envelope dengan
event_type,event_version,occurred_at, dandata. - Request ditandatangani dengan HMAC atas
timestamp.raw_body. - Ada retry policy eksplisit untuk timeout, 429, dan 5xx.
- Ada backoff dengan jitter.
- Durasi maksimal retry terdokumentasi.
- Event yang gagal permanen masuk antrean investigasi atau dead-letter.
Checklist consumer
- Memverifikasi HTTPS, timestamp, dan signature atas raw body.
- Menolak request usang di luar jendela toleransi.
- Memvalidasi field wajib dan versi event.
- Menyimpan dedup dengan operasi atomik dan unique key.
- Memperlakukan event duplikat sebagai sukses yang aman diabaikan.
- Tidak mengasumsikan urutan event terjamin.
- Mengakui penerimaan lebih cepat dan mendelegasikan kerja berat ke queue internal.
- Memiliki observability untuk duplicate rate, failure rate, dan processing latency.
- Menerapkan idempotensi juga pada efek ke sistem downstream.
Kapan cukup dengan event_id saja, dan kapan perlu idempotency key tambahan
Cukup dengan event_id jika consumer hanya perlu memastikan satu event webhook tidak diproses dua kali dalam boundary sistem yang sama.
Perlu idempotency key tambahan jika:
- Consumer memanggil API lain yang juga bisa menerima retry.
- Satu event memicu beberapa tahap yang bisa gagal parsial.
- Efek bisnis bernilai tinggi, seperti billing, provisioning, atau perubahan status final.
Dalam kasus seperti ini, gunakan event_id sebagai input untuk membentuk kunci idempoten downstream agar jejaknya konsisten lintas layanan.
Penutup
Webhook yang andal bukan webhook yang “jarang retry”, melainkan webhook yang tetap benar meskipun retry dan duplikasi terjadi. Fondasinya adalah kontrak event yang jelas, event_id yang stabil, verifikasi signature/HMAC dengan timestamp, kebijakan status code dan retry yang konsisten, dedup atomik, serta desain consumer yang tidak bergantung pada urutan pengiriman.
Jika Anda merancang integrasi untuk pemicu job, sinkronisasi hasil, atau callback lintas layanan, anggap webhook sebagai kanal at-least-once. Dengan asumsi itu, Anda akan memilih arsitektur yang lebih aman: cepat mengakui penerimaan, memproses secara asinkron, dan menjaga agar setiap event dapat diulang tanpa menimbulkan efek ganda.
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!