Bug webhook yang memproses grant dua kali biasanya bukan karena provider “salah kirim”, tetapi karena sistem penerima mengasumsikan setiap request hanya datang sekali. Dalam praktiknya, banyak provider webhook akan melakukan retry saat menerima timeout, koneksi putus, atau respons non-2xx. Jika handler backend tidak idempotent, satu event yang sama bisa menghasilkan dua insert, dua grant approval, atau dua notifikasi.

Studi kasus ini relevan untuk sistem pendaftaran grant, sponsorship, atau hackathon. Belakangan makin banyak peluang developer dan open source, termasuk program grant dan benefit contributor seperti yang sering dirangkum dalam komunitas developer. Semakin banyak integrasi dengan form provider, payment gateway, automation platform, atau sistem review eksternal, semakin besar pula risiko webhook grant dobel karena retry tanpa idempotensi jika desain backend tidak disiplin.

Konteks insiden

Bayangkan sebuah platform internal untuk menerima aplikasi grant dari developer atau tim open source. Alurnya kira-kira seperti ini:

  1. Pelamar mengisi form.
  2. Sistem pihak ketiga memvalidasi submission lalu mengirim webhook ke backend kita.
  3. Backend membuat record applications, memberi status received, lalu menambahkan entri ke grant_allocations jika kampanye masih aktif.
  4. Tim operasional melihat dashboard untuk verifikasi lanjutan.

Masalah muncul ketika provider mengirim event yang sama dua kali. Pada hari insiden, beberapa pelamar terlihat memperoleh dua alokasi grant awal, padahal hanya submit sekali. Tidak semua request terduplikasi, hanya sebagian kecil. Justru pola seperti ini sering membuat debugging lebih sulit karena bug tampak acak.

Gejala dan dampak bisnis

Gejala teknis

  • Jumlah record pada tabel alokasi lebih besar dari jumlah submission unik.
  • Log menunjukkan dua request webhook dengan payload sangat mirip atau identik.
  • Beberapa request memiliki event ID yang sama, tetapi tetap diproses dua kali.
  • Grafik error tidak selalu tinggi karena kedua request sama-sama berhasil dan mengembalikan 200.

Dampak bisnis

  • Over-allocation budget: dana grant atau kuota hackathon terkunci dua kali.
  • Dashboard operasional menyesatkan: tim mengira ada lebih banyak penerima daripada seharusnya.
  • Trust issue: pelamar menerima email atau status ganda.
  • Beban rekonsiliasi manual: tim harus menelusuri record yang dobel dan memperbaikinya satu per satu.

Dalam konteks program developer atau open source, dampaknya sensitif. Grant yang seharusnya membantu contributor justru memicu proses rollback, audit manual, dan potensi ketidakadilan antar pelamar.

Timeline insiden debugging

Berikut contoh timeline yang realistis untuk menunjukkan bagaimana bug ini biasanya terdeteksi:

  1. 09:10 — Tim operasional melihat dua grant allocation untuk satu email pelamar.
  2. 09:20 — Dugaan awal: user submit form dua kali.
  3. 09:35 — Dicek di frontend, tidak ada dua submission dari browser yang sama.
  4. 10:00 — Query database menunjukkan beberapa allocation memiliki payload hash identik.
  5. 10:20 — Log HTTP ingress menunjukkan dua request masuk dalam jarak 8 detik dengan header event yang sama.
  6. 10:45 — Ditemukan bahwa request pertama memakan waktu lama karena call sinkron ke service notifikasi.
  7. 11:00 — Provider menganggap request pertama gagal atau timeout, lalu me-retry.
  8. 11:15 — Handler webhook tidak mengecek event yang pernah diproses dan tidak ada unique constraint di database.
  9. 11:40 — Root cause terkonfirmasi: retry provider + handler tidak idempoten + transaksi data tidak diproteksi constraint unik.

False lead yang sempat mengecoh

