Two-Way Conversations
Sent's conversation window lets you hold a free-form, two-way conversation with a contact without managing per-channel session rules yourself. Sent owns the transport — the window state, cross-channel continuity, and compliance teardown. Your own agent (human or AI) owns the dialogue, integrating over webhooks and the freeform-send API.
The 24-hour Window
The 24-hour window applies to WhatsApp only.
When you send a message and the contact replies, WhatsApp opens a 24-hour window from the moment their reply is received. While that window is open, you can respond with free-form text — no template required.
If the contact does not reply within 24 hours, the window closes and you must use a template to send the next message.
If the contact replies with any compliance keyword (see the Keyword Detection table for the full list) — it does not trigger the two-way conversation window. No free-text reply is sent; the keyword is handled by the compliance engine only.
The 24-hour countdown starts from the contact's last inbound message, not from when you sent the original outbound. Each new inbound from the contact resets the window.
SMS and RCS have no time window — as long as you have received an inbound message from the contact, you can reply with free text.
How Inbound Messages Work
1. Customer Resolution
Sent matches an inbound message to a customer using the receiving number on that channel:
- SMS — matched via the messaging profile or number assigned to that customer
- WhatsApp — matched via the customer's WhatsApp
PhoneNumberId
If the receiving number isn't provisioned for any customer on that channel, the inbound message is dropped. No customer match, no storage.
If a message arrives from a contact you have never interacted with or don't have registered, Sent automatically creates the contact record before responding. If the contact already exists, Sent replies directly without any additional steps.
2. Every Inbound Is Stored
Once the number resolves to a customer, all inbound messages are stored as RECEIVED in the Sent backend — including keyword messages like STOP or HELP. Only non-keyword replies are surfaced to you in the message log and visible in the dashboard.
If the customer has no provider configured for the inbound channel, that channel's conversation handling does not run and nothing is stored.
3. Reply-From-Same-Sender Routing
When an inbound arrives, Sent records the channel, provider, and sender number it came in on. Future outbound messages to that contact will prefer the same sender — so your reply comes from the same number the contact originally wrote to.
Keyword Detection
Sent enforces CTIA/TCPA rules for keyword handling. The rules are strict by design.
Exact match only. The entire trimmed message body must equal the keyword. "STOP" matches. "Please stop messaging me" does not. Case is ignored — stop, Stop, and STOP all match.
| Keyword | Action |
|---|---|
STOP | Opt the contact out |
CANCEL | Opt the contact out |
UNSUBSCRIBE | Opt the contact out |
QUIT | Opt the contact out |
END | Opt the contact out |
START | Opt the contact back in |
UNSTOP | Opt the contact back in |
SUBSCRIBE | Opt the contact back in |
HELP | Send the configured help/info auto-reply |
INFO | Send the configured help/info auto-reply |
Custom Opt-Out Keywords
You can extend the defaults with words that fit your brand or audience. For example, instead of STOP you could configure NDALO or any word your contacts are more likely to use.
Custom keywords are configured in the dashboard under Compliance → Opt-Out Keywords. The same exact-match rule applies — the entire trimmed message body must equal the configured keyword.
Default keywords remain active alongside any custom keywords you add. You cannot disable the CTIA-mandated defaults.
Opt-Out and Opt-In
STOP — Always Honored
When a contact sends STOP (or any opt-out keyword), opt-out is recorded unconditionally. Sent does not check whether the receiving number is currently active or assigned — consent suppression always wins.
Timing: The auto-reply confirmation is sent before the consent write. This is intentional: TCPA permits one final message to confirm opt-out, so the reply goes out first, then the opt-out flag is set.
START — Always Honored
When a contact sends START (or any opt-in keyword), opt-in is recorded unconditionally. If the sending number is not yet a contact, Sent creates the contact record first, then writes the opt-in consent, then sends the auto-reply.
Cross-Channel Opt-Out
Opt-out is contact-level and channel-agnostic. A STOP received on any channel — SMS, RCS, or WhatsApp — suppresses the contact across all channels. The optOut flag on the contact record is the single source of truth; all outbound sends check it regardless of which channel they use.
Where Consent Lives
Opt-out state is stored on the contact record (optOut on the customer ↔ contact link). This is the single source of truth for suppression — all outbound messages, including auto-replies, check this flag before sending.
Auto-Replies
Auto-replies are not special-cased messages — they go through the same send pipeline as any outbound message. That means they require:
- An approved template configured for the action (STOP reply, START reply, HELP reply)
- Consent to be valid (checked after the opt-out write for START; before for STOP)
- A matching message route with a valid E.164 sender number
WhatsApp exception: On WhatsApp, keyword auto-replies are sent as free-form text inside the 24-hour conversation window — no approved template is required. If the window has expired, the auto-reply must be sent as an approved template rather than free-form text.
Template must be approved (SMS and RCS). Auto-replies using a template in DRAFT or PAUSED status will fail silently — the keyword is still processed, but no reply is sent. Make sure your STOP, START, and HELP templates are published.
Routing Auto-Replies
Auto-replies are routed through the standard routing engine. The provider and sender number are resolved by scored match. Phone numbers must be in canonical E.164 format (e.g. +14155551234) on both the stored route and the contact record for a match to succeed.
Channel Scope
Two-way conversation features are not uniform across channels.
| Feature | SMS | RCS | |
|---|---|---|---|
| Inbound stored as RECEIVED | ✓ (MO-capable providers only) | ✓ | ✓ |
| Reply-from-same-sender routing | ✓ (MO-capable providers only) | ✓ | ✓ |
| Keyword detection (STOP/START/HELP) | ✓ (MO-capable providers only) | ✓ | ✓ |
| Auto-reply | ✓ (MO-capable providers only) | ✓ | ✓ (free-form text within 24h window) |
| Opt-out engine | ✓ (MO-capable providers only) | ✓ | ✓ |
WhatsApp runs the same keyword detection, opt-out, and auto-reply pipeline as SMS and RCS. Auto-reply messages are sent as free-form text inside the 24-hour conversation window — no template required. An opt-out keyword flips the shared contact-level optOut flag, suppressing the contact across all channels. Note that Meta also enforces its own opt-out natively (in-app block/report, quality rating) — this is in addition to Sent's pipeline, not instead of it.
SMS two-way messaging is not supported for all SMS configurations. Support depends on the provider and the phone number type:
- Supported: Long codes and short codes on MO-capable providers (Telnyx, Sinch, Vibes)
- Not supported: Alphanumeric sender IDs — these are send-only and cannot receive inbound messages
- Not supported: SMPP providers (Vala, Ipko, Mitto, BICS) — delivery receipts only, no inbound path
If you are using an alphanumeric sender ID or an SMPP provider and contacts send keywords (STOP, START, HELP), those messages will never reach Sent.
Common Failure Scenarios
These are the most frequent reasons two-way conversation handling silently stops working.
SMS channel removed or unprovisioned
If a customer's SMS channel is removed or the provisioned number is no longer assigned, inbound messages on that number cannot be matched to any customer. The opt-out (or any other keyword) is dropped — it is never processed. Channel resolution must succeed before anything else runs.
If contacts are texting in and nothing is happening, verify that the SMS provider and number are still active and assigned under the customer's channel settings.
SMS provider or number type does not support inbound
Two-way messaging on SMS requires both an MO-capable provider and a supported number type.
MO (Mobile Originated) refers to a message that originates from a contact's phone and travels toward your application. An MO path is the provider's ability to receive those inbound messages and forward them to Sent. Providers that only support MT (Mobile Terminated) can deliver messages to a phone but cannot receive replies from one — making two-way messaging impossible on those routes.
Two common silent failure points:
- SMPP providers (Vala, Ipko, Mitto, BICS) — support delivery receipts only. No inbound message is received, stored, or keyword-processed on these routes.
- Alphanumeric sender IDs — send-only by design. Contacts cannot reply to an alphanumeric sender; any attempt is dropped at the carrier level before it reaches Sent.
If contacts are sending keywords and nothing is happening, verify that your route uses a long code or short code on a MO-capable provider (Telnyx, Sinch, or Vibes).
Free-form opt-out phrases are not recognized
Messages like "Cancel my subscription", "Please remove me", or "I don't want these anymore" do not trigger opt-out. Only an exact keyword match does. Contacts who send free-form messages will continue to receive outbound messages until they send an exact keyword (STOP, CANCEL, or a configured custom keyword).
If your contacts are likely to use natural language, consider adding a custom keyword that matches a common phrase — but it must still be a single, exact token, not a sentence.
Auto-reply template not approved
If the template configured for a STOP, START, or HELP auto-reply is in DRAFT or PAUSED status, the keyword is still processed (the opt-out is written) but no confirmation reply is sent. Contacts will not receive any acknowledgement.
Resolve this by publishing the template in the dashboard. See Working with Templates for approval steps.
Receiving Inbound Messages via Webhook
Subscribe to message.received events to be notified in real-time when a contact replies.
{
"field": "message",
"event": "message.received",
"timestamp": "2025-01-15T08:35:00Z",
"payload": {
"account_id": "7ba7b820-9dad-11d1-80b4-00c04fd430c8",
"message_id": "msg_01HV2K3J4M5N6P7Q8R9S0T1U2V",
"inbound_number": "+1234567890",
"outbound_number": "+1987654321",
"text": "Yes, tell me more",
"channel": "sms",
"received_at": "2025-01-15T08:34:58Z"
}
}Use message_id as your idempotency key. Providers may redeliver the same event — deduplicate on message_id before processing to avoid double inserts or duplicate follow-up flows.
The examples below show how to listen for inbound messages on your webhook endpoint, store them, and trigger a follow-up flow when the message is not a compliance keyword.
import express from 'express';
const app = express();
app.use(express.json());
app.post('/webhooks/sent', async (req, res) => {
res.sendStatus(200); // Always acknowledge quickly
const { field, event, payload } = req.body;
if (field === 'message' && event === 'message.received') {
const { message_id, inbound_number, outbound_number, text, channel, received_at } = payload;
// Store inbound for your CRM / conversation view
await db.inbound.insert({ messageId: message_id, from: inbound_number, to: outbound_number, text, channel, receivedAt: received_at });
// Optionally trigger a follow-up flow
const COMPLIANCE_KEYWORDS = ['stop', 'cancel', 'unsubscribe', 'quit', 'end', 'start', 'unstop', 'subscribe', 'help', 'info'];
if (text && !COMPLIANCE_KEYWORDS.includes(text.toLowerCase().trim())) {
await triggerResponseFlow({ from: inbound_number, channel });
}
}
});from flask import Flask, request
app = Flask(__name__)
@app.route('/webhooks/sent', methods=['POST'])
def handle_webhook():
data = request.json
if data['field'] == 'message' and data.get('event') == 'message.received':
p = data['payload']
db.inbound.insert(
message_id=p['message_id'],
from_number=p['inbound_number'], to=p['outbound_number'],
text=p.get('text'), channel=p['channel'],
received_at=p['received_at']
)
compliance_keywords = {'STOP', 'CANCEL', 'UNSUBSCRIBE', 'QUIT', 'END', 'START', 'UNSTOP', 'SUBSCRIBE', 'HELP', 'INFO'}
text = p.get('text')
if text and text.strip().upper() not in compliance_keywords:
trigger_response_flow(from_number=p['inbound_number'], channel=p['channel'])
return '', 200package main
import (
"encoding/json"
"net/http"
"strings"
)
type Payload struct {
MessageID string `json:"message_id"`
InboundNumber string `json:"inbound_number"`
OutboundNumber string `json:"outbound_number"`
Text string `json:"text"`
Channel string `json:"channel"`
ReceivedAt string `json:"received_at"`
}
type WebhookEvent struct {
Field string `json:"field"`
Event string `json:"event"`
Payload Payload `json:"payload"`
}
func webhookHandler(w http.ResponseWriter, r *http.Request) {
var event WebhookEvent
if err := json.NewDecoder(r.Body).Decode(&event); err != nil {
w.WriteHeader(http.StatusBadRequest)
return
}
w.WriteHeader(http.StatusOK)
if event.Field == "message" && event.Event == "message.received" {
p := event.Payload
db.Inbound.Insert(p.MessageID, p.InboundNumber, p.OutboundNumber, p.Text, p.Channel, p.ReceivedAt)
complianceKeywords := map[string]bool{
"STOP": true, "CANCEL": true, "UNSUBSCRIBE": true, "QUIT": true, "END": true,
"START": true, "UNSTOP": true, "SUBSCRIBE": true, "HELP": true, "INFO": true,
}
if p.Text != "" && !complianceKeywords[strings.ToUpper(strings.TrimSpace(p.Text))] {
triggerResponseFlow(p.InboundNumber, p.Channel)
}
}
}[HttpPost("/webhooks/sent")]
public async Task<IActionResult> HandleWebhook([FromBody] JsonElement body)
{
var field = body.GetProperty("field").GetString();
var evt = body.GetProperty("event").GetString();
if (field == "message" && evt == "message.received")
{
var payload = body.GetProperty("payload");
var messageId = payload.GetProperty("message_id").GetString();
var from = payload.GetProperty("inbound_number").GetString();
var to = payload.GetProperty("outbound_number").GetString();
var text = payload.TryGetProperty("text", out var t) ? t.GetString() : null;
var channel = payload.GetProperty("channel").GetString();
var receivedAt = payload.GetProperty("received_at").GetString();
await _db.Inbound.InsertAsync(new InboundMessage
{
MessageId = messageId, From = from, To = to, Text = text,
Channel = channel, ReceivedAt = receivedAt
});
var complianceKeywords = new HashSet<string>
{ "STOP", "CANCEL", "UNSUBSCRIBE", "QUIT", "END", "START", "UNSTOP", "SUBSCRIBE", "HELP", "INFO" };
if (text != null && !complianceKeywords.Contains(text.Trim().ToUpper()))
{
await _flowService.TriggerResponseFlowAsync(from, channel);
}
}
return Ok();
}See the Webhooks Guide for full webhook setup, signature verification, and retry handling.
Retrieving Conversations
Sent exposes two customer-scoped endpoints for reading conversation history.
List conversations
GET /v3/conversations?page=1&page_size=20Returns a paginated list of conversations for the authenticated customer. Each conversation groups messages between your customer and a single contact.
Get a single conversation
GET /v3/conversations/{id}Returns all messages for one conversation, filtered by the conversation id.
{
"id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"contact_id": "cnt_01HV2K3J4M5N6P7Q8R9S0T1U2V",
"channel": "sms",
"messages": [
{
"message_id": "msg_01HV2K3J4M5N6P7Q8R9S0T1U2V",
"direction": "outbound",
"text": "Hi! Your appointment is confirmed for Tuesday at 2pm.",
"sent_at": "2025-01-15T08:30:00Z"
},
{
"message_id": "msg_01HV2K3J4M5N6P7Q8R9S0T1U2X",
"direction": "inbound",
"text": "Yes, tell me more",
"received_at": "2025-01-15T08:34:58Z"
}
]
}The conversation id is a deterministic v5 UUID derived from customerId + contactId. Because it is computed, not stored, it is stable and reproducible — you can reconstruct it at any time without querying the API first.
Summary
| Concept | Behavior |
|---|---|
| Customer match | By receiving number + channel — no match means no storage |
| Storage | All inbounds stored as RECEIVED in the Sent backend; only non-keyword replies are visible in the dashboard |
| Keyword matching | Exact, full-body, case-insensitive — partial phrases don't match |
| Default opt-out keywords | STOP, CANCEL, UNSUBSCRIBE, QUIT, END |
| Default opt-in keywords | START, UNSTOP, SUBSCRIBE |
| Default help keywords | HELP, INFO |
| STOP | Always opts out; auto-reply sent before consent write |
| START | Always honored; contact created if new, then consent written, then auto-reply sent |
| HELP | Sends configured info auto-reply |
| Opt-out scope | Contact-level and channel-agnostic — a STOP on any channel suppresses across all channels |
| Consent source of truth | optOut on the contact record |
| Auto-reply pipeline | Same as any outbound — template approval, consent, routing required |
| Same keyword/opt-out/auto-reply pipeline as SMS/RCS; auto-reply sent as free text within 24-hour window | |
| SMS two-way limitations | Not supported on SMPP providers or alphanumeric sender IDs; requires long/short code on Telnyx, Sinch, or Vibes |
| Idempotency | Deduplicate on message_id — providers may redeliver the same event |