Webhook tahan duplikasi adalah fondasi penting saat Anda mengintegrasikan vendor AI atau API pihak ketiga. Masalah utamanya bukan hanya menerima payload, tetapi memastikan sistem Anda tetap benar ketika event dikirim lebih dari sekali, datang terlambat, urutannya berubah, atau diulang berkali-kali karena timeout dan kegagalan jaringan.

Dalam praktiknya, integrasi modern sering melibatkan eksperimen multi-vendor: misalnya membandingkan beberapa model atau penyedia untuk workflow yang sama. Konteks ini makin relevan ketika tim produk mencoba banyak tool AI secara paralel. Referensi seperti TryAI yang membahas eksperimen lintas vendor menunjukkan bahwa perubahan vendor adalah hal realistis; karena itu, kontrak integrasi yang stabil lebih penting daripada asumsi perilaku satu provider tertentu. Consumer webhook Anda harus dirancang agar tahan terhadap variasi perilaku vendor, bukan hanya cocok untuk satu implementasi.

Mengapa webhook vendor AI sering bermasalah di produksi

Banyak tim menganggap webhook sebagai HTTP POST biasa. Padahal secara operasional, webhook adalah bentuk distributed messaging dengan sifat yang tidak selalu rapi. Vendor bisa mengirim event:

  • Ganda: event yang sama dikirim ulang karena vendor tidak menerima respons 2xx atau karena mekanisme retry internal.
  • Terlambat: event sukses datang setelah state lokal sudah berubah ke status lain.
  • Out-of-order: event completed tiba lebih dulu daripada processing, atau event lama tiba setelah event baru.
  • Retry agresif: vendor mencoba berkali-kali dalam interval pendek ketika endpoint Anda lambat.

Kalau consumer webhook tidak idempoten, konsekuensinya nyata: job diproses dua kali, billing tercatat ganda, file dipindah berulang, status mundur ke kondisi lama, atau notifikasi pengguna terkirim berkali-kali.

Prinsip desain webhook tahan duplikasi

1. Verifikasi signature sebelum memproses payload

Jangan percaya payload webhook hanya karena request datang ke endpoint yang benar. Anda perlu memverifikasi bahwa request benar berasal dari vendor dan belum dimodifikasi.

Umumnya vendor menyediakan:

  • Header signature, misalnya HMAC atas raw body.
  • Timestamp request untuk membatasi replay attack.
  • Secret per endpoint atau per akun.

Prinsip pentingnya:

  • Gunakan raw request body saat verifikasi, bukan JSON yang sudah diparse ulang.
  • Bandingkan signature dengan constant-time comparison untuk mengurangi risiko timing attack.
  • Terapkan replay attack window, misalnya hanya menerima request dengan timestamp yang masih dalam toleransi beberapa menit.
  • Dukung secret rotation bila vendor mengizinkan pergantian secret.

Verifikasi signature adalah kontrol autentikasi pesan. Ini tidak menggantikan idempotency dan deduplikasi; keduanya menangani masalah yang berbeda.

2. Idempotency di level business action

Idempotency berarti efek akhir dari operasi tetap sama meskipun event yang sama diproses lebih dari sekali. Ini penting karena deduplikasi berbasis event ID tidak selalu cukup. Misalnya, dua event berbeda bisa memicu aksi bisnis yang sama.

Contoh:

  • Webhook job.completed diterima dua kali dengan event ID sama - ini kasus dedup klasik.
  • Dua webhook berbeda dari vendor berbeda sama-sama menyatakan artefak untuk permintaan internal yang sama sudah siap - ini membutuhkan idempotency berbasis resource atau workflow lokal.

Implementasi umumnya memakai idempotency key yang diturunkan dari konteks bisnis, misalnya tenant_id + internal_job_id + action_type. Simpan hasil pemrosesan atau penanda bahwa aksi sudah dijalankan.

3. Dedup berbasis event ID

Jika vendor menyediakan event_id, gunakan itu sebagai identitas pesan. Simpan setiap event yang pernah diterima dalam tabel atau store cepat seperti Redis, lalu tolak pemrosesan ulang bila event ID yang sama datang lagi.

Namun, ada beberapa catatan:

  • Jangan hanya mengandalkan cache volatil bila konsekuensi duplikasi tinggi. Redis cocok untuk percepatan, tetapi penyimpanan persisten di database lebih aman untuk audit.
  • Pastikan ada constraint unik pada pasangan yang relevan, misalnya (vendor, event_id).
  • Jangan menganggap event ID selalu global. Beberapa vendor hanya menjamin keunikan dalam namespace akun atau endpoint tertentu.

4. Simpan status resource, bukan hanya log event

