Campaign events (WebSocket)
WS /api/v1/campaign/{id}/events — live campaign events pushed to the browser as they happen.
WS /api/v1/campaign/{campaign_id}/events?api_key=...A live event stream for a single campaign. The server bridges the campaign's internal event bus to your socket and forwards each event as a JSON message — call outcomes, batch completions, pause/resume/complete transitions, circuit-breaker trips, scheduled retries, and more. Use it to keep a campaign detail view ticking without polling.
Authentication
Authenticate with a query parameter — browsers can't set custom headers on a WebSocket handshake. Which parameter you use depends on the credential type:
| Credential | Query parameter |
|---|---|
API key (zsk_...) | api_key |
| Session JWT | token |
The socket is authenticated before it is accepted, and org ownership of the campaign is verified:
| Situation | Behavior |
|---|---|
| Missing or invalid credential | Socket closed with code 1008. |
| Campaign not found or not in your org | Socket closed with code 1008 (reason: "Invalid campaign"). |
| Authenticated and authorized | Socket accepted; events begin streaming. |
The credential is in the query string
Since it travels in the URL, treat the URL as sensitive — don't log it or share it.
Heartbeats
When no event arrives for ~25 seconds, the server sends {"type":"ping"} to keep the connection alive through proxies and to detect dead clients. Ignore pings in your handler (or use them as a liveness signal).
Event catalog
Every message is a JSON object with a type field. Payloads pass through verbatim from the internal event bus and always include campaign_id and a timestamp. The events you'll act on:
type | Fired when | Notable fields |
|---|---|---|
call_outcome_recorded | A dial attempt reached a terminal outcome | queued_run_id, root_queued_run_id, outcome (completed / busy / no_answer / voicemail / failed / canceled) |
batch_completed | A dispatch batch finished | processed_count, failed_count, batch_size |
sync_completed | The CSV finished syncing into contacts | total_rows, source_type, source_id |
retry_scheduled | A retry was queued for a future time | queued_run_id, retry_run_id, retry_count, scheduled_for, reason |
campaign_paused | The campaign paused (manual pause or the failure policy; the circuit breaker emits circuit_breaker_tripped instead) | processed_rows, failed_rows |
campaign_resumed | The campaign resumed (from paused or failed) | processed_rows, failed_rows |
campaign_completed | The last call landed and the campaign finished | total_rows, processed_rows, failed_rows, duration_seconds |
circuit_breaker_tripped | The failure rate crossed the threshold | failure_rate, failure_count, success_count, threshold, window_seconds |
ping | ~25 s heartbeat | — |
The internal retry_needed event may also appear; treat any unrecognized type as a no-op so new events never break your client.
Client model: snapshot + deltas + reconciliation
The socket is a change signal, not the source of truth. A missed event is never fatal — reconcile by refetching. The recommended pattern:
Fetch a REST snapshot on load
Load the authoritative state first: GET /campaign/{id} plus whatever section you're showing (contacts, insights).
Apply deltas as events arrive
Update local state from each event (flip a contact's status on call_outcome_recorded, append to an activity feed on any lifecycle event, debounce a section refetch when an event that affects it lands).
Reconcile on reconnect or refocus
On socket reconnect or tab refocus, refetch the snapshot once. Never trust deltas alone across a disconnect.
Example: connect from the browser
const apiKey = "zsk_..."; // an API key uses ?api_key= (a session JWT would use ?token=)
const url = `wss://dashboard.zoxa.ai/api/v1/campaign/7/events?api_key=${encodeURIComponent(apiKey)}`;
const ws = new WebSocket(url);
ws.onmessage = (evt) => {
const event = JSON.parse(evt.data);
switch (event.type) {
case "ping":
return; // heartbeat — ignore
case "call_outcome_recorded":
// flip the contact row's status, then debounce an insights refetch
updateContact(event.queued_run_id, event.outcome);
break;
case "campaign_completed":
case "campaign_paused":
refetchSnapshot();
break;
default:
// unknown / lifecycle event — safe to ignore or log
break;
}
};
ws.onclose = (evt) => {
if (evt.code === 1008) {
console.error("Auth or authorization failed:", evt.reason);
return; // do not reconnect on 1008
}
scheduleReconnect(); // then refetch the snapshot once reconnected
};Related
GET /campaign/{id}— the snapshot to load firstGET /campaign/{id}/contacts·GET /campaign/{id}/insights— sections to refetch on relevant events