OmniStream Docs
  • Panduan Pengguna
  • Developer
  • API Reference
Developer Hub
Pendahuluan
Autentikasi
Model Data
Webhook
    Webhook WhatsApp inboundWebhook Instagram inboundWebhook MessengerWebhook Email inbound dan HMAC signatureOutgoing webhook — dispatcher, event schema, dan retry
WebSocket
Self-Hosting
Error & Rate Limit
Webhook

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 APIAkses
POST /api/outgoing-webhooksadmin
GET /api/outgoing-webhooksadmin
PATCH /api/outgoing-webhooks/:idadmin
DELETE /api/outgoing-webhooks/:idadmin

Setiap baris memiliki:

  • url — endpoint target (HTTPS direkomendasikan)
  • secret — shared secret untuk HMAC signing
  • events — array event types yang ingin diterima (mis. ["message.received", "message.sent"])
  • is_active — flag aktif
  • headers (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:

  1. Tipe event → event type string (lihat tabel di bawah)
  2. Pencocokan subscribers — SELECT * FROM outgoing_webhooks WHERE is_active = true AND $1 = ANY(events)
  3. Setiap subscriber menghasilkan satu baris di webhook_deliveries dengan status pending

Loop 2: Delivery Processor

process_deliveries berjalan periodik (setiap 30 detik via scheduler api-gateway) dan memproses batch pending:

  1. SELECT * FROM webhook_deliveries WHERE status = 'pending' AND (next_attempt_at IS NULL OR next_attempt_at <= NOW()) LIMIT 50
  2. Untuk tiap row: hitung HMAC, POST ke URL, catat hasil
  3. Sukses (2xx) → status = 'success', attempts += 1, delivered_at = NOW()
  4. Gagal (non-2xx atau timeout) → attempts += 1, next_attempt_at = NOW() + backoff, last_error
  5. 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:

ConversationUpdateEventEvent typePemicu
MessageReceivedmessage.receivedPesan inbound dari customer melalui webhook-ingestor
MessageSentmessage.sentAgent/bot mengirim pesan keluar yang berhasil
MessageStatusmessage.statusStatus delivery berubah (sent/delivered/read/failed) dari provider
ConversationCreatedconversation.createdPercakapan baru dibuat (biasanya bersamaan dengan pesan inbound pertama)
ConversationResolvedconversation.resolvedAgent menutup percakapan (status = 'resolved')
ConversationAssignedconversation.assignedPercakapan 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
{ "event": "<event.type>", "event_id": "7f3c4d2e-9a1b-4c5d-8e6f-1a2b3c4d5e6f", "timestamp": "2026-04-11T10:20:00Z", "data": { /* event-specific */ } }

message.received

Dikirim ketika pesan inbound muncul di sistem.

Code
{ "event": "message.received", "event_id": "7f3c4d2e-...", "timestamp": "2026-04-11T10:20:00Z", "data": { "message_id": "a1b2c3d4-...", "conversation_id": "b2c3d4e5-...", "contact": { "id": "c3d4e5f6-...", "name": "Budi", "channel_user_id": "628111222333" }, "channel": "whatsapp", "direction": "inbound", "content": { "type": "text", "text": "Halo, butuh bantuan" }, "integration_account_id": "d4e5f6a7-..." } }

message.sent

Dikirim ketika outbound message berhasil dikirim oleh message-sender-*.

Code
{ "event": "message.sent", "event_id": "...", "timestamp": "2026-04-11T10:21:00Z", "data": { "message_id": "e5f6a7b8-...", "conversation_id": "b2c3d4e5-...", "agent_id": "f6a7b8c9-...", "channel": "whatsapp", "direction": "outbound", "content": { "type": "text", "text": "Terima kasih, bagaimana kami bisa membantu?" }, "provider_message_id": "wamid.HBgLMzEx..." } }

message.status

Status delivery berubah. Nilai status mengikuti provider: sent, delivered, read, failed.

Code
{ "event": "message.status", "event_id": "...", "timestamp": "2026-04-11T10:21:05Z", "data": { "message_id": "e5f6a7b8-...", "conversation_id": "b2c3d4e5-...", "status": "delivered", "provider_message_id": "wamid.HBgLMzEx..." } }

conversation.created

Code
{ "event": "conversation.created", "event_id": "...", "timestamp": "2026-04-11T10:20:00Z", "data": { "conversation_id": "b2c3d4e5-...", "contact_id": "c3d4e5f6-...", "channel": "whatsapp", "status": "open", "assigned_agent_id": null, "integration_account_id": "d4e5f6a7-..." } }

conversation.resolved

Code
{ "event": "conversation.resolved", "event_id": "...", "timestamp": "2026-04-11T11:00:00Z", "data": { "conversation_id": "b2c3d4e5-...", "resolved_by_agent_id": "f6a7b8c9-...", "resolution_note": "Masalah sudah diatasi" } }

conversation.assigned

Code
{ "event": "conversation.assigned", "event_id": "...", "timestamp": "2026-04-11T10:22:00Z", "data": { "conversation_id": "b2c3d4e5-...", "assigned_agent_id": "f6a7b8c9-...", "previous_agent_id": null, "transferred_by_agent_id": null } }

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
X-Omnistream-Signature: sha256=<hex>

Di mana <hex> adalah HMAC-SHA256 lowercase hex dari raw body JSON memakai outgoing_webhooks.secret sebagai kunci, dengan prefix literal sha256=:

Code
signature = "sha256=" + hex( hmac_sha256(secret, request_body_bytes) )

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
format!("sha256={}", hex::encode(result.into_bytes()))

Implementasi di outgoing_webhooks.rs::compute_hmac_signature:

Code
use hmac::{Hmac, Mac}; use sha2::Sha256; type HmacSha256 = Hmac<Sha256>; let mut mac = HmacSha256::new_from_slice(secret.as_bytes()) .expect("HMAC accepts any key size"); mac.update(payload.as_bytes()); let result = mac.finalize(); let signature = format!("sha256={}", hex::encode(result.into_bytes()));

Memverifikasi signature di penerima

Node.js:

Code
import crypto from "crypto"; app.post("/webhook", express.raw({ type: "application/json" }), (req, res) => { const received = req.headers["x-omnistream-signature"] || ""; const expected = "sha256=" + crypto .createHmac("sha256", process.env.OMNISTREAM_WEBHOOK_SECRET) .update(req.body) // raw Buffer, BUKAN JSON.parse .digest("hex"); // Buffer length guard — timingSafeEqual melempar RangeError jika // panjang tidak sama. Ini juga mencegah timing leak dari perbandingan // panjang string. const receivedBuf = Buffer.from(received); const expectedBuf = Buffer.from(expected); if ( receivedBuf.length !== expectedBuf.length || !crypto.timingSafeEqual(receivedBuf, expectedBuf) ) { return res.status(401).send("invalid signature"); } const event = JSON.parse(req.body.toString()); // proses event... res.status(200).send("ok"); });

Python (FastAPI):

Code
import hmac, hashlib, json from fastapi import Request, HTTPException @app.post("/webhook") async def webhook(req: Request): body = await req.body() # raw bytes received = req.headers.get("x-omnistream-signature", "") expected = "sha256=" + hmac.new( SECRET.encode(), body, hashlib.sha256 ).hexdigest() # hmac.compare_digest constant-time, tahan terhadap length mismatch. if not hmac.compare_digest(received, expected): raise HTTPException(status_code=401, detail="invalid signature") event = json.loads(body) # proses event... return {"ok": True}

Go (net/http):

Code
import ( "crypto/hmac" "crypto/sha256" "encoding/hex" "io" "net/http" ) func webhookHandler(w http.ResponseWriter, r *http.Request) { body, _ := io.ReadAll(r.Body) received := r.Header.Get("X-Omnistream-Signature") mac := hmac.New(sha256.New, []byte(os.Getenv("OMNISTREAM_WEBHOOK_SECRET"))) mac.Write(body) expected := "sha256=" + hex.EncodeToString(mac.Sum(nil)) // hmac.Equal constant-time, tahan terhadap length mismatch. if !hmac.Equal([]byte(received), []byte(expected)) { http.Error(w, "invalid signature", http.StatusUnauthorized) return } // proses body... w.WriteHeader(http.StatusOK) }

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
let retry_delay_secs = 30i64 * (4i64.pow((attempt - 1) as u32)); let next_attempt_at = Utc::now() + Duration::seconds(retry_delay_secs);

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)

AttemptDelay setelah kegagalan sebelumnyaTotal elapsed kira-kira
1— (dikirim segera pada created_at)0s
230s (formula: 30 * 4^0)30s
3120s (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:

AttemptDelay (30 * 4^(attempt-1))
230s
3120s
4480s (8 menit)
51920s (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 events pada outgoing_webhooks; hanya event yang terdaftar yang dikirim. Jalankan SELECT events FROM outgoing_webhooks WHERE id = '<id>'.
  • Retry tidak berjalan meskipun ada delivery gagal — pastikan scheduler api-gateway hidup dan tidak ada exception di log webhook_dispatcher. Periksa kolom last_error di webhook_deliveries untuk 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 pending tanpa pernah diproses — next_attempt_at mungkin di masa depan karena retry. Cek:
    Code
    SELECT id, status, attempts, next_attempt_at, last_error FROM webhook_deliveries WHERE webhook_id = '<id>' ORDER BY created_at DESC LIMIT 20;
  • Endpoint webhook Anda sering 500 — dispatcher akan mencoba ulang sebanyak max_attempts (default 3) sebelum menandai delivery failed. Setelah endpoint diperbaiki, dispatcher tidak akan mengirim ulang baris failed secara 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 (migrations migrations/)
Last modified on June 8, 2026
Webhook Email inbound dan HMAC signatureKoneksi WebSocket
On this page
  • Konfigurasi endpoint
  • Arsitektur dispatcher
    • Loop 1: Event Listener
    • Loop 2: Delivery Processor
  • Event types
  • Skema payload per event
    • message.received
    • message.sent
    • message.status
    • conversation.created
    • conversation.resolved
    • conversation.assigned
  • Signing: X-Omnistream-Signature
    • Memverifikasi signature di penerima
  • Retry dengan exponential backoff
    • Jadwal default (max_attempts = 3)
    • Menaikkan max_attempts
    • Retry manual
  • Timeout request
  • Idempotency
  • Troubleshooting
  • File terkait
JSON
JSON
JSON
JSON
JSON
JSON
JSON
Rust
Rust
Javascript
Rust