Your first conversation
This walks one message all the way through: connect a channel, receive an inbound, list threads, read one, reply. Every call below is real; substitute your base URL, credential and tenant.
AXIS="https://inbox.example.com"
KEY="sk_live_…" # Accounts service key with inbox:* scopes
TENANT="ws_<accountsUserGroupId>" # the workspace
AUTH=(-H "authorization: Bearer $KEY" -H "x-axis-tenant: $TENANT")
WRITE=("${AUTH[@]}" -H "content-type: application/json")
# every write also needs: -H "idempotency-key: $(uuidgen)"
import { Inbox } from '@~inbox/sdk';
const inbox = new Inbox({
baseUrl: process.env.INBOX_URL!,
appKey: process.env.INBOX_KEY!, // or `session` — exactly one of the two
tenant: `ws_${accountsUserGroupId}`,
});
The SDK generates and holds idempotency keys across retries, so you do not pass one unless you want to control it.
1. Connect a channel
curl -sS "${WRITE[@]}" -H "idempotency-key: $(uuidgen)" \
-d '{ "redirectUrl": "https://your-app.example/connected" }' \
$AXIS/v1/connections/instagram/start
const start = await inbox.connections.start('instagram', {
redirectUrl: 'https://your-app.example/connected',
});
// send the user's browser to start.redirectUrl
The user authorizes at the provider; Inbox completes the handshake server-side on
GET /v1/connections/callback (a public route — it is the OAuth return leg, not something you call) and
bounces the browser to your redirectUrl with connectionId in the query string. Confirm at any time:
curl -sS "${AUTH[@]}" $AXIS/v1/connections
Provider credentials are encrypted at rest and never leave the service.
2. An inbound message arrives
You do not create the conversation. The provider does, and this is the part worth understanding.
provider POST → /webhooks/:integrationKey
│
verifyWebhook ← signature checked BEFORE any persistence
│
WebhookInbox row ← durable record of the verified delivery
│
queue (BullMQ)
│
normalizeWebhook → NormalizedEvent[]
│
InboxService.ingest()
├── upsertThread keyed (connectionId, threadType, externalThreadId)
├── upsertEntry keyed (threadId, externalEntryId)
├── upsertParticipant
└── contact resolution (runs on EVERY inbound, not just the first)
│
realtime publish + outbound webhook fan-out
Threads are never created directly. There is no create-thread endpoint. upsertThread is called
only from ingest(), so a thread exists because a message arrived — through a webhook in steady state,
or through the backfill scheduled right after a successful connect, which pulls the last 25
conversations best-effort and silently (no realtime, no outbound events for history).
Contact resolution runs on every inbound rather than only at thread creation, because identity arrives
late: a WhatsApp contact can be nameless on the first message and named on the third, and a LiveChat
visitor becomes a real identity the moment they leave a phone number. InboxThread.contactId is
nullable for exactly this reason — resolution must never block ingest.
One sharp edge worth knowing now: backfill only fires via the adapter's completeConnect. A
credential-import path has to schedule it explicitly, or the connection stays empty until the first
webhook lands.
3. List threads
curl -sS "${AUTH[@]}" "$AXIS/v1/inbox/threads?type=dm&unreadOnly=true&limit=25"
const { data: threads, meta } = await inbox.inbox.threads.list({
type: 'dm',
unreadOnly: true,
limit: 25,
});
{
"data": [
{
"id": "…",
"connectionId": "…",
"threadType": "dm",
"previewText": "Is this still available?",
"unreadCount": 1,
"lastInboundAt": "2026-06-10T08:30:00.000Z",
"participants": [{ "externalUserId": "946838…", "isOwner": false, "name": "Asha" }]
}
],
"meta": { "nextCursor": "…present only when there are more pages…" }
}
The list is cursor-paginated: default 25, max 100, and meta.nextCursor is absent on the last page.
Beyond type, status, connectionId and unreadOnly, the query accepts labelId, priority,
assignedUserId, unassigned, contactId and free-text q (matched against entry bodies and
participant names).
participants is the other side of the conversation — take the entry with isOwner: false for the name
and avatar to render.
4. Read one thread
curl -sS "${AUTH[@]}" $AXIS/v1/inbox/threads/$THREAD
const thread = await inbox.inbox.threads.get(threadId); // includes entries[]
This returns the thread with its entries[] and marks it read, which is a side effect worth
remembering if you are prefetching.
Order entries by seq, not createdAt. seq is a bigint assigned by a Postgres sequence; createdAt
has millisecond precision, so two entries written in the same millisecond tie and ordering then falls to
an arbitrary cuid. That is not hypothetical — an AI-generated answer routinely lands in the same
millisecond as the message it answers.
5. Reply
curl -sS "${WRITE[@]}" -H "idempotency-key: $(uuidgen)" \
-d '{ "message": "Yes — we ship nationwide." }' \
$AXIS/v1/inbox/threads/$THREAD/reply
await inbox.inbox.threads.reply(threadId, { message: 'Yes — we ship nationwide.' });
The reply resolves the adapter from the thread's connection binding and sends through that provider. It
returns the created outbound entry. ReplyRequest also carries subject, html, plainText (email),
attachmentUrl / attachmentType, quickReplies (rendered natively where the channel supports it,
otherwise degraded to plain text), providerTemplate, metadata and parentEntryId.
Direct-message replies are subject to the provider's 24-hour messaging window: outside it you get
422 messaging_window_expired. To reach someone outside that window you send a provider template
instead, through POST /v1/connections/{id}/send-template.
What you built on
Everything above is the core loop. Tickets, labels, notes, contacts, segments, templates, campaigns and flows all attach to these same threads and entries — see the Data model.
If your thread list comes back empty and you expected data, check the tenant header before anything else: Tenancy.