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 |
|---|---|---|
| SSE endpoint | GET /events/stream | API key, events:read scope |
| GraphQL subscription | subscription { eventStream { … } } over SSE | API key, events:read scope |
Both 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).
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;
exceeding it returns
429. - 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 / # API key, events:read
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.