Merancang Webhook yang Andal: Retry, Signing, dan Replay

Foto oleh Nicola since 1972 on flickr
Pengiriman at-least-once menjamin sebuah event dikirim satu kali atau lebih tanpa pernah hilang tanpa jejak, sedangkan exactly-once menjamin pengiriman tunggal tanpa duplikat. Exactly-once tidak bisa dicapai melalui jaringan yang tidak dapat diandalkan tanpa kerja sama konsumen, karena pengirim tidak selalu bisa memastikan apakah pengiriman berhasil. Karena itu, sebagian besar sistem webhook yang andal berkomitmen pada pengiriman at-least-once dan menyerahkan penanganan duplikat kepada konsumen lewat idempotency key.
Hitung hash HMAC dari badan request mentah dan timestamp menggunakan secret bersama, lalu bandingkan dengan signature yang dikirim di header request menggunakan fungsi perbandingan waktu konstan. Tolak signature yang timestamp-nya berada di luar jendela toleransi, biasanya sekitar lima menit, untuk mencegah serangan replay. Jangan pernah menggunakan pengecekan kesetaraan string biasa, karena itu bisa membocorkan informasi waktu yang membantu penyerang menebak signature.
Mengulang segera setelah kegagalan mengasumsikan kegagalan itu hanya gangguan sesaat, padahal jika endpoint konsumen kelebihan beban atau down, badai retry langsung dari banyak event yang antre justru memperburuk gangguan itu. Exponential backoff memberi jeda antar retry yang makin lama, memberi waktu konsumen untuk pulih, dan menambahkan jitter acak mencegah banyak pengiriman yang antre retry pada saat yang persis sama.
Wajibkan pengirim menyertakan pengenal unik dan stabil di setiap event yang tetap sama di setiap retry. Sebelum melakukan pekerjaan yang berefek samping, periksa pengenal itu terhadap tabel event yang sudah diproses, dan catat sebagai sudah diproses dalam transaksi database yang sama dengan efek sampingnya. Jika pengenal itu sudah pernah terlihat, kembalikan respons sukses alih-alih memproses ulang event tersebut.
Bisa, asalkan Anda menjaga log event yang persisten dan terpisah dari upaya pengiriman masing-masing. Replay menjadi operasi sederhana: masukkan baris pengiriman baru yang menunjuk ke catatan event yang sudah ada dan biarkan worker pengiriman Anda memprosesnya sesuai jadwal normal. Ini memungkinkan konsumen mengisi data yang terlewat atau memproses ulang event setelah merilis perbaikan, tanpa Anda perlu membuat ulang event aslinya.

