Desain API webhook yang tahan retry, duplikasi, dan out-of-order berangkat dari satu asumsi penting: pengiriman event tidak pernah benar-benar “sekali dan pasti berhasil”. Dalam sistem nyata, provider bisa mengirim ulang event karena timeout, jaringan bermasalah, worker crash, atau karena penerima merespons lambat. Akibatnya, event yang sama bisa diterima lebih dari sekali, datang terlambat, atau datang dalam urutan yang berbeda dari kejadian aslinya.
Karena itu, desain webhook yang baik tidak bergantung pada urutan kirim dan tidak mengasumsikan setiap request unik. Solusi praktisnya adalah membuat kontrak payload yang stabil, memberi identitas jelas pada event dan percobaan pengiriman, memverifikasi keaslian request, menyimpan jejak deduplikasi, dan memproses event secara idempotent di sisi consumer. Artikel ini membahas pola-pola tersebut secara praktis untuk integrasi produksi.
Mental model: webhook adalah sistem pengiriman at-least-once
Dalam banyak implementasi, webhook lebih aman diperlakukan sebagai mekanisme at-least-once delivery, bukan exactly-once delivery. Artinya:
- Event bisa dikirim lebih dari sekali.
- Event yang sama bisa punya beberapa percobaan delivery.
- Tidak ada jaminan urutan kedatangan.
- Consumer mungkin sedang down ketika event dikirim pertama kali.
Begitu model ini diterima, keputusan desain jadi lebih jelas:
- Producer harus memberi identitas event yang stabil dan aman untuk diverifikasi.
- Consumer harus tahan terhadap duplikasi dan tidak bergantung pada urutan.
- Retry harus eksplisit, terukur, dan tidak menyebabkan efek samping berulang.
Kontrak payload yang stabil
Payload webhook sebaiknya kecil, jelas, dan stabil terhadap perubahan. Hindari payload yang berubah-ubah tergantung kondisi internal producer, karena ini menyulitkan validasi dan kompatibilitas jangka panjang.
Field minimum yang sebaiknya ada
- event_id: ID unik dan stabil untuk event bisnis.
- delivery_id: ID unik untuk setiap percobaan pengiriman.
- event_type: tipe event, misalnya
invoice.paidatauuser.updated. - occurred_at: kapan event terjadi di sistem producer.
- sent_at: kapan delivery ini dikirim.
- schema_version: versi kontrak payload.
- resource_id: identitas entity utama yang berubah.
- data: payload inti yang relevan untuk consumer.
Contoh payload yang praktis:
{
"event_id": "evt_01HZX9Y5W8K7",
"delivery_id": "dlv_01HZXA0NC8TR",
"event_type": "order.paid",
"schema_version": "2025-01-01",
"occurred_at": "2025-04-04T09:12:31Z",
"sent_at": "2025-04-04T09:12:35Z",
"resource_id": "ord_102938",
"data": {
"order_id": "ord_102938",
"customer_id": "cus_7788",
"status": "paid",
"amount": 250000,
"currency": "IDR"
}
}Kenapa event_id dan delivery_id harus dipisah
Ini kesalahan desain yang sering terjadi. event_id merepresentasikan satu kejadian bisnis. Jika producer mengirim ulang event yang sama, event_id harus tetap sama. Sebaliknya, delivery_id berubah untuk tiap percobaan pengiriman.
Manfaat pemisahan ini:
- Consumer bisa mendeteksi duplikasi event berdasarkan
event_id. - Tim operasional tetap bisa melacak percobaan kirim individual lewat
delivery_id. - Logging dan debugging jadi jauh lebih mudah.
Gunakan timestamp dengan makna yang jelas
Timestamp sering dipakai sembarangan. Minimal bedakan dua waktu berikut:
- occurred_at: waktu kejadian bisnis sebenarnya.
- sent_at: waktu request webhook dikirim.
Perbedaan ini penting saat terjadi backlog, retry, atau antrian tertunda. Jika event diproses berdasarkan sent_at saja, consumer bisa salah menafsirkan urutan perubahan state.
Versioning payload
Jangan mengandalkan perubahan diam-diam pada struktur JSON. Cantumkan schema_version atau versi kontrak lain yang eksplisit. Pendekatan ini membantu saat:
- Menambah field baru.
- Mengubah struktur nested object.
- Menghapus field lama secara bertahap.
Praktik yang aman:
- Tambahkan field baru secara backward-compatible.
- Hindari mengganti makna field lama tanpa versi baru.
- Dokumentasikan field wajib dan opsional.
Catatan: Versioning bukan hanya untuk perubahan besar. Menentukan kontrak eksplisit sejak awal akan mengurangi integrasi yang rapuh saat sistem berkembang.
Keamanan: verifikasi signature sebelum memproses
Webhook tidak cukup diamankan dengan IP allowlist atau secret dalam URL. Cara yang lebih umum dan kuat adalah menambahkan signature pada request, lalu memverifikasinya menggunakan secret bersama.
Pola umum signature
Producer menghitung HMAC dari payload mentah dan metadata tertentu, misalnya timestamp + body, lalu mengirimnya melalui header. Consumer menghitung ulang HMAC dengan secret yang sama dan membandingkan hasilnya.
Contoh header:
X-Webhook-Timestamp: 1712221955
X-Webhook-Signature: sha256=ab12cd34...Contoh pseudocode verifikasi:
raw_body = read_raw_request_body()
timestamp = request.header("X-Webhook-Timestamp")
signature = request.header("X-Webhook-Signature")
if timestamp is missing or signature is missing:
return 400
if abs(now_unix - timestamp) > allowed_skew_seconds:
return 401
signed_payload = timestamp + "." + raw_body
expected = hmac_sha256(secret, signed_payload)
if not constant_time_compare(signature, "sha256=" + expected):
return 401
Kenapa harus pakai raw body
Signature harus dihitung dari body mentah, bukan JSON yang sudah diparse lalu diserialisasi ulang. Reformat JSON dapat mengubah spasi, urutan key, atau encoding dan membuat signature valid terlihat salah.
Replay protection
Timestamp di header bukan hanya untuk logging. Ia juga membantu mencegah replay attack, yaitu request valid lama yang dikirim ulang oleh pihak tak berwenang. Batasi toleransi waktu, misalnya beberapa menit, sesuai karakteristik jaringan dan retry Anda.
Deduplikasi dan consumer yang idempoten
Dua lapisan ini saling melengkapi. Deduplikasi membantu menghindari pemrosesan ulang event yang sama, sedangkan idempotent consumer memastikan bahwa kalau event terproses ulang pun hasil akhirnya tetap benar.
Perbedaan idempotensi producer vs consumer
Istilah ini sering tercampur, padahal konteksnya berbeda.
- Idempotensi producer: producer memastikan satu kejadian bisnis menghasilkan identitas event yang stabil. Jika terjadi retry pengiriman, producer tidak membuat event bisnis baru secara tidak sengaja.
- Idempotensi consumer: consumer memastikan menerima event yang sama berkali-kali tidak menimbulkan efek samping ganda, seperti saldo bertambah dua kali atau email terkirim berulang.
Keduanya penting. Producer yang baik tanpa consumer idempoten tetap berisiko. Consumer yang idempoten tanpa event identity yang konsisten juga lebih sulit dioperasikan.
Dedupe store: apa yang disimpan?
Pola umum adalah menyimpan event_id yang sudah pernah diproses. Penyimpanan ini bisa berupa tabel database, key-value store, atau kombinasi cache dan database, tergantung kebutuhan durability.
Contoh struktur tabel sederhana:
CREATE TABLE processed_webhook_events (
event_id VARCHAR(64) PRIMARY KEY,
event_type VARCHAR(100) NOT NULL,
processed_at TIMESTAMP NOT NULL,
status VARCHAR(20) NOT NULL
);Alur dasarnya:
- Verifikasi signature.
- Parse payload dan validasi field wajib.
- Cek apakah
event_idsudah pernah diproses. - Jika belum, simpan penanda proses secara atomik.
- Jalankan logika bisnis.
- Tandai sukses atau gagal sesuai kebutuhan operasional.
Pentingnya operasi atomik
Jangan lakukan “cek lalu insert” tanpa proteksi konkurensi. Dua worker paralel bisa sama-sama melihat event belum ada lalu memproses keduanya. Gunakan salah satu pendekatan berikut:
- Unique constraint di database pada
event_id. - Atomic set-if-not-exists di key-value store.
- Transactional insert sebelum eksekusi efek samping.
Pseudocode yang lebih aman:
verify_signature(request)
payload = parse_json(request.body)
inserted = dedupe_store.insert_if_absent(payload.event_id)
if not inserted:
return 200 # duplikat, aman diabaikan
try:
apply_business_logic(payload)
mark_processed(payload.event_id)
return 200
except TemporaryError:
unmark_or_record_failure(payload.event_id)
return 500
except PermanentValidationError:
mark_rejected(payload.event_id)
return 422Idempoten di level bisnis, bukan hanya di level event
Menyimpan event_id saja kadang belum cukup. Misalnya event order.paid diproses untuk membuat transaksi keuangan. Jika sistem crash setelah transaksi tercatat tetapi sebelum status dedupe tersimpan, retry berikutnya bisa tetap membuat transaksi ganda.
Karena itu, idealnya ada guard idempotensi di sisi efek samping utama. Contohnya:
- Simpan transaksi dengan unique key berdasarkan
event_id. - Gunakan natural key bisnis jika lebih tepat.
- Pastikan operasi write kritis bisa diulang tanpa menggandakan hasil.
Jangan mengandalkan urutan kirim
Masalah out-of-order muncul saat event B tiba sebelum event A, padahal secara waktu kejadian A terjadi lebih dulu. Ini bisa terjadi karena retry selektif, perbedaan jalur worker, atau backlog antrian.
Kenapa memproses berdasarkan urutan kirim berbahaya
Misalnya ada dua event untuk pengguna yang sama:
user.updatedstatus menjadiactivepada 10:00user.updatedstatus menjadisuspendedpada 10:05
Jika event kedua dikirim lebih cepat dan event pertama terlambat, consumer yang hanya melakukan “last request wins” berdasarkan urutan kedatangan bisa berakhir dengan status salah: active, padahal state terbaru adalah suspended.
Strategi menangani out-of-order
- Gunakan occurred_at atau revision number, bukan urutan request masuk.
- Sertakan version/revision pada resource jika memungkinkan.
- Fetch latest state dari producer untuk event yang sensitif terhadap urutan.
- Desain event sebagai fakta final, bukan delta ambigu, jika cocok.
Contoh payload dengan revision:
{
"event_id": "evt_2001",
"delivery_id": "dlv_9002",
"event_type": "user.updated",
"occurred_at": "2025-04-04T10:05:00Z",
"resource_id": "usr_77",
"data": {
"user_id": "usr_77",
"status": "suspended",
"revision": 12
}
}Jika consumer sudah menyimpan revision 12, lalu menerima event lama dengan revision 11, event itu bisa diabaikan atau ditangani sebagai event usang.
Prinsip praktis: jika akurasi state penting, jangan infer state akhir dari urutan kedatangan webhook. Gunakan versi data, timestamp kejadian, atau ambil state terbaru dari sumber kebenaran.
Status HTTP yang tepat untuk webhook
Status HTTP mempengaruhi apakah producer akan retry. Karena itu, respons tidak boleh dipilih asal-asalan.
Kapan memakai 2xx
Gunakan 2xx saat request berhasil diterima dan tidak perlu dikirim ulang. Ini termasuk kasus:
- Event valid dan berhasil diproses.
- Event valid tetapi merupakan duplikat yang aman diabaikan.
- Event diterima untuk diproses async dan sudah tersimpan aman di queue internal.
Untuk webhook, 200 OK, 202 Accepted, atau 204 No Content biasanya cukup. Pilih konsisten. Jika Anda menerima request lalu memproses lewat queue internal, 202 dapat membantu menyatakan bahwa penerimaan sukses walau pemrosesan belum selesai.
Kapan memakai 4xx
Gunakan 4xx saat masalah ada pada request dan retry tanpa perubahan tidak akan membantu.
- 400 Bad Request: payload tidak valid secara sintaks atau field wajib hilang.
- 401 Unauthorized atau 403 Forbidden: signature salah atau secret tidak valid.
- 404 Not Found: endpoint salah. Biasanya masalah konfigurasi.
- 409 Conflict: bisa dipakai pada konflik state tertentu, tetapi untuk webhook sering lebih aman menyelesaikannya internal daripada memaksa retry semantik.
- 422 Unprocessable Entity: format valid tetapi isi tidak bisa diproses secara permanen, misalnya nilai field bertentangan dengan kontrak bisnis.
Hati-hati: jika producer Anda tetap me-retry semua non-2xx, maka pemakaian 4xx tidak otomatis menghentikan retry. Pastikan kontrak integrasi mendefinisikan perilaku ini.
Kapan memakai 5xx
Gunakan 5xx saat kegagalan bersifat sementara dan producer sebaiknya mencoba lagi.
- Database internal down.
- Queue internal penuh atau tidak tersedia.
- Timeout ke dependency yang wajib.
- Deadlock atau resource exhaustion sementara.
Jangan mengembalikan 500 untuk error validasi permanen. Itu hanya memperbanyak retry yang tidak akan pernah sukses.
Pola alur implementasi yang aman
Alur sinkron minimum
- Terima request.
- Baca raw body.
- Verifikasi signature dan timestamp.
- Parse JSON.
- Validasi field wajib:
event_id,delivery_id,event_type,occurred_at. - Simpan event ke inbox atau dedupe store secara atomik.
- Jika duplikat, kembalikan 200.
- Proses logika bisnis atau enqueue ke worker internal.
- Kembalikan 2xx bila event sudah diterima aman.
Pola webhook inbox
Untuk sistem yang lebih kompleks, pola yang sangat berguna adalah webhook inbox:
- Endpoint publik hanya memverifikasi, memvalidasi dasar, lalu menyimpan payload mentah ke tabel inbox.
- Worker internal membaca inbox dan menjalankan logika bisnis.
- Status inbox menyimpan jejak received, processing, processed, failed.
Keuntungan pola ini:
- Respons ke producer cepat.
- Retry dari producer tidak langsung membebani logika bisnis.
- Audit dan replay internal lebih mudah.
Trade-off-nya adalah kompleksitas tambahan: Anda perlu worker, storage, dan observability yang lebih baik.
Contoh pseudocode endpoint consumer
function handleWebhook(request):
rawBody = request.rawBody
if not verifySignature(request.headers, rawBody, secret):
return response(401)
payload = parseJson(rawBody)
if not isValidPayload(payload):
return response(400)
inserted = inbox.insertIfAbsent(
event_id = payload.event_id,
delivery_id = payload.delivery_id,
event_type = payload.event_type,
occurred_at = payload.occurred_at,
raw_payload = rawBody,
status = "received"
)
if not inserted:
return response(200)
queued = internalQueue.enqueue(payload.event_id)
if not queued:
return response(503)
return response(202)Contoh pseudocode worker idempoten
function processWebhookEvent(eventId):
event = inbox.get(eventId)
if event.status == "processed":
return
payload = parseJson(event.raw_payload)
begin transaction
if businessEffectAlreadyExists(payload.event_id):
inbox.markProcessed(eventId)
commit
return
applyBusinessChange(payload)
recordBusinessEffect(payload.event_id)
inbox.markProcessed(eventId)
commitRetry policy di sisi producer
Jika Anda juga membangun sisi pengirim webhook, retry policy harus jelas dan tidak agresif berlebihan.
Prinsip retry yang sehat
- Retry hanya untuk kegagalan yang mungkin sementara.
- Gunakan exponential backoff dengan jitter agar tidak menimbulkan lonjakan serentak.
- Batasi total durasi atau jumlah percobaan.
- Simpan histori delivery berdasarkan
delivery_id. - Jangan membuat
event_idbaru untuk retry event yang sama.
Contoh urutan retry yang wajar secara konsep: percobaan ulang setelah beberapa detik, lalu puluhan detik, menit, dan seterusnya. Nilai pastinya tergantung toleransi bisnis dan SLA integrasi Anda.
Retry dan timeout
Timeout producer harus realistis. Jika consumer butuh waktu untuk menyimpan ke inbox atau queue internal, producer tidak boleh terlalu cepat memutus koneksi. Sebaliknya, consumer juga sebaiknya tidak memproses pekerjaan berat langsung di request thread.
Tabel edge case yang sering muncul
| Skenario | Penyebab umum | Risiko | Penanganan yang disarankan |
|---|---|---|---|
| Request timeout, lalu producer retry | Consumer lambat atau koneksi putus | Efek samping ganda | Gunakan event_id stabil, dedupe store, dan operasi bisnis idempoten |
| Payload sama datang dua kali dengan delivery_id berbeda | Retry delivery normal | Duplikasi proses | Deduplikasi berdasarkan event_id, bukan delivery_id |
| delivery_id sama terkirim ulang | Network replay atau bug producer | Audit membingungkan | Log sebagai anomali; verifikasi signature dan timestamp |
| Event lama tiba setelah event baru | Out-of-order akibat backlog atau retry | State mundur ke versi lama | Gunakan occurred_at atau revision; abaikan event usang |
| Signature valid tapi timestamp terlalu lama | Replay attack atau delay ekstrem | Pemrosesan request lama yang tidak relevan | Tolak dengan 401/403 sesuai kebijakan; batasi skew waktu |
| Payload valid secara JSON, tapi field bisnis tidak masuk akal | Bug producer | Retry sia-sia atau data korup | Balas 422 jika kegagalan permanen dan dokumentasikan alasan |
| Dedupe record tersimpan, tapi logika bisnis gagal di tengah | Urutan transaksi salah | Event dianggap selesai padahal efek belum terjadi | Gunakan transaksi atomik atau inbox + worker + status yang jelas |
| Consumer mengandalkan urutan arrival | Desain state simplistis | Data akhir salah | Gunakan revision, happened time, atau fetch current state |
Kesalahan umum dalam desain webhook
- Menggunakan delivery_id untuk dedupe. Ini salah jika event yang sama dikirim ulang dengan delivery baru.
- Menganggap 200 berarti semua selesai. Jika proses lanjutan async gagal, Anda tetap perlu mekanisme retry internal.
- Memproses langsung pekerjaan berat di endpoint publik. Ini meningkatkan timeout dan retry tak perlu.
- Memvalidasi signature setelah parse payload. Risiko keamanan dan inkonsistensi body meningkat.
- Menentukan state dari urutan arrival. Ini rapuh saat out-of-order.
- Tidak menyimpan raw payload. Debugging insiden jadi jauh lebih sulit.
- Tidak punya observability. Tanpa log event_id dan delivery_id, tracing insiden lintas sistem hampir mustahil.
Tips debugging dan observability
Webhook yang gagal sering sulit ditelusuri karena melibatkan dua sistem. Minimal, log berikut sebaiknya tersedia:
event_iddelivery_idevent_type- hasil verifikasi signature
- timestamp request diterima
- status dedupe: baru atau duplikat
- status pemrosesan internal
- HTTP response code yang dikembalikan
Untuk operasi produksi, berguna juga memiliki:
- dashboard event gagal per endpoint
- jumlah retry dan usia event tertua yang belum selesai
- dead-letter queue atau daftar event yang butuh intervensi
- fitur replay internal dengan kontrol yang aman
Checklist implementasi produksi
- Payload memiliki
event_idyang stabil dandelivery_idper percobaan. - Ada
occurred_at,sent_at,event_type, danschema_version. - Signature diverifikasi memakai raw body dan perbandingan konstan waktu.
- Timestamp diverifikasi untuk membatasi replay.
- Consumer menyimpan dedupe record atau inbox secara atomik.
- Efek samping utama idempoten di level bisnis, bukan hanya level request.
- Endpoint cepat merespons dan pekerjaan berat dipindah ke queue/worker internal.
- 2xx dipakai untuk sukses atau duplikat aman; 4xx untuk error permanen; 5xx untuk error sementara.
- Sistem tidak bergantung pada urutan kedatangan event.
- Ada strategi menangani event usang dengan revision atau occurred_at.
- Retry producer memakai backoff dan tidak membuat event_id baru.
- Log dan metrik menyertakan event_id serta delivery_id.
- Raw payload disimpan untuk audit dan debugging sesuai kebijakan retensi data.
- Dokumentasi kontrak payload dan perilaku retry tersedia untuk integrator.
Penutup
Webhook yang andal bukanlah webhook yang berharap jaringan selalu baik, melainkan webhook yang tetap benar saat request diulang, datang ganda, terlambat, atau tidak berurutan. Fondasinya adalah kontrak payload yang stabil, pemisahan event_id dan delivery_id, verifikasi signature, deduplikasi atomik, consumer idempoten, serta penggunaan status HTTP yang sesuai.
Jika Anda harus memilih prioritas implementasi, urutannya sederhana: amankan request, simpan event dengan identitas yang benar, buat pemrosesan idempoten, lalu anggap urutan kedatangan tidak bisa dipercaya. Dengan pendekatan ini, desain API webhook Anda jauh lebih siap menghadapi kondisi produksi yang nyata.
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!