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 callPOST /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:
- Receive replies on a webhook
- Track a thread per sender
- Use keyword auto-reply instead of code when it suffices
- Upgrade to the Inbox when humans enter the loop
- Reply through a sender pool
- Sample: receive, read the thread, reply
- Handle STOP on any thread message
- SMS, WhatsApp, and cross-channel threading
1. Receive replies on a webhook
Prerequisites: an API key (sandboxdv_test_sk_… while you build), an SMS-capable number, and a public HTTPS URL. The Send & Receive Messages quickstart 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:
- Verify the
X-Orbit-Signatureheader before reading the body, and deduplicate on the eventid— delivery is at-least-once, so the samemessage.receivedcan arrive twice. - Return
200immediately and do the thread work asynchronously. Slow receivers cause retries, and a retriedmessage.receivedis indistinguishable from a second reply unless you deduplicate.
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:+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 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; 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 yourMap:
- 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.
5. Reply through a sender pool
If you send outbound through a sender pool, a reply must come back from whichever pool member the thread started on — otherwise the handset splits your reply into a second conversation. Pick thesticky 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 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 onmessage_id, and a thread row keyed on the endpoint pair.
- The reply is a plain
POST /messages/smswithto/fromswapped — there is no special reply endpoint and no header to set. - “Fetch thread” is your table read; sending on the same
fromkeeps the handset ThreadView together even though Orbit delivers SMS as individual messages. - The
message.deliveredstatus webhook for the reply is what flipspending_replyback — 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-mandatedSTOP / HELP / START handling by default; per-brand aliases and response copy attach to a custom opt-out list. 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 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
replyFormust 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 guide, and it subsumes the to-from map once the thread leaves SMS.
Next steps
- Send & Receive Messages — the full send → track → receive quickstart this page assumes
- First webhook quickstart — tunnel, register, verify, replay loop
- Webhook event catalog — every
message.*payload schema - Keyword auto-reply rules — configure the deterministic reply layer
- Keyword rules recipes — worked match outcomes and precedence
- Sender pools — sticky routing and member selection
- Opt-out lists — custom STOP / HELP / START per brand
- Inbox setup — stand up the omnichannel queue when you need it