Salah satu bagian paling penting dalam debugging adalah mengenali dugaan yang masuk akal tetapi salah. Dalam kasus ini ada beberapa false lead:

  • User menekan tombol submit dua kali. Mungkin saja, tetapi bukti server menunjukkan event ID yang sama dari provider.
  • Worker queue menjalankan job dua kali. Bisa terjadi, tetapi duplikasi sudah muncul sebelum job async diproses.
  • Race condition di frontend. Tidak relevan bila sumber kebenaran datang dari webhook provider.
  • Masalah cache. Cache mungkin memperburuk observability, tetapi bukan penyebab insert ganda jika query insert memang dieksekusi dua kali.

Pelajaran pentingnya: jangan berhenti pada gejala permukaan. Cocokkan data di tiga tempat sekaligus: ingress log, application log, dan database state.

Analisis log: apa yang harus dicari

Log yang berguna untuk kasus seperti ini sebaiknya punya korelasi jelas. Minimal simpan:

  • request_id internal
  • provider_event_id atau header unik dari provider
  • payload_hash
  • status verifikasi signature
  • durasi request
  • hasil operasi database utama

Contoh log terstruktur yang membantu:

{
  "ts": "2026-07-11T09:58:12Z",
  "request_id": "req_8f2a",
  "provider": "grant-provider",
  "provider_event_id": "evt_92ab1",
  "payload_hash": "6f1d...",
  "path": "/webhooks/grants",
  "signature_valid": true,
  "status": 200,
  "duration_ms": 12450,
  "application_id": "app_501"
}

{
  "ts": "2026-07-11T09:58:20Z",
  "request_id": "req_91ce",
  "provider": "grant-provider",
  "provider_event_id": "evt_92ab1",
  "payload_hash": "6f1d...",
  "path": "/webhooks/grants",
  "signature_valid": true,
  "status": 200,
  "duration_ms": 340,
  "application_id": "app_502"
}

Dari dua log di atas, ada sinyal kuat:

  • provider_event_id sama.
  • payload_hash sama.
  • Request pertama sangat lambat.
  • Request kedua membuat application_id baru, artinya event yang sama diproses ulang sebagai entitas baru.

Jika Anda belum menyimpan provider_event_id, debugging akan jauh lebih mahal. Anda hanya bisa menebak lewat timestamp, payload, atau email pelamar, yang tidak selalu unik.

Root cause: retry provider + handler tidak idempoten

Akar masalahnya sederhana tetapi sering terlewat: provider webhook mengirim ulang event yang sama, sementara endpoint backend memperlakukan setiap request sebagai perintah baru.

Biasanya alurnya seperti ini:

  1. Provider mengirim event evt_92ab1.
  2. Backend mulai memproses, lalu melakukan operasi lambat: insert banyak tabel, panggil layanan lain, kirim email, atau tunggu lock database.
  3. Provider tidak menerima respons 2xx dalam batas waktu yang diharapkan, lalu melakukan retry.
  4. Retry masuk sebagai request kedua.
  5. Karena tidak ada tabel deduplikasi, idempotency key, atau constraint unik, event diproses lagi dan menghasilkan grant dobel.

Idempotensi berarti menjalankan operasi yang sama berkali-kali tetap menghasilkan keadaan akhir yang sama. Pada webhook, artinya event yang sama boleh diterima lebih dari sekali, tetapi sistem hanya mengeksekusi efek bisnis sekali.

Cara reproduksi bug secara aman

Untuk memastikan hipotesis benar, reproduksi di environment staging. Jangan langsung bereksperimen di production.

Langkah reproduksi

  1. Ambil satu payload webhook valid dari log atau sandbox provider.
  2. Kirim payload yang sama dua kali ke endpoint staging.
  3. Jika provider mengirim signature, gunakan mekanisme signing yang valid untuk environment uji.
  4. Buat handler sengaja lambat, misalnya tambahkan delay sementara atau simulasi dependency lambat.
  5. Lihat apakah dua request identik menghasilkan dua record bisnis.

Contoh dengan curl untuk mengirim payload yang sama dua kali:

