Keandalan6 Agustus 20267 mnt baca

Double Booking: Seperti Apa Percobaan Ulang Webhook bagi Orang Sungguhan

Argumen soal idempotensi biasanya berakhir di baris database duplikat. Di webhook penjadwalan, ujungnya dua orang datang untuk slot pukul 10.00 yang sama.

SATU BOOKING, DIKIRIM DUA KALIinvitee.created200 terlambathandler Andamenulis slotkalender10.00 terisipercobaan ulangbooking yang samakalender10.00 terisi dua kalidua orang,satu slotkonfliknya bukan di database Anda. konfliknya ada di pagi hari seseorang.
Satu booking, dua pengiriman, dua entri kalender. Duplikatnya tidak ada di tabel, tetapi di pagi seseorang.

Sebagian besar tulisan tentang idempotensi webhook, termasuk tulisan kami, berargumen dari database ke luar. Anda mendapat baris duplikat, kuitansi kedua, counter yang berlipat dua. Masalah nyata, tetapi abstrak. Pembaca mengangguk lalu lanjut.

Integrasi penjadwalan adalah tempat argumen itu berhenti menjadi abstrak. Duplikatnya bukan sebuah baris. Duplikatnya adalah dua orang di panggilan video pukul 10.00 yang sama-sama mengira slot itu milik mereka, dan salah satunya sudah izin tidak masuk pagi itu demi pertemuan ini.

TL;DR

  • Webhook penjadwalan punya timeout yang longgar, sehingga terasa aman.
  • Akibat dari percobaan ulang (retry) adalah konflik di dunia nyata yang harus diselesaikan manusia, bukan baris yang bisa Anda bersihkan.
  • Calendly menandatangani dengan Calendly-Webhook-Signature, pasangan stempel waktu (timestamp) dan tanda tangan (signature), HMAC-SHA256 atas timestamp.body.
  • Lakukan deduplikasi berdasarkan URI atau UUID milik booking itu sendiri, bukan isi payload, karena penjadwalan ulang sangat mirip dengan booking baru.
  • Pembatalan dan penjadwalan ulang yang tiba tidak berurutan adalah kasus yang benar-benar merusak kalender.

Mengapa kategori ini terasa aman padahal tidak

Sebagian besar hal yang membuat kategori webhook lain menakutkan tidak ada di sini. Tidak ada batas waktu respons lima detik seperti milik Shopify. Tidak ada langganan yang terhapus setelah sore yang buruk. Volume booking di sebagian besar produk cukup rendah sehingga Anda tidak akan pernah menyentuh rate limit.

Jadi handler-nya ditulis cepat, diuji dengan satu booking, lalu dirilis.

Risikonya justru ada di sisi lain. Saat webhook pembayaran diproses dua kali, Anda menyadarinya di data Anda sendiri dan memperbaikinya sebelum ada pihak luar yang terdampak. Saat webhook booking diproses dua kali, salinan kedua langsung terlihat oleh pelanggan, dan saat Anda mengetahuinya, kerusakannya sudah berupa konflik jadwal, bukan inkonsistensi data.

SATU BOOKING, DIKIRIM DUA KALIinvitee.created200 terlambathandler Andamenulis slotkalender10.00 terisipercobaan ulangbooking yang samakalender10.00 terisi dua kalidua orang,satu slotkonfliknya bukan di database Anda. konfliknya ada di pagi hari seseorang.

Urutan kejadiannya

Seorang tamu memesan slot pukul 10.00. Calendly mengirim invitee.created.

Handler Anda menulis slot itu, lalu memanggil provider kalender untuk membuat acara, lalu mengirim email konfirmasi. Pagi itu, panggilan ke kalender lambat. Semuanya memakan waktu cukup lama sehingga pengiriman tercatat gagal.

