> ## Documentation Index
> Fetch the complete documentation index at: https://docs.expys.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Conversations

> The member-mode concierge: list conversations, page through message history, and send messages. Live incoming messages stream over SSE.

The concierge lets a member hold a conversation - a support thread, a booking
chat, a notification feed. You list a member's conversations, read message
history, and send new messages, all from the app with the member token.

<Note>
  Conversations are **member-mode**: every call here uses the short-lived member
  token your backend mints, not the Org-API-Key. See
  [Authentication](/authentication) for the two-token model and refresh
  contract.
</Note>

For **live incoming messages**, do not poll. Each SDK exposes a streaming
subscription over Server-Sent Events that delivers new messages as they arrive.

<Card title="Stream live messages" icon="bolt" href="/guides/streaming">
  Subscribe to new concierge messages over SSE, consumed as an `AsyncIterable`,
  `AsyncStream`, or `Flow`. The right way to receive incoming messages.
</Card>

## The flow

List the member's conversations, read a thread's history, then send a reply:

<CodeGroup>
  ```typescript TypeScript theme={null}
  import { initialize } from "@expys/sdk";

  const token = process.env.EXPYS_MEMBER_TOKEN;
  if (!token) {
    throw new Error(
      "Set EXPYS_MEMBER_TOKEN (a member token from your backend's /v1/auth/exchange)",
    );
  }

  const expys = initialize({
    baseUrl: process.env.EXPYS_BASE_URL,
    environment: "sandbox",
    token,
  });

  const externalUserID = process.env.EXPYS_EXTERNAL_USER_ID;

  async function main(): Promise<void> {
    const { conversations } = await expys.listConversations({ externalUserID });
    console.log(`found ${conversations.length} conversations`);

    const conversation = conversations[0];
    if (!conversation) {
      return;
    }
    console.log(`reading: ${conversation.title ?? conversation.id}`);

    const { messages } = await expys.listMessages(conversation.id, {
      externalUserID,
      limit: 50,
    });
    for (const message of messages) {
      console.log(`[${message.authorID}] ${message.body ?? "(no body)"}`);
    }

    // Writes auto-send an Idempotency-Key so a retry replays rather than double-posts.
    const result = await expys.sendMessage(conversation.id, "Hello from the SDK");
    console.log(`message sent: ok=${result.ok}`);
  }
  ```

  ```swift Swift theme={null}
  import ExpysSDK
  import Foundation

  @main
  struct ConversationsExample {
    static func main() async throws {
      let environment = ProcessInfo.processInfo.environment
      guard let token = environment["EXPYS_MEMBER_TOKEN"] else {
        fatalError("Set EXPYS_MEMBER_TOKEN (a member token from your backend's /v1/auth/exchange)")
      }
      let baseURL =
        environment["EXPYS_BASE_URL"].flatMap(URL.init(string:))
        ?? ExpysConfiguration.defaultBaseURL
      let externalUserID = environment["EXPYS_EXTERNAL_USER_ID"]

      let client = ExpysClient(
        configuration: ExpysConfiguration(token: token, environment: .sandbox, baseURL: baseURL)
      )

      let conversations = try await client.listConversations(externalUserID: externalUserID)
      print("found \(conversations.conversations.count) conversations")

      guard let conversation = conversations.conversations.first else { return }
      print("reading: \(conversation.title ?? conversation.id)")

      let messages = try await client.listMessages(
        id: conversation.id, limit: 50, externalUserID: externalUserID)
      for message in messages.messages {
        print("[\(message.authorID)] \(message.body ?? "(no body)")")
      }

      // Writes auto-send an Idempotency-Key so a retry replays rather than double-posts.
      let result = try await client.sendMessage(id: conversation.id, message: "Hello from the SDK")
      print("message sent: ok=\(result.ok)")
    }
  }
  ```

  ```kotlin Kotlin theme={null}
  package com.expys.sdk.examples.conversations

  import com.expys.sdk.ExpysClient
  import com.expys.sdk.ExpysConfiguration
  import kotlinx.coroutines.runBlocking

  fun main() = runBlocking {
    val token = System.getenv("EXPYS_MEMBER_TOKEN")
      ?: error("Set EXPYS_MEMBER_TOKEN (a member token from your backend's /v1/auth/exchange)")
    val externalUserID = System.getenv("EXPYS_EXTERNAL_USER_ID")

    val client = ExpysClient.create(
      ExpysConfiguration(
        token = token,
        baseUrl = System.getenv("EXPYS_BASE_URL") ?: ExpysConfiguration.DEFAULT_BASE_URL,
      ),
    )

    val conversations = client.listConversations(externalUserID = externalUserID)
    println("found ${conversations.conversations.size} conversations")

    val conversation = conversations.conversations.firstOrNull() ?: return@runBlocking
    println("reading: ${conversation.title ?: conversation.id}")

    val messages = client.listMessages(conversation.id, limit = 50, externalUserID = externalUserID)
    for (message in messages.messages) {
      println("[${message.authorID}] ${message.body ?: "(no body)"}")
    }

    // Writes auto-send an Idempotency-Key so a retry replays rather than double-posts.
    val result = client.sendMessage(conversation.id, "Hello from the SDK")
    println("message sent: ok=${result.ok}")
  }
  ```
