Inbox & conversations

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. 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 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 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.

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.

StatusCodeWhen
400invalid_requestto 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.
403test_mode_onlyThe request used a live key.
403insufficient_scopeA restricted key without the messages:send scope.

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