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:
{
"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:
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.
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);
}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.
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.