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:

SurfaceEndpointAuth
Developer (programmatic)GET /events/streamAPI key, events:read scope
GraphQL subscriptionsubscription { eventStream { … } } over SSEAPI key (developer) / JWT (admin)
Admin (console)GET /admin/events/streamJWT + 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-stream

Each message is a standard SSE event:

id: eyJ...opaque-cursor...
data: {"id":"…","appId":"…","eventType":"message.whatsapp.inbound","payload":{…},"edges":[…],"attachments":[…],"createdAt":"…"}
  • id is the opaque (createdAt, id) event cursor — the same cursor used by the events GraphQL connection. Persist the last one you saw.
  • data is the event as JSON, the same shape as an events connection node (id, appId, eventType, payload, edges, attachments, timestamps, …).
  • Comment lines : keep-alive are sent periodically so proxies don’t drop an idle connection. Ignore them.

Query parameters (filters)#

All optional; combine freely (AND semantics):

ParameterMeaning
eventTypesComma-separated list of fnmatch patterns, e.g. message.*,custom.order.created. Same wildcard semantics as webhook subscriptions.
userIdOnly events involving this user.
conversationIdOnly 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 returns 429.
  • A 429 means 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/json

Each 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_ENABLED config flag.