Pada integrasi backend modern, webhook sering dipakai untuk mengalirkan data dari platform eksternal ke sistem internal: formulir pendaftaran, automation tool, platform peluang developer, sampai notifikasi pembayaran. Masalahnya, webhook yang terlihat sukses di sisi provider belum tentu benar-benar tersimpan dengan benar di sisi kita.
Studi kasus ini membahas bug yang cukup umum tetapi mahal dampaknya: webhook hilang akibat retry tanpa idempotensi. Gejalanya membingungkan: sebagian event tidak tercatat, sebagian lain justru tercatat ganda. Dari luar terlihat seperti gangguan acak, padahal akar masalahnya biasanya ada pada kombinasi retry, timeout, dan desain penyimpanan yang tidak tahan terhadap pengiriman ulang event.
Konteks insiden
Bayangkan sebuah tim mengelola portal internal untuk mengumpulkan data pendaftaran program developer: fellowship, grant, open source sponsorship, atau form minat untuk peluang komunitas. Data masuk dari beberapa sumber melalui webhook:
- platform form eksternal mengirim event
submission.created, - automation tool meneruskan data ke endpoint internal,
- beberapa provider akan mengirim ulang event jika respons lambat atau gagal.
Arsitektur awal terlihat sederhana:
- Provider mengirim HTTP POST ke endpoint
/webhooks/applications. - Backend memverifikasi signature, mem-parsing payload, lalu menulis ke tabel
applications. - Jika berhasil, backend mengembalikan status
200 OK.
Masalah muncul ketika traffic meningkat pada hari pengumuman program atau kampanye komunitas. Dashboard provider menunjukkan banyak webhook berstatus delivered, tetapi tim operasional menemukan dua anomali:
- sebagian pendaftar tidak muncul di dashboard internal,
- sebagian lainnya muncul dua kali atau lebih.
Gejala yang terlihat di lapangan
1. Provider melaporkan sukses, sistem internal tidak lengkap
Ini biasanya terjadi karena provider menganggap event sudah dikirim selama endpoint merespons dalam pola tertentu. Namun di sisi internal, write ke database bisa gagal, rollback, atau terputus setelah respons hampir terkirim.
2. Ada duplikasi dengan payload identik
Duplikasi sering muncul ketika provider melakukan retry karena timeout, koneksi putus, atau menerima status non-2xx. Jika backend tidak memiliki mekanisme idempotensi, event yang sama diproses sebagai data baru setiap kali datang.
3. Log aplikasi tampak normal secara parsial
Ini yang membuat debugging backend jadi sulit. Misalnya, ada log received webhook dan validated signature, tetapi tidak ada korelasi yang jelas antara event masuk, transaksi database, dan respons akhir.
Timeline insiden
Berikut contoh timeline yang realistis.
- 09:00 — Kampanye pendaftaran dibuka. Traffic webhook meningkat tajam.
- 09:12 — Provider mulai mengirim ulang sebagian event karena respons endpoint melambat.
- 09:20 — Tim support menerima laporan bahwa beberapa data pendaftaran belum muncul.
- 09:35 — Ditemukan juga data ganda untuk email yang sama, tetapi tidak semua ganda memiliki jumlah yang konsisten.
- 10:00 — Pemeriksaan provider dashboard menunjukkan event berstatus delivered, retry, dan sebagian timeout.
- 10:30 — Investigasi log aplikasi menunjukkan event yang sama diproses lebih dari sekali tanpa pengecekan idempotensi.
- 11:15 — Root cause dikonfirmasi: backend memproses setiap retry sebagai insert baru, dan pada beberapa kasus transaksi gagal setelah respons tidak tercatat secara benar.
Langkah investigasi: dari log ke database
1. Kumpulkan identifier dari provider
Langkah pertama adalah mencari apakah provider menyertakan identifier event yang stabil, misalnya event_id, delivery_id, atau kombinasi unik lain. Banyak tim terlalu cepat fokus ke payload bisnis seperti email atau nama, padahal kunci utama untuk debugging webhook adalah identitas event dari sumbernya.
Kalau provider tidak punya field eksplisit, simpan hash dari payload mentah ditambah metadata penting. Ini bukan solusi ideal, tetapi lebih baik daripada tidak punya korelasi sama sekali.
2. Telusuri request log berdasarkan event yang sama
Idealnya setiap request webhook menghasilkan log terstruktur seperti:
timestamp=2026-07-06T09:12:01Z level=info route=/webhooks/applications provider=external-form event_id=evt_9f12 status=received request_id=req_a1timestamp=2026-07-06T09:12:04Z level=info route=/webhooks/applications provider=external-form event_id=evt_9f12 status=db_insert_started request_id=req_a1timestamp=2026-07-06T09:12:11Z level=warn route=/webhooks/applications provider=external-form event_id=evt_9f12 status=timeout_near_limit request_id=req_a1timestamp=2026-07-06T09:12:20Z level=info route=/webhooks/applications provider=external-form event_id=evt_9f12 status=received request_id=req_b7 retry=1Dari pola seperti ini, Anda bisa melihat apakah event yang sama datang beberapa kali, apakah proses pertama sempat menulis ke database, dan apakah retry masuk sebelum proses pertama selesai.
3. Periksa tabel data bisnis dan tabel log webhook
Jika sistem hanya menyimpan hasil akhir ke tabel bisnis seperti applications, investigasi akan lebih sulit. Idealnya ada tabel penerimaan webhook terpisah, misalnya webhook_receipts, untuk menyimpan:
- provider,
- event_id eksternal,
- signature verification result,
- payload mentah atau referensinya,
- waktu diterima,
- status pemrosesan,
- error terakhir jika ada.
Query sederhana yang sering membantu:
SELECT provider, external_event_id, COUNT(*) AS total_receipts
FROM webhook_receipts
GROUP BY provider, external_event_id
HAVING COUNT(*) > 1;Untuk melihat data bisnis yang tercatat ganda:
SELECT source_event_id, COUNT(*) AS total_rows
FROM applications
GROUP BY source_event_id
HAVING COUNT(*) > 1;Kalau belum ada source_event_id di tabel bisnis, itu sendiri sudah menjadi petunjuk desain yang lemah.
4. Cocokkan timestamp antar sistem
Perhatikan perbedaan zona waktu, presisi timestamp, dan urutan kejadian. Sering kali tim menyimpulkan “event hilang” padahal sebenarnya event terlambat masuk akibat retry beberapa menit kemudian. Sebaliknya, event yang tampak sukses di provider bisa jadi ditandai sukses setelah beberapa percobaan, bukan pada percobaan pertama.
Root cause: retry tanpa idempotensi
Akar masalah pada kasus ini adalah backend mengasumsikan bahwa setiap webhook hanya akan datang satu kali. Asumsi itu salah.
Dalam sistem terdistribusi, webhook harus dianggap sebagai at-least-once delivery. Artinya event yang sama bisa datang lagi karena:
- provider timeout menunggu respons,
- koneksi putus setelah request diterima tetapi sebelum provider menerima respons,
- backend mengembalikan status error sementara,
- load balancer atau reverse proxy memutus koneksi,
- job downstream lambat dan membuat endpoint melebihi batas waktu.
Tanpa idempotensi, setiap retry akan diperlakukan sebagai event baru. Akibatnya ada dua kegagalan sekaligus:
- Data ganda karena insert dilakukan berulang untuk event yang sama.
- Data hilang secara semu karena sebagian event gagal diproses pada percobaan pertama, lalu retry tidak memiliki mekanisme status yang jelas sehingga sistem internal tidak konsisten dengan dashboard provider.
Perlu dibedakan juga antara deduplication berbasis data bisnis dan idempotensi berbasis event. Mengecek email agar tidak duplikat tidak selalu cukup, karena:
- satu pengguna bisa sah mengirim dua form berbeda,
- dua event berbeda bisa punya email sama,
- payload bisa diperbarui dengan event baru yang memang valid untuk diproses.
Solusi yang lebih kuat adalah memproses berdasarkan identitas event dari provider atau idempotency key yang ditentukan secara eksplisit.
Contoh implementasi yang bermasalah
Berikut pola handler yang rentan:
app.post('/webhooks/applications', async (req, res) => {
const payload = req.body;
await db.query(
'INSERT INTO applications (email, name, source, submitted_at) VALUES ($1, $2, $3, $4)',
[payload.email, payload.name, 'external-form', payload.submitted_at]
);
res.status(200).send('ok');
});Masalah pada kode di atas:
- tidak ada penyimpanan
external_event_id, - tidak ada pengecekan apakah event pernah diproses,
- tidak ada tabel receipt untuk melacak status,
- respons baru dikirim setelah write bisnis selesai, sehingga retry mudah terjadi saat database lambat.
Perbaikan: tambahkan idempotensi di level penyimpanan
Perbaikan paling penting adalah membuat pemrosesan webhook aman terhadap pengiriman ulang. Tempat terbaik untuk menegakkan ini biasanya di database, bukan hanya di memori aplikasi.
1. Simpan event receipt dengan kunci unik
Buat tabel khusus untuk receipt webhook:
CREATE TABLE webhook_receipts (
id BIGSERIAL PRIMARY KEY,
provider TEXT NOT NULL,
external_event_id TEXT NOT NULL,
payload JSONB NOT NULL,
received_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
processing_status TEXT NOT NULL,
processed_at TIMESTAMPTZ NULL,
error_message TEXT NULL,
UNIQUE (provider, external_event_id)
);Constraint unik ini adalah fondasi idempotensi. Jika event yang sama datang lagi, database akan menolak insert duplikat atau memungkinkan pola upsert.
2. Kaitkan data bisnis dengan event sumber
ALTER TABLE applications
ADD COLUMN source_provider TEXT,
ADD COLUMN source_event_id TEXT,
ADD CONSTRAINT applications_source_event_unique UNIQUE (source_provider, source_event_id);Dengan begitu, meskipun ada bug di layer aplikasi, database tetap punya pagar terakhir agar satu event tidak menghasilkan banyak baris.
3. Gunakan alur transaksi yang eksplisit
Contoh handler yang lebih aman:
app.post('/webhooks/applications', async (req, res) => {
const payload = req.body;
const provider = 'external-form';
const eventId = payload.event_id;
if (!eventId) {
return res.status(400).send('missing event_id');
}
try {
await db.tx(async (trx) => {
const inserted = await trx.query(
`INSERT INTO webhook_receipts
(provider, external_event_id, payload, processing_status)
VALUES ($1, $2, $3, $4)
ON CONFLICT (provider, external_event_id) DO NOTHING
RETURNING id`,
[provider, eventId, JSON.stringify(payload), 'processing']
);
if (inserted.rowCount === 0) {
return;
}
await trx.query(
`INSERT INTO applications
(email, name, source, submitted_at, source_provider, source_event_id)
VALUES ($1, $2, $3, $4, $5, $6)`,
[payload.email, payload.name, provider, payload.submitted_at, provider, eventId]
);
await trx.query(
`UPDATE webhook_receipts
SET processing_status = $3, processed_at = NOW()
WHERE provider = $1 AND external_event_id = $2`,
[provider, eventId, 'processed']
);
});
res.status(200).send('ok');
} catch (err) {
res.status(500).send('error');
}
});Mengapa pendekatan ini bekerja:
- Insert receipt dengan constraint unik mencegah event sama diproses dua kali.
- Status processing/processed membantu investigasi saat terjadi crash di tengah proses.
- Kunci unik di tabel bisnis memberi lapisan proteksi tambahan.
4. Jika pemrosesan berat, akui cepat lalu proses async
Sering kali webhook memicu validasi tambahan, enrichment, atau sinkronisasi ke sistem lain. Jangan lakukan semua itu di request yang sama jika bisa dihindari.
Pola yang umum lebih aman:
- verifikasi signature,
- simpan receipt secara idempotent,
- enqueue job internal,
- kembalikan respons 2xx secepat mungkin,
- proses lanjutan dilakukan oleh worker.
Trade-off-nya: arsitektur jadi sedikit lebih kompleks, tetapi jauh lebih tahan terhadap timeout dan lonjakan traffic.
Kasus penting: duplicate receipt bukan selalu duplicate business action
Dalam beberapa sistem, satu event webhook bisa memicu operasi yang tidak boleh diulang, misalnya:
- mengirim email konfirmasi,
- membuat tiket review,
- menyalin data ke CRM,
- men-trigger workflow lanjutan.
Karena itu idempotensi harus dipikirkan per langkah. Menyimpan receipt saja tidak cukup bila worker downstream masih bisa mengeksekusi efek samping yang sama dua kali.
Solusinya:
- sertakan
source_event_idpada job payload, - simpan status setiap side effect penting,
- gunakan kunci unik atau guard check pada operasi downstream yang kritis.
Dampak ke konsistensi data
Bug ini bukan sekadar soal data ganda. Dampaknya bisa meluas:
- Laporan salah karena jumlah pendaftar lebih tinggi atau lebih rendah dari kenyataan.
- Operasional kacau karena reviewer memproses entri ganda atau melewatkan pendaftar yang hilang.
- Integrasi turunan ikut tercemar ketika data salah diteruskan ke CRM, email tool, atau dashboard analitik.
- Sulit melakukan rekonsiliasi jika tidak ada jejak event sumber dan status pemrosesan yang rapi.
Pada sistem yang mengelola peluang developer atau pendaftaran komunitas, ini juga bisa berdampak reputasional karena peserta merasa sudah mendaftar, tetapi panitia tidak melihat datanya.
Observability yang seharusnya ada sejak awal
1. Structured logging
Minimal setiap webhook punya field log berikut:
provider,external_event_id,request_id,delivery_attemptjika tersedia,processing_status,db_transaction_idatau korelasi internal bila ada.
2. Metrics
Beberapa metrik yang sangat berguna:
- jumlah webhook diterima per provider,
- jumlah event unik vs duplicate receipt,
- latensi endpoint webhook,
- jumlah insert gagal,
- jumlah event berstatus processing terlalu lama,
- rasio retry dari provider.
3. Dead-letter atau retry internal
Jika proses async gagal, jangan diam-diam membuang event. Simpan ke antrean gagal atau tandai statusnya agar bisa di-replay secara manual.
4. Dashboard rekonsiliasi
Untuk integrasi penting, sediakan query atau dashboard yang bisa menjawab pertanyaan berikut dalam hitungan menit:
- event mana yang diterima provider hari ini,
- mana yang sudah masuk receipt,
- mana yang sudah jadi row bisnis,
- mana yang gagal atau tertahan.
Kesalahan umum yang sering memperparah masalah
- Mengandalkan email sebagai kunci unik. Ini sering salah secara bisnis dan teknis.
- Idempotensi hanya di cache sementara. Jika node restart atau deployment berganti pod, proteksi bisa hilang.
- Tidak menyimpan payload mentah. Investigasi jadi sulit ketika mapping payload ternyata salah.
- Merespons 200 terlalu cepat tanpa jejak yang durable. Kalau belum ada penyimpanan receipt, event bisa benar-benar hilang.
- Menganggap retry provider adalah bug provider. Dalam banyak kasus, retry adalah perilaku normal.
Checklist pencegahan untuk webhook yang lebih tahan insiden
- Pastikan setiap webhook memiliki identifier event unik yang disimpan.
- Buat tabel receipt dengan unique constraint pada
(provider, external_event_id). - Kaitkan entitas bisnis dengan source_event_id.
- Terapkan idempotensi di database, bukan hanya di layer aplikasi.
- Gunakan queue untuk pekerjaan berat agar endpoint cepat mengembalikan 2xx.
- Simpan payload mentah atau referensinya untuk audit dan replay.
- Tambahkan structured logs dan metrik duplicate receipt.
- Siapkan prosedur replay aman untuk event gagal.
- Uji skenario retry, timeout, dan duplicate delivery di environment staging.
- Dokumentasikan status pemrosesan agar support dan engineer bisa melakukan rekonsiliasi tanpa menebak-nebak.
Penutup
Kasus webhook hilang akibat retry tanpa idempotensi hampir selalu terlihat membingungkan pada awalnya: provider merasa sukses, sistem internal tidak konsisten, dan log seolah tidak menunjukkan masalah besar. Namun begitu alur event ditelusuri dengan benar, pola dasarnya biasanya sama: sistem menerima pengiriman ulang, tetapi tidak dirancang untuk memproses event yang sama secara aman.
Perbaikan paling efektif bukan menambal satu bug di handler, melainkan memperbaiki kontrak integrasi secara menyeluruh: simpan receipt, tegakkan unique constraint, proses secara idempotent, dan bangun observability yang cukup untuk rekonsiliasi. Dengan itu, retry tidak lagi menjadi sumber kekacauan data, melainkan perilaku normal yang sudah diantisipasi oleh desain backend Anda.
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!