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 ofRe: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.
convId:
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.
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.