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:
{
"identity": "alice.acme@inboxbase.ai",
"conversation": {
"id": "conv_dd6e7130674645d3",
"with": "morgan@northwindrobotics.com",
"subject": "Quick intro — fleet rotation",
"messageCount": 5,
"archived": false,
"labels": ["hot-lead"],
"lastEventAt": 1777278929000,
"noReplyAt": null,
"lastNoReplyAt": null,
"events": [/* discriminated union, see below */]
}
}
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:
type | What it is |
|---|
message | A message sent or received. Body, direction, snippet, timestamps. |
no_reply_expired | The no-reply timer fired without a reply. Carries waitedMs. |
label_added | A label was added to the conv (today, dashboard-only). |
label_removed | A label was removed. |
archived / unarchived | The conv was archived or unarchived. |
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.
const messages = conv.events
.filter((e) => e.type === "message")
.map((e) => e.message);
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:
curl https://api.inboxbase.ai/v1/identities/alice.acme@inboxbase.ai/send \
-H "Authorization: Bearer sk_live_..." \
-d '{ "convId": "conv_dd6e...", "text": "Friday 10 works." }'
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:
| Param | Type | Effect |
|---|
archived | bool | Include only archived (or only active). |
with | email | Exact correspondent match. |
withDomain | string | Suffix match (e.g. acme.com). |
since | ms epoch | lastEventAt >= since. |
until | ms epoch | lastEventAt <= until. |
cursor | ms epoch | Paginate before this lastEventAt. |
limit | int 1..200 | Page size; default 50. |
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.