</CodeGroup>

## Operations

| Operation          | SDK method                                               | Returns                               |
| ------------------ | -------------------------------------------------------- | ------------------------------------- |
| List conversations | `listConversations({ externalUserID? })`                 | `{ conversations: Conversation[] }`   |
| List messages      | `listMessages(id, { limit?, cursor?, externalUserID? })` | `{ messages: Message[], nextCursor }` |
| Send a message     | `sendMessage(id, message)`                               | `{ ok }`                              |

### List conversations

`GET /v1/conversations` returns the member's conversations.

<ParamField query="externalUserID" type="string">
  Names the member when a machine token calls on their behalf.
</ParamField>

The response is a `ListConversationsResponse`:

<ResponseField name="conversations" type="Conversation[]" required>
  The member's conversations.
</ResponseField>

### List messages

`GET /v1/conversations/{id}/messages` returns one page of a conversation's
messages with cursor pagination.

<ParamField path="id" type="string" required>
  The conversation id.
</ParamField>

<ParamField query="limit" type="integer">
  Page size. Defaults to the server's default if omitted.
</ParamField>

<ParamField query="cursor" type="string">
  The `nextCursor` from the previous page. Omit for the first page.
</ParamField>

<ParamField query="externalUserID" type="string">
  Names the member when a machine token calls on their behalf.
</ParamField>

The response is a `ListMessagesResponse`:

<ResponseField name="messages" type="Message[]" required>
  The page of messages.
</ResponseField>

<ResponseField name="nextCursor" type="string | null" required>
  Pass this back as `cursor` to fetch the next page. `null` marks the end of the
  history.
</ResponseField>

### Send a message

`POST /v1/conversations/{id}/messages` posts a message to the conversation.

<ParamField path="id" type="string" required>
  The conversation id.
</ParamField>

<ParamField body="message" type="string" required>
  The message text to send.
</ParamField>

```bash theme={null}
curl -X POST https://api.expys.com/v1/conversations/CONVERSATION_ID/messages \
  -H "Authorization: Bearer YOUR_MEMBER_TOKEN" \
  -H "Idempotency-Key: 0f3a9c2e-4b1d-4e6a-9c7b-1f2e3d4c5b6a" \
  -H "Content-Type: application/json" \
  -d '{ "message": "Hello from the concierge" }'
```

The response is a `SendMessageResponse`:

<ResponseField name="ok" type="boolean" required>
  `true` when the message was accepted.
</ResponseField>

<Info>
  Idempotency on `sendMessage` **is** supported via the `Idempotency-Key` header

  * a retried send replays rather than double-posting. The [API
    reference](/api-reference/introduction) omits the header on the `{id}` route
    because of an emitter limitation, not because it is unsupported, so set the
    header yourself when retrying. The SDKs send one automatically on every write.
    See [Retries and idempotency](/guides/retries-and-idempotency).
</Info>

## Schemas

### Conversation

| Field           | Type           | Meaning                                                                                   |
| --------------- | -------------- | ----------------------------------------------------------------------------------------- |
| `id`            | string         | The conversation id. Use it for `listMessages` and `sendMessage`.                         |
| `type`          | string         | The conversation kind.                                                                    |
| `title`         | string \| null | A display title. May be `null` for untitled threads.                                      |
| `lastMessageAt` | string \| null | ISO-8601 time of the most recent message, or `null` if empty. Useful for sorting threads. |

### Message

| Field       | Type           | Meaning                                                                              |
| ----------- | -------------- | ------------------------------------------------------------------------------------ |
| `id`        | string         | The message id.                                                                      |
| `type`      | string         | The message kind.                                                                    |
| `authorID`  | string         | Identifies the sender. Compare it against the member to tell incoming from outgoing. |
| `body`      | string \| null | The message text. May be `null` (for example, a non-text or system message).         |
| `createdAt` | string         | ISO-8601 timestamp.                                                                  |

<Tip>
  Messages paginate by `cursor`, newest history reachable page by page. To
  follow a thread live instead of re-fetching, print the recent backlog with
  `listMessages`, then [stream](/guides/streaming) everything that follows.
</Tip>

## Testing in the sandbox

In the sandbox, the concierge is fully self-contained: a message you send is
**never** forwarded to our Ops team, and a sandbox concierge bot replies
automatically. The bot's reply streams back to you over
`GET /v1/conversations/{id}/stream` exactly like a real concierge message, so you
can build and test the whole send -> stream -> reply loop end to end without paging
a human. In LIVE, messages reach a real concierge as usual.

## Related

<CardGroup cols={2}>
  <Card title="Streaming messages" icon="bolt" href="/guides/streaming">
    Receive live incoming messages over SSE.
  </Card>

  <Card title="Errors" icon="triangle-exclamation" href="/guides/errors">
    The error taxonomy and stable codes for failed calls.
  </Card>
</CardGroup>