Foto oleh Nicola since 1972 on flickr
Webhook terlihat sederhana dari luar: sistem Anda mendeteksi sebuah event, mengirim HTTP POST ke URL yang didaftarkan konsumen, lalu selesai. Pada praktiknya, satu kalimat itu menyembunyikan masalah sistem terdistribusi yang nyata. Server konsumen bisa saja sedang down untuk maintenance. Jaringan bisa saja kehilangan respons setelah konsumen sebenarnya sudah memproses event tersebut. Proses deploy bisa saja me-restart worker pengiriman Anda di tengah request. Semua kegagalan ini adalah hal rutin, bukan pengecualian, dan sistem webhook yang mengabaikannya cepat atau lambat akan mengirim event dua kali, kehilangan event selamanya, atau membiarkan penyerang memalsukan payload yang terlihat sah.
Tulisan ini membahas empat pilar sistem webhook yang bisa dijadikan fondasi integrasi produksi oleh konsumen: menandatangani setiap payload agar konsumen bisa memverifikasi keasliannya, mengulang pengiriman yang gagal dengan backoff alih-alih membombardir endpoint yang sedang bermasalah, memberi konsumen cara aman menangani pengiriman duplikat, serta menjaga log event yang persisten agar kedua pihak bisa memutar ulang riwayat saat ada yang salah. Semua ini bukan hal eksotis. Ini adalah pola-pola yang sama yang secara independen dipakai oleh penyedia payment gateway dan platform API besar.
Ada tiga jaminan pengiriman yang secara teori bisa ditawarkan: at-most-once, exactly-once, dan at-least-once. At-most-once berarti event bisa saja hilang tanpa jejak, yang tidak bisa diterima untuk apa pun yang diandalkan konsumen untuk penagihan, inventaris, atau sinkronisasi state. Exactly-once terdengar ideal tetapi tidak bisa dicapai melalui batas jaringan yang tidak dapat diandalkan tanpa kerja sama konsumen, karena Anda tidak bisa tahu apakah request Anda berhasil jika acknowledgment hilang di tengah jalan. Karena itu at-least-once adalah satu-satunya jaminan yang jujur bisa Anda berikan: setiap event dikirim satu kali atau lebih, dan tugas konsumenlah memperlakukan pengiriman duplikat sebagai hal wajar, bukan bug.
Sertakan pengenal event di header khusus, selain di badan payload. Sebagian konsumen memfilter atau mentransformasi badan request sebelum sampai ke handler mereka, dan header lebih tahan melewati jalur itu.
Siapa pun bisa mengirim HTTP POST ke URL publik, sehingga endpoint konsumen butuh cara untuk memastikan request benar-benar berasal dari Anda dan bukan hasil pemalsuan atau replay oleh penyerang yang mencegat pengiriman lama. Pendekatan standarnya adalah HMAC: Anda dan konsumen berbagi secret, dan setiap request keluar menyertakan signature yang dihitung dari hash badan request digabung dengan secret dan timestamp tersebut. Konsumen menghitung ulang hash yang sama di sisi mereka dan membandingkannya dengan signature yang Anda kirim. Jika cocok, payload dianggap asli dan tidak diubah; jika tidak, konsumen menolak request tersebut sepenuhnya.
// Signing an outgoing webhook payload — Node.js
import crypto from "node:crypto"
function signPayload(rawBody: string, secret: string, timestamp: number) {
const signedPayload = `${timestamp}.${rawBody}`
const signature = crypto
.createHmac("sha256", secret)
.update(signedPayload)
.digest("hex")
return `t=${timestamp},v1=${signature}`
}
// Consumer-side verification (constant-time compare)
function verifySignature(rawBody: string, header: string, secret: string, toleranceSec = 300) {
const parts = Object.fromEntries(
header.split(",").map((p) => p.split("=") as [string, string])
)
const timestamp = Number(parts.t)
if (Math.abs(Date.now() / 1000 - timestamp) > toleranceSec) {
throw new Error("Timestamp outside tolerance window")
}
const expected = crypto
.createHmac("sha256", secret)
.update(`${timestamp}.${rawBody}`)
.digest("hex")
const a = Buffer.from(expected)
const b = Buffer.from(parts.v1)
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
throw new Error("Invalid signature")
}
}Dua detail membedakan implementasi yang benar dari yang keliru. Pertama, timestamp harus menjadi bagian dari konten yang ditandatangani dan konsumen harus menolak signature di luar jendela toleransi, biasanya sekitar lima menit, karena kalau tidak, penyerang yang menangkap satu request valid bisa memutarnya ulang tanpa batas. Kedua, perbandingan antara signature hasil hitungan dan signature yang diterima harus berjalan dalam waktu konstan. Perbandingan string naif yang langsung berhenti begitu menemukan karakter yang tidak cocok membocorkan informasi waktu yang bisa dimanfaatkan penyerang untuk menebak signature yang benar satu byte demi satu byte.
Jangan pernah membandingkan signature dengan pengecekan kesetaraan string biasa. Gunakan fungsi perbandingan waktu konstan, dan hanya lakukan perbandingan setelah memastikan kedua string memiliki panjang yang sama, karena kalau tidak, perbedaan panjang itu sendiri bisa membocorkan informasi atau memicu exception yang tidak tertangani.
Ketika sebuah upaya pengiriman gagal, entah karena timeout, respons lima ratus, atau koneksi yang terputus, perbaikan naif adalah langsung mengulang beberapa kali dengan jadwal tetap. Pendekatan itu justru memperburuk keadaan: jika endpoint konsumen down karena kelebihan beban, badai retry langsung dari setiap event yang Anda coba kirim hanya memperdalam gangguan itu. Solusinya adalah exponential backoff, di mana setiap retry menunggu lebih lama dari sebelumnya, dipadukan dengan sedikit jitter acak agar banyak pengiriman yang antre tidak sekaligus bangun dan retry pada saat yang persis sama.
| Percobaan | Jeda sebelum percobaan ini | Alasan |
|---|---|---|
| 1 | Segera | Percobaan pertama, belum ada alasan mencurigai masalah sistemik. |
| 2 | Sekitar 30 detik | Menutupi gangguan singkat: restart, error jaringan yang sesaat. |
| 3 | Sekitar 5 menit | Memberi waktu bagi proses deploy atau gangguan singkat untuk pulih. |
| 6 | Sekitar 1 jam | Mengasumsikan insiden yang lebih panjang; berhenti membebani server konsumen secara agresif. |
| 10 | Sekitar 12 jam, lalu berhenti | Percobaan terakhir sebelum event dipindahkan ke status dead letter untuk replay manual atau otomatis. |
Baris terakhir itu sama pentingnya dengan baris-baris awal. Retry tanpa henti membuang sumber daya pada endpoint yang sudah benar-benar hilang, misalnya konsumen yang menghapus integrasinya berbulan-bulan lalu. Setelah anggaran retry habis, pindahkan pengiriman itu ke status dead letter, beri tahu konsumen lewat dashboard atau ringkasan email, dan biarkan mereka memicu replay manual setelah memperbaiki apa pun yang rusak di sisi mereka.
Karena jaminan pengiriman at-least-once memastikan duplikat akan terjadi, handler di sisi konsumen perlu bersifat idempotent, artinya memproses event yang sama dua kali menghasilkan state akhir yang sama seperti memprosesnya sekali. Ini adalah tugas kedua belah pihak: Anda menyediakan pengenal event yang stabil, dan konsumen menyimpan pengenal mana saja yang sudah mereka proses. Polanya cukup sederhana untuk diimplementasikan dengan benar.
Model mental yang berguna: tugas pengirim adalah pengiriman at-least-once, dan tugas konsumen adalah pemrosesan exactly-once. Tidak ada satu pihak pun yang bisa mencapai exactly-once sendirian, tetapi bersama-sama keduanya membentuk sistem yang andal.
Setiap event yang akan Anda kirim seharusnya lebih dulu ditulis ke log event yang persisten, terpisah dari upaya pengiriman itu sendiri. Pemisahan ini penting karena memisahkan pertanyaan apa yang terjadi di sistem Anda dari pertanyaan apakah endpoint konsumen tertentu berhasil menerimanya. Dengan pemisahan itu, replay menjadi operasi sederhana: masukkan baris pengiriman baru yang menunjuk ke event yang sudah ada dan biarkan worker pengiriman Anda mengambilnya sesuai jadwal normalnya.
-- Event log table for replay support
CREATE TABLE webhook_events (
id UUID PRIMARY KEY,
event_type TEXT NOT NULL,
payload JSONB NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE webhook_deliveries (
id UUID PRIMARY KEY,
event_id UUID NOT NULL REFERENCES webhook_events(id),
endpoint_id UUID NOT NULL,
status TEXT NOT NULL, -- pending | delivered | failed | dead_letter
attempt_count INT NOT NULL DEFAULT 0,
next_attempt_at TIMESTAMPTZ,
last_response_code INT,
UNIQUE (event_id, endpoint_id)
);
-- Replaying a single event to a single endpoint on demand
INSERT INTO webhook_deliveries (id, event_id, endpoint_id, status, next_attempt_at)
VALUES (gen_random_uuid(), $1, $2, 'pending', now())
ON CONFLICT (event_id, endpoint_id) DO UPDATE
SET status = 'pending', next_attempt_at = now();Log event juga bermanfaat jauh melampaui pemulihan error. Konsumen dengan bug di versi handler yang lebih lama bisa meminta replay event seminggu terakhir setelah merilis perbaikan. Integrasi baru yang baru saja dikonfigurasi bisa mengisi state awalnya dari event historis alih-alih menunggu kemunculan berikutnya dari tiap jenis event. Jaga agar log tetap immutable, simpan event untuk jendela waktu terbatas yang sesuai kebutuhan kepatuhan dan penyimpanan Anda, dan indeks berdasarkan jenis event serta waktu pembuatan agar replay per kategori tetap murah.
Beberapa keputusan kecil menentukan apakah sistem webhook tetap sehat saat berkembang dari segelintir konsumen menjadi ratusan atau ribuan endpoint terdaftar.
Tampilkan log pengiriman webhook di dashboard Anda sendiri, lengkap dengan status, kode respons, dan waktu setiap percobaan. Ini adalah satu fitur yang paling banyak mengurangi tiket dukungan, karena konsumen bisa mendiagnosis sendiri alih-alih bertanya mengapa sebuah event tidak pernah sampai.
Keempat pilar ini, signing, backoff, idempotency, dan log yang bisa diputar ulang, tidak sulit satu per satu. Kesulitannya ada pada memperlakukannya sebagai satu sistem yang utuh sejak awal, bukan menambalnya setelah insiden produksi pertama. Konsumen menilai API webhook dari perilakunya di hari terburuk mereka, bukan hari terbaiknya, dan rancangan yang dibangun di atas pengiriman at-least-once dengan perkakas yang membuat duplikat dan gangguan bukan lagi masalah besar adalah yang benar-benar mendapatkan kepercayaan mereka.