Event Streaming#
Outeract pushes events to you in realtime over Server-Sent Events (SSE), with lossless resume backed by the event ledger. Unlike outbound webhooks, streaming needs no public receiver — you open a long-lived HTTP connection and read events as they happen.
Because every event has a durable, keyset-ordered cursor, a dropped connection
resumes exactly where it left off: reconnect with the standard Last-Event-ID
header and the server replays every event you missed from the ledger, then goes
live. There is no history window to fall outside of.
Two surfaces expose the same stream:
| Surface | Endpoint | Auth |
|---|---|---|
| Developer (programmatic) | GET /events/stream | API key, events:read scope |
| GraphQL subscription | subscription { eventStream { … } } over SSE | API key (developer) / JWT (admin) |
| Admin (console) | GET /admin/events/stream | JWT + X-Outeract-Org-ID / X-Outeract-App-ID |
All three are scoped strictly to a single application. Authentication failures are
ordinary HTTP responses (401/403), never a stream.
SSE endpoint#
GET /events/stream
Authorization: Bearer YOUR_API_KEY
Accept: text/event-streamEach message is a standard SSE event:
id: eyJ...opaque-cursor...
data: {"id":"…","appId":"…","eventType":"message.whatsapp.inbound","payload":{…},"edges":[…],"attachments":[…],"createdAt":"…"}idis the opaque(createdAt, id)event cursor — the same cursor used by theeventsGraphQL connection. Persist the last one you saw.datais the event as JSON, the same shape as aneventsconnection node (id,appId,eventType,payload,edges,attachments, timestamps, …).- Comment lines
: keep-aliveare sent periodically so proxies don’t drop an idle connection. Ignore them.
Query parameters (filters)#
All optional; combine freely (AND semantics):
| Parameter | Meaning |
|---|---|
eventTypes | Comma-separated list of fnmatch patterns, e.g. message.*,custom.order.created. Same wildcard semantics as webhook subscriptions. |
userId | Only events involving this user. |
conversationId | Only events in this conversation. |
GET /events/stream?eventTypes=message.*&userId=8f3b…Resume semantics (Last-Event-ID)#
On the first connection without Last-Event-ID, the stream starts live from now
(with a small recent backfill — see STREAM_BACKFILL).
On reconnect, send the last cursor you received:
GET /events/stream
Authorization: Bearer YOUR_API_KEY
Last-Event-ID: eyJ...last-cursor-you-saw...The server replays every matching event strictly after that cursor from the ledger, in order, then switches to live. Delivery is exactly-once-in-order per cursor: because subscribers always re-fetch from the ledger by cursor, duplicate or late wake signals can never produce duplicate or out-of-order events.
Browsers’ native EventSource sends Last-Event-ID automatically. If you need to
send Authorization/tenant headers (which EventSource cannot set), use
fetch() + a streaming reader and set the header yourself.
Reconnect guidance#
- Cloud Run caps a single request at 60 minutes, so the stream will end
periodically. Always reconnect with
Last-Event-ID— resume is lossless. - Reconnect with backoff on network errors. The stream routes are exempt from
the 100 req/min rate limit, but an app has a cap on concurrently open streams
(
STREAM_MAX_CONCURRENT_PER_APP); exceeding it returns429. - A
429means too many open streams for the app — close idle ones or retry after one frees up.
Example (fetch + streaming reader)#
async function stream(apiKey, onEvent) {
let lastEventId = localStorage.getItem("outeract:lastEventId");
for (;;) {
const headers = { Authorization: `Bearer ${apiKey}`, Accept: "text/event-stream" };
if (lastEventId) headers["Last-Event-ID"] = lastEventId;
const resp = await fetch("https://api.outeract.com/events/stream", { headers });
if (!resp.ok) throw new Error(`stream failed: ${resp.status}`);
const reader = resp.body.pipeThrough(new TextDecoderStream()).getReader();
let buffer = "";
while (true) {
const { value, done } = await reader.read();
if (done) break; // connection ended — loop reconnects with Last-Event-ID
buffer += value;
let sep;
while ((sep = buffer.indexOf("\n\n")) !== -1) {
const frame = buffer.slice(0, sep);
buffer = buffer.slice(sep + 2);
if (frame.startsWith(":")) continue; // keep-alive
const id = frame.match(/^id: (.*)$/m)?.[1];
const data = frame.match(/^data: (.*)$/m)?.[1];
if (id) { lastEventId = id; localStorage.setItem("outeract:lastEventId", id); }
if (data) onEvent(JSON.parse(data));
}
}
await new Promise((r) => setTimeout(r, 1000)); // backoff before reconnect
}
}GraphQL subscription#
The eventStream subscription is the realtime counterpart of the events query,
served over GraphQL-over-SSE (graphql-sse “distinct connections mode”). Send the
subscription operation with an Accept: text/event-stream header:
subscription {
eventStream(eventTypes: ["message.*"], conversationId: "…") {
id
eventType
payload
createdAt
}
}POST / # developer API (API key, events:read)
POST /admin/ # admin API (JWT + org headers)
Accept: text/event-stream
Content-Type: application/jsonEach new event arrives as an SSE next event carrying the GraphQL execution
result. The subscription starts live (no historical replay); use the SSE endpoint
above when you need Last-Event-ID resume. Auth is enforced exactly like queries —
a subscription operation is never treated as introspection and never bypasses
authentication.
Notes#
- Timing: events are streamed after worker processing. Transcribed-audio message events queue their downstream notifications after transcription completes, so stream and webhook timing can diverge slightly for audio.
- Multi-tenancy: every fetch and every wake signal is scoped by the authenticated application; a stream never sees another tenant’s events.
- Disabling: streaming is gated by the
STREAM_ENABLEDconfig flag.