Masalah yang Harus Diselesaikan
Pada sinkronisasi data riset, provider webhook sering mengirim event dalam kondisi berikut:
- Duplikat: event yang sama dikirim ulang karena timeout atau provider tidak menerima respons sukses.
- Out-of-order: event versi lama datang setelah versi baru.
- Terlambat: event tiba beberapa menit atau jam setelah dibuat.
- Retry burst: beberapa retry datang berdekatan ketika ada kegagalan jaringan.
- Replay: request lama dikirim ulang oleh pihak yang tidak berwenang atau oleh infrastruktur perantara.
Solusinya bukan sekadar “cek apakah ID sudah ada”. Sistem yang baik perlu menggabungkan beberapa lapisan: kontrak event yang jelas, verifikasi signature, validasi timestamp, deduplikasi, aturan ordering, penyimpanan audit, dan observability.
Prinsip Desain Webhook Idempoten
1. Pisahkan identitas event dari identitas data
event_id mengidentifikasi kiriman event, sedangkan entity_id mengidentifikasi objek bisnis yang diperbarui, misalnya dokumen riset, kutipan, atau ringkasan sumber. Keduanya tidak boleh dicampur.
- event_id: unik per event, dipakai untuk deduplikasi transport.
- entity_id: unik per objek domain, dipakai untuk update/upsert data tujuan.
- source_version atau occurred_at: dipakai untuk menilai apakah event lebih baru daripada state saat ini.
2. Simpan referensi sumber yang dapat diverifikasi
Karena konteksnya data riset publik, payload sebaiknya membawa metadata sumber seperti URL sumber, jenis dokumen, hash konten, atau identifier publik lain. Tujuannya agar consumer dapat menyimpan jejak asal data, bukan hanya nilai akhirnya.
3. Anggap retry sebagai perilaku normal
Webhook producer yang sehat akan melakukan retry saat gagal menerima respons sukses. Karena itu, endpoint consumer harus dirancang idempoten sejak awal, bukan ditambal setelah terjadi duplikasi.
Kontrak Payload yang Disarankan
Payload perlu cukup kaya untuk audit dan ordering, tetapi tetap sederhana. Contoh skema event generik:
{
"event_id": "evt_01J8Y7Y8R8M3K2P4Z9A1",
"event_type": "research.document.updated",
"occurred_at": "2026-08-25T10:15:30Z",
"delivered_at": "2026-08-25T10:15:35Z",
"producer": "public-research-sync",
"entity": {
"entity_id": "doc_4f9a2c",
"entity_type": "research_document",
"source_version": 12
},
"source": {
"url": "https://example.org/research/report-2026-08-25",
"title": "Research Report",
"content_hash": "sha256:7c9d...",
"published_at": "2026-08-24T21:00:00Z"
},
"data": {
"summary": "Konten ringkasan terbaru",
"authors": ["A", "B"],
"citations": [
{
"text": "Kutipan yang dapat diverifikasi",
"reference_url": "https://example.org/research/report-2026-08-25#section-2"
}
]
}
}Beberapa catatan penting:
- event_id harus stabil untuk event yang sama. Jika provider melakukan retry, nilai ini tidak boleh berubah.
- occurred_at adalah waktu kejadian di sisi producer, bukan waktu diterima consumer.
- delivered_at opsional, berguna untuk debugging latency distribusi.
- source_version sangat membantu untuk menangani event out-of-order. Jika provider bisa menyediakannya, gunakan.
- content_hash berguna untuk mendeteksi perubahan isi walau metadata tampak sama.
- reference_url dan metadata sumber mendukung auditabilitas.
Autentikasi Signature dan Perlindungan Replay
Header yang umum dipakai
Gunakan header terpisah untuk identitas signature dan timestamp. Misalnya:
X-Webhook-Id: evt_01J8Y7Y8R8M3K2P4Z9A1
X-Webhook-Timestamp: 1724580935
X-Webhook-Signature: v1=2c7d7b...Pola aman yang umum adalah menghitung HMAC dari timestamp + raw request body menggunakan shared secret.
signed_payload = timestamp + "." + raw_body
signature = HMAC_SHA256(secret, signed_payload)Mengapa harus pakai raw body?
Karena serialisasi JSON bisa berubah-ubah. Jika consumer menghitung signature dari objek JSON yang sudah diparse lalu diserialisasi ulang, hasilnya bisa berbeda walau isinya sama. Verifikasi harus menggunakan body mentah persis seperti yang diterima.
Validasi timestamp drift
Batasi selisih waktu agar request lama tidak bisa diputar ulang tanpa batas. Misalnya terima hanya request dengan drift maksimum beberapa menit, disesuaikan dengan toleransi jaringan dan kemungkinan clock skew.
Jika selisih waktu terlalu ketat, request sah bisa ditolak saat ada antrian, retry, atau sinkronisasi jam yang buruk. Jika terlalu longgar, jendela replay menjadi terlalu besar. Pilih batas yang realistis dan ukur dari trafik nyata.
Mitigasi replay attack
- Verifikasi HMAC signature.
- Validasi X-Webhook-Timestamp terhadap drift maksimum.
- Simpan event_id yang sudah diproses atau minimal sudah diterima.
- Opsional: simpan kombinasi signature + timestamp untuk jendela waktu tertentu jika model ancamannya lebih tinggi.
Tabel Idempotency dan Penyimpanan Event
Jangan langsung menganggap tabel data utama cukup untuk deduplikasi. Anda biasanya butuh tabel khusus untuk pencatatan event masuk.
Contoh tabel idempotency key
CREATE TABLE webhook_inbox (
event_id VARCHAR(128) PRIMARY KEY,
event_type VARCHAR(128) NOT NULL,
entity_id VARCHAR(128) NOT NULL,
source_version BIGINT NULL,
payload_hash VARCHAR(128) NOT NULL,
signature_valid BOOLEAN NOT NULL,
received_at TIMESTAMP NOT NULL,
processed_at TIMESTAMP NULL,
processing_status VARCHAR(32) NOT NULL,
http_status INTEGER NULL,
error_message TEXT NULL
);Tujuan kolom-kolom tersebut:
- event_id: deduplikasi utama.
- payload_hash: membantu mendeteksi kasus aneh ketika event_id sama tetapi isi berbeda, yang seharusnya dianggap inkonsistensi serius.
- processing_status: misalnya received, processing, processed, rejected, failed.
- signature_valid: berguna untuk audit dan investigasi keamanan.
Tabel state domain
Selain inbox, data utama juga sebaiknya menyimpan versi terakhir yang diterapkan:
CREATE TABLE research_documents (
entity_id VARCHAR(128) PRIMARY KEY,
current_source_version BIGINT NOT NULL,
source_url TEXT NOT NULL,
source_content_hash VARCHAR(128) NOT NULL,
summary TEXT NULL,
updated_at TIMESTAMP NOT NULL
);Dengan pola ini, Anda dapat membedakan dua pertanyaan:
- Apakah event ini sudah pernah diterima?
- Apakah event ini boleh mengubah state saat ini?
Keduanya sering berbeda. Event bisa belum pernah diterima, tetapi tetap tidak boleh diterapkan karena versinya lebih lama.
Alur Consumer yang Aman
Alur berikut cukup umum dan cocok untuk banyak backend:
- Terima request dan baca raw body.
- Verifikasi signature dan timestamp.
- Validasi skema payload minimum.
- Coba insert ke tabel webhook_inbox menggunakan event_id sebagai kunci unik.
- Jika insert gagal karena duplicate key, anggap event duplikat dan kembalikan respons sukses yang sesuai.
- Jika insert berhasil, enqueue job asinkron untuk pemrosesan domain.
- Worker memuat state entity saat ini.
- Bandingkan source_version atau occurred_at dengan versi/state terakhir.
- Jika event lebih baru, terapkan perubahan dalam transaksi.
- Tandai inbox sebagai processed atau ignored_out_of_order.
Mengapa enqueue lebih baik daripada memproses sinkron?
Endpoint webhook sebaiknya fokus pada verifikasi, deduplikasi, dan penerimaan yang cepat. Pemrosesan berat seperti transformasi, fetch tambahan, atau penulisan ke beberapa tabel lebih aman dilakukan di worker queue agar:
- latensi respons tetap rendah,
- retry dari provider tidak meningkat akibat timeout consumer,
- kegagalan downstream tidak langsung memperpanjang request HTTP.
Pseudocode handler
function handleWebhook(request):
rawBody = request.rawBody
eventId = request.header("X-Webhook-Id")
timestamp = request.header("X-Webhook-Timestamp")
signature = request.header("X-Webhook-Signature")
if not verifySignature(rawBody, timestamp, signature):
return 401
if isTimestampExpired(timestamp):
return 400
payload = parseJson(rawBody)
validateRequiredFields(payload)
inserted = insertInboxIfAbsent(
event_id=payload.event_id,
event_type=payload.event_type,
entity_id=payload.entity.entity_id,
source_version=payload.entity.source_version,
payload_hash=sha256(rawBody),
processing_status="received"
)
if not inserted:
return 200
enqueue("process-webhook-event", payload.event_id)
return 202Pseudocode worker
function processWebhookEvent(eventId):
inbox = loadInbox(eventId)
payload = loadOriginalPayload(eventId)
current = loadResearchDocument(payload.entity.entity_id)
if current exists and payload.entity.source_version <= current.current_source_version:
markInbox(eventId, "ignored_out_of_order")
return
begin transaction
upsert research_documents using payload data
set current_source_version = payload.entity.source_version
markInbox(eventId, "processed")
commitStatus Code yang Benar
Pemilihan status code memengaruhi perilaku retry provider. Aturan praktisnya:
- 200 OK: event valid dan tidak perlu retry. Cocok untuk event duplikat yang sudah pernah diterima.
- 202 Accepted: event diterima dan akan diproses asinkron. Umum dipakai jika enqueue berhasil.
- 400 Bad Request: payload salah atau timestamp tidak valid. Biasanya tidak layak di-retry tanpa perubahan.
- 401 Unauthorized atau 403 Forbidden: signature salah atau secret tidak cocok. Jangan proses payload.
- 409 Conflict: bisa dipakai untuk inkonsistensi tertentu, tetapi untuk webhook sering lebih aman tetap mengembalikan 200 pada duplikat agar provider berhenti retry.
- 429 Too Many Requests: hanya jika Anda benar-benar menerapkan throttling dan siap menerima retry tambahan.
- 5xx: hanya untuk kegagalan sementara di sisi consumer, misalnya database down atau queue tidak tersedia. Ini akan memicu retry dari provider.
Kesalahan umum adalah mengembalikan 409 atau 500 untuk event duplikat. Akibatnya provider terus mengirim ulang padahal kondisi itu normal dan seharusnya dianggap sukses secara idempoten.
Ordering: source_version Lebih Andal daripada Timestamp
Jika provider bisa menyediakan source_version yang meningkat monoton per entity, gunakan itu sebagai dasar ordering. Timestamp sering cukup, tetapi lebih rentan terhadap:
- clock skew antar node producer,
- dua update yang terjadi sangat berdekatan,
- perbedaan pembulatan presisi waktu.
Jika tidak ada source_version, gunakan kombinasi berikut:
- occurred_at sebagai pembanding utama,
- event_id sebagai tie-breaker hanya untuk determinisme internal,
- aturan bisnis yang eksplisit jika timestamp sama.
Jangan menyamakan urutan kedatangan HTTP dengan urutan kebenaran data. Event yang datang paling akhir belum tentu state paling baru.
Retry dengan Backoff di Sisi Provider dan Consumer
Retry provider
Walau artikel ini fokus pada consumer, desain Anda perlu mengantisipasi retry eksponensial dengan jitter dari provider. Artinya dua hal:
- consumer harus aman menerima event yang sama berkali-kali,
- respons sukses harus diberikan secepat mungkin setelah request dinyatakan aman diterima.
Retry internal consumer
Jika worker gagal memproses event karena masalah sementara, lakukan retry internal dengan backoff. Namun retry worker berbeda dari retry provider:
- retry provider mengulang pengiriman HTTP yang sama,
- retry worker mengulang pemrosesan event yang sudah tersimpan di inbox.
Pola inbox + queue memudahkan pemisahan dua jenis retry ini.
Trade-off backoff
- Backoff terlalu agresif bisa memperlambat sinkronisasi.
- Tanpa jitter, banyak worker dapat retry serempak dan memperburuk beban.
- Retry tanpa batas membuat event macet tersembunyi. Tetapkan batas percobaan dan kirim ke dead-letter queue atau status investigasi.
Edge Case yang Sering Terjadi
1. Event duplikat dengan event_id sama
Ini kasus normal. Solusi:
- kunci unik pada event_id,
- respons 200 atau 202 tanpa memproses ulang domain.
2. Event duplikat dengan payload berbeda
Ini lebih serius. Jika event_id sama tetapi payload_hash berbeda, kemungkinan ada bug di provider atau masalah keamanan. Tandai sebagai rejected_inconsistent_duplicate, simpan bukti, dan jangan timpa data diam-diam.
3. Event terlambat
Misalnya source_version 10 datang setelah source_version 12 sudah diterapkan. Event tetap bisa dicatat di inbox untuk audit, tetapi state domain tidak boleh mundur.
4. Event out-of-order tanpa source_version
Jika hanya ada timestamp, Anda perlu aturan fallback. Simpan last_occurred_at pada entity dan abaikan event yang lebih lama. Namun pahami keterbatasannya: timestamp bukan jaminan total ordering.
5. Timestamp drift besar tetapi signature valid
Request bisa saja autentik tetapi terlalu lama. Jangan proses jika melewati jendela waktu yang diizinkan. Ini salah satu kontrol replay paling penting.
6. Job worker jalan dua kali
Walau HTTP layer sudah idempoten, queue worker juga bisa mengalami redelivery. Karena itu, update domain harus tetap aman di level transaksi, misalnya dengan pengecekan versi saat menulis.
Observability dan Audit Trail
Webhook yang sehat harus mudah ditelusuri ketika ada sengketa data atau mismatch sinkronisasi. Minimal catat:
- event_id, entity_id, event_type,
- waktu diterima, waktu diproses, dan durasi total,
- hasil verifikasi signature,
- status inbox: processed, duplicate, ignored_out_of_order, failed, rejected,
- source URL dan content hash,
- alasan penolakan atau kegagalan.
Metric yang berguna
- jumlah event diterima per tipe,
- rasio duplicate,
- rasio signature invalid,
- latensi dari occurred_at ke processed_at,
- jumlah event ignored_out_of_order,
- retry worker dan dead-letter count.
Jika Anda memakai tracing, hubungkan request webhook, enqueue job, dan transaksi database dengan correlation ID yang konsisten. Umumnya event_id cocok dijadikan ID korelasi utama.
Pola Penyimpanan: Inbox Saja atau Inbox + Event Store?
Inbox saja
Cukup untuk banyak sistem internal. Anda menyimpan metadata event dan mungkin payload mentah dalam kolom JSON atau object storage. Pilih ini jika kebutuhan audit masih moderat.
Inbox + event store
Lebih cocok jika audit dan penelusuran sumber sangat penting. Event mentah disimpan utuh, immutable, dan bisa diputar ulang ke pipeline lain. Trade-off-nya adalah biaya penyimpanan dan kompleksitas operasional lebih tinggi.
Untuk sinkronisasi data riset yang harus dapat ditelusuri sumbernya, menyimpan payload asli atau snapshot referensi sumber sering layak dipertimbangkan, selama kebijakan retensi dan kepatuhan data mengizinkan.
Kesalahan Implementasi yang Sering Muncul
- Mendeduplikasi berdasarkan entity_id saja. Akibatnya update sah berikutnya malah dianggap duplikat.
- Menganggap retry adalah error. Padahal retry adalah perilaku normal sistem terdistribusi.
- Memverifikasi signature setelah parse JSON. Ini rawan mismatch.
- Tidak menyimpan versi terakhir entity. Sulit menangani event lama yang datang belakangan.
- Memproses sinkron terlalu lama. Provider mengira gagal lalu mengirim ulang.
- Tidak mencatat alasan event diabaikan. Audit jadi sulit ketika terjadi selisih data.
Checklist Implementasi Praktis
- Definisikan payload dengan event_id, entity_id, occurred_at, dan idealnya source_version.
- Sertakan metadata sumber yang dapat diverifikasi: URL, hash, referensi kutipan.
- Verifikasi HMAC signature menggunakan raw body.
- Tolak request dengan timestamp di luar jendela drift yang diizinkan.
- Simpan event ke tabel inbox dengan kunci unik pada event_id.
- Kembalikan 200 untuk duplikat yang sudah diterima, bukan error.
- Gunakan queue untuk pemrosesan domain.
- Terapkan update domain hanya jika event lebih baru daripada state saat ini.
- Catat status akhir event: processed, duplicate, ignored_out_of_order, failed.
- Pasang metric, log terstruktur, dan correlation ID berbasis event_id.
Penutup
Webhook idempoten untuk sinkronisasi data riset bukan sekadar menolak duplikasi. Desain yang benar harus menggabungkan autentikasi signature, validasi timestamp, inbox deduplication, aturan ordering, retry yang aman, dan observability yang memadai. Dengan pola ini, event yang datang berulang, terlambat, atau out-of-order tidak akan merusak state, dan setiap pembaruan tetap bisa ditelusuri kembali ke sumber publiknya.
Jika Anda membangun integrasi semacam ini, prioritas utamanya adalah kebenaran data dan audit trail, bukan hanya “request berhasil”. Dalam sistem terdistribusi, keberhasilan nyata adalah saat event boleh dikirim ulang kapan saja tanpa mengubah hasil akhir secara keliru.
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!