Skip to main content

Events & streaming — the live wire

List events or tail the live SSE stream. Every event type, the inbound payload, and a reconnect-safe consumer.

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 ISO 8601 time or a date), 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 : connected comment, 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.delayed, message.bounced, message.complained, message.failed.
  • Inbound: message.received for first contact, reply.received when the inbound matches one of your sends, and message.received.unauthenticated when the sender looks forged: no DKIM signature aligned with its domain verifies, and that domain’s DMARC policy is quarantine or reject (treat that sender with suspicion). message.received.over_limit reports mail received while the workspace was over a limit, and a service’s sign-in code sent to a plus address.
  • Drafts: draft.scheduled, draft.sending, draft.sent, draft.failed, and draft.created, draft.updated and draft.deleted for a draft’s life before it goes.
  • Agents: agent.created, agent.updated, agent.deleted.
  • Threads and suppression: thread.created, thread.updated, thread.deleted, suppression.added.
  • Team inbox: thread.assigned and thread.unassigned when a thread’s assignee changes, and thread.note_added when someone leaves a note (its id and author, not its words).
  • Diagnostics: webhook.test (fired by the webhook test endpoint).

Agent and draft events

agent.created, agent.updated and agent.deleted follow an agent's life. Their data holds agent_id, agent (the agent as GET /v1/agents/{id} answers it, without unread_count, when the event was raised) and source: api, dashboard, or system for our own checks. agent.created is raised for every agent but the one sign-up makes, including one a send or a draft provisions. agent.updated is raised only when something really changed, and changed names those fields: a PATCH, a forwarding test sent or arriving, or a pause by our bounce, complaint or abuse checks and its lifting. agent.deleted is raised once, when DELETE /v1/agents/{id} archives the agent: it stays archived, not erased, so its data shows status: "archived" with archived_at.

draft.created, draft.updated and draft.deleted follow a draft beside the events its sending raises. A PATCH that schedules raises draft.scheduled and then draft.updated, and clearing send_at raises draft.updated alone. Their data holds draft_id, agent_id, status, send_at, in_reply_to_message_id, thread_id, updated_at and source, never the words: read those with GET /v1/drafts/{id}. draft.deleted adds deleted_at.

draft.updated follows every PATCH /v1/drafts/{id}, so an API caller gets every update. It also follows a file added to a draft, that file's last chunk arriving and a file removed, each of which moves updated_at, and deleting a thread raises it for a scheduled reply that goes with it. The dashboard composer saves about once a second with the header X-Mails-Autosave: 1, and such a save raises nothing when the draft changed under a minute ago, unless it changes to, cc or bcc or sets send_at: those always raise it. So while someone types the subject or body a subscriber gets at most one a minute, plus one when the composer settles: about 10 seconds after typing stops, or when it loses focus, is minimised or is closed. A closed tab can miss that last one. draft.sent always comes when the draft goes out, and GET /v1/messages/{id} with its message_id reads the final content. A webhook endpoint gets these six, like the thread events, only when it lists them by name.

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 within your plan’s limits:

{
  "agent_id": "agt_01J9...",
  "agent_email": "hello@acme.mails.ai",
  "thread_id": "thrd_01J9...",
  "source_message_id": "rcv_01J9...",
  "is_thread_reply": false,
  "from": { "address": "user@example.com", "name": "Jordan Lee" },
  "subject": "Demo next week?",
  "injection_score": 0.02,
  "sender_reputation": 0.91,
  "spf_pass": null,
  "dkim_pass": true,
  "intent": "schedule_demo",
  "entities": { "date": "2026-05-14", "time": "10:00" },
  "urgency": 0.8,
  "body_text_excerpt": "Could we see a demo Tuesday at 10am?",
  "raw_url": "/v1/messages/rcv_01J9.../raw"
}

agent_email is the agent’s canonical sender identity. The message itself arrived on the agent’s inbound address, <name>.<slug>@in.mails.ai (here hello.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 within your plan’s limits. The intent, entities, and urgency fields are read from the message only when it is first contact and the receiving agent has classify_inbound enabled; otherwise intent is a fixed value (general_reply for a reply in a thread, unknown or unclassified) and entities is empty. A robust handler reads the signal before acting on the body — for example, hold a high-injection-score message for a person instead of following its instructions:

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;
  // Hold for a person: quarantined, not scanned (no score), or 0.5 and up
  if (d.quarantined || typeof d.injection_score !== "number" || d.injection_score >= 0.5) {
    return flagForReview(evt);
  }
  if (d.intent === "schedule_demo") return scheduleDemo(d.entities);
  return llm.handle(d.body_text_excerpt);
}

Was this page helpful?