The Only API docs

Server-sent events

Subscribe to a live event stream over GET /events/stream.

GET /api/crm/{crm_id}/events/stream

A long-lived text/event-stream connection carrying events for your panel as they are emitted, plus export.progress and export.complete for running exports.

This endpoint is exempt from rate limiting — a persistent connection would otherwise burn your per-minute budget immediately.

Connecting

curl -N "$BASE/api/crm/$CRM/events/stream" \
  -H "X-API-Key: $KEY"
: connected

id: 12345
event: new_tip
data: {"id":12345,"event_type":"new_tip","crm_id":"crm_…","of_user_id":"1234567","occurred_at":"…","created_at":"…","source_event_id":"tx:99","payload":{…}}

event: export.progress
data: {"event_type":"export.progress","payload":{"job_id":"f01152d3…","status":"running","phase":"messages","phase_index":6,"phase_total":7,"counts":{…}}}

: keep-alive

Lines beginning with : are comments — one : connected on open, then : keep-alive every 15 seconds. Ignore them.

Progress frames have a different shape from account events

Account events are the flat event envelope and carry an id: line. Progress frames (export.*, import.*, refresh.*) nest everything under payload and have no id, crm_id or occurred_at. A client that assumes the envelope will crash on them.

Filtering server-side

GET /events/stream?types=new_tip,import.progress

An unknown type is a 400; omitting it (or *) means everything. This is the only way to subscribe to just the job-progress events.

import json, requests

with requests.get(
    f"{BASE}/api/crm/{CRM}/events/stream",
    headers={"X-API-Key": KEY, "Accept": "text/event-stream"},
    stream=True,
    timeout=None,
) as r:
    r.raise_for_status()
    event_type = None
    for line in r.iter_lines(decode_unicode=True):
        if not line:                     # blank line terminates an event
            event_type = None
        elif line.startswith(":"):       # keep-alive
            continue
        elif line.startswith("event:"):
            event_type = line[6:].strip()
        elif line.startswith("data:"):
            handle(event_type, json.loads(line[5:].strip()))

Reconnecting

SSE has no replay — a dropped connection loses events

Unlike webhooks, the stream does not retry or replay. Anything emitted while you were disconnected is gone from your point of view.

There is also a bounded 200-event queue per connection: if you read more slowly than we emit, the overflow is dropped silently. A client that must not miss events should reconcile against GET /events, not rely on the stream alone.

Reconnect with exponential backoff and backfill from GET /events?since=<last created_at you saw>. Track created_atsince is an exclusive bound on our insert time, whereas occurred_at is the platform's own timestamp and can be much older.

SSE or webhooks?

SSEWebhooks
Needs a public URLnoyes
Retriesno5s → 30s → 5m → 30m → 2h
Survives your restartnoyes
Latencylowestlow
Good fordashboards, live UI, watching an exportserver-side automation, anything that must not be missed

Use SSE for anything a human is watching. Use webhooks for anything that has to be reliable. Using both is reasonable: SSE to update the UI instantly, webhooks as the durable path.

Behind a proxy

The response already sets X-Accel-Buffering: no, which nginx honours. For other proxies or CDNs, disable response buffering for this route or events arrive in batches. Cloudflare buffers text/event-stream on some plans; keep-alive comments mitigate but do not eliminate this.

On this page