Keandalan3 Agustus 20269 mnt baca

Anda Menerima Webhook yang Sama Dua Kali. Itu Memang Kontraknya, Bukan Bug.

Provider besar umumnya mengirim setidaknya sekali (at-least-once): duplikat itu pasti, bukan anomali. Empat pola idempotensi, dari terlemah ke terkuat.

DARI SISI PENGIRIM, KEDUANYA IDENTIKproviderPOSThandler berjalanpesanan dipenuhiack hilang di jaringanproviderPOSTmati lebih dulutidak terjadi apa-apaack juga tidak adacoba ulang → duplikat yang bisa Anda tangani di kodejangan coba ulang → pendapatan hilang diam-diam tanpa bisa Anda deteksi. setiap provider memilih coba ulang.
Dilema pengirim, dan alasan sebagian besar provider menyelesaikannya dengan cara yang sama.

Seseorang ditagih sekali, tetapi menerima email dua kali. Atau pesanan dikirim dua kali, atau dua orang datang untuk jadwal pukul 10.00 yang sama. Anda menggali log dan menemukan event.id yang sama diproses pukul 14.02.07, lalu sekali lagi pukul 14.02.41, dan naluri pertama Anda: provider-nya rusak.

Provider-nya tidak rusak. Provider melakukan persis apa yang dijanjikannya. Stripe, Shopify, Twilio, dan sebagian besar pengirim webhook penting lainnya menjamin pengiriman setidaknya sekali (at-least-once), dan setidaknya sekali adalah istilah teknis yang presisi: artinya, duplikat adalah bagian dari kontrak. Membangun sistem seolah-olah pengiriman terjadi tepat satu kali (exactly-once), itulah bug yang sebenarnya, dan bug ini hanya muncul di production, biasanya pada jam tersibuk Anda.

TL;DR

  • Setidaknya sekali berarti duplikat pasti terjadi cepat atau lambat, bukan hanya sesekali.
  • Pengiriman tepat satu kali tidak bisa dibeli berapa pun harganya; pemrosesan tepat satu kali bisa, dan itu dibangun di sisi Anda.
  • Indeks unik pada ID event dari provider menangani 90% masalahnya dengan satu baris SQL.
  • Efek samping non-transaksional (email, API pihak ketiga) butuh pola claim-then-commit.
  • Event yang tiba tidak berurutan adalah masalah saudaranya yang tidak pernah direncanakan siapa pun, dan solusinya versioning, bukan deduplikasi.

Mengapa tidak ada yang menjual jaminan tepat satu kali

Lihat dari posisi pengirim. Pengirim mengirim event Anda lewat POST, dan koneksi TCP putus sebelum respons kembali. Server Anda mungkin sudah memprosesnya sepenuhnya dan ACK-nya yang hilang, atau server Anda mungkin mati sebelum sempat menyentuhnya. Dari luar, kedua keadaan itu identik.

Pengirim punya tepat dua pilihan. Mencoba ulang, dan jika Anda sudah memprosesnya, kini Anda punya duplikat. Atau tidak mencoba ulang, dan jika Anda belum pernah memprosesnya, event itu hilang selamanya.

Itulah seluruh dilemanya, dan tidak ada pintu ketiga. Sebagian besar provider memilih mencoba ulang, karena notifikasi pembayaran duplikat adalah gangguan yang bisa Anda atasi secara teknis, sedangkan notifikasi pembayaran yang hilang adalah pendapatan yang lenyap diam-diam tanpa bisa Anda deteksi. Keputusan mereka tepat. Hanya saja, deduplikasi harus ditempatkan di suatu tempat, dan satu-satunya tempat dengan konteks yang cukup adalah database Anda.

Sumber duplikat di dunia nyata, kira-kira diurutkan dari yang paling sering kami lihat:

  • Respons Anda lambat dan pengirim mengalami timeout, tetapi handler Anda tetap selesai.
  • Load balancer atau proxy di depan aplikasi Anda membalas 502 setelah aplikasi Anda mengembalikan 200.
  • Handler Anda berhasil, lalu melempar error saat cleanup, dan framework Anda mengubahnya menjadi 500.
  • Sebuah deploy mematikan proses setelah penulisan, tetapi sebelum respons dikirim.
  • Anda mengeklik “kirim ulang” di sebuah dashboard, lalu lupa.

Perhatikan bahwa empat dari lima kasus itu adalah kasus ketika pekerjaannya sudah selesai, tetapi konfirmasinya hilang. Itu kasus normal, bukan kasus langka.

Pola 1: Indeks unik pada ID event

Jika Anda hanya melakukan satu hal, lakukan ini.

