Webhook out-of-order adalah masalah yang nyata di produksi: event status paid bisa datang lebih dulu daripada created, event lama bisa menyusul setelah state terbaru tersimpan, dan retry dari provider dapat mengirim payload yang sama berkali-kali. Jika konsumen webhook hanya “update state terakhir yang datang”, sistem mudah korup: status mundur, stok salah, invoice ganda, atau notifikasi terkirim dua kali.

Solusinya bukan berharap provider selalu mengirim event secara berurutan, melainkan mendesain konsumen yang idempotent, aman terhadap duplikasi, dan mampu menolak atau mengkarantina event lama tanpa merusak state. Kuncinya ada pada kontrak event, validasi signature, penyimpanan deduplikasi, aturan versi/urutan, dan kemampuan rekonsiliasi ulang lewat API sumber data.

Mengapa webhook sering datang tidak berurutan?

Pada banyak sistem, webhook dikirim secara asinkron melalui antrean internal, worker paralel, dan jaringan yang tidak deterministik. Karena itu, urutan pembuatan event di sisi provider tidak selalu sama dengan urutan penerimaan di sisi consumer.

Gejala yang sering muncul di produksi

  • Status resource “mundur”, misalnya dari paid kembali ke pending.
  • Record lokal terbuat dua kali karena event yang sama diterima ulang.
  • Webhook gagal diverifikasi pada sebagian request karena implementasi signature membaca body yang sudah diubah parser.
  • Retry tak berujung karena endpoint selalu mengembalikan error untuk event lama.
  • Data lokal berbeda dengan sumber utama karena ada event yang hilang atau terlambat.

Root cause umum

  • At-least-once delivery: provider sengaja mengirim ulang saat tidak menerima respons sukses.
  • Parallel dispatch: event berbeda untuk entitas sama diproses worker yang berbeda.
  • Network delay: request yang lebih lama bisa tiba belakangan.
  • Retry setelah timeout: provider menganggap request gagal, padahal consumer sudah sempat memproses.
  • Tidak ada ordering guarantee di level global atau bahkan per resource.
  • Perubahan payload schema tanpa versioning yang jelas.

Asumsi aman untuk webhook adalah: event dapat terlambat, datang ganda, dan tidak berurutan. Jika provider menjanjikan ordering, tetap siapkan fallback untuk kondisi ketika janji itu gagal di dunia nyata.

Prinsip desain konsumen webhook yang tahan gangguan

1. Validasi signature sebelum memproses apa pun

Webhook adalah endpoint publik. Sebelum memikirkan urutan event, pastikan request memang berasal dari provider. Signature umumnya dihitung dari raw request body dan secret bersama. Kesalahan umum adalah memverifikasi body yang sudah diubah parser JSON, sehingga signature valid menjadi gagal.

  • Ambil raw body persis seperti diterima.
  • Ambil header signature dan, jika ada, timestamp dari provider.
  • Hitung ulang HMAC atau mekanisme verifikasi yang didokumentasikan provider.
  • Tolak jika signature tidak valid atau timestamp terlalu jauh dari waktu server.

Validasi signature bukan pengganti otorisasi bisnis, tetapi lapisan pertama agar endpoint Anda tidak menjadi pintu masuk event palsu.

2. Simpan event mentah sebelum logika bisnis

Pola yang sangat berguna adalah memisahkan ingestion dari processing. Endpoint webhook sebaiknya:

  1. Memverifikasi signature.
  2. Mengekstrak metadata penting.
  3. Menyimpan event mentah ke tabel atau log persistennya.
  4. Memberi respons cepat.
  5. Memproses lebih lanjut secara asinkron melalui queue internal.

Ini memberi beberapa keuntungan: audit trail, replay lebih mudah, debugging lebih jelas, dan beban endpoint publik tetap kecil sehingga retry dari provider berkurang.

3. Gunakan idempotency key dan deduplication store

