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_type dan event_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_id yang 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.completed diterima dua kali.
  • Consumer harus membuat invoice di layanan lain.
  • Gunakan event_id sebagai dasar atau komponen downstream idempotency key agar invoice tidak dibuat ganda.

Aturan praktis: event_id untuk identitas event pada lapisan transport/integrasi. idempotency key untuk 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:

  1. Memeriksa timestamp masih dalam toleransi, misalnya beberapa menit.
  2. Memverifikasi HMAC.
  3. 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 200 atau 204 agar 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 OK atau 204 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_id
  • event_type
  • received_at
  • processing_status seperti received, processing, processed, failed
  • payload_hash opsional untuk audit
  • response_code atau 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 exists atau 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 completed kembali ke running jika 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)
        return

Pseudocode 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_id unik dan stabil di semua retry.
  • Payload dibungkus dalam envelope dengan event_type, event_version, occurred_at, dan data.
  • 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.