Outgoing webhook — dispatcher, event schema, dan retry
Outgoing webhook
Outgoing webhook memungkinkan sistem eksternal menerima event OmniStream (pesan baru, percakapan ditugaskan, dll.) sebagai HTTP POST ke endpoint mereka. Implementasi ada di crates/api-gateway/src/webhook_dispatcher.rs dan routes administrasinya di crates/api-gateway/src/routes/outgoing_webhooks.rs.
Halaman ini mencakup:
- Dua loop dispatcher (event listener + delivery processor)
- Daftar lengkap event types dan skema payload JSON-nya
- Algoritma signing HMAC header
X-Omnistream-Signature - Strategi retry dengan exponential backoff
Konfigurasi endpoint
Endpoint didaftarkan di tabel outgoing_webhooks. Hanya admin yang boleh memanggil:
| Endpoint API | Akses |
|---|---|
POST /api/outgoing-webhooks | admin |
GET /api/outgoing-webhooks | admin |
PATCH /api/outgoing-webhooks/:id | admin |
DELETE /api/outgoing-webhooks/:id | admin |
Setiap baris memiliki:
url— endpoint target (HTTPS direkomendasikan)secret— shared secret untuk HMAC signingevents— array event types yang ingin diterima (mis.["message.received", "message.sent"])is_active— flag aktifheaders(opsional) — header tambahan yang akan dikirim bersama request
Arsitektur dispatcher
Dispatcher berjalan sebagai dua task async di dalam api-gateway:
Loop 1: Event Listener
listen_events berlangganan Redis channel conversation_updates. Setiap event ConversationUpdateEvent diterjemahkan menjadi satu atau lebih delivery berdasarkan:
- Tipe event → event type string (lihat tabel di bawah)
- Pencocokan subscribers —
SELECT * FROM outgoing_webhooks WHERE is_active = true AND $1 = ANY(events) - Setiap subscriber menghasilkan satu baris di
webhook_deliveriesdengan statuspending
Loop 2: Delivery Processor
process_deliveries berjalan periodik (setiap 30 detik via scheduler api-gateway) dan memproses batch pending:
SELECT * FROM webhook_deliveries WHERE status = 'pending' AND (next_attempt_at IS NULL OR next_attempt_at <= NOW()) LIMIT 50- Untuk tiap row: hitung HMAC, POST ke URL, catat hasil
- Sukses (2xx) →
status = 'success',attempts += 1,delivered_at = NOW() - Gagal (non-2xx atau timeout) →
attempts += 1,next_attempt_at = NOW() + backoff,last_error - Setelah 5 percobaan gagal →
status = 'failed'permanen
Batch 50 dan interval 30 detik terdefinisi di crates/api-gateway/src/scheduler.rs dan webhook_dispatcher.rs::process_deliveries.
Event types
Dispatcher memetakan ConversationUpdateEvent variants ke nama event webhook:
| ConversationUpdateEvent | Event type | Pemicu |
|---|---|---|
MessageReceived | message.received | Pesan inbound dari customer melalui webhook-ingestor |
MessageSent | message.sent | Agent/bot mengirim pesan keluar yang berhasil |
MessageStatus | message.status | Status delivery berubah (sent/delivered/read/failed) dari provider |
ConversationCreated | conversation.created | Percakapan baru dibuat (biasanya bersamaan dengan pesan inbound pertama) |
ConversationResolved | conversation.resolved | Agent menutup percakapan (status = 'resolved') |
ConversationAssigned | conversation.assigned | Percakapan di-assign/di-transfer ke agent tertentu |
Enum sumbernya ada di crates/omni-common/src/models/events.rs::ConversationEventType.
Skema payload per event
Semua payload memiliki envelope umum:
Code
message.received
Dikirim ketika pesan inbound muncul di sistem.
Code
message.sent
Dikirim ketika outbound message berhasil dikirim oleh message-sender-*.
Code
message.status
Status delivery berubah. Nilai status mengikuti provider: sent, delivered, read, failed.
Code
conversation.created
Code
conversation.resolved
Code
conversation.assigned
Code
Struktur eksak diset oleh build_event_payload di webhook_dispatcher.rs. Bidang opsional disertakan sebagai null ketika tidak tersedia, bukan dihilangkan.
Signing: X-Omnistream-Signature
Tiap request HTTP POST keluar menyertakan header:
Code
Di mana <hex> adalah HMAC-SHA256 lowercase hex dari raw body JSON memakai outgoing_webhooks.secret sebagai kunci, dengan prefix literal sha256=:
Code
PENTING — format prefix wajib. Header selalu dimulai dengan string literal
sha256=(7 karakter) diikuti 64 karakter hex lowercase, total 71 karakter. Jangan membandingkan hanya 64-char hex — Anda akan menolak setiap delivery yang valid. Sumber kebenaran:crates/api-gateway/src/routes/outgoing_webhooks.rs:402:Code
Implementasi di outgoing_webhooks.rs::compute_hmac_signature:
Code
Memverifikasi signature di penerima
Node.js:
Code
Python (FastAPI):
Code
Go (net/http):
Code
Catatan raw body: framework yang mem-parse JSON otomatis (mis. Express express.json()) akan me-reserialize body sehingga signature invalid. Gunakan raw body handler.
Retry dengan exponential backoff
Ketika delivery gagal (non-2xx atau timeout), dispatcher menjadwalkan ulang dengan delay yang bertambah eksponensial. Formula dari crates/api-gateway/src/webhook_dispatcher.rs:344:
Code
Formula menghasilkan deret tanpa batas: 30s, 120s, 480s, 1920s, 7680s, ... — tetapi jumlah percobaan dibatasi oleh kolom max_attempts per-delivery. Default max_attempts = 3 (lihat migrations/20260306000022_outgoing_webhooks.sql:31), dan kondisi gagal permanen adalah attempt >= max_attempts (lihat webhook_dispatcher.rs:330).
Jadwal default (max_attempts = 3)
| Attempt | Delay setelah kegagalan sebelumnya | Total elapsed kira-kira |
|---|---|---|
| 1 | — (dikirim segera pada created_at) | 0s |
| 2 | 30s (formula: 30 * 4^0) | 30s |
| 3 | 120s (formula: 30 * 4^1) | 2m 30s |
| Gagal permanen | — (kondisi attempt >= max_attempts terpenuhi) | — |
Jadi dengan default, delivery mendapat 3 total percobaan (pengiriman awal + 2 retry). Setelah attempt ke-3 gagal, status diset failed dan dispatcher berhenti mencoba.
Menaikkan max_attempts
Kolom max_attempts dapat dinaikkan per-delivery sehingga formula berlanjut ke delay berikutnya. Contoh dengan max_attempts = 5:
| Attempt | Delay (30 * 4^(attempt-1)) |
|---|---|
| 2 | 30s |
| 3 | 120s |
| 4 | 480s (8 menit) |
| 5 | 1920s (32 menit) |
Delay tumbuh cepat — max_attempts = 6 akan menambahkan 7680s (~2 jam) sebelum percobaan terakhir. Pilih nilai yang wajar untuk endpoint Anda.
Retry manual
Admin dapat memicu retry manual via POST /api/outgoing-webhooks/deliveries/:id/retry (jika endpoint diaktifkan) atau dengan me-reset status='pending' dan next_attempt_at=now() pada baris webhook_deliveries.
Payload yang sama persis dikirim ulang setiap retry — signature tetap valid karena body tidak berubah.
Timeout request
Tiap HTTP POST memakai timeout 10 detik. Endpoint lambat dianggap gagal dan masuk jadwal retry. Endpoint ideal harus merespons 200 OK dalam <1 detik dan memproses event secara asinkron di sisi penerima.
Idempotency
event_id adalah UUID unik per event. Penerima wajib menyimpan event_id yang sudah diproses dan mengabaikan duplikat — retry menghasilkan body yang identik termasuk event_id. Tanpa dedup, retry sukses setelah timeout akan memproses event dua kali.
Troubleshooting
- Endpoint mengembalikan 2xx tetapi tidak menerima semua event — periksa kolom
eventspadaoutgoing_webhooks; hanya event yang terdaftar yang dikirim. JalankanSELECT events FROM outgoing_webhooks WHERE id = '<id>'. - Retry tidak berjalan meskipun ada delivery gagal — pastikan scheduler
api-gatewayhidup dan tidak ada exception di logwebhook_dispatcher. Periksa kolomlast_errordiwebhook_deliveriesuntuk pesan kesalahan terakhir. - Signature mismatch di penerima — pastikan Anda memverifikasi terhadap raw body, bukan body yang sudah diparse. Lihat catatan di atas.
- Delivery status tetap
pendingtanpa pernah diproses —next_attempt_atmungkin di masa depan karena retry. Cek:Code - Endpoint webhook Anda sering 500 — dispatcher akan mencoba ulang sebanyak
max_attempts(default 3) sebelum menandai deliveryfailed. Setelah endpoint diperbaiki, dispatcher tidak akan mengirim ulang barisfailedsecara otomatis — event sudah keluar dari window retry dan harus dipicu ulang manual.
File terkait
- Dispatcher:
crates/api-gateway/src/webhook_dispatcher.rs - Routes admin:
crates/api-gateway/src/routes/outgoing_webhooks.rs - Event types:
crates/omni-common/src/models/events.rs - Scheduler:
crates/api-gateway/src/scheduler.rs - Tabel DB:
webhook_deliveries,outgoing_webhooks(migrationsmigrations/)