curl -X POST https://staging.example.com/webhooks/grants \
  -H 'Content-Type: application/json' \
  -H 'X-Provider-Event-Id: evt_92ab1' \
  -H 'X-Signature: test-signature' \
  -d '{"applicant_email":"dev@example.org","campaign_id":"oss-grant-2026","submission_id":"sub_1001"}'

curl -X POST https://staging.example.com/webhooks/grants \
  -H 'Content-Type: application/json' \
  -H 'X-Provider-Event-Id: evt_92ab1' \
  -H 'X-Signature: test-signature' \
  -d '{"applicant_email":"dev@example.org","campaign_id":"oss-grant-2026","submission_id":"sub_1001"}'

Jika backend belum idempoten, biasanya akan terbentuk dua record. Ini adalah bukti reproduksi yang sangat kuat.

Perbaikan desain: jangan hanya di kode, amankan juga di database

Kesalahan umum adalah memperbaiki bug hanya dengan if di level aplikasi. Itu membantu, tetapi belum cukup. Solusi yang lebih kuat biasanya terdiri dari beberapa lapisan:

  • Idempotency key atau event ledger.
  • Unique constraint di database.
  • Transaksi untuk memastikan cek dan tulis konsisten.
  • Respons cepat ke provider, pekerjaan berat dipindahkan ke queue.

Pola tabel deduplikasi event

Salah satu pendekatan paling aman adalah menyimpan event yang sudah pernah diproses ke tabel khusus, misalnya processed_webhook_events.

CREATE TABLE processed_webhook_events (
  provider VARCHAR(50) NOT NULL,
  event_id VARCHAR(255) NOT NULL,
  received_at TIMESTAMP NOT NULL,
  payload_hash VARCHAR(255) NULL,
  PRIMARY KEY (provider, event_id)
);

Dengan skema ini, setiap event dari provider hanya boleh masuk sekali untuk kombinasi provider dan event ID yang sama.

Constraint unik pada entitas bisnis

Selain ledger event, tambahkan constraint pada data bisnis yang memang harus unik. Misalnya satu submission eksternal hanya boleh menghasilkan satu application internal.

ALTER TABLE applications
ADD CONSTRAINT uq_applications_provider_submission
UNIQUE (provider, external_submission_id);

Jika event yang sama lolos sampai ke layer aplikasi karena race condition, database tetap menjadi benteng terakhir.

Contoh handler webhook yang lebih aman

Berikut contoh pseudocode bergaya umum. Tujuannya bukan menunjukkan framework tertentu, tetapi pola implementasinya.

function handleGrantWebhook(request) {
  verifySignature(request)

  const provider = 'grant-provider'
  const eventId = request.header('X-Provider-Event-Id')
  const payload = request.json()

  beginTransaction()
  try {
    const inserted = insertProcessedEventIfAbsent({
      provider,
      eventId,
      payloadHash: sha256(request.rawBody),
      receivedAt: now()
    })

    if (!inserted) {
      commitTransaction()
      return response(200, { status: 'duplicate_ignored' })
    }

    const application = upsertApplicationByExternalSubmission({
      provider,
      externalSubmissionId: payload.submission_id,
      applicantEmail: payload.applicant_email,
      campaignId: payload.campaign_id,
      status: 'received'
    })

    enqueuePostProcessing({
      applicationId: application.id,
      eventId
    })

    commitTransaction()
    return response(200, { status: 'accepted' })
  } catch (err) {
    rollbackTransaction()
    throw err
  }
}

Ada beberapa hal penting di sini:

  • Verifikasi signature dilakukan dulu, agar event palsu tidak ikut tercatat sebagai valid.
  • Insert deduplikasi terjadi sebelum side effect bisnis.
  • Insert deduplikasi dan perubahan data utama berada dalam transaksi.
  • Respons 200 dikirim setelah event aman dicatat, sedangkan kerja berat dipindahkan ke queue.

Kenapa tidak cukup hanya cek lalu insert?

Pola seperti ini rawan race condition:

if (!findProcessedEvent(provider, eventId)) {
  createProcessedEvent(provider, eventId)
  createApplication(...)
}

