# Inbox & conversations

Source: https://jointextkit.com/docs/inbox
Index: https://jointextkit.com/llms.txt

Every message on your numbers is grouped into conversations, one per
(your number, counterparty) pair. The console inbox is a shared view: your
whole team sees the same threads and the same unread state.

## How threading works

The thread key is `{ourDigits}_{theirDigits}`: the last 10 digits of your
number, an underscore, then the last 10 digits of the other party. A text from
`+14155550132` to your number `+15005550100` belongs to
`5005550100_4155550132`, and so does your reply. The key does not depend on
direction. The same person texting two of your numbers is two conversations.

Every message joins its conversation the moment it is stored, whatever
created it: an API send, a console send, an inbound text, a simulated inbound,
a keyword reply, or an [auto-reply](/docs/auto-reply). Each conversation keeps
the first 200 characters of its latest message, that message's direction and
status, the time of the last activity, a message count and an unread count.
The inbox lists conversations by latest activity, newest first, and shows the
[contact](/docs/contacts) name when the other party's number matches one.

## Unread counts

An inbound message adds 1 to the conversation's unread count. Outbound
messages leave it alone. Opening the thread in the console sets it to 0 for
everyone: read state belongs to the conversation, not to a team member, so one
person reading a thread clears the badge for the whole team.

While the inbox is open it reloads the conversation list and the open thread
every 15 seconds. A thread shows its 50 most recent messages.

## The conversation field on webhooks

`message.received` events carry a `conversation` field with the thread key,
so your own systems can thread without re-deriving it:

```json
{
  "id": "evt_a1B2c3D4e5F6g7H8",
  "object": "event",
  "type": "message.received",
  "created_at": "2026-10-05T17:02:11.204Z",
  "data": {
    "message_id": "msg_x9Y8z7W6v5U4t3S2",
    "to": "+15005550100",
    "from": "+14155550132",
    "body": "Hey, got your message!",
    "conversation": "5005550100_4155550132"
  }
}
```

Only `message.received` has the field. For outbound events such as
`message.sent`, build the same key from the message's `from` (your number)
and `to`. See [Webhooks](/docs/webhooks#event-types) for the other events.

## Read a thread from the API

The API has no conversations endpoint. To read one person's history, list
messages with the `number` filter, which matches either side:

```bash
curl "https://api.jointextkit.com/v1/messages?number=%2B14155550132&limit=50" \
  -H "Authorization: Bearer ssms_sk_test_YOUR_KEY"
```

The response is the usual list, newest first, with inbound and outbound
messages together. `number` matches that person across all of your numbers,
so if they have texted more than one, split the results by your side of each
message. A restricted key needs the `messages:read` scope. Paging and the
other filters are described in [Messages](/docs/messages#list-messages).

```js
import { TextKit } from 'jointextkit';
const sms = new TextKit(process.env.TEXTKIT_API_KEY);

for await (const message of sms.messages.listAll({ number: '+14155550132' })) {
  console.log(message.direction, message.body);
}
```

```python
from jointextkit import TextKit

client = TextKit()  # reads TEXTKIT_API_KEY

for message in client.messages.list_all(number="+14155550132"):
    print(message["direction"], message["body"])
```

## Replying from the console

The composer under a thread sends from the number that thread is on. "New
message" starts a thread from the first number on your account. Enter sends;
Shift+Enter adds a line.

A console reply goes through the same send checks as `POST /v1/messages`,
with three additions:

- Merge tags such as `{{first_name}}` are filled from the recipient's contact.
- If the team member has a signature, it is added on a new line.
- You can insert a saved template into the composer.

The body may be at most 1600 characters after the merge and the signature.

## Attribution

Outbound messages carry a `sent_by` field on the Message object. Console
sends have the team member's name, and the thread shows it under the message.
Messages sent with an API key, including scheduled ones, have
`"sent_by": "API"`. Inbound messages and automatic replies have no
`sent_by`.

## Media

Inbound MMS attachments are stored on the message as a `media` array of URLs
and shown in the thread as links. The `message.received` event does not
include them: fetch the message with `GET /v1/messages/{id}` to read
`media`. Outbound MMS is not available yet. A `POST /v1/messages` request
with a `media` field returns `400 mms_not_enabled`.

## Try it in the sandbox

`POST /v1/test/inbound` puts a fake inbound text on one of your sandbox
numbers. It is stored and threaded like a real one, so it appears in the
inbox with an unread count and emits `message.received`.

```bash
curl -X POST https://api.jointextkit.com/v1/test/inbound \
  -H "Authorization: Bearer ssms_sk_test_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "from": "+14155550132", "body": "Hey, got your message!" }'
```

It returns `201` with the inbound Message object (`direction` is
`inbound`, `status` is `received`, `test` is `true`). Add a `media`
array of up to 10 http or https URLs to simulate an MMS.

| Status | Code | When |
| --- | --- | --- |
| `400` | `invalid_request` | `to` is not one of your numbers, `from` is not a valid US or Canada number, or `body` is empty or longer than 1600 characters. `param` names the field. |
| `403` | `test_mode_only` | The request used a live key. |
| `403` | `insufficient_scope` | A restricted key without the `messages:send` scope. |

The SDKs have no call for this endpoint; use plain HTTP.
