zoxaAI
Homepage
API ReferenceCampaigns

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:

CredentialQuery parameter
API key (zsk_...)api_key
Session JWTtoken

The socket is authenticated before it is accepted, and org ownership of the campaign is verified:

SituationBehavior
Missing or invalid credentialSocket closed with code 1008.
Campaign not found or not in your orgSocket closed with code 1008 (reason: "Invalid campaign").
Authenticated and authorizedSocket 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:

typeFired whenNotable fields
call_outcome_recordedA dial attempt reached a terminal outcomequeued_run_id, root_queued_run_id, outcome (completed / busy / no_answer / voicemail / failed / canceled)
batch_completedA dispatch batch finishedprocessed_count, failed_count, batch_size
sync_completedThe CSV finished syncing into contactstotal_rows, source_type, source_id
retry_scheduledA retry was queued for a future timequeued_run_id, retry_run_id, retry_count, scheduled_for, reason
campaign_pausedThe campaign paused (manual pause or the failure policy; the circuit breaker emits circuit_breaker_tripped instead)processed_rows, failed_rows
campaign_resumedThe campaign resumed (from paused or failed)processed_rows, failed_rows
campaign_completedThe last call landed and the campaign finishedtotal_rows, processed_rows, failed_rows, duration_seconds
circuit_breaker_trippedThe failure rate crossed the thresholdfailure_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
};

On this page