Banyak bug terjadi karena sistem hanya mencatat bahwa event sudah diterima, tetapi tidak memodelkan state resource yang sedang berubah. Untuk webhook yang bisa datang terlambat atau out-of-order, Anda perlu menyimpan:

  • status terakhir resource lokal,
  • waktu event vendor,
  • versi atau urutan logis bila tersedia,
  • payload mentah atau ringkasan payload untuk audit.

Dengan begitu, saat event lama tiba setelah event baru, Anda bisa memutuskan apakah event tersebut diabaikan, dicatat, atau tetap memicu proses tertentu.

Arsitektur yang aman: ack cepat, proses async

Pola paling aman untuk consumer webhook adalah verify - persist - ack - process async.

  1. Terima request dan ambil raw body.
  2. Verifikasi signature dan timestamp.
  3. Simpan event secara atomik ke datastore dengan constraint unik.
  4. Jika event baru, enqueue job async untuk diproses.
  5. Balas HTTP 2xx secepat mungkin.
  6. Worker memproses event dengan idempotency tambahan di level bisnis.

Mengapa pola ini bekerja?

  • Ack cepat menurunkan kemungkinan vendor melakukan retry agresif karena timeout.
  • Persist sebelum ack mencegah event hilang bila proses downstream gagal setelah respons 200.
  • Async processing menjaga endpoint tetap ringan dan tahan lonjakan trafik.
  • Pemisahan ingest dan process memudahkan observability, retry internal, dan dead-letter handling.

Skema tabel yang disarankan

Berikut contoh skema yang cukup umum. Nama kolom dapat disesuaikan dengan database dan model domain Anda.

table webhook_events (
  id                    bigserial primary key,
  vendor                varchar not null,
  endpoint_name         varchar not null,
  event_id              varchar not null,
  event_type            varchar,
  signature_valid       boolean not null,
  received_at           timestamp not null,
  vendor_event_time     timestamp null,
  payload_raw           text not null,
  payload_hash          varchar not null,
  processing_status     varchar not null, -- received, queued, processed, ignored, failed
  processing_attempts   integer not null default 0,
  last_error            text null,
  resource_key          varchar null,
  idempotency_key       varchar null,
  unique (vendor, event_id)
);

table resource_states (
  id                    bigserial primary key,
  vendor                varchar not null,
  resource_key          varchar not null,
  external_resource_id  varchar null,
  current_status        varchar not null,
  status_updated_at     timestamp not null,
  last_event_id         varchar null,
  last_vendor_event_time timestamp null,
  metadata_json         text null,
  unique (vendor, resource_key)
);

table processed_actions (
  id                    bigserial primary key,
  action_key            varchar not null,
  result_ref            varchar null,
  processed_at          timestamp not null,
  unique (action_key)
);

Pemisahan ini berguna karena:

  • webhook_events menjadi sumber audit dan dedup event.
  • resource_states menyimpan state bisnis saat ini.
  • processed_actions menangani idempotency untuk aksi yang tidak boleh dijalankan dua kali.

Contoh alur implementasi backend

Flow endpoint webhook

function handleWebhook(request):
    rawBody = request.getRawBody()
    headers = request.headers

    vendor = detectVendor(request)
    timestamp = headers.get("X-Timestamp")
    signature = headers.get("X-Signature")

    if not isTimestampWithinWindow(timestamp, 5 minutes):
        return httpResponse(401, "stale request")

    if not verifySignature(vendor.secret, rawBody, timestamp, signature):
        return httpResponse(401, "invalid signature")

    payload = parseJson(rawBody)
    eventId = extractEventId(payload)
    eventType = extractEventType(payload)
    vendorEventTime = extractEventTime(payload)
    resourceKey = mapToInternalResourceKey(payload)
    idempotencyKey = buildIdempotencyKey(payload)

    inserted = insertWebhookEventIfNotExists({
        vendor: vendor.name,
        endpoint_name: "vendor-ai-main",
        event_id: eventId,
        event_type: eventType,
        signature_valid: true,
        received_at: now(),
        vendor_event_time: vendorEventTime,
        payload_raw: rawBody,
        payload_hash: sha256(rawBody),
        processing_status: "received",
        resource_key: resourceKey,
        idempotency_key: idempotencyKey
    })

    if not inserted:
        return httpResponse(200, "duplicate ignored")

    enqueueJob("process_webhook_event", {
        vendor: vendor.name,
        event_id: eventId
    })

    markWebhookEventQueued(vendor.name, eventId)
    return httpResponse(200, "ok")

Flow worker pemrosesan event