Jika dua request masuk hampir bersamaan, keduanya bisa sama-sama lolos dari pengecekan sebelum salah satunya menulis. Karena itu, lebih aman memakai operasi insert atomik dengan constraint unik, lalu tangani konflik sebagai duplikat yang sah.

Strategi retry yang aman

Retry bukan musuh. Retry justru bagian normal dari sistem terdistribusi. Yang harus diperbaiki adalah cara backend bereaksi terhadap retry.

Prinsip yang perlu diikuti

  • Endpoint webhook harus idempoten.
  • Balas 2xx secepat mungkin setelah event diterima dan dicatat dengan aman.
  • Pindahkan kerja lambat ke asynchronous processing.
  • Jangan kirim error untuk event duplikat. Biasanya lebih aman mengembalikan 200 dengan status internal “already processed” atau serupa.

Kapan perlu 409 atau 5xx?

Untuk webhook dari provider eksternal, mengembalikan 409 pada duplikat sering tidak membantu karena provider mungkin justru me-retry lagi. Untuk kasus ini, respons 2xx biasanya lebih aman jika Anda yakin event tersebut memang duplikat valid. Sementara 5xx sebaiknya dipakai hanya saat sistem benar-benar belum bisa menjamin event tercatat atau diproses dengan aman.

Memindahkan side effect ke queue

Salah satu penyebab request pertama timeout adalah terlalu banyak kerja sinkron di dalam handler, misalnya:

  • mengirim email konfirmasi,
  • memanggil layanan scoring,
  • sinkronisasi ke CRM,
  • menghasilkan dokumen atau PDF,
  • meng-update leaderboard atau dashboard agregat.

Langkah yang lebih aman:

  1. Verifikasi request.
  2. Simpan event dan entitas inti dalam transaksi singkat.
  3. Masukkan job ke queue.
  4. Segera balas 200.

Namun queue sendiri juga harus idempoten. Jangan berasumsi job hanya dieksekusi sekali. Banyak sistem queue bersifat at least once delivery. Jika worker crash setelah melakukan side effect tetapi sebelum ack, job bisa diambil lagi.

Idempotensi di layer job

Untuk job notifikasi atau alokasi lanjutan, gunakan kunci unik tambahan, misalnya satu application_id hanya boleh menghasilkan satu grant_allocation.

ALTER TABLE grant_allocations
ADD CONSTRAINT uq_grant_allocations_application UNIQUE (application_id);

Dengan begitu, walaupun job dijalankan dua kali, hasil akhirnya tetap satu alokasi.

Regresi test yang wajib ditambahkan

Perbaikan tanpa test regresi hampir pasti akan terulang saat refactor berikutnya. Minimal tambahkan test berikut:

1. Event yang sama dikirim dua kali

it('memproses satu event hanya sekali', async () => {
  await sendWebhook({ eventId: 'evt_1', submissionId: 'sub_1' })
  await sendWebhook({ eventId: 'evt_1', submissionId: 'sub_1' })

  expect(countProcessedEvents('evt_1')).toBe(1)
  expect(countApplicationsBySubmission('sub_1')).toBe(1)
})

2. Dua request identik masuk paralel

Ini penting untuk menguji race condition, bukan hanya duplikasi berurutan.

it('aman terhadap request paralel untuk event yang sama', async () => {
  await Promise.all([
    sendWebhook({ eventId: 'evt_parallel', submissionId: 'sub_parallel' }),
    sendWebhook({ eventId: 'evt_parallel', submissionId: 'sub_parallel' })
  ])

  expect(countApplicationsBySubmission('sub_parallel')).toBe(1)
})

3. Job queue dieksekusi ulang

Simulasikan worker crash setelah side effect pertama, lalu pastikan retry tidak menambah alokasi kedua.

Observability: metrik yang benar-benar membantu

Insiden seperti ini sering lolos karena sistem hanya memantau error rate. Padahal duplikasi bisa terjadi saat semua request sukses. Tambahkan observability yang lebih relevan:

  • Counter event duplicate: berapa banyak event yang ditandai duplikat.
  • Webhook processing duration: request lambat adalah indikator risiko retry.
  • Insert conflict rate pada constraint unik: lonjakan kecil bisa jadi sinyal adanya retry atau race.
  • Queue retry count untuk job turunan.
  • Business invariant alert: misalnya satu submission tidak boleh punya lebih dari satu allocation.

