Skip to main content
A conversation is a thread between one identity and one external recipient. Each conversation has a stable convId (the only identifier your code needs), an event-sourced timeline, and a few summary fields that update as the conversation evolves. The first send to a recipient creates a conversation. Replies stitch into it by Message-ID / References headers. A reply that doesn’t match any existing conversation creates a new one with type email.received.

The shape

GET /v1/identities/:handle/conversations/:convId returns this:
events[] is the source of truth. Everything else is a projection of it that we materialize for you so you don’t have to.

The timeline

events[] is a discriminated union. Today it includes: To pull just the messages, filter on e.type === "message" and read e.message. To pull just the no-reply expirations, filter on e.type === "no_reply_expired". The discriminator is always type.

Threading we handle

Three things have to be right for a thread to look correct in the recipient’s inbox:
  • Subject line. Replies use Re: <original> (we trim chains of Re: to a single one).
  • In-Reply-To and References headers. Both are set to the most recent message’s Message-ID, and References is the cumulative chain.
  • Sender continuity. All messages in one conversation go from the same backing mailbox, even though our rotation picks a different one for new conversations. This is recipient affinity.
You get this for free when you reply by convId:
We resolve the recipient, the subject, the threading headers, and the mailbox from the convId. You pass the body, nothing else.

Filtering and pagination

GET /conversations returns a paginated list, newest activity first. Filters live in query params: The list rows are summary shapes — id, correspondent, subject, snippet, message count, last event timestamps. Not the full timeline. For the timeline you fetch one conv at a time.

Reading is cheap; copying is wasteful

The conversation read endpoint serves a fresh snapshot every call, off the materialized state inside the durable object that owns the conversation. There is no version of “give me the diff since last read” — and you don’t need one. The pattern we want you to use is:
  • Events stream tells you something changed on convId X.
  • GET /conversations/:convId gives you the current state.
You never reduce events into your own copy of the thread. We’re the reducer. See Live thread list for the full pattern.
We don’t track per-conversation read state. Every integrator wants different semantics — a CRM marks “read” when the rep opens it, an agent marks it when the model has consumed it, a monitoring tool marks it when an alert was acknowledged. Run your own cursor on top of lastEventAt or webhook delivery dedup; it’s three lines of code and you get the semantics you actually want.