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,reply.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 for first contact, reply.received when the inbound matches one of your sends, 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 and reply.received are the two you build on — same payload shape, so one handler serves both. The 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"
}

agent_email is the agent’s canonical senderidentity. The message itself arrived on the agent’s inbound address, <name>.<slug>@in.mails.ai (here sarah.acme@in.mails.ai) — that inbound form, not the sender identity, is the address to give anyone who needs to email the agent.

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) {
  // reply.received fires when the inbound matches one of your sends; message.received
  // is first contact. Handle BOTH or you silently ignore every reply.
  if (evt.type !== "message.received" && evt.type !== "reply.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);
}