Server-sent events
Subscribe to a live event stream over GET /events/stream.
GET /api/crm/{crm_id}/events/streamA 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-aliveLines 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.progressAn 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_at — since 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?
| SSE | Webhooks | |
|---|---|---|
| Needs a public URL | no | yes |
| Retries | no | 5s → 30s → 5m → 30m → 2h |
| Survives your restart | no | yes |
| Latency | lowest | low |
| Good for | dashboards, live UI, watching an export | server-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.