Consumer harus mampu mengenali bahwa payload yang sama atau event yang sama pernah diproses. Untuk itu, gunakan idempotency key yang stabil. Idealnya provider mengirim event_id unik. Jika tidak ada, Anda perlu menurunkan key dari kombinasi yang cukup stabil, misalnya provider + event_type + resource_id + external_event_version. Hindari membuat key dari seluruh payload mentah jika provider dapat menambah field non-esensial yang tidak memengaruhi makna event.

Deduplication store adalah penyimpanan yang menandai bahwa event tertentu sudah pernah diterima atau diproses. Ini bisa berupa tabel database dengan unique constraint, atau penyimpanan key-value untuk horizon waktu tertentu jika kebutuhan audit tidak tinggi.

4. Bedakan “diterima”, “diproses”, dan “diterapkan”

Banyak bug terjadi karena sistem hanya punya status biner: sukses atau gagal. Padahal untuk webhook, ada beberapa tahap berbeda:

  • Received: request valid dan payload tersimpan.
  • Processing: worker sedang menangani event.
  • Applied: perubahan state benar-benar diterapkan ke domain.
  • Ignored: event valid tetapi lebih lama dari state saat ini.
  • Quarantined: event valid namun tidak dapat diproses otomatis, perlu investigasi atau rekonsiliasi.

Pemisahan ini sangat membantu saat mendiagnosis apakah masalah ada di penerimaan webhook, antrean internal, atau logika domain.

Desain kontrak event untuk menangani out-of-order

Jika Anda mengontrol sisi producer, kontrak event yang baik akan mengurangi banyak ambiguitas. Jika Anda hanya sebagai consumer, pahami apakah provider mengirim metadata serupa dan manfaatkan semaksimal mungkin.

Field yang sebaiknya ada pada event

  • event_id: identitas unik event untuk deduplikasi.
  • event_type: tipe perubahan, misalnya payment.updated.
  • occurred_at: waktu kejadian di sisi producer, bukan waktu diterima consumer.
  • resource_id: identitas entitas utama yang berubah.
  • resource_version atau event_version: versi monotonik untuk entitas atau payload schema.
  • schema_version: versi bentuk payload agar parser aman saat kontrak berkembang.
  • correlation_id atau trace_id: memudahkan pelacakan lintas sistem.

Event version vs schema version

Dua istilah ini sering tercampur:

  • Schema version menjelaskan format payload. Gunanya untuk kompatibilitas parser.
  • Event/resource version menjelaskan urutan perubahan state. Gunanya untuk mencegah state mundur.

Untuk masalah webhook out-of-order, yang paling penting biasanya adalah resource version atau indikator urutan yang monotonik per entitas.

Monotonic timestamp: berguna, tapi jangan terlalu dipercaya

Jika provider tidak menyediakan version integer yang meningkat, occurred_at dapat menjadi fallback. Namun timestamp punya keterbatasan:

  • Presisi bisa terlalu kasar sehingga dua event tampak terjadi bersamaan.
  • Clock skew di sisi producer dapat membuat urutan salah.
  • Tidak selalu aman untuk konflik jika ada update paralel.

Karena itu, timestamp cocok sebagai heuristic atau pagar tambahan, tetapi version per resource lebih kuat daripada sekadar waktu.

Pola pemrosesan yang aman: optimistic check sebelum ubah state

Inti penanganan event tidak berurutan adalah jangan menerapkan perubahan jika event lebih lama daripada state lokal yang sudah diketahui. Ini biasa dilakukan dengan optimistic check.

Aturan dasar per resource

Untuk setiap entitas lokal, simpan metadata seperti:

  • versi terakhir yang sudah diterapkan, atau
  • timestamp kejadian terakhir yang dianggap otoritatif.

Saat event baru datang:

  1. Temukan resource lokal berdasarkan resource_id.
  2. Bandingkan resource_version event dengan versi terakhir yang tersimpan.
  3. Jika versi event lebih kecil atau sama, anggap sebagai duplikasi atau event lama.
  4. Jika versi lebih besar, terapkan perubahan dan perbarui versi terakhir.