Calendly mencoba ulang. Handler Anda berjalan lagi. Tidak ada bagian di dalamnya yang tahu bahwa eksekusi pertama sudah selesai, karena eksekusi pertama selesai setelah responsnya sudah hilang. Dua entri kalender, dua email konfirmasi, dan slot yang kini, menurut logika ketersediaan Anda, terisi dua kali.

Setiap langkah berjalan dengan benar. Handler itu tidak punya bug yang bisa ditemukan unit test mana pun.

Deduplikasi berdasarkan booking, bukan payload

Naluri yang paling jelas adalah meng-hash payload dan menganggap hash yang sama sebagai duplikat.

Gunakan pengenal yang diberikan provider untuk booking itu sendiri. Payload Calendly membawa URI yang secara unik mengidentifikasi tamu dan acara yang dijadwalkan. Cal.com dan Acuity punya padanannya. Pengenal itu tetap sama di setiap percobaan ulang untuk pengiriman yang sama, dan berbeda untuk booking yang memang berbeda.

CREATE TABLE processed_bookings (
  booking_uri TEXT NOT NULL,
  event_type  TEXT NOT NULL,
  occurred_at TIMESTAMPTZ NOT NULL,
  PRIMARY KEY (booking_uri, event_type)
);
const { rowCount } = await db.query(
  `INSERT INTO processed_bookings (booking_uri, event_type, occurred_at)
   VALUES ($1, $2, $3) ON CONFLICT (booking_uri, event_type) DO NOTHING`,
  [body.payload.uri, body.event, body.created_at],
);
if (rowCount === 0) return new Response(null, { status: 200 });

Perhatikan key komposit pada (booking_uri, event_type), bukan URI saja. Booking yang sama memang menghasilkan invitee.created dan kemudian invitee.canceled, dan Anda ingin keduanya, masing-masing tepat satu kali.

Pembatalan yang tidak berurutan adalah bug yang lebih buruk

Duplikat menciptakan konflik yang langsung disadari seseorang. Kegagalan urutan menciptakan kalender yang diam-diam salah.

Seseorang memesan pukul 10.00 dan membatalkannya empat menit kemudian. Pengiriman invitee.created gagal dan dicoba ulang. Pengiriman invitee.canceled berhasil lebih dulu. Handler Anda membatalkan booking yang belum ada, mencatat peringatan di log, lalu lanjut. Kemudian percobaan ulang tiba dan membuat booking itu.

Slot itu kini terblokir untuk pertemuan yang tidak akan dihadiri siapa pun, dan tidak ada error yang muncul di mana pun. Deduplikasi tidak relevan: tidak ada event yang tiba dua kali. Keduanya tiba dalam urutan yang salah.

Perbaikannya adalah guard monotonik yang sama dengan yang dipakai untuk langganan dan record CRM, diterapkan pada stempel waktu milik booking itu sendiri, bukan waktu kedatangan di sisi Anda:

UPDATE bookings
SET status = $1, updated_at = $2
WHERE booking_uri = $3
  AND updated_at < $2;

Untuk state machine sekecil ini, pemeriksaan transisi yang eksplisit bahkan lebih baik. Booking yang sudah canceled harus menolak menjadi active karena event create yang datang terlambat, dan penolakan itu harus dicatat di log dengan mencolok, bukan diam-diam. Pembahasan umumnya ada di Anda Menerima Webhook yang Sama Dua Kali.

Verifikasi tanda tangan

Calendly mengirim header Calendly-Webhook-Signature yang berisi stempel waktu dan tanda tangan, dipisahkan koma. Payload yang ditandatangani adalah stempel waktu, sebuah titik, dan raw body permintaan, di-hash dengan HMAC-SHA256 memakai kunci penandatanganan yang Anda terima saat langganan dibuat.

Jika bentuk itu terasa familier, itu konstruksi yang sama dengan yang dipakai Stripe (referensi provider webhook menunjukkan provider mana saja yang memakainya), dan membawa dua syarat yang sama: hash byte mentahnya, bukan objek yang diserialisasi ulang, dan bandingkan dalam waktu konstan. Detail tiap provider ada di Verifikasi Tanda Tangan Webhook Stripe, GitHub, dan Shopify.

