# Polling events

Source: https://docs.simail.ai/events/

> Read every event with GET /v0/events and a cursor; no public endpoint needed. Cursors, filters, retention and a polling loop.

`GET /v0/events` is the simplest way to react to mail: no public URL, no signature checks, and
nothing is missed while your agent is offline (events are kept for 30 days). It returns the same
thin payloads as [webhooks](/webhooks/) and the [WebSocket](/realtime/); see
[Event types](/event-types/).

`GET /v0/events`

Needs `event_read`. Results are limited to your key's scope: an inbox-scoped key sees only its
inbox's events.

| Query parameter | Notes |
|---|---|
| `after` | cursor from the previous response; omit it on the first call to start from the oldest retained event |
| `types` | comma-separated event types, for example `message.received,thread.locked` |
| `inbox_ids` | comma-separated inbox IDs inside your scope |
| `limit` | 1–200, default 50 |

```sh
curl -s "https://api.simail.ai/v0/events?types=message.received&limit=100" \
  -H "Authorization: Bearer $SIMAIL_API_KEY"
```

```json
{
  "count": 1,
  "limit": 100,
  "events": [
    { "event_id": "evt_…", "event_type": "message.received", "occurred_at": "2026-10-18T17:02:12Z", "organization_id": "org_…", "inbox_id": "shop-helper@simail.email", "thread_id": "thr_…", "message_id": "msg_…", "data": { "from": "ada@example.com", "labels": ["received", "unread"], "held": false, "classification": "normal", "wake": true } }
  ],
  "next_cursor": "c_…"
}
```

Store `next_cursor` and pass it as `after` next time. Events come in the order they were recorded.
Polling returns **every** event, including ones that did not wake your agent (`data.wake: false`);
filter on it if you only want waking events.

## Cursors

- A cursor is opaque and signed. It is bound to your organization, your key's scope and the filters
  you used; using it with another key or other filters returns `400 invalid_request`.
- Events are kept for 30 days. A cursor older than that returns `410 gone`: start again without `after`
  and reconcile by listing threads.
- Delivery is at least once. De-duplicate on `event_id`.

## A polling loop

```ts
let cursor: string | undefined = await loadCursor();
for (;;) {
  const url = new URL("https://api.simail.ai/v0/events");
  url.searchParams.set("types", "message.received,thread.locked,thread.unlocked");
  url.searchParams.set("limit", "100");
  if (cursor) url.searchParams.set("after", cursor);
  const res = await fetch(url, { headers: { Authorization: `Bearer ${process.env.SIMAIL_API_KEY}` } });
  if (res.status === 429) { await sleep(Number(res.headers.get("retry-after") ?? 5) * 1000); continue; }
  const page = await res.json();
  for (const event of page.events) await handle(event); // idempotent on event.event_id
  if (page.next_cursor) { cursor = page.next_cursor; await saveCursor(cursor); }
  if (page.count < 100) await sleep(5000);
}
```

Handle each event before saving the cursor, so a crash replays events instead of losing them.

## Inbox events

`GET /v0/inboxes/{inbox_id}/events`

lists the events of one inbox with the same parameters (except `inbox_ids`). Needs `event_read`.

The MCP tool `list_events` takes the same `after`, `types`, `inbox_ids` and `limit`.