CREATE TABLE processed_events (
  event_id    TEXT PRIMARY KEY,
  provider    TEXT NOT NULL,
  processed_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
const { rowCount } = await db.query(
  `INSERT INTO processed_events (event_id, provider)
   VALUES ($1, $2) ON CONFLICT (event_id) DO NOTHING`,
  [event.id, "stripe"],
);
if (rowCount === 0) return new Response(null, { status: 200 }); // already handled
await doTheWork(event);

Dua detail yang sering salah.

Kembalikan 200 untuk duplikat, bukan 409. Duplikat yang Anda abaikan dengan benar adalah keberhasilan dari sudut pandang pengirim. Mengembalikan error membuat pengirim mencoba ulang sesuatu yang baru saja Anda katakan sudah Anda terima, dan Anda telah membuat loop.

ID-nya harus milik provider, bukan milik Anda: event.id dari Stripe, X-GitHub-Delivery dari GitHub, X-Shopify-Webhook-Id dari Shopify. Meng-hash body payload adalah pengganti yang menggoda sekaligus jebakan: jika pengirim tidak menaruh ID di body, dua event yang benar-benar berbeda bisa punya body yang identik sampai ke byte-nya. Dua pesanan dengan jumlah yang sama, pelanggan yang sama, berselang satu detik, adalah dua pesanan sungguhan. Deduplikasi berdasarkan hash body, dan Anda baru saja menolak memenuhi pesanan yang sudah dibayar seseorang.

Jika provider sama sekali tidak memberi ID, Anda terpaksa mengarang key sintetis dari field bisnis apa pun yang benar-benar unik. Posisi itu lebih buruk, dan layak diberi catatan di kode yang menjelaskan pilihan tersebut.

Pola 2: Taruh penanda dalam transaksi yang sama dengan pekerjaannya

Pola 1 punya celah. Jika insert sudah di-commit lalu doTheWork melempar error, Anda sudah menandai event sebagai diproses tanpa benar-benar memprosesnya. Percobaan ulang (retry) diabaikan dan event hilang tanpa jejak, dan itu kegagalan yang jelas lebih buruk daripada duplikat yang ingin Anda cegah.

Jika pekerjaannya berupa operasi database, tutup celah itu dengan transaksi:

await db.transaction(async (tx) => {
  const { rowCount } = await tx.query(
    `INSERT INTO processed_events (event_id, provider)
     VALUES ($1, $2) ON CONFLICT DO NOTHING`,
    [event.id, "stripe"],
  );
  if (rowCount === 0) return;          // duplicate, commit an empty tx
  await tx.query(`UPDATE orders SET status = 'paid' WHERE id = $1`, [orderId]);
});

Keduanya berhasil, atau tidak satu pun. Inilah bentuk yang benar, dan sebaiknya menjadi pilihan default Anda.

Pola 3: Claim-then-commit untuk efek samping yang tidak bisa dibatalkan

Transaksi tidak menjangkau Resend, Salesforce, atau API ekspedisi. Begitu email terkirim, tidak ada jalan kembali. Di sini, langkah yang jujur adalah penanda dua fase:

CREATE TABLE processed_events (
  event_id     TEXT PRIMARY KEY,
  status       TEXT NOT NULL,          -- 'processing' | 'done'
  claimed_at   TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  completed_at TIMESTAMPTZ
);

Klaim event sebelum melakukan apa pun. Kerjakan tugasnya. Tandai selesai. Duplikat konkuren yang datang di tengah proses akan melihat processing dan mundur, alih-alih berlomba dengan Anda.

Bagian yang memerlukan keputusan: apa yang terjadi pada baris yang tersangkut di processing karena proses mati di antara claim dan commit? Anda tidak bisa tahu apakah email-nya sudah terkirim. Kedua pilihan sama-sama bisa dipertanggungjawabkan, dan Anda harus memilihnya dengan sadar:

// Rows stuck in 'processing' >15min are re-claimed. Biases toward
// a possible duplicate email over a definitely-missing one. Flip this for
// side effects where duplication costs money (charges, shipments).
const STALE_CLAIM_MS = 15 * 60 * 1000;

Untuk kuitansi dan notifikasi, mengeklaim ulang adalah pilihan yang benar, karena email kedua itu murah. Untuk apa pun yang memindahkan uang atau barang, biarkan tetap tersangkut dan kirim peringatan ke seseorang. Menuliskan pilihan mana yang Anda ambil, beserta alasannya, dalam komentar di samping konstanta itu akan menyelamatkan orang berikutnya dari satu jam yang sangat membingungkan.

Pola 4: Version guard untuk pengiriman yang tidak berurutan

Deduplikasi tidak membantu di sini, dan inilah kegagalan yang benar-benar merusak data.

Percobaan ulang berarti event #2 bisa menyalip event #1, dan jadwal backoff justru yang membuka celahnya: pengiriman yang menunggu lima menit akan tiba setelah semua yang dikirim selama lima menit itu. Sebuah langganan diubah menjadi active pukul 14.00.01 dan menjadi cancelled pukul 14.00.03. Pengiriman pertama gagal dan dicoba ulang lima menit kemudian. Database Anda kini mencatat active, dan pelanggan tetap punya akses ke sesuatu yang sudah dibatalkannya. Tidak ada event yang diproses dua kali. Hasilnya tetap salah.

DIKIRIM SESUAI URUTANaktif 14:00:01dibatalkan 14:00:03TIBA SETELAH PERCOBAAN ULANGdibatalkanaktif (terlambat 5 mnt)tidak ada event yang diproses dua kali. barisnya masih berstatus aktif.solusinya guard monoton, bukan tabel deduplikasi: WHERE updated_at < $new
Deduplikasi adalah alat yang salah di sini. Tidak ada yang terkirim dua kali; urutannya saja yang salah.

Idempotensi tidak membantu di sini. Yang Anda butuhkan adalah guard monotonik di klausa WHERE:

UPDATE subscriptions
SET status = $1, updated_at = $2
WHERE id = $3
  AND updated_at < $2;    -- refuse to apply stale state

Gunakan field monotonik apa pun yang diberikan provider: field version, atau updated_at dari objek itu sendiri. Jangan NOW() milik Anda sendiri, yang mencerminkan kapan Anda menerimanya, dan justru urutan itulah yang salah.

Sebagian provider tidak memberi ID unik maupun urutan. HubSpot menyatakan dalam dokumentasinya bahwa eventId yang dikirimnya tidak dijamin unik, dan mengirim event secara batch tanpa janji urutan; apa yang harus dipakai untuk deduplikasi adalah masalah tersendiri.

Untuk state machine dengan invarian sungguhan, lakukan lebih jauh: tolak transisi ilegal sepenuhnya, alih-alih mengandalkan stempel waktu (timestamp). Transisi dari expired ke active seharusnya memicu error, bukan diterapkan diam-diam.

Catatan soal pengujian

Anda tidak akan menemukan bug ini dengan mengirim satu webhook lalu memeriksa hasilnya. Tes yang layak ditulis adalah:

  • Kirim event yang sama dua kali berturut-turut. Pastikan hanya terjadi satu efek samping.
  • Kirim event yang sama dua kali secara bersamaan. Tes inilah yang menemukan indeks unik yang belum ada, dan tes berurutan tidak akan pernah gagal.
  • Kirim dua event secara tidak berurutan. Pastikan state yang lebih baru yang menang.
  • Matikan proses di antara penulisan penanda dan efek sampingnya. Pastikan hasilnya sesuai keputusan Anda di Pola 3.

Tes konkuren itulah yang paling berharga. Sebagian besar kode idempotensi ditulis dengan asumsi pengiriman berurutan, dan ambruk saat pertama kali dua percobaan ulang mendarat di dua instance pada milidetik yang sama. Kami menulis lebih banyak tentang jarak antara tes webhook yang lolos dan kode webhook yang benar-benar berfungsi di Tes Tanda Tangan Webhook Tidak Membuktikan Apa Pun.

Peran AnyHook

AnyHook tidak menghapus kebutuhan idempotensi di sisi Anda, dan layanan apa pun yang mengeklaim bisa melakukannya sedang melebih-lebihkan. Duplikat berasal dari pengirim dan bisa muncul di jaringan antara kami dan Anda, dan lapisan itu tidak bisa dihilangkan siapa pun.

Yang berubah adalah kemampuan Anda melihat masalahnya:

  • Setiap event dicatat bersama permintaan lengkap, jumlah upaya pengirimannya, dan respons terakhir, sehingga pertanyaan “apakah ini terkirim dua kali, atau handler saya yang berjalan dua kali untuk satu pengiriman” bisa dijawab di dashboard, bukan dengan mencocokkan tiga sumber log pukul 2 dini hari.
  • Nomor upaya tercantum jelas di setiap pengiriman, jadi percobaan ulang bisa dibedakan dari event baru.
  • Header X-AnyHook-Delivery membawa ID event yang sama di setiap percobaan ulang sebuah event, sehingga Anda punya key deduplikasi untuk duplikat yang muncul di antara kami dan Anda, bahkan saat provider upstream pelit memberikannya.
  • Putar ulang (replay) dilakukan dengan sengaja dan ditandai. Event yang Anda putar ulang diberi flag is_replay, sehingga backfill tidak terlihat seperti lonjakan duplikat misterius seminggu kemudian.
  • Log AnyHook bersifat tamper-evident (setiap perubahan dapat terdeteksi). Saat pertanyaannya adalah “apa persisnya yang tiba, dan apakah baris ini pernah diedit”, database yang bisa diubah hanyalah klaim, bukan bukti: Log Webhook yang Tamper-Evident.

Kesimpulan

Duplikat bukan cacat pada provider Anda, melainkan harga dari tidak pernah kehilangan event, dan itu pertukaran yang tepat. Pasang indeks unik pada ID event dari provider hari ini. Pindahkan penanda ke dalam transaksi saat Anda menyentuh kode itu lagi. Dan jika Anda menangani apa pun yang punya siklus hidup (langganan, pesanan, deployment), tambahkan version guard sebelum pengiriman yang tidak berurutan menulis state usang yang tidak Anda sadari selama sebulan.

Semua artikel3 Agustus 2026 · 9 mnt

Berhenti kehilangan webhook.

Ganti satu URL. Dapatkan percobaan ulang, log event, dan putar ulang sekali klik. Atau lihat dulu: daftar dan jalankan latihan gangguan 30 detik, lalu saksikan event tetap selamat saat server Anda mati. Tidak perlu server.