Tolak permintaan yang tidak membawa header tanda tangan, alih-alih mengizinkannya secara default. Endpoint booking yang menerima POST tanpa tanda tangan membiarkan siapa pun di internet mengisi kalender Anda.

Jadikan kalender sebagai constraint

Perbaikan yang paling tangguh di kategori ini sama sekali tidak ada di handler webhook. Perbaikannya adalah uniqueness constraint di level resource:

CREATE UNIQUE INDEX one_booking_per_slot
  ON bookings (resource_id, starts_at)
  WHERE status = 'active';

Kini pengiriman duplikat tidak bisa menghasilkan double booking meskipun semua lapisan lain gagal, karena database menolaknya. Handler menangkap pelanggaran constraint itu dan memperlakukannya sebagai duplikat, bukan error.

Ini layak dilakukan meskipun idempotensinya sudah benar, karena jaminannya berpindah dari “handler kami berhati-hati” menjadi “skema membuatnya mustahil”. Kekuatan keduanya berbeda, dan untuk sesuatu yang dialami langsung oleh pelanggan, Anda menginginkan yang kedua.

Peran AnyHook

Percobaan ulang adalah perilaku di lapisan transport, dan handler Anda melihatnya dua kali karena pengiriman pertama tidak bisa dikonfirmasi tepat waktu.

Arahkan langganan webhook ke in.anyhook.net/you/scheduling. AnyHook menyimpan event di edge (Cloudflare Worker AnyHook di in.anyhook.net) dan mengonfirmasi penerimaannya dalam sekitar satu detik, sehingga API kalender yang lambat di sisi Anda tidak pernah menghasilkan pengiriman gagal, yang berarti percobaan ulang dari provider tidak pernah terpicu dan duplikat itu tidak pernah sampai ke handler Anda.

  • Pengiriman ke endpoint Anda dicoba ulang dengan backoff eksponensial, dengan jeda yang memberi ruang bagi sistem yang sedang pulih, bukan tekanan.
  • Setiap event booking disimpan sebelum dikirim, sehingga pertanyaan “apakah kami menerima pembatalannya” bisa dijawab dari log, bukan disimpulkan dari kalender yang mungkin salah.
  • Putar ulang (replay) untuk satu rentang waktu menjalankan ulang periode yang bermasalah dengan handler yang sudah diperbaiki, dan untuk booking, itu berarti memperbaiki kalender, bukan mengirim email ke pelanggan agar mereka memesan ulang.
  • Peringatan kegagalan dikirim pada 1, 5, dan 20 kegagalan berturut-turut, yang di kategori ini membedakan antara tahu dari pemantauan Anda dan tahu dari orang yang sudah telanjur datang.

Handler Anda tetap butuh key deduplikasi, version guard, dan idealnya indeks unik. Relay menghilangkan percobaan ulang dari provider, bukan kewajiban untuk tetap benar saat percobaan ulang terjadi.

Kesimpulan

Penjadwalan adalah kategori ketika ketepatan webhook berhenti menjadi urusan engineering dan menjadi urusan yang langsung dihadapi pelanggan. Timeout-nya cukup longgar sehingga tidak ada yang memaksa Anda memikirkannya, sampai dua orang berada di slot yang sama.

Lakukan deduplikasi berdasarkan pengenal booking dan jenis event sekaligus. Lindungi penulisan dengan stempel waktu dari provider, bukan stempel waktu Anda sendiri. Pasang indeks unik pada slot agar skema menangkap apa yang terlewat oleh handler. Dan perlakukan pembatalan yang terlambat sama seperti refund yang terlambat, karena kalender yang diam-diam salah lebih mahal diperbaiki daripada baris yang salah.

Semua artikel6 Agustus 2026 · 7 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.