Documentation
Guides

Events & streaming — the live wire

7 min read

Events are how mails.ai tells you something happened — a send delivered, a reply arrived, a draft sent. There are three ways to consume them: list them on demand, stream them live over SSE, or have them pushed to a webhook. This guide covers the first two.

List events

GET /v1/events (scope read) returns typed events, newest first. Filter by event_type, agent_id, or since (an event id or timestamp), and page with limit + cursor:

curl "https://api.mails.ai/v1/events?event_type=message.received&limit=50" \
  -H "Authorization: Bearer $MAILS_API_KEY"

Each event carries the signal computed for it:

{
  "id": "evt_01J9X2K7Q8...",
  "type": "message.received",
  "workspace_id": "wsk_01J9...",
  "agent_id": "agt_01J9...",
  "source_message_id": "rcv_01J9...",
  "intent": "schedule_demo",
  "urgency": 0.8,
  "injection_score": 0.02,
  "sender_reputation": 0.91,
  "entities": { "date": "2026-05-14", "time": "10:00" },
  "test_mode": false,
  "data": { "...": "type-specific payload" },
  "created_at": "2026-06-19T18:02:44.000Z"
}

Stream events (SSE)

GET /v1/events/stream (scope read) is a Server-Sent Events live tail. Pass ?since=<event_id> (or a Last-Event-ID header) to resume from where you left off, and ?event_types= with a comma-separated list to filter. The connection sends a : connectedcomment, then events as they arrive with keepalive comments between, and self-closes after about 50 seconds — your client reconnects with the last id it saw.

The frame format is standard SSE:

id: evt_01J9X2K7Q8...
event: message.received
data: {"id":"evt_01J9...","type":"message.received","agent_id":"agt_01J9...","data":{...}}

Because auth is a bearer header, use a streaming HTTP client (not the browser EventSource, which can’t set headers). A reconnect-safe consumer in Node:

let lastId: string | undefined;

async function stream() {
  const url = new URL("https://api.mails.ai/v1/events/stream");
  url.searchParams.set("event_types", "message.received");

  const res = await fetch(url, {
    headers: {
      Authorization: `Bearer ${process.env.MAILS_API_KEY}`,
      ...(lastId ? { "Last-Event-ID": lastId } : {}),
    },
  });

  const reader = res.body!.pipeThrough(new TextDecoderStream()).getReader();
  let buffer = "";
  for (;;) {
    const { value, done } = await reader.read();
    if (done) break; // server closed at ~50s — reconnect below
    buffer += value;
    const frames = buffer.split("\n\n");
    buffer = frames.pop() ?? "";
    for (const frame of frames) {
      const id = frame.match(/^id: (.+)$/m)?.[1];
      const data = frame.match(/^data: (.+)$/m)?.[1];
      if (id) lastId = id;
      if (data) handleEvent(JSON.parse(data));
    }
  }
}

// Reconnect forever, resuming from lastId.
for (;;) {
  try { await stream(); } catch (e) { /* log */ }
  await new Promise((r) => setTimeout(r, 1000));
}

Event types

The events mails.ai emits, grouped by what they describe:

  • Send lifecycle: message.sent, message.scheduled, message.delivered, message.bounced, message.complained, message.replied, message.forwarded, message.canceled, message.rescheduled.
  • Inbound: message.received, and message.received.unauthenticated when both SPF and DKIM fail (treat that sender with suspicion).
  • Drafts: draft.scheduled, draft.sending, draft.sent, draft.failed.
  • Diagnostics: webhook.test (fired by the webhook test endpoint).

The inbound payload

message.received is the one you build on. Its data carries the sender, the parsed content, the authentication results, and the security signal mails.ai computes on every inbound:

{
  "agent_id": "agt_01J9...",
  "agent_email": "sarah@acme.mails.ai",
  "thread_id": "thrd_01J9...",
  "source_message_id": "rcv_01J9...",
  "is_thread_reply": true,
  "from": { "address": "user@example.com", "name": "Jordan Lee" },
  "subject": "Re: Your order shipped",
  "injection_score": 0.02,
  "sender_reputation": 0.91,
  "spf_pass": true,
  "dkim_pass": true,
  "intent": "schedule_demo",
  "entities": { "date": "2026-05-14", "time": "10:00" },
  "urgency": 0.8,
  "body_text_excerpt": "Tuesday at 10am works…",
  "raw_url": "/v1/messages/rcv_01J9.../raw"
}

injection_score and sender_reputation are present on every inbound. The intent, entities, and urgency fields appear only when the receiving agent has classify_inboundenabled. A robust handler reads the signal before acting on the body — for example, refuse to follow instructions from a high-injection-score message:

function handleEvent(evt) {
  if (evt.type !== "message.received") return;
  const d = evt.data;
  if (d.injection_score > 0.5) return; // do not act on likely-injected mail
  if (d.intent === "schedule_demo") return scheduleDemo(d.entities);
  return llm.handle(d.body_text_excerpt);
}