Two-Way Inbox
Read and reply to inbound replies from your customers — the two-way messaging inbox. A conversation groups every inbound message from one customer (on any channel) together with the replies your workspace sends back.
Not the support inbox
This is the customer-facing inbox only. YeboLink also has an internal support inbox for your own team's threads with YeboLink support — that lives under a separate, dashboard-only /support API and is never returned here.
Authentication
All five endpoints accept either auth method (authenticateAny):
- API Key —
X-API-Key: ybk_...— the scope required is noted per endpoint below (read_messagesorsend_messages) - JWT Token —
Authorization: Bearer <token>— full dashboard access
Unread state
unread is computed per request, not stored — a conversation is unread when it holds an inbound message newer than the thread's read watermark (last_read_at). Nothing marks a thread read as a side effect of listing or fetching it:
GET /conversationsandGET /conversations/:id/messagesonly ever read the watermark.POST /conversations/:id/readand a successfulPOST /conversations/:id/replyare the only two ways to move it.
This is deliberate: if fetching a thread's messages marked it read, a client that polls the inbox on an interval would silently drain its own unread count without a human ever having looked at the thread.
unread_count (returned by the list endpoint) is the workspace-wide total across every customer-facing conversation — it feeds the dashboard's sidebar badge, so it's independent of the current page and of the status filter. It doesn't drop when you page through the list or switch to the Resolved tab.
List Conversations
Get a paginated list of your workspace's conversations, newest activity first.
Endpoint
GET /api/v1/inbox/conversationsScope: read_messages
Query Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
page | number | No | Page number (default: 1) |
limit | number | No | Results per page (default: 50, max: 100) |
status | string | No | Filter by open or resolved. An unrecognized value returns 400, not an unfiltered list. |
Example Requests
# All conversations
curl "https://api.yebolink.com/api/v1/inbox/conversations" \
-H "X-API-Key: ybk_live_your_api_key"
# Only open threads
curl "https://api.yebolink.com/api/v1/inbox/conversations?status=open&page=1&limit=20" \
-H "X-API-Key: ybk_live_your_api_key"const listConversations = async (filters = {}) => {
const params = new URLSearchParams(filters);
const response = await fetch(
`https://api.yebolink.com/api/v1/inbox/conversations?${params}`,
{ headers: { 'X-API-Key': process.env.YEBOLINK_API_KEY } }
);
return await response.json();
};
// Usage
const open = await listConversations({ status: 'open' });
console.log('Unread across the workspace:', open.data.unread_count);import os
import requests
def list_conversations(status: str = None, page: int = 1, limit: int = 50):
params = {'page': page, 'limit': limit}
if status:
params['status'] = status
response = requests.get(
'https://api.yebolink.com/api/v1/inbox/conversations',
headers={'X-API-Key': os.environ.get('YEBOLINK_API_KEY')},
params=params
)
return response.json()
# Usage
open_conversations = list_conversations(status='open')Response
{
"success": true,
"data": {
"conversations": [
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"workspace_id": "workspace-id",
"contact_id": "contact-id",
"customer_identifier": "+1234567890",
"channels_used": ["sms"],
"status": "open",
"assigned_to": null,
"last_message_at": "2025-11-02T12:00:00Z",
"last_read_at": null,
"last_message_preview": "Hey, is my order shipping today?",
"created_at": "2025-10-30T09:00:00Z",
"unread": true
}
],
"unread_count": 4,
"pagination": {
"total": 37,
"page": 1,
"pages": 2,
"limit": 20
}
}
}Error Responses
Invalid status filter (400)
{
"success": false,
"error": "Invalid status filter. Expected one of: open, resolved"
}Get Conversation Messages
Retrieve the full thread — inbound and outbound messages — for one conversation, oldest first.
Does not mark the thread read
Fetching a conversation's messages never touches its read watermark, even though you're "reading" it. This keeps a background poll from silently clearing the unread badge for a thread nobody actually looked at — call POST /conversations/:id/read explicitly when your client displays the thread to a human.
Endpoint
GET /api/v1/inbox/conversations/:id/messagesScope: read_messages
Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
id | string | Yes | Conversation ID (UUID) |
limit | number | No | Max messages to return (default: 100, max: 200) |
Example Requests
curl "https://api.yebolink.com/api/v1/inbox/conversations/550e8400-e29b-41d4-a716-446655440000/messages" \
-H "X-API-Key: ybk_live_your_api_key"const getConversationMessages = async (conversationId) => {
const response = await fetch(
`https://api.yebolink.com/api/v1/inbox/conversations/${conversationId}/messages`,
{ headers: { 'X-API-Key': process.env.YEBOLINK_API_KEY } }
);
if (response.status === 404) {
throw new Error('Conversation not found');
}
return await response.json();
};def get_conversation_messages(conversation_id: str):
response = requests.get(
f'https://api.yebolink.com/api/v1/inbox/conversations/{conversation_id}/messages',
headers={'X-API-Key': os.environ.get('YEBOLINK_API_KEY')}
)
if response.status_code == 404:
raise Exception('Conversation not found')
return response.json()Response
{
"success": true,
"data": {
"conversation": {
"id": "550e8400-e29b-41d4-a716-446655440000",
"workspace_id": "workspace-id",
"customer_identifier": "+1234567890",
"channels_used": ["sms"],
"status": "open",
"last_message_at": "2025-11-02T12:00:00Z",
"last_read_at": "2025-11-02T11:00:00Z",
"last_message_preview": "Hey, is my order shipping today?",
"created_at": "2025-10-30T09:00:00Z"
},
"messages": [
{
"id": "8a1b2c3d-0000-4000-8000-000000000001",
"workspace_id": "workspace-id",
"conversation_id": "550e8400-e29b-41d4-a716-446655440000",
"channel": "sms",
"direction": "inbound",
"sender": "+1234567890",
"recipient": "+15005550006",
"content_type": "text",
"content": { "text": "Hey, is my order shipping today?" },
"status": "delivered",
"credits_used": 0,
"created_at": "2025-11-02T12:00:00Z"
},
{
"id": "8a1b2c3d-0000-4000-8000-000000000002",
"workspace_id": "workspace-id",
"conversation_id": "550e8400-e29b-41d4-a716-446655440000",
"api_key_id": "api-key-id",
"channel": "sms",
"direction": "outbound",
"sender": "+15005550006",
"recipient": "+1234567890",
"content_type": "text",
"content": { "text": "Yes! It ships today, tracking lands in your inbox tonight." },
"provider": "twilio",
"provider_message_id": "SM1234567890",
"status": "delivered",
"credits_used": 1,
"metadata": {},
"created_at": "2025-11-02T12:05:00Z"
}
]
}
}Error Responses
Conversation Not Found (404)
{
"success": false,
"error": "Conversation not found"
}A conversation ID that belongs to another workspace, and the ID of an internal support thread, both return this same 404 — neither leaks that the row exists.
Reply to a Conversation
Send a reply to the customer on this thread. This goes through the normal send path and is charged credits like any other message.
Endpoint
POST /api/v1/inbox/conversations/:id/replyScope: send_messages
Request Body
| Parameter | Type | Required | Description |
|---|---|---|---|
text | string | Yes* | Reply body. *Also accepted as content.text — either form works. |
content.text | string | Yes* | Same as text, nested form. |
content.media_urls | array | No | Media attachment URLs (channel-dependent support) |
channel | string | No | Overrides the channel to reply on. Defaults to the channel of the conversation's most recent message. |
Example Requests
curl -X POST "https://api.yebolink.com/api/v1/inbox/conversations/550e8400-e29b-41d4-a716-446655440000/reply" \
-H "Content-Type: application/json" \
-H "X-API-Key: ybk_live_your_api_key" \
-d '{
"text": "Yes! It ships today, tracking lands in your inbox tonight."
}'const replyToConversation = async (conversationId, text) => {
const response = await fetch(
`https://api.yebolink.com/api/v1/inbox/conversations/${conversationId}/reply`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-API-Key': process.env.YEBOLINK_API_KEY
},
body: JSON.stringify({ text })
}
);
return await response.json();
};
// Usage
await replyToConversation(
'550e8400-e29b-41d4-a716-446655440000',
'Yes! It ships today, tracking lands in your inbox tonight.'
);def reply_to_conversation(conversation_id: str, text: str):
response = requests.post(
f'https://api.yebolink.com/api/v1/inbox/conversations/{conversation_id}/reply',
headers={'X-API-Key': os.environ.get('YEBOLINK_API_KEY')},
json={'text': text}
)
return response.json()
# Usage
reply_to_conversation(
'550e8400-e29b-41d4-a716-446655440000',
'Yes! It ships today, tracking lands in your inbox tonight.'
)Response
201 Created — the standard message object, same shape as POST /api/v1/messages/send:
{
"success": true,
"data": {
"message": {
"id": "8a1b2c3d-0000-4000-8000-000000000002",
"workspace_id": "workspace-id",
"conversation_id": "550e8400-e29b-41d4-a716-446655440000",
"channel": "sms",
"direction": "outbound",
"sender": "+15005550006",
"recipient": "+1234567890",
"content_type": "text",
"content": { "text": "Yes! It ships today, tracking lands in your inbox tonight." },
"status": "queued",
"credits_used": 1,
"metadata": {},
"created_at": "2025-11-02T12:05:00Z"
}
}
}Marks the thread read — but only on success
A successful reply marks the conversation read, on the theory that answering a thread means you've read it. This happens after the send completes, so a failed send (e.g. insufficient credits) leaves the thread unread and still in your triage queue instead of silently disappearing from the badge count.
Error Responses
Reply Text Required (400)
{
"success": false,
"error": "Reply text is required"
}No Channel Could Be Determined (400)
{
"success": false,
"error": "Could not determine a channel for this conversation"
}Insufficient Credits (402)
{
"success": false,
"error": "Insufficient credits"
}Conversation Not Found (404)
{
"success": false,
"error": "Conversation not found"
}Same anti-leak behavior as the other endpoints: another workspace's conversation and an internal support thread both 404.
Mark a Conversation Read
Explicitly move the conversation's read watermark to now, clearing its unread state and removing it from unread_count.
Endpoint
POST /api/v1/inbox/conversations/:id/readScope: read_messages — not send_messages. Marking a thread read is part of reading your inbox, so a read-only key can still clear an unread count it can see.
Example Requests
curl -X POST "https://api.yebolink.com/api/v1/inbox/conversations/550e8400-e29b-41d4-a716-446655440000/read" \
-H "X-API-Key: ybk_live_your_api_key"const markConversationRead = async (conversationId) => {
const response = await fetch(
`https://api.yebolink.com/api/v1/inbox/conversations/${conversationId}/read`,
{
method: 'POST',
headers: { 'X-API-Key': process.env.YEBOLINK_API_KEY }
}
);
return await response.json();
};def mark_conversation_read(conversation_id: str):
response = requests.post(
f'https://api.yebolink.com/api/v1/inbox/conversations/{conversation_id}/read',
headers={'X-API-Key': os.environ.get('YEBOLINK_API_KEY')}
)
return response.json()Response
{
"success": true,
"data": {
"conversation": {
"id": "550e8400-e29b-41d4-a716-446655440000",
"workspace_id": "workspace-id",
"customer_identifier": "+1234567890",
"channels_used": ["sms"],
"status": "open",
"last_message_at": "2025-11-02T12:00:00Z",
"last_read_at": "2025-11-02T12:10:00Z",
"last_message_preview": "Hey, is my order shipping today?",
"created_at": "2025-10-30T09:00:00Z",
"unread": false
}
}
}Error Responses
Conversation Not Found (404)
{
"success": false,
"error": "Conversation not found"
}Update Conversation Status
Triage a conversation: resolve it (handled, hidden from the default Open view) or reopen it.
Endpoint
PATCH /api/v1/inbox/conversations/:idScope: send_messages — resolving a thread is a triage decision visible to your whole team, so a read-only key cannot make it.
Request Body
| Parameter | Type | Required | Description |
|---|---|---|---|
status | string | Yes | open or resolved |
Example Requests
curl -X PATCH "https://api.yebolink.com/api/v1/inbox/conversations/550e8400-e29b-41d4-a716-446655440000" \
-H "Content-Type: application/json" \
-H "X-API-Key: ybk_live_your_api_key" \
-d '{ "status": "resolved" }'const setConversationStatus = async (conversationId, status) => {
const response = await fetch(
`https://api.yebolink.com/api/v1/inbox/conversations/${conversationId}`,
{
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
'X-API-Key': process.env.YEBOLINK_API_KEY
},
body: JSON.stringify({ status })
}
);
return await response.json();
};
// Usage
await setConversationStatus('550e8400-e29b-41d4-a716-446655440000', 'resolved');def set_conversation_status(conversation_id: str, status: str):
response = requests.patch(
f'https://api.yebolink.com/api/v1/inbox/conversations/{conversation_id}',
headers={'X-API-Key': os.environ.get('YEBOLINK_API_KEY')},
json={'status': status}
)
return response.json()
# Usage
set_conversation_status('550e8400-e29b-41d4-a716-446655440000', 'resolved')Response
{
"success": true,
"data": {
"conversation": {
"id": "550e8400-e29b-41d4-a716-446655440000",
"workspace_id": "workspace-id",
"customer_identifier": "+1234567890",
"channels_used": ["sms"],
"status": "resolved",
"last_message_at": "2025-11-02T12:00:00Z",
"last_read_at": "2025-11-02T12:15:00Z",
"last_message_preview": "Hey, is my order shipping today?",
"created_at": "2025-10-30T09:00:00Z"
}
}
}Resolving also marks the thread read
Setting status: "resolved" stamps the read watermark too — otherwise a thread resolved straight from the list, without ever being opened, would stay unread forever: counted in the badge but filtered out of the default Open view, with no way to clear it. Reopening (status: "open") leaves the watermark alone.
A resolved thread reopens itself on a new reply
If the customer replies again, the conversation's status flips back to open automatically — a resolved thread is not a permanently closed one. You don't need to (and can't meaningfully) keep something resolved once new inbound arrives.
Error Responses
Invalid or Missing Status (400)
{
"success": false,
"error": "status is required and must be one of: open, resolved"
}Conversation Not Found (404)
{
"success": false,
"error": "Conversation not found"
}Next Steps
- Retrieve Messages — the message shape returned in a thread
- Send SMS / Send Email — the same underlying send path used by replies
- API Reference — complete API documentation