function processWebhookEvent(vendor, eventId):
    event = getWebhookEvent(vendor, eventId)
    if event is null:
        return

    incrementProcessingAttempts(event)

    payload = parseJson(event.payload_raw)
    resourceKey = event.resource_key
    newStatus = mapVendorStatus(payload)
    vendorEventTime = event.vendor_event_time
    actionKey = event.idempotency_key

    beginTransaction()

    currentState = getResourceStateForUpdate(vendor, resourceKey)

    if currentState exists:
        if isOlderEvent(vendorEventTime, currentState.last_vendor_event_time):
            markWebhookEventIgnored(event, "older-than-current-state")
            commit()
            return

    if actionAlreadyProcessed(actionKey):
        markWebhookEventProcessed(event)
        commit()
        return

    applyStateTransition(resourceKey, newStatus, vendorEventTime, eventId, payload)

    performBusinessSideEffects(payload)
    recordProcessedAction(actionKey)

    markWebhookEventProcessed(event)
    commit()

Beberapa hal penting dari contoh di atas:

  • Insert-if-not-exists harus atomik. Gunakan unique constraint database, bukan cek manual lalu insert.
  • Worker tetap idempoten meskipun event sudah didedup saat ingest.
  • Lock row saat update state untuk mencegah race condition antar worker.
  • Event lama tidak selalu gagal; kadang cukup ditandai ignored bila state yang lebih baru sudah ada.

Menangani out-of-order dan event terlambat

Ini area yang sering diremehkan. Tidak semua vendor menjamin urutan pengiriman. Karena itu, aturan transisi state perlu eksplisit.

Pilih sumber kebenaran urutan

Urutan bisa ditentukan berdasarkan salah satu dari:

  • Timestamp dari vendor, jika cukup konsisten.
  • Sequence number, jika vendor menyediakannya.
  • Status precedence lokal, misalnya completed tidak boleh mundur ke processing.
  • Versi resource hasil fetch API, jika webhook hanya menjadi sinyal untuk melakukan sinkronisasi.

Jika vendor tidak memberi sequence number yang andal, pendekatan paling aman sering kali adalah:

  • anggap webhook sebagai sinyal perubahan,
  • lalu lakukan read-after-webhook ke API vendor untuk mendapatkan state terbaru yang otoritatif.

Trade-off-nya adalah tambahan latensi dan beban API, tetapi ini lebih aman untuk workflow yang sensitif.

Gunakan state machine sederhana

Definisikan transisi yang valid. Misalnya:

queued -> processing -> completed
queued -> failed
processing -> failed
completed -/-> processing   (invalid rollback)

Dengan aturan ini, event out-of-order yang mencoba memundurkan status dapat diabaikan atau dicatat untuk investigasi.

Replay attack window dan keamanan operasional

Selain memverifikasi HMAC, Anda perlu membatasi kemungkinan replay attack, yaitu request valid yang direkam lalu dikirim ulang.

Praktik umum:

  • Pastikan signature memasukkan timestamp.
  • Tolak request yang timestamp-nya terlalu lama atau terlalu jauh di masa depan.
  • Simpan kombinasi vendor + signature/timestamp + event_id bila perlu untuk deteksi replay yang lebih ketat.
  • Gunakan HTTPS end-to-end.
  • Batasi akses endpoint hanya ke route yang diperlukan; IP allowlist bisa membantu jika vendor mendukungnya, tetapi jangan dijadikan satu-satunya kontrol.

Jendela replay harus cukup sempit untuk keamanan, tetapi cukup longgar untuk mengakomodasi drift jam dan variasi jaringan. Jangan memilih angka terlalu kecil tanpa memahami karakteristik infrastruktur Anda.

Retry policy: vendor retry vs retry internal

Jangan campur retry dari vendor dengan retry di sistem Anda. Keduanya perlu dikendalikan terpisah.

Kapan mengembalikan 2xx

Balas 2xx jika:

  • signature valid,
  • event berhasil dipersist,
  • dan sistem internal Anda sudah menerima tanggung jawab untuk memprosesnya secara async.

Anda tidak perlu menunggu seluruh proses bisnis selesai sebelum memberi 2xx.

Kapan mengembalikan 4xx atau 5xx

  • 4xx: signature salah, payload tidak valid, timestamp di luar replay window.
  • 5xx: kegagalan sementara di sisi Anda, misalnya database tidak tersedia sehingga event tidak bisa dipersist.

Jika Anda sudah menyimpan event dengan aman, tetapi worker downstream gagal, jangan minta vendor mengirim ulang hanya untuk menutupi masalah internal. Tangani dengan retry internal melalui queue.

Retry internal yang sehat

  • Gunakan exponential backoff agar kegagalan beruntun tidak memperparah beban sistem.
  • Batasi jumlah percobaan sebelum masuk dead-letter queue.
  • Bedakan error yang bisa dicoba ulang dengan error permanen.
  • Simpan last_error dan jumlah percobaan untuk debugging.

Observability yang benar-benar berguna