Perbandingan ini harus dilakukan secara atomik agar dua worker tidak saling menimpa hasil.

Kapan event lama boleh tetap diproses?

Tidak semua event lama harus langsung dibuang. Ada dua kategori:

  • State-changing event: event yang memengaruhi state utama. Jika lebih lama, biasanya harus diabaikan atau dikarantina.
  • Side-effect event: misalnya logging, audit, atau metrik. Event lama masih bisa disimpan untuk histori tanpa memengaruhi state domain.

Memisahkan keduanya membantu mencegah status mundur tetapi tetap menjaga jejak audit lengkap.

Contoh alur implementasi backend

Alur end-to-end

  1. Provider mengirim webhook ke endpoint publik.
  2. Server membaca raw body dan memverifikasi signature.
  3. Server menyimpan event ke webhook_events dengan status received.
  4. Server mengembalikan respons sukses secepat mungkin jika request valid secara teknis.
  5. Worker internal mengambil event dan menjalankan deduplikasi berbasis event_id.
  6. Worker mengunci atau memperbarui state target secara atomik dengan optimistic check pada versi/timestamp.
  7. Jika event terlalu lama, tandai ignored atau quarantined.
  8. Jika data domain tidak cukup atau ada gap versi, lakukan rekonsiliasi ke API provider.

Skema tabel sederhana

Table webhook_events
- id (internal primary key)
- provider_name
- event_id
- event_type
- resource_id
- resource_version
- occurred_at
- signature_valid
- payload_json
- received_at
- processing_status   -- received | processing | applied | ignored | quarantined | failed
- processing_error
- applied_at

Unique index:
- (provider_name, event_id)

Table payment_state
- payment_id
- status
- amount
- last_event_version
- last_occurred_at
- updated_at

Index:
- (payment_id)

Jika provider tidak punya event_id yang unik, Anda bisa menambah tabel deduplikasi terpisah:

Table processed_event_keys
- dedupe_key
- first_seen_at
- last_seen_at
- status

Unique index:
- (dedupe_key)

Pseudocode endpoint ingestion

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

    if not verifySignature(headers, rawBody):
        return 401

    event = parseJson(rawBody)

    upsert webhook_events using unique(provider_name, event.event_id):
        provider_name = "provider-x"
        event_id = event.event_id
        event_type = event.event_type
        resource_id = event.resource_id
        resource_version = event.resource_version
        occurred_at = event.occurred_at
        signature_valid = true
        payload_json = rawBody
        received_at = now()
        processing_status = "received"

    enqueue internal job with event_id

    return 200

Pola upsert + enqueue membuat endpoint tetap cepat. Jika request yang sama datang ulang, insert kedua akan gagal di unique index atau berubah menjadi no-op yang aman.

Pseudocode worker pemrosesan event

function processWebhookEvent(providerName, eventId):
    event = load webhook_events by providerName + eventId

    if event.processing_status in ["applied", "ignored"]:
        return

    mark event as "processing"

    resource = load payment_state by payment_id = event.resource_id

    if resource does not exist:
        resource = create placeholder payment_state with:
            payment_id = event.resource_id
            status = "unknown"
            last_event_version = 0
            last_occurred_at = null

    if event.resource_version is not null:
        if event.resource_version <= resource.last_event_version:
            mark event as "ignored"
            return
    else if event.occurred_at is not null and resource.last_occurred_at is not null:
        if event.occurred_at <= resource.last_occurred_at:
            mark event as "ignored"
            return

    newState = deriveStateFromEvent(event, resource)

    atomically update payment_state where:
        payment_id = event.resource_id
        and last_event_version < current event.resource_version
    set:
        status = newState.status
        amount = newState.amount
        last_event_version = event.resource_version
        last_occurred_at = event.occurred_at
        updated_at = now()

    mark event as "applied"

