> ## Documentation Index
> Fetch the complete documentation index at: https://docs.orbit.devotel.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Two-way SMS conversations without the Inbox

> Track a per-sender SMS conversation outside the Inbox: receive replies on a webhook, key a thread map on the to/from pair, send replies with the pair swapped, and hand off to keywords, sender pools, or the Inbox at the right time.

# Two-way SMS conversations without the Inbox

Most two-way SMS needs a handful of webhook handlers and a thread map in your own database — not the Inbox product. If you call `POST /messages/sms` and subscribe to `message.received`, you already have everything a conversation loop is built from. This guide shows how to hold that loop yourself: receive the reply, attribute it to an ongoing thread, answer on the same sender, and upgrade each step to a managed feature only when the step outgrows a `Map`.

**You will:**

1. [Receive replies on a webhook](#1-receive-replies-on-a-webhook)
2. [Track a thread per sender](#2-track-a-thread-per-sender)
3. [Use keyword auto-reply instead of code when it suffices](#3-use-keyword-auto-reply-when-it-suffices)
4. [Upgrade to the Inbox when humans enter the loop](#4-upgrade-to-the-inbox-when-humans-enter-the-loop)
5. [Reply through a sender pool](#5-reply-through-a-sender-pool)
6. [Sample: receive, read the thread, reply](#6-sample-receive-read-the-thread-reply)
7. [Handle STOP on any thread message](#7-handle-stop-on-any-thread-message)
8. [SMS, WhatsApp, and cross-channel threading](#8-sms-whatsapp-and-cross-channel-threading)

## 1. Receive replies on a webhook

Prerequisites: an API key (sandbox `dv_test_sk_…` while you build), an SMS-capable number, and a public HTTPS URL. The [Send & Receive Messages quickstart](/guides/send-receive-messages) covers the full send → track → receive setup; start there if this is your first message.

Subscribe `message.received` plus the delivery lifecycle events on one endpoint:

```bash theme={null}
curl -X POST https://api.orbit.devotel.io/api/v1/webhooks \
  -H "X-API-Key: dv_test_sk_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://yourapp.com/webhooks/orbit",
    "events": ["message.received", "message.delivered", "message.failed"],
    "secret": "whsec_your_signing_secret"
  }'
```

Two rules keep a conversation handler honest:

* Verify the `X-Orbit-Signature` header before reading the body, and deduplicate on the event `id` — delivery is at-least-once, so the same `message.received` can arrive twice.
* Return `200` immediately and do the thread work asynchronously. Slow receivers cause retries, and a retried `message.received` is indistinguishable from a second reply unless you deduplicate.

If you have not stood up a receiver before, the [first webhook quickstart](/guides/first-webhook-quickstart) walks the tunnel → register → verify → replay loop, and the [webhook event catalog](/guides/webhook-event-catalog) shows every `message.*` payload schema before you wire against it.

## 2. Track a thread per sender

SMS has no reply headers or in-reply-to metadata — a reply is a new inbound message that shares the two endpoints. Thread identity comes from the **direction-aware endpoint pair**: the customer's number and your number, ordered so an inbound and an outbound from the same conversation hash to one key:

```ts theme={null}
function threadKey(event: { from: string; to: string }): string {
  // Inbound:  from = customer, to   = your number.
  // Outbound: from = your number, to = customer.
  return event.direction === "inbound"
    ? `${event.to}→${event.from}`
    : `${event.from}→${event.to}`;
}
```

Normalize to E.164 before keying (`+14155552671`, not `(415) 555-2671`) and deduplicate inbound rows on `message_id` — not on the pair, or two contacts messaging one number collapse into a single thread.

Store a thread record per key: `thread_id`, a sequence of `{ message_id, direction, body, status, timestamp }` entries, a `state` field (`open` / `pending_reply` / `closed`), and the endpoint pair. Set `pending_reply` when your outbound goes out and `open` when the customer answers — that state is what "unanswered conversations" reporting and per-thread SLA timers hang off.

Always reply on the same `from` the thread started with. Swapping numbers mid-thread splits the conversation into two threads on the customer's handset.

## 3. Use keyword auto-reply when it suffices

Before you write conversation logic, check whether the reply the customer needs is deterministic. `STATUS` → tracking link, `HOURS` → opening times, `HELP` → support line: each is one inbound keyword matched to one stored reply, and that is exactly what the auto-reply layer executes — match a keyword, send a configured answer — without a state machine in your code. The [keyword rules recipes](/guides/keyword-rules-recipes) page shows worked match outcomes per match type, precedence between overlapping rules, and where the evidence lands.

Reach for your own thread logic when a reply depends on conversation state ("¿tienes la orden?" is not a keyword question you can pre-key), when more than one keyword pattern fires in a thread, or when keywords must join a transactional fetch — order status against live data. Configure the deterministic layer in the [auto-reply rules guide](/guides/keyword-auto-reply-rules); let your webhook handler branch to it instead of reinventing a matcher.

## 4. Upgrade to the Inbox when humans enter the loop

Strip the Inbox out of the loop until one of these breaks your `Map`:

* Replies that need a person, or routing between teams — assignment, internal notes, and SLA timers are Inbox features, not thread-map fields.
* Customers in more than one channel — the Inbox unifies SMS, WhatsApp, email, RCS, and web chat onto one queue with a single assignment model.
* Supervision — a queue that needs macros, first-response SLAs, AI-drafted replies, and agents working the same thread.

Until then the webhook model covers it. When you cross one of those thresholds, stand the queue up with [Inbox setup](/guides/inbox-setup) — inbound SMS keeps arriving on the same numbers, and your existing messages become conversations on the queue with no re-wiring. If you keep transactional traffic off the queue intentionally, the [transactional inbox/no-conversation pattern](/guides/transactional-inbox-no-conversation) keeps OTP-class traffic out of the Inbox while this page's mapping holds.

## 5. Reply through a sender pool

If you send outbound through a [sender pool](/guides/sender-pools), a reply must come back from whichever pool member the thread started on — otherwise the handset splits your reply into a second conversation. Pick the `sticky` strategy for anything conversational: the same recipient is pinned to the same sender on every send, so your outbound and the customer's inbound share one number and your thread key stays stable across threads and days.

Key the thread map on the effective sender — the specific pool member actually used for that customer — not on the pool id. With `sticky` semantics both are equivalent per customer, but read the sender off your own outbound send (or its delivery webhook) rather than re-deriving it from the pool. See [sender pools](/guides/sender-pools) for the `sticky` vs `round_robin` trade.

## 6. Sample: receive, read the thread, reply

The receive → fetch → reply loop reduces to two tables: a per-message row keyed on `message_id`, and a thread row keyed on the endpoint pair.

```ts theme={null}
// POST /webhooks/orbit — verified signature, already deduplicated on event id.
export async function onMessageReceived(event: MessageReceivedEvent) {
  const m = event.data;                 // { message_id, channel, from, to, body }
  if (m.channel !== "sms") return;

  const key = threadKey(m);             // direction-aware to→from pair, §2
  await db.threads.upsert({
    key,
    customer: m.from,
    sender: m.to,
    state: "open",
  });
  await db.messages.insert({
    id: m.message_id,                   // idempotency of at-least-once delivery
    thread: key,
    direction: "inbound",
    body: m.body,
  });

  const answer = await replyFor(m.body, key);  // your keyword/flow logic
  if (answer) {
    await orbit.messages.sms.send({
      to: m.from,                       // endpoints swapped on reply
      from: m.to,                       // same sender the thread started on
      body: answer,
    });
    await db.threads.update({ key, state: "pending_reply" });
  }
}
```

Flags worth noting:

* The reply is a plain `POST /messages/sms` with `to`/`from` swapped — there is no special reply endpoint and no header to set.
* "Fetch thread" is your table read; sending on the same `from` keeps the handset ThreadView together even though Orbit delivers SMS as individual messages.
* The `message.delivered` status webhook for the reply is what flips `pending_reply` back — subscribe it and mark the delivery outcome on the thread as it lands.

## 7. Handle STOP on any thread message

Exit handling has to trigger regardless of which thread a message arrives on — a STOP on an appointment thread suppresses the customer, not the flow. Every tenant gets carrier-mandated `STOP` / `HELP` / `START` handling by default; per-brand aliases and response copy attach to a [custom opt-out list](/guides/opt-out-lists). Suppression is enforced at send time, so a subsequent `POST /messages/sms` to an opted-out number returns an error rather than leaking a send.

Your thread map only owns the bookkeeping, not the filtering: run opt-out detection on `body` before any keyword branching (a STOP on thread 3 closes all threads for that customer), suppress at the customer level — by phone number, not by thread — and treat STOP as a terminal thread state so your replyFor logic never answers a suppressed contact. The same pre-branch step is where `START` reopens threads after an opt-in returns. The [preference-center opt-out page guide](/guides/preference-center-opt-out-page) covers the customer-facing suppression surface if you want STOP to redirect into a page rather than terminate silently.

## 8. SMS, WhatsApp, and cross-channel threading

The map above is SMS-shaped: one number in the customer's SMS app, message-pair threading. Two channel realities bound when the pattern needs replacing:

* **WhatsApp is threadlike but windowed.** A free-form reply is only deliverable inside 24 hours after the customer's last message; past that you fall back to pre-approved templates. The thread key holds, but the reply with `replyFor` must branch to a template send once the window closes.
* **Crossing SMS → web chat / email / voice mid-thread.** Moving a conversation between channels without restarting it keeps one conversation id and full transcript — that is the [continue a conversation on another channel](/guides/continue-conversation-across-channels) guide, and it subsumes the to-from map once the thread leaves SMS.

Same-model flows stay on this page. Channel changes graduate to the cross-channel guide.

## Next steps

* [Send & Receive Messages](/guides/send-receive-messages) — the full send → track → receive quickstart this page assumes
* [First webhook quickstart](/guides/first-webhook-quickstart) — tunnel, register, verify, replay loop
* [Webhook event catalog](/guides/webhook-event-catalog) — every `message.*` payload schema
* [Keyword auto-reply rules](/guides/keyword-auto-reply-rules) — configure the deterministic reply layer
* [Keyword rules recipes](/guides/keyword-rules-recipes) — worked match outcomes and precedence
* [Sender pools](/guides/sender-pools) — sticky routing and member selection
* [Opt-out lists](/guides/opt-out-lists) — custom STOP / HELP / START per brand
* [Inbox setup](/guides/inbox-setup) — stand up the omnichannel queue when you need it