Selain metrik, dashboard query juga berguna. Contoh SQL audit sederhana:

SELECT external_submission_id, COUNT(*)
FROM applications
GROUP BY external_submission_id
HAVING COUNT(*) > 1;

Dan untuk mendeteksi allocation ganda:

SELECT application_id, COUNT(*)
FROM grant_allocations
GROUP BY application_id
HAVING COUNT(*) > 1;

Perbaikan data setelah insiden

Setelah kode diperbaiki, jangan lupa remediation data. Langkah ini harus hati-hati karena menyangkut audit bisnis.

  1. Identifikasi semua event duplikat berdasarkan provider_event_id, external_submission_id, atau korelasi payload.
  2. Tentukan record mana yang dianggap canonical, biasanya yang pertama sukses.
  3. Batalkan atau hapus allocation ganda sesuai kebijakan audit.
  4. Perbaiki notifikasi yang terlanjur terkirim, misalnya kirim klarifikasi jika perlu.
  5. Simpan laporan insiden dan query rekonsiliasi untuk kebutuhan audit di masa depan.

Jika sistem finansial atau grant sudah menyentuh pencairan nyata, hindari penghapusan sembarangan. Lebih aman menambah status pembatalan atau reversal agar jejak audit tetap lengkap.

Trade-off beberapa pendekatan idempotensi

Mengandalkan event ID provider

  • Kelebihan: sederhana, akurat jika provider menjamin ID unik dan stabil.
  • Kekurangan: tidak semua provider memberi event ID yang nyaman dipakai atau konsisten di semua jenis event.

Menggunakan external submission ID

  • Kelebihan: dekat dengan entitas bisnis.
  • Kekurangan: tidak selalu cukup jika satu submission bisa memicu beberapa event berbeda.

Menggunakan payload hash

  • Kelebihan: berguna sebagai cadangan saat header unik tidak ada.
  • Kekurangan: payload yang semantis sama bisa berbeda hash jika urutan field atau metadata berubah.

Dalam banyak kasus, pendekatan terbaik adalah kombinasi: event ledger berbasis event ID provider ditambah constraint unik pada identitas bisnis.

Checklist pencegahan webhook grant dobel karena retry tanpa idempotensi

  • Verifikasi signature webhook sebelum memproses payload.
  • Simpan provider_event_id dan request_id di log terstruktur.
  • Buat tabel deduplikasi event dengan constraint unik.
  • Tambahkan constraint unik pada entitas bisnis yang memang harus satu-satu.
  • Gunakan transaksi untuk cek dan tulis yang saling bergantung.
  • Balas 2xx secepat mungkin setelah event tercatat aman.
  • Pindahkan side effect lambat ke queue.
  • Pastikan worker queue juga idempoten.
  • Tambahkan test untuk request duplikat dan request paralel.
  • Pantau metrik duplicate, latency, dan conflict unik.
  • Siapkan query audit untuk rekonsiliasi data pascainsiden.

Penutup

Bug webhook grant dobel karena retry tanpa idempotensi adalah contoh klasik masalah sistem terdistribusi: request yang sama tidak selalu datang sekali, dan “sukses” di level HTTP belum tentu benar di level bisnis. Provider webhook boleh saja me-retry, dan itu normal. Yang tidak boleh adalah backend mengubah state bisnis dua kali untuk event yang sama.

Perbaikan yang tahan lama hampir selalu melibatkan beberapa lapisan sekaligus: ledger event, unique constraint database, transaksi, queue yang idempoten, serta observability yang memantau duplikasi sebagai sinyal utama. Jika sistem Anda menangani program grant, hackathon, atau peluang contributor open source yang terus bertambah, disiplin ini bukan sekadar optimasi teknis, melainkan perlindungan terhadap data, budget, dan kepercayaan pengguna.