Webhook untuk sinkronisasi tema sering gagal bukan karena format JSON yang salah, tetapi karena hal-hal operasional: event terkirim ganda, urutan datang tidak konsisten, consumer lambat, retry tanpa kontrol, atau signature tidak diverifikasi dengan benar. Jika Anda menyinkronkan katalog desain, konfigurasi tema, atau varian jam digital antar layanan, maka webhook harus dirancang dengan asumsi bahwa jaringan tidak andal dan delivery at-least-once.
Panduan ini berfokus pada desain webhook yang bisa langsung dipakai untuk sinkronisasi tema, misalnya ketika sebuah layanan sumber menerbitkan perubahan pada koleksi desain, palet warna, layout, atau pengaturan widget, lalu layanan tujuan harus menerapkannya dengan aman. Intinya: definisikan kontrak event yang stabil, verifikasi signature pada raw body, gunakan idempotency dan deduplikasi, tangani out-of-order delivery, terapkan retry dengan backoff, dan pastikan observability cukup untuk investigasi insiden.
Arsitektur dasar sinkronisasi webhook
Dalam skenario sinkronisasi tema, biasanya ada dua peran:
- Producer: layanan sumber yang mengetahui perubahan data tema dan mengirim event webhook.
- Consumer: layanan penerima yang memverifikasi, mencatat, lalu memproses event.
Desain yang aman biasanya mengikuti alur berikut:
- Producer membuat event dengan event_id, event_type, occurred_at, dan resource_version.
- Producer menandatangani payload dengan secret bersama.
- Consumer menerima request, membaca raw body, memverifikasi signature, memeriksa timestamp, lalu menyimpan log penerimaan.
- Consumer melakukan deduplikasi berdasarkan event_id atau idempotency key.
- Consumer mengantrekan pekerjaan asinkron agar endpoint webhook tetap cepat merespons.
- Worker memproses event dengan aturan ordering dan versi resource.
- Jika gagal sementara, worker melakukan retry dengan backoff. Jika gagal permanen, event ditandai dead-letter atau perlu intervensi manual.
Prinsip penting: endpoint webhook sebaiknya hanya melakukan validasi, persistensi minimal, dan enqueue job. Jangan melakukan seluruh sinkronisasi berat di dalam request HTTP yang sama.
Kontrak event yang stabil dan mudah dievolusikan
Masalah umum pada webhook sinkronisasi tema adalah payload terlalu bergantung pada model internal producer. Akibatnya, perubahan kecil pada schema internal memecahkan integrasi. Solusinya adalah kontrak event yang eksplisit dan berversi.
Field minimum yang sebaiknya ada
- event_id: ID unik untuk deduplikasi global.
- event_type: misalnya theme.created, theme.updated, theme.deleted.
- event_version: versi kontrak event, bukan versi resource.
- occurred_at: waktu event dibuat di producer.
- delivery_attempt: opsional, berguna untuk observability.
- tenant_id atau workspace_id: jika sistem multi-tenant.
- resource.id: ID tema atau katalog yang berubah.
- resource.version: versi monotonik untuk menangani ordering.
- idempotency_key: bisa sama dengan event_id, atau dibuat berdasarkan operasi bisnis.
- data: representasi perubahan yang diperlukan consumer.
Contoh payload JSON
{
"event_id": "evt_01J9X8A7P3Q4K5M6N7R8S9T0U1",
"event_type": "theme.updated",
"event_version": 1,
"occurred_at": "2026-08-27T10:15:30Z",
"delivery_attempt": 1,
"tenant_id": "tenant_acme",
"idempotency_key": "theme:thm_123:v42",
"resource": {
"type": "theme",
"id": "thm_123",
"version": 42
},
"data": {
"slug": "minimal-dark",
"name": "Minimal Dark",
"status": "active",
"palette": {
"background": "#0B0B0C",
"foreground": "#F5F7FA",
"accent": "#7C5CFF"
},
"layout": {
"clockStyle": "split-flap",
"showSeconds": false,
"timezone": "Asia/Jakarta"
},
"assets": [
{
"type": "thumbnail",
"url": "https://cdn.example.com/themes/thm_123/thumb.png",
"checksum": "sha256:3d4f..."
}
]
}
}Beberapa catatan desain:
- resource.version harus naik setiap kali ada perubahan yang relevan untuk sinkronisasi.
- idempotency_key lebih berguna jika satu operasi bisnis bisa diterbitkan ulang dengan event_id berbeda.
- Jika payload besar, Anda bisa mengirim snapshot parsial plus URL fetch ke API sumber. Namun ini menambah ketergantungan jaringan saat konsumsi event.
Event snapshot vs delta
Ada dua pola umum:
- Snapshot event: payload berisi status resource terbaru yang cukup untuk di-upsert. Lebih mudah untuk replay dan recovery.
- Delta event: payload hanya berisi field yang berubah. Lebih hemat ukuran, tetapi lebih sulit jika event datang tidak berurutan atau ada event hilang.
Untuk sinkronisasi tema, snapshot event sering lebih aman karena consumer dapat menerapkan state terbaru tanpa harus mengetahui semua perubahan sebelumnya.
Verifikasi signature dan keamanan endpoint
Webhook tidak boleh dipercaya hanya karena datang dari IP tertentu atau karena URL-nya rahasia. Signature diperlukan agar consumer bisa memastikan payload benar-benar berasal dari producer dan tidak dimodifikasi di tengah jalan.
Header yang sebaiknya dikirim
X-Webhook-Id: evt_01J9X8A7P3Q4K5M6N7R8S9T0U1
X-Webhook-Timestamp: 1724753730
X-Webhook-Signature: v1=4f0c7a...
X-Webhook-Event: theme.updated
X-Idempotency-Key: theme:thm_123:v42Format header boleh berbeda, tetapi pola umumnya sama:
- ID event untuk korelasi dan deduplikasi.
- Timestamp untuk mencegah replay tak terbatas.
- Signature hasil HMAC dari string yang disepakati.
- Idempotency key jika ingin dipisahkan dari body.
Apa yang ditandatangani?
Pola yang umum dan aman adalah menandatangani gabungan timestamp dan raw request body, misalnya:
signed_payload = timestamp + "." + raw_body
signature = HMAC_SHA256(secret, signed_payload)Mengapa raw body penting? Karena serialisasi JSON bisa berubah jika body sudah di-parse lalu di-stringify ulang: urutan key, whitespace, atau encoding bisa berbeda. Verifikasi harus dilakukan terhadap bytes mentah yang diterima server.
Pseudocode verifikasi signature
function verifyWebhook(headers, rawBody, secret, nowEpochSeconds):
timestamp = headers["X-Webhook-Timestamp"]
signature = headers["X-Webhook-Signature"]
if timestamp is missing or signature is missing:
return fail("missing_signature_headers")
if abs(nowEpochSeconds - parseInt(timestamp)) > 300:
return fail("timestamp_out_of_window")
signedPayload = timestamp + "." + rawBody
expected = hmac_sha256_hex(secret, signedPayload)
if not constantTimeEquals(signatureWithoutPrefix(signature), expected):
return fail("invalid_signature")
return ok()Praktik yang disarankan:
- Gunakan constant-time comparison untuk menghindari timing attack.
- Terapkan window timestamp terbatas, misalnya beberapa menit, agar replay lama ditolak.
- Dukung secret rotation: verifikasi dengan secret aktif dan secret lama untuk sementara waktu.
- Jangan log secret atau signature penuh. Jika perlu, log hanya prefix pendek.
Kesalahan umum saat verifikasi
- Memverifikasi setelah body diubah oleh middleware.
- Menggunakan parsed JSON ketimbang raw bytes.
- Tidak memeriksa timestamp, sehingga event lama bisa di-replay tanpa batas.
- Mengembalikan pesan error yang terlalu detail ke publik.
Idempotency, deduplikasi, dan out-of-order delivery
Webhook hampir selalu harus diasumsikan at-least-once. Artinya event yang sama bisa dikirim lebih dari sekali. Selain itu, event versi lama bisa tiba setelah event versi baru. Karena itu, deduplikasi saja tidak cukup; Anda juga perlu aturan ordering berbasis versi resource.
Idempotency key vs event ID
- event_id unik untuk setiap event delivery logical.
- idempotency_key mewakili efek bisnis yang diinginkan.
Contoh: producer mungkin membangkitkan event baru saat replay manual, tetapi masih ingin consumer memperlakukan operasi itu sebagai efek yang sama. Dalam kasus ini, idempotency_key lebih stabil daripada event_id.
Jika Anda belum punya kebutuhan replay dengan ID berbeda, Anda bisa memulai dengan:
- dedup berdasarkan event_id di level penerimaan, dan
- guard ordering berdasarkan resource.id + resource.version di level pemrosesan.
Strategi penyimpanan deduplikasi
Pilih berdasarkan kebutuhan retensi dan throughput:
- Redis: cepat, cocok untuk menyimpan event_id dengan TTL beberapa hari.
- Database relasional: cocok jika butuh audit trail kuat dan transaksi dengan tabel sinkronisasi.
- Tabel inbox: pola umum di mana setiap event masuk disimpan terlebih dahulu sebelum diproses.
Untuk integrasi backend yang ingin andal, inbox table sering lebih aman daripada hanya cache. Anda bisa menyimpan status received, processing, processed, failed_transient, failed_permanent.
Menangani event yang datang tidak berurutan
Misalkan consumer sudah menerapkan theme.updated versi 42, lalu beberapa detik kemudian menerima versi 41. Jika consumer hanya melihat event baru berdasarkan waktu terima, state bisa mundur. Solusinya: simpan versi terakhir per resource, lalu terapkan hanya jika versi event lebih baru.
if incoming.resource.version < current_resource_version:
drop as stale
elif incoming.resource.version == current_resource_version:
treat as duplicate/no-op
else:
apply update and set current_resource_versionTrade-off dari pendekatan ini:
- Kelebihan: sederhana dan efektif untuk resource dengan versi monotonik.
- Kekurangan: butuh producer yang disiplin menaikkan versi pada setiap perubahan relevan.
Jika producer tidak punya versi resource yang baik, alternatifnya adalah memakai occurred_at. Namun timestamp lebih lemah karena bergantung pada sinkronisasi clock dan tidak selalu mewakili urutan commit di database.
Kasus delete lalu update terlambat
Ini edge case yang sering terlupakan. Misalnya:
- Event A: theme.deleted versi 50
- Event B terlambat: theme.updated versi 49
Jika consumer hanya meng-upsert berdasarkan payload, tema yang sudah dihapus bisa hidup kembali. Gunakan aturan versi yang sama, dan pertimbangkan status tombstone untuk resource yang sudah dihapus.
Retry dengan backoff dan pemetaan error code yang aman
Retry adalah bagian inti dari webhook andal, tetapi retry tanpa klasifikasi error justru menciptakan duplikasi dan beban tak perlu. Producer dan consumer harus sepakat kapan sebuah kegagalan layak dicoba lagi, dan kapan event harus dihentikan.
Pedoman respons HTTP dari consumer
- 2xx: event diterima. Tidak berarti seluruh sinkronisasi selesai, hanya berarti request valid dan telah dicatat/enqueue.
- 4xx: ada masalah pada request yang kemungkinan tidak akan membaik dengan retry yang sama.
- 5xx: kegagalan sementara di sisi consumer; retry diperbolehkan.
Consumer sebaiknya tidak memproses sinkronisasi berat sebelum memberi 2xx. Jika validasi signature lolos dan event berhasil dipersist ke inbox/queue, balas cepat dengan 202 atau 204 lalu lanjutkan asinkron.
Tabel keputusan retry vs drop
| Kondisi | HTTP dari Consumer | Aksi Producer | Catatan |
|---|---|---|---|
| Signature tidak valid | 401 atau 403 | Drop, jangan retry otomatis | Masalah autentikasi atau secret mismatch |
| Timestamp terlalu lama | 400 atau 403 | Drop | Mencegah replay lama |
| Schema payload tidak valid | 422 | Drop | Bug kontrak atau producer |
| Resource version stale | 204 atau 200 | Stop, anggap sukses/no-op | Bukan error operasional |
| Duplikasi event_id | 200 atau 204 | Stop, anggap sukses | Idempotent acknowledgement |
| Database/queue consumer down | 500/503 | Retry dengan backoff | Kegagalan sementara |
| Rate limit sementara | 429 | Retry sesuai kebijakan | Jika consumer memang memakai throttling |
| Timeout koneksi | Tidak ada respons | Retry dengan backoff | Anggap hasil tidak diketahui |
Backoff yang disarankan
Gunakan exponential backoff dengan jitter. Tujuannya untuk menghindari gelombang retry serentak saat consumer sedang bermasalah.
Pola umum:
attempt 1: segera atau beberapa detik
attempt 2: 10-30 detik
attempt 3: 1-2 menit
attempt 4: 5-10 menit
attempt 5+: makin jarang, sampai batas tertentuJangan membuat jadwal retry yang terlalu agresif. Untuk sinkronisasi tema, keterlambatan beberapa menit biasanya lebih baik daripada membanjiri consumer dengan ribuan request ulang.
Partial failure di sisi consumer
Contoh: event sudah tersimpan di inbox, tetapi worker gagal mengunduh aset thumbnail dari CDN. Dalam situasi ini, endpoint webhook tetap boleh membalas 2xx karena penerimaan request berhasil. Kegagalan pemrosesan lanjutan harus ditangani oleh sistem job internal consumer, bukan dengan memaksa producer mengirim ulang request yang sama terus-menerus.
Ini alasan penting memisahkan:
- delivery success: request webhook diterima dan disimpan aman.
- processing success: sinkronisasi bisnis benar-benar selesai.
Pseudocode consumer yang praktis
Berikut contoh alur consumer yang cukup aman untuk dipakai sebagai acuan implementasi.
function handleWebhook(request):
rawBody = request.rawBody
headers = request.headers
verify = verifyWebhook(headers, rawBody, secretStore.currentAndPrevious(), now())
if verify.failed:
return http(401, { "error": "unauthorized" })
event = parseJson(rawBody)
if not isValidSchema(event):
return http(422, { "error": "invalid_payload" })
begin transaction
if inbox.existsByEventId(event.event_id):
commit transaction
return http(200, { "status": "duplicate" })
inbox.insert({
event_id: event.event_id,
idempotency_key: event.idempotency_key,
event_type: event.event_type,
tenant_id: event.tenant_id,
resource_id: event.resource.id,
resource_version: event.resource.version,
payload: rawBody,
status: "received",
received_at: now()
})
queue.enqueue("process-theme-webhook", { "event_id": event.event_id })
commit transaction
return http(202, { "status": "accepted" })Worker pemrosesan:
function processThemeWebhook(eventId):
event = inbox.getForUpdate(eventId)
if event.status in ["processed", "processing"]:
return
markInboxStatus(eventId, "processing")
current = themeSyncState.get(event.tenant_id, event.resource_id)
if current exists and event.resource_version < current.version:
markInboxStatus(eventId, "processed", note="stale_event")
return
if current exists and event.resource_version == current.version:
markInboxStatus(eventId, "processed", note="duplicate_version")
return
payload = parseJson(event.payload)
upsertTheme(
tenantId = payload.tenant_id,
themeId = payload.resource.id,
version = payload.resource.version,
data = payload.data
)
themeSyncState.save(
tenantId = payload.tenant_id,
themeId = payload.resource.id,
version = payload.resource.version,
updatedAt = now()
)
markInboxStatus(eventId, "processed")Catatan implementasi:
- getForUpdate atau mekanisme lock serupa mencegah dua worker memproses event yang sama secara paralel.
- Simpan themeSyncState terpisah agar pengecekan versi cepat.
- Jika operasi sinkronisasi menyentuh banyak tabel, lakukan dalam transaksi yang sesuai agar tidak meninggalkan state setengah jadi.
Replay manual, event terlambat, dan dead-letter handling
Replay manual
Replay manual diperlukan saat:
- consumer sempat down lama,
- ada bug yang sudah diperbaiki,
- operator ingin mensinkronkan ulang satu tenant atau satu tema.
Agar replay aman:
- pastikan consumer idempotent,
- gunakan event snapshot jika memungkinkan,
- tentukan apakah replay mempertahankan event_id lama atau menerbitkan event baru dengan idempotency_key yang sama.
Jika replay menghasilkan event_id baru, jangan hanya dedup berdasarkan event_id. Pastikan aturan versi resource tetap mencegah state mundur.
Event terlambat
Event terlambat tidak selalu salah. Bisa jadi producer sudah retry beberapa kali lalu salah satu delivery baru masuk jauh belakangan. Karena itu, lateness saja sebaiknya tidak langsung dianggap invalid jika signature dan timestamp masih sesuai kebijakan. Gunakan resource.version untuk menentukan apakah event masih relevan.
Dead-letter queue
Jika event terus gagal diproses setelah beberapa kali retry internal, pindahkan ke dead-letter queue atau status failed_permanent. Sertakan alasan yang cukup untuk investigasi:
- kode error internal,
- ringkasan exception,
- resource_id, tenant_id, event_id, version,
- waktu percobaan terakhir.
Jangan menyimpan data sensitif berlebihan di log atau DLQ jika payload mengandung konfigurasi privat.
Observability: apa yang harus diukur dan dilog
Webhook yang andal bukan hanya soal sukses saat normal, tetapi juga mudah di-debug saat ada insiden. Minimal, siapkan tiga lapisan observability: log, metrik, dan tracing atau korelasi ID.
Log yang berguna
Setiap penerimaan webhook sebaiknya memiliki log terstruktur dengan field seperti:
- event_id
- idempotency_key
- event_type
- tenant_id
- resource_id
- resource_version
- signature_verification: pass/fail
- inbox_status
- processing_result: processed/stale/duplicate/retried/failed
- attempt_count
Hindari log body penuh secara default jika payload besar atau sensitif. Lebih aman log field penting plus checksum payload.
Metrik yang perlu dipantau
- webhook_requests_total per event type dan status code
- webhook_signature_failures_total
- webhook_duplicates_total
- webhook_stale_events_total
- webhook_processing_latency
- webhook_queue_lag
- dead_letter_total
Dari metrik ini, Anda bisa cepat melihat apakah masalah ada pada keamanan, throughput, atau ordering.
Correlation ID
Gunakan event_id sebagai correlation ID lintas komponen. Jika worker membuat request ke layanan lain, teruskan correlation ID itu agar investigasi tidak terputus.
Kesalahan desain yang sering terjadi
- Memproses webhook sinkron penuh di request thread, sehingga timeout mudah terjadi.
- Tidak menyimpan raw body, lalu signature tidak bisa diverifikasi konsisten.
- Menganggap webhook exactly-once, padahal duplicate delivery itu normal.
- Tidak punya resource version, sehingga event lama bisa menimpa state baru.
- Retry semua 4xx, padahal banyak 4xx adalah error permanen.
- Tidak membedakan penerimaan dan pemrosesan, sehingga producer dijadikan mekanisme retry untuk kegagalan internal consumer.
- Tidak menyiapkan replay, padahal itu sangat membantu saat perbaikan bug.
Checklist implementasi producer dan consumer
Checklist producer
- Definisikan event schema yang stabil dan berversi.
- Sertakan event_id, event_type, occurred_at, tenant_id, resource.id, dan resource.version.
- Tentukan strategi idempotency_key.
- Tandatangani timestamp + raw body dengan HMAC.
- Dukung secret rotation.
- Terapkan retry dengan exponential backoff dan jitter.
- Jangan retry untuk signature invalid atau schema invalid.
- Sediakan mekanisme replay manual yang terkendali.
- Catat delivery attempt, status, dan respons consumer.
Checklist consumer
- Baca raw request body sebelum parsing.
- Verifikasi signature dengan constant-time compare.
- Validasi timestamp untuk mencegah replay lama.
- Validasi schema payload sebelum enqueue.
- Simpan event ke inbox atau penyimpanan durable sebelum membalas 2xx.
- Dedup berdasarkan event_id dan/atau idempotency_key.
- Terapkan ordering berdasarkan resource.version.
- Pisahkan endpoint penerimaan dan worker pemrosesan.
- Gunakan retry internal untuk kegagalan sementara saat processing.
- Sediakan dead-letter handling dan replay tooling.
- Siapkan log terstruktur, metrik, dan korelasi ID.
Penutup
Webhook untuk sinkronisasi tema yang andal tidak bergantung pada satu trik, melainkan pada kombinasi beberapa kontrol: kontrak event yang jelas, signature yang diverifikasi pada raw body, idempotency dan deduplikasi, ordering berbasis versi resource, retry dengan backoff, serta observability yang cukup. Jika Anda merancang integrasi katalog desain atau konfigurasi tema antar layanan, anggap duplicate delivery, event terlambat, dan partial failure sebagai kondisi normal yang harus ditangani sejak awal.
Desain paling praktis untuk banyak kasus adalah: kirim event snapshot dengan versi resource monotonik, terima webhook secara cepat ke inbox durable, verifikasi signature sebelum parse, proses asinkron di worker, dan abaikan event stale secara aman. Dengan pola ini, sinkronisasi tetap konsisten meskipun jaringan, queue, atau layanan penerima tidak selalu dalam kondisi ideal.
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!