Dalam implementasi nyata, kondisi atomik dapat berbentuk transaksi database, compare-and-swap, atau update bersyarat. Tujuannya sama: dua worker tidak boleh menerapkan event dengan urutan yang salah akibat race condition.

Strategi saat event lama atau gap versi ditemukan

1. Ignore dengan alasan yang eksplisit

Jika event jelas lebih lama dari versi terakhir yang sudah diterapkan, Anda bisa menandainya sebagai ignored. Jangan tandai sebagai failed, karena ini bukan error teknis. Simpan alasan seperti stale_event agar observabilitas tetap baik.

2. Karantina jika ada anomali

Ada situasi yang tidak aman untuk diabaikan otomatis, misalnya:

  • Versi event meloncat jauh dan Anda curiga ada event hilang.
  • Payload tidak cukup untuk menghitung state baru.
  • Event kontradiktif dengan data lokal.
  • Resource lokal belum ada, padahal semestinya sudah pernah dibuat.

Untuk kasus seperti ini, masukkan ke status quarantined dan siapkan proses investigasi atau replay.

3. Rekonsiliasi via pull API

Webhook sebaiknya diperlakukan sebagai sinyal bahwa “sesuatu berubah”, bukan selalu sebagai satu-satunya sumber kebenaran. Jika provider menyediakan API untuk mengambil state terkini, gunakan itu ketika:

  • Anda mendeteksi gap versi.
  • Event datang terlalu lama.
  • Payload webhook parsial.
  • Logika domain membutuhkan konsistensi lebih tinggi.

Pola ini sangat kuat: webhook memicu pengambilan state terbaru dari sumber utama, lalu sistem Anda menerapkan snapshot yang paling mutakhir. Trade-off-nya adalah tambahan latensi, beban API, dan kebutuhan rate limit handling.

Kapan memilih push-only vs push-then-pull?

  • Push-only cocok jika payload lengkap, event version andal, dan domain relatif sederhana.
  • Push-then-pull lebih aman jika payload parsial, ordering lemah, atau konsekuensi inkonsistensi tinggi seperti pembayaran, pengiriman, dan subscription billing.

Retry policy: apa yang perlu dilakukan consumer?

Consumer biasanya tidak mengendalikan retry provider, tetapi Anda tetap harus mendesain endpoint dan worker agar retry tidak memperparah masalah.

Prinsip respons HTTP

  • Kembalikan 2xx jika request valid secara teknis dan sudah tersimpan, walaupun pemrosesan bisnis dilakukan asinkron.
  • Kembalikan 4xx jika request tidak valid dan mengulanginya tidak akan membantu, misalnya signature salah atau payload rusak.
  • Kembalikan 5xx hanya jika ada kegagalan sementara yang memang layak di-retry oleh provider, misalnya database down sebelum event tersimpan.

Kesalahan umum adalah mengembalikan 500 untuk event lama. Akibatnya provider terus retry, padahal consumer sebenarnya tidak membutuhkan event itu lagi.

Retry internal worker

Di dalam sistem Anda sendiri, worker pemroses event juga perlu retry, tetapi dengan aturan berbeda:

  • Gunakan exponential backoff untuk error sementara seperti lock timeout atau API provider timeout.
  • Batasi jumlah retry agar event buruk tidak menahan antrean terlalu lama.
  • Kirim ke dead-letter queue atau status quarantined setelah batas retry terlampaui.
  • Jangan retry tanpa batas untuk error deterministik seperti versi payload tidak dikenali.

Trade-off implementasi yang perlu dipahami

Unique event_id saja tidak cukup

Menyimpan event_id unik hanya menyelesaikan duplikasi event yang identik. Itu tidak mencegah event lama yang sah secara teknis menimpa state baru. Karena itu Anda tetap membutuhkan version check per resource.

Timestamp lebih mudah, tetapi lebih rapuh

