Threads API

A thread groups messages that share a thread_id — typically a reply chain keyed by a stripped RFC Message-Id (angle brackets removed). Standalone messages that have no thread of their own appear as single-message threads keyed by the message's own UUID. Every thread is scoped to exactly one mailbox.

The id you get back from the list endpoint is the thread key. It is either a stripped Message-Id (e.g. [email protected]) or a message UUID string. Because a thread key can contain characters like @, always URL-encode it before passing it to GET /v1/threads/:id.

This page is the human reference. The machine-readable contract for the same operations ships as an OpenAPI document at /docs/openapi.json.

At a glance

OperationMethod + pathAuthClassification
List threadsGET /v1/mailboxes/:id/threadsBearer admin/agent or sessionread-only
Read a threadGET /v1/threads/:idBearer admin/agent or sessionread-only
Continue a threadPOST /v1/messages/send (thread mode)Bearer admin/agent or sessionsend-triggering, human-review-possible
Mark a thread readPOST /v1/mailboxes/:id/threads/:thread_id/readBearer admin/agent or sessionmutating
Star / unstar a threadPATCH /v1/mailboxes/:id/threads/:thread_id/starBearer admin/agent or sessionmutating

Agent-role keys are scoped to their bound mailboxes. How an unbound access is masked depends on the read shape — see the per-operation notes below.

GET /v1/mailboxes/:id/threads — list threads

Classification: read-only. Returns one row per thread, ordered by last_message_at descending with the thread key ascending as a deterministic tie-breaker. :id accepts a mailbox name or UUID.

Auth: Bearer admin key, Bearer agent key (scoped), or dashboard session.

Query params:

ParamMeaning
limitMax threads to return. Default 50, capped at 200.
before_tsCursor: return threads whose last_message_at is strictly less than this ISO timestamp. Pass the previous page's last thread's last_message_at to page backward.
since_tsReturn threads whose last_message_at is strictly greater than this ISO timestamp. Use it for delta polling ("only new mail since I last looked"). Combined with before_ts, returns threads strictly between the two.
has_inboundtrue returns only threads with at least one inbound message (the "Inbox" semantic — sent-only threads are excluded). Default: no filter.
starredtrue / false filters to starred (or unstarred) threads.
include_firewall_blockedtrue includes messages that were held by the inbound firewall in the thread aggregation. Default excluded.

Both before_ts and since_ts use strict inequality, so a thread whose last_message_at exactly equals the cursor can be skipped at a page boundary. Organic traffic almost never produces millisecond-exact ties; heavy test seeding can.

Unlike the flat messages list, the thread endpoints exclude deleted messages from every aggregate (counts, participants, subject, timestamps). A deleted message never contributes to a thread.

Response (200):

{
  "threads": [
    {
      "id": "[email protected]",
      "subject": "Contract question",
      "first_message_at": "2026-04-10T12:00:00.000Z",
      "last_message_at": "2026-04-14T15:30:00.000Z",
      "message_count": 4,
      "unread_count": 1,
      "starred": false,
      "participants": ["Alice <[email protected]>", "[email protected]"],
      "scan": { "verdict": "quarantined", "categories": [], "findings": [] },
      "body_preview": "Thanks — could you confirm the renewal date before we…",
      "has_attachment": true
    }
  ]
}
  • participants is the deduplicated union of senders and recipients across the thread's non-deleted messages.
  • unread_count counts inbound messages that have not been marked read.
  • scan, body_preview, and has_attachment are additive row enrichments (an older API may omit all three):
    • scan is the worst-across-thread verdict summary, or null when no member has a scan result. Held delivery states are folded in, so an older blocked or quarantined message is never hidden by a newer clean reply. See the message verdict vocabulary for the meaning of verdict.
    • body_preview is a short (≤200 char) preview of the thread's latest message, redacted according to the mailbox's PII delivery mode; null when the latest body is empty or unavailable.
    • has_attachment is true when any non-deleted member carries at least one attachment.

Pagination: page forward by passing the last row's last_message_at as the next request's before_ts. A page shorter than limit is the last page.

