User Events (SSE)¶
The User API can stream workspace events to you in real time over a single long-lived HTTP connection using Server-Sent Events (SSE). This is the lowest-latency way to react to changes without polling, and — unlike webhooks — it needs no public endpoint of your own.
| Requirement | Value |
|---|---|
| Method | GET |
| Required scope | any of meetings:read, action-items:read, calendar:read, workspace:read |
Response Content-Type | text/event-stream |
Connecting¶
Send your bearer token and keep the connection open:
On success the server immediately sends a preamble comment and then begins streaming events:
: connected
id: 1609459200000-0
event: meeting.created
data: {"type":"meeting.created","event_id":"...","workspace_id":"...","actor_user_id":"...","occurred_at":"2024-01-15T10:30:00Z","data":{}}
Event framing¶
Each event is a standard SSE frame with three fields:
| Line | Description |
|---|---|
id: | The stream ID of this event (e.g. 1609459200000-0). Use it as a resume token. |
event: | The event type (see the table below). |
data: | A JSON payload describing the event. |
The data payload has a consistent shape:
{
"type": "meeting.created",
"event_id": "123e4567-e89b-12d3-a456-426614174000",
"workspace_id": "123e4567-e89b-12d3-a456-426614174002",
"actor_user_id": "123e4567-e89b-12d3-a456-426614174001",
"occurred_at": "2024-01-15T10:30:00Z",
"data": { }
}
| Field | Description |
|---|---|
type | The event type (matches the SSE event: line). |
event_id | UUID of the underlying domain event. |
workspace_id | The workspace the event belongs to. |
actor_user_id | The user who triggered the event. Empty for system-triggered events. |
occurred_at | RFC 3339 timestamp of when the event was produced. |
data | Optional event-specific payload; omitted when empty. |
Event types¶
| Category | Types |
|---|---|
| Meeting | meeting.created, meeting.updated, meeting.started, meeting.completed, meeting.deleted |
| Participant | participant.added, participant.removed |
| Action item | action_item.created, action_item.updated, action_item.completed, action_item.deleted |
| Agenda item | agenda_item.created, agenda_item.updated, agenda_item.deleted |
| Meeting context | meeting.context.created, meeting.context.processed, meeting.context.deleted |
| Calendar event | calendar_event.created, calendar_event.updated |
Access control and filtering¶
Opening the stream requires any of these read scopes:
meetings:read— opens the stream; receivemeeting.*,participant.*,agenda_item.*, andmeeting.context.*events.action-items:read— opens the stream; receiveaction_item.*events.calendar:read— opens the stream; receivecalendar_event.*events.workspace:read— admin-only; receive all event types and all actors in the workspace.
After the connection is open, events are screened in two ways:
- Scope screening: each event type is delivered only if your token has the matching read scope. For example,
action_item.updatedis not sent to a token that only hasmeetings:read. - Actor screening: unless your token has
workspace:read(admin), you receive only: - Events with an empty
actor_user_id(system-triggered events, e.g. auto-finalization). - Events where
actor_user_idmatches your user ID.
This means non-admin users can open the stream with the least-privilege scope they need, while admins with workspace:read can observe activity from every user in the workspace. The actor_user_id field lets you distinguish who triggered each event.
Heartbeats¶
To keep the connection alive through idle-timeout proxies, the server sends a comment heartbeat every 15 seconds:
Comment lines (those beginning with :) carry no data and should be ignored by your client. Standard SSE libraries do this automatically.
Resuming after a disconnect¶
If your connection drops, reconnect and send the stream ID of the last event you processed in the Last-Event-ID header. The server replays every event after that ID (gap-free, no duplicates) before resuming the live stream:
curl -N https://api.contio.ai/v1/user/events \
-H "Authorization: Bearer $CONTIO_TOKEN" \
-H "Last-Event-ID: 1609459200000-0"
If you omit Last-Event-ID, you receive only events that occur after you connect.
Replay window
Recent events are retained for a short window per workspace, so brief reconnections resume seamlessly. After an extended outage some events may fall outside the replay window — reconcile by fetching current state via the list endpoints.
Connection limits¶
Concurrent event-stream connections are capped:
| Scope | Maximum concurrent connections |
|---|---|
| Per user | 5 |
| Per workspace | 20 |
Exceeding a cap returns 429 Too Many Requests with Retry-After: 5 and this body:
{
"error": "Too many concurrent event connections",
"code": "too_many_requests",
"request_id": "abc123xyz"
}
Distinct from request rate limiting
This concurrent-connection cap (code = too_many_requests) is separate from the per-minute request rate limit (code = rate_limit_exceeded). Both use HTTP 429.
Consuming the stream¶
import { EventSource } from "eventsource"; // Node polyfill; browsers have EventSource built in
const es = new EventSource("https://api.contio.ai/v1/user/events", {
fetch: (url, init) =>
fetch(url, { ...init, headers: { ...init.headers, Authorization: `Bearer ${token}` } }),
});
es.addEventListener("meeting.created", (e) => {
const event = JSON.parse(e.data);
console.log("New meeting:", event.event_id, "in", event.workspace_id);
});
es.onerror = () => console.warn("stream error; the client will auto-reconnect");
The EventSource API automatically reconnects and sends Last-Event-ID for you.
Next steps¶
- Workspace Webhooks — push events to your own HTTPS endpoint
- Rate Limits — request budgets and connection caps
- Errors — the standard error envelope