Webhook sulit di-debug jika Anda hanya punya log HTTP biasa. Anda perlu observability yang berorientasi event.

Metric yang sebaiknya ada

  • jumlah request webhook per vendor dan event type,
  • jumlah signature invalid,
  • rasio duplicate event,
  • waktu dari received_at ke processed,
  • jumlah event ignored karena out-of-order,
  • error rate worker dan dead-letter count.

Log dan tracing

  • Log minimal harus memuat vendor, event_id, resource_key, processing_status, dan alasan jika event diabaikan.
  • Jangan log secret atau payload sensitif secara penuh tanpa sanitasi.
  • Gunakan correlation ID dari request hingga worker agar alur mudah ditelusuri.

Dengan observability yang baik, Anda bisa membedakan apakah masalah berasal dari retry vendor, race condition internal, atau state machine yang terlalu longgar.

Failure mode umum dan cara menghindarinya

1. Verifikasi signature setelah parsing JSON

Masalah: normalisasi whitespace atau urutan field dapat membuat signature mismatch atau, lebih buruk, verifikasi palsu jika implementasinya salah.

Solusi: verifikasi terhadap raw body asli.

2. Cek duplicate dengan query lalu insert

Masalah: race condition. Dua request paralel bisa sama-sama lolos cek lalu keduanya insert.

Solusi: pakai unique constraint dan operasi atomik upsert atau insert-on-conflict.

3. Menjalankan side effect sebelum status dedup tersimpan

Masalah: jika proses mati di tengah, vendor retry dapat memicu side effect yang sama lagi.

Solusi: simpan event lebih dulu, lalu proses async dengan idempotency tambahan.

4. Menganggap urutan event selalu benar

Masalah: status bisa mundur atau proses salah dijalankan.

Solusi: gunakan timestamp, sequence, atau state precedence yang eksplisit.

5. Mengembalikan 200 terlalu dini tanpa persist

Masalah: event hilang permanen bila proses lokal gagal setelah respons.

Solusi: hanya ack setelah event tersimpan secara andal.

6. Tidak ada retention dan audit trail

Masalah: sulit melakukan forensik ketika vendor mengklaim sudah mengirim event tertentu.

Solusi: simpan payload hash, timestamp, status pemrosesan, dan error ringkas.

Kapan perlu fetch ulang ke API vendor

Tidak semua webhook sebaiknya diproses sebagai sumber kebenaran final. Pertimbangkan fetch ulang jika:

  • vendor sering mengirim event out-of-order,
  • payload webhook minim dan tidak cukup untuk menentukan state final,
  • workflow menyangkut billing, file final, atau artefak yang harus konsisten,
  • Anda menggabungkan beberapa vendor AI untuk fungsi yang serupa dan ingin kontrak internal tetap stabil.

Dalam pola ini, webhook berfungsi sebagai trigger. State final diambil dari API vendor lalu dipetakan ke model domain internal Anda. Ini sangat membantu saat Anda bereksperimen dengan banyak provider, karena aplikasi inti tidak bergantung pada bentuk event mentah setiap vendor.

Checklist produksi untuk webhook tahan duplikasi

  • Verifikasi signature menggunakan raw body.
  • Terapkan replay attack window berbasis timestamp.
  • Simpan event dengan unique constraint pada (vendor, event_id).
  • Ack 2xx hanya setelah persist berhasil.
  • Proses lanjutan dilakukan async melalui queue.
  • Terapkan idempotency key di level aksi bisnis.
  • Simpan state resource dan aturan transisi status.
  • Tangani event terlambat dan out-of-order secara eksplisit.
  • Pisahkan retry vendor dari retry internal.
  • Siapkan dead-letter queue untuk error berulang.
  • Tambahkan metric duplicate rate, invalid signature, dan processing latency.
  • Sanitasi log dan lindungi payload sensitif.
  • Rencanakan secret rotation dan audit trail.
  • Uji paralel request, duplicate delivery, dan reorder event di environment staging.

Penutup

Merancang webhook tahan duplikasi untuk integrasi API vendor AI bukan soal menangani satu POST dengan benar, tetapi soal membangun kontrak ingest yang aman, idempoten, dan tahan terhadap perilaku dunia nyata: retry, reorder, delay, dan replay. Kombinasi signature verification, dedup berbasis event ID, idempotency key, penyimpanan state, dan observability yang baik akan mencegah sebagian besar bug produksi yang paling mahal.

Jika tim Anda sedang mencoba banyak vendor AI sekaligus, anggap webhook bukan sebagai integrasi sekali jadi, melainkan lapisan stabilisasi. Vendor boleh berganti, payload boleh berbeda, tetapi consumer Anda harus tetap konsisten, aman, dan dapat diaudit.