Events & streaming — the live wire
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.receivedfor first contact,reply.receivedwhen the inbound matches one of your sends, andmessage.received.unauthenticatedwhen 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);
}