Skip to main content
Most integrations end up wanting the same UI: a list of recent threads that updates as new replies land, with pagination as the user scrolls back. The ergonomic way to build it on 12m is to combine two endpoints. You don’t keep a copy of our state.

The pattern

GET /v1/identities/:handle/conversations         ← initial snapshot
GET /v1/identities/:handle/events?since=...      ← live deltas
GET /v1/identities/:handle/conversations?cursor=... ← scroll-back
Three primitives, three jobs. The first paints the screen. The second patches rows as they change. The third pages backward when the user scrolls past the bottom.

Putting it together

// 1. Initial render.
const { conversations, nextCursor } = await fetch12m(
  `/v1/identities/${handle}/conversations?limit=50`,
);
const list = new Map(conversations.map((c) => [c.id, c]));
let scrollCursor = nextCursor;

// 2. Subscribe to deltas.
let eventCursor = 0;
while (mounted) {
  const r = await fetch12m(
    `/v1/identities/${handle}/events?since=${eventCursor}&timeoutMs=25000`,
  );
  for (const ev of r.events) {
    if (!ev.convId) continue;

    if (list.has(ev.convId)) {
      // Existing row — patch from the event.
      const row = list.get(ev.convId);
      row.lastEventAt = ev.ts;
      if (ev.data?.message?.snippet)  row.snippet  = ev.data.message.snippet;
      if (ev.data?.message?.subject)  row.subject  = ev.data.message.subject;
      if (ev.type === "email.received" || ev.type === "email.replied") {
        row.messageCount = (row.messageCount ?? 0) + 1;
      }
    } else {
      // New thread — fetch the row shape, prepend.
      const { conversation } = await fetch12m(
        `/v1/identities/${handle}/conversations/${ev.convId}`,
      );
      list.set(conversation.id, conversation);
    }
    render(Array.from(list.values()).sort((a, b) => b.lastEventAt - a.lastEventAt));
  }
  eventCursor = r.cursor;
}

// 3. Pagination — when the user scrolls past the bottom.
async function loadOlder() {
  if (!scrollCursor) return;
  const older = await fetch12m(
    `/v1/identities/${handle}/conversations?cursor=${scrollCursor}&limit=50`,
  );
  for (const c of older.conversations) list.set(c.id, c);
  scrollCursor = older.nextCursor;
}
That is the whole thing. ~30 lines, no client-side reducer, no database, no replay logic.

Why no refetch on every event

The event payload already carries the fields you need to update a row: subject, snippet, the timestamps. The single-conv refetch is reserved for two cases:
  • A new thread — the convId isn’t in your map yet, you need its full row shape (correspondent, label list, archived state).
  • You need a field the event doesn’t carry — labels were added, conversation was archived. Today these are dashboard-only mutations and the event stream doesn’t broadcast them.
For the typical “render an inbox UI as new replies land” loop, refetch is unnecessary >80% of the time.

Why no streaming list endpoint

You might expect /conversations to support SSE or websockets so the list itself “is the stream.” We chose not to build that. One stream (the events log) covers every live-update use case in the system, and adding a per-resource SSE would multiply the surface area for no expressive gain. The mental model stays simple:
  • The list endpoint answers “what’s the snapshot?”
  • The events stream answers “what just changed?”
  • The single-conv endpoint answers “what’s the current state of X?”
Three answers, three endpoints, no overlap.

Filtering live

If your UI shows only replied threads, only archived, only a particular correspondent — apply the filter on both ends. The list endpoint takes filter query params. The events stream takes types:
GET /events?types=email.replied,email.no_reply&since=...
Filter the same way in both calls and your live list stays consistent. Server-side filtering keeps your client small.

What the list rows contain

Just enough to render a row, never the full timeline:
{
  "id":           "conv_dd6e...",
  "with":         "morgan@northwindrobotics.com",
  "subject":      "Quick intro — fleet rotation",
  "snippet":      "Friday 10 works...",
  "messageCount": 5,
  "archived":     false,
  "lastEventAt":  1777278929000,
  "createdAt":    1777200000000
}
If you want to render an “open thread” view on click, fetch the single-conv endpoint at that point. It returns the full timeline as events[].