Jika tidak ada version integer, timestamp adalah pilihan praktis. Namun ia rentan terhadap kejadian dengan waktu sama atau clock skew. Jika bisnis sensitif, pertimbangkan rekonsiliasi via pull API sebagai pagar tambahan.

Locking ketat vs throughput

Mengunci per resource dapat menyederhanakan konsistensi, tetapi throughput bisa turun pada entitas yang sering berubah. Optimistic concurrency biasanya memberi keseimbangan yang lebih baik, selama Anda siap menangani retry saat konflik update terjadi.

Checklist operasional untuk webhook yang lebih andal

  • Validasi signature menggunakan raw body.
  • Simpan event mentah dan metadata penting untuk audit.
  • Terapkan unique constraint pada provider + event_id atau dedupe key yang stabil.
  • Simpan versi atau timestamp terakhir per resource.
  • Jalankan optimistic check sebelum update state.
  • Balas cepat dari endpoint publik; proses berat lakukan di queue internal.
  • Tandai status event secara eksplisit: received, applied, ignored, quarantined, failed.
  • Pisahkan error teknis dari event stale.
  • Siapkan rekonsiliasi via pull API untuk gap versi atau data parsial.
  • Monitor rasio duplicate, stale, failed, dan quarantined event.
  • Pastikan ada dashboard atau query untuk melacak event per resource_id.
  • Uji skenario out-of-order dan retry dalam test integration, bukan hanya unit test.

Kesalahan yang sering terjadi

1. Menganggap urutan kedatangan sama dengan urutan kejadian

Ini asumsi yang paling sering menyebabkan state mundur. Waktu diterima server Anda bukan sumber kebenaran urutan bisnis.

2. Menggunakan side effect sebelum deduplikasi

Jika email, invoice, atau update stok dilakukan sebelum event ditandai idempotent, duplikasi hampir pasti terjadi saat retry.

3. Tidak menyimpan payload mentah

Tanpa payload mentah, investigasi produksi menjadi sulit. Anda kehilangan bukti apakah provider mengirim data yang salah, terlambat, atau berubah bentuk.

4. Mengembalikan 500 untuk semua masalah bisnis

Tidak semua kondisi harus memicu retry dari provider. Event stale, event duplikat, atau event yang aman diabaikan biasanya layak mendapat 2xx setelah disimpan dan dicatat.

5. Tidak punya strategi saat resource belum ada

Event updated bisa datang sebelum created. Tanpa placeholder state, karantina, atau rekonsiliasi, worker akan gagal berulang-ulang.

Debugging saat insiden terjadi

Pertanyaan yang perlu dijawab cepat

  • Apakah signature valid?
  • Apakah event yang sama sudah pernah diterima?
  • Versi event ini lebih baru atau lebih lama dari state lokal?
  • Apakah ada gap versi yang mengindikasikan event hilang?
  • Apakah endpoint sudah menyimpan event sebelum mengembalikan respons?
  • Apakah worker internal gagal menerapkan perubahan atau sengaja mengabaikannya?

Data observabilitas yang berguna

  • event_id, resource_id, event_type, resource_version.
  • Timestamp: occurred_at, received_at, applied_at.
  • Status akhir event dan alasan ignore/fail.
  • Correlation ID antara request webhook dan job internal.
  • Snapshot versi resource sebelum dan sesudah update.

Penutup

Masalah utama pada webhook bukan hanya “bagaimana menerima request”, tetapi bagaimana menjaga state tetap benar ketika event datang ulang, terlambat, atau tidak berurutan. Desain yang tahan gangguan biasanya memiliki beberapa lapisan sekaligus: signature validation, idempotency key, deduplication store, version/timestamp check, dan rekonsiliasi via pull API ketika event tidak cukup dapat dipercaya.

Jika Anda harus memilih prioritas implementasi, mulailah dari ini: simpan event mentah, deduplikasi berdasarkan event_id, dan cegah state mundur dengan version check per resource. Tiga langkah itu saja sudah menghilangkan sebagian besar bug fatal pada integrasi webhook modern.