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:
{
  "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:
typeWhat it is
messageA message sent or received. Body, direction, snippet, timestamps.
no_reply_expiredThe no-reply timer fired without a reply. Carries waitedMs.
label_addedA label was added to the conv (today, dashboard-only).
label_removedA label was removed.
archived / unarchivedThe 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:
ParamTypeEffect
archivedboolInclude only archived (or only active).
withemailExact correspondent match.
withDomainstringSuffix match (e.g. acme.com).
sincems epochlastEventAt >= since.
untilms epochlastEventAt <= until.
cursorms epochPaginate before this lastEventAt.
limitint 1..200Page 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.