Errors: 404 NOT_FOUND (mailbox not found, wrong account, or — for an agent key passing a mailbox name it isn't bound to — masked as not-found). An agent key passing an unbound mailbox UUID gets 403, which leaks no existence because the scope check runs before any lookup. Full remediation for every code is in the error reference.

GET /v1/threads/:id — read a thread

Classification: read-only. Returns the full message sequence for one thread in chronological order (oldest first). :id is the URL-encoded thread key from the list endpoint.

Auth: Bearer admin key, Bearer agent key (scoped), or dashboard session.

Query params:

ParamMeaning
viewsummary (default) or verbose scan rendering.
body_formattext (default) or html. HTML is available to dashboard session callers only; a Bearer/agent caller requesting html gets 400 BODY_FORMAT_HTML_SESSION_ONLY.
include_firewall_blockedtrue includes firewall-held messages in the returned messages array. Default excluded.
mailboxOptional mailbox scope (name or UUID). A thread key is not globally unique, so the same key can legitimately exist in two of your mailboxes. When that happens the account-wide lookup returns 404 (the single-mailbox invariant); pass mailbox to resolve the collision to the one you mean. Omitted → account-wide.

Access control — three layers:

  1. Row-level isolation — only messages belonging to the authenticated account are ever visible.
  2. Single-mailbox invariant — a valid thread lives in exactly one mailbox; a result that would span mailboxes is rejected as 404. Pass mailbox to scope past a cross-mailbox key collision.
  3. Mailbox scope — an agent key bound to mailbox A that asks for a thread in mailbox B receives 404 (not 403), so unbound keys cannot enumerate threads across mailboxes. Passing an unknown or unbound mailbox also returns 404.

Response (200):

{
  "id": "[email protected]",
  "mailbox_id": "uuid",
  "mailbox_name": "support",
  "subject": "Contract question",
  "message_count": 3,
  "messages": [
    {
      "id": "uuid",
      "direction": "outbound",
      "state": "delivered",
      "sender": "[email protected]",
      "recipient": "[email protected]",
      "subject": "Contract question",
      "mailbox_name": "support",
      "body": {
        "format": "text",
        "content": "Hi Alice, ...",
        "char_count": 13,
        "returned_char_count": 13,
        "truncated": false
      },
      "attachments": [],
      "scan": { "verdict": "clean", "categories": [], "findings": [] },
      "thread_id": "[email protected]",
      "in_reply_to": null,
      "read_at": null,
      "starred": false,
      "dashboard_url": "https://app.replylayer.ai/messages?id=uuid",
      "created_at": "2026-04-10T12:00:00.000Z"
    }
  ]
}

Each item in messages has the same shape as a single-message detail read — safe-view only, no raw body fields — including the inbound agent_safety_context, sender_authentication, and (on outbound rows) email_effect objects, plus each attachment's preview_status / preview_kind / preview_reason_code / preview_char_count / preview_page_count / preview_truncated / preview_generated_at projection. See the Messages API for the full per-message field reference. dashboard_url is a deep link into the web inbox, or null when web deep-linking is not configured for your account.

Reading a thread is side-effect-free for read state — it does not mark any message read. Mark messages read explicitly with the thread mark-read operation below (or the per-message read endpoint on the Messages API). Each message returned is recorded as a safe_view entry in your content-access audit trail.

Errors: 404 NOT_FOUND (thread not found, cross-account, or — for an agent key — a thread in an unbound mailbox); 400 for an invalid body_format or an agent HTML request. See the error reference.

POST /v1/messages/send — continue a thread (thread mode)

Classification: send-triggering, human-review-possible. There is no separate "thread send" endpoint — you continue a conversation by calling the standard send (or reply, or draft-create) endpoint in thread mode, which you enter by supplying a thread_id. This is the sanctioned way to follow up on a conversation whose latest message is your own outbound: the plain reply endpoint refuses an outbound target (400 VALIDATION_ERROR), because thread mode keys on the thread, not on a target message's direction.

Auth: Bearer admin key, Bearer agent key (scoped), or dashboard session.

Request (thread continuation):

{
  "thread_id": "[email protected]",
  "body": "Just following up on this."
}

When thread_id is present:

  • from_mailbox and subject are derived from the thread. subject becomes Re: <thread subject> unless you pass an override. A from_mailbox that doesn't own the thread → 404.
  • to is an optional participant selector, resolved against the thread's real, visible inbound history (messages that were never delivered — firewall-held, still scanning, received-but-unprocessed, or deleted — never count):
    • exactly one visible inbound participant and no to → that participant is used;
    • more than one and no to422 AMBIGUOUS_THREAD_RECIPIENT (pass to);
    • zero visible inbound participants → 422 THREAD_HAS_NO_INBOUND_RECIPIENT;
    • a to that is not a visible inbound participant → 422 RECIPIENT_NOT_IN_THREAD (this prevents smuggling a brand-new recipient past a contained mailbox).
  • Mailbox resolution: a thread key is not unique, so if the thread spans more than one mailbox you can reach, and you omit from_mailbox, the send returns 409 AMBIGUOUS_THREAD_MAILBOX (pass from_mailbox). A thread that only exists in a mailbox your agent key can't see returns 404 (never 403 — existence is not leaked).

thread_id may be a thread key (a stripped Message-Id) or the root message's UUID.

Recipient containment interaction. In a mailbox running recipient containment (allowlist mode), a thread-mode send to a participant who is not on the allowlist is admitted only when the mailbox permits thread replies and the recipient is a visible inbound participant of that thread — and no standing allowlist entry is written. When thread replies are disabled for the mailbox, an un-allowlisted participant is rejected with 403 RECIPIENT_NOT_ON_ALLOWLIST. See the recipient allowlist guide for the thread-reply bypass, and the send-gate reference for the full ordered gate stack (suppression, agent containment, strict-recipient, deliverability).

Response (200): identical to a non-thread send — message_id, status (sent / quarantined / blocked / pending_review), scan, hold_context, email_effect, daily_limit, sends_remaining, and the outbound-HTML html_sanitized / removed_categories fields. Thread continuations reuse the reply webhook origin — no new event type. Branch on email_effect.effect_status to know what actually happened in one read; see send outcomes and the full send contract on the Messages API.

Human-review note. When the source mailbox holds outbound messages for human review, the response status is pending_review and the send awaits an explicit approve/deny rather than going on the wire. This is why the operation is classified human-review-possible.

Thread-mode error codes (this operation's own):

CodeStatusMeaning
AMBIGUOUS_THREAD_MAILBOX409thread_id spans multiple accessible mailboxes; pass from_mailbox.
AMBIGUOUS_THREAD_RECIPIENT422Thread has more than one inbound participant; pass to.
RECIPIENT_NOT_IN_THREAD422The to you passed is not a visible inbound participant.
THREAD_HAS_NO_INBOUND_RECIPIENT422The thread has no visible inbound participant to reply to.

These stack on top of the standard send gates (RECIPIENT_SUPPRESSED, RECIPIENT_AGENT_CONTAINED, RATE_LIMITED, the outbound-HTML codes, and more). Full remediation is in the error reference.

Thread mode is also available on POST /v1/messages/:id/reply and on draft creation (POST /v1/drafts) with the same thread_id semantics and error codes; on a draft, the recipient gate re-runs authoritatively at send time, so a thread that later loses its inbound fails closed.

POST /v1/mailboxes/:id/threads/:thread_id/read — mark a thread read

Classification: mutating. Marks every visible inbound unread message in the thread as read, in one transaction. :id is the mailbox name or UUID; :thread_id is the URL-encoded thread key.

Auth: Bearer admin key, Bearer agent key (scoped, masked as 404 when unbound), or dashboard session.

Request: empty body.

Response (200):

{
  "thread_id": "[email protected]",
  "marked_count": 3
}

marked_count is the number of rows newly stamped by this call — it excludes already-read, outbound, deleted, and firewall-held messages. A thread whose messages are all already read returns 200 with marked_count: 0 (the thread still exists, so it is not a 404).

Errors: 404 Mailbox not found (mailbox absent or agent key not bound to it) and 404 Thread not found (mailbox exists but the thread doesn't) are distinct so callers can disambiguate. See the error reference.

PATCH /v1/mailboxes/:id/threads/:thread_id/star — star or unstar a thread

Classification: mutating. Sets or clears the star flag on every visible message in the thread. :id is the mailbox name or UUID; :thread_id is the URL-encoded thread key.

Auth: Bearer admin key, Bearer agent key (scoped, masked as 404 when unbound), or dashboard session.

Request:

{ "starred": true }

Response (200):

{ "thread_id": "[email protected]", "starred": true, "updated_count": 3 }

updated_count is the number of message rows updated.

Errors: 404 NOT_FOUND (mailbox not found, thread not found, or agent key not bound to the mailbox). See the error reference.

SDK example

The TypeScript and Python SDKs mirror these operations method-for-method. In TypeScript:

import { ReplyLayer } from '@replylayer/sdk';
const rl = new ReplyLayer({ apiKey: process.env.REPLYLAYER_API_KEY! });

// List threads with at least one inbound message.
const page = await rl.threads.list('support', { limit: 50, has_inbound: true });
for (const t of page.data) {
  console.log(t.id, t.subject, `unread=${t.unread_count}`, t.scan?.verdict ?? 'no-scan');
}

// Read the full sequence for one thread (URL-encoding is handled for you).
const thread = await rl.threads.get(page.data[0].id, { view: 'summary' });

// Mark it read, then star it.
await rl.threads.markRead('support', thread.id);
await rl.threads.setStarred('support', thread.id, true);

// Continue the conversation — thread mode derives mailbox + subject.
await rl.messages.send({ thread_id: thread.id, body: 'Following up on this.' });

The full machine-readable schema for every operation on this page is served at /docs/openapi.json.