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.