Run two-way support conversations over SMS
You build a support line customers can text. Inbound messages thread into conversations that your team answers from the shared console inbox, or they flow into your own helpdesk through webhooks and your agents reply through the API. Opt-out keywords and after-hours replies are handled on the number.
What you build
- A support number customers text, published on your site, receipts and packaging.
- One of two reply surfaces. The console inbox works with no code: the whole team sees the same threads and the same unread counts, and each console reply is stamped with the agent's name. Or a webhook integration:
message.receivedcreates or updates a ticket in your helpdesk, and an agent's answer goes out throughPOST /v1/messages. - An after-hours auto-reply per number, so a text at midnight gets "we are back at 9am" once, not on every message.
- Opt-out awareness in your tool. When a customer opts out, the agent's next reply is refused, and your UI should say why.
How it works
- Buy a number, or port the support number you already publish. Register it with the
customer_careuse case, which covers support conversations. - Add a webhook endpoint under Webhooks in the console and copy its signing secret (
whsec_...). Endpoints receive every event by default; filter to the message events you need. - A customer texts in. The message is stored, the conversation's unread counter goes up, and
message.receivedis delivered withfrom,to,body,message_idandconversation. - Thread on
conversation. There is one conversation per pair of your number and the customer's number, and the key is{ourDigits}_{theirDigits}, so you do not need to derive it. - Reply with
POST /v1/messages, settingfromto the number the customer texted (the event'sto). If you send from a sender pool ("from": "pool_..."), each recipient always gets the same pool member, so the thread on their phone stays in one place. - Configure the auto-reply on the number in the console:
enabled, amessageof up to 320 characters, and optional office hours (an IANA time zone, open days, start and end times) with modealwaysorafter_hours. - Read history with
GET /v1/messages?number=+15005550100, which returns messages to or from that number, newest first.
Code
Receive a text and reply to it, in Node.js:
js
import express from 'express';
import { SimpleSMS, SimpleSMSError } from 'joinsimplesms';
const sms = new SimpleSMS(process.env.SIMPLESMS_API_KEY);
const app = express();
app.post('/webhooks/simplesms', express.raw({ type: 'application/json' }), async (req, res) => {
let event;
try {
event = await sms.webhooks.verify({
payload: req.body, // raw Buffer, verified before parsing
headers: req.headers,
secret: process.env.SIMPLESMS_WEBHOOK_SECRET,
});
} catch {
return res.status(400).end();
}
res.status(200).end();
if (event.type === 'message.received') {
// data.conversation is the thread key: one per (your number, customer number)
await upsertTicket({
thread: event.data.conversation,
customer: event.data.from,
supportNumber: event.data.to,
text: event.data.body,
eventId: event.id, // dedupe on this
});
}
if (event.type === 'message.opted_out') {
await markDoNotText(event.data.phone, event.data.method); // keyword | phrase | ai
}
});
// Called when an agent presses Send in your helpdesk.
export async function replyToCustomer(ticket, text) {
try {
return await sms.messages.send({ from: ticket.supportNumber, to: ticket.customer, body: text });
} catch (err) {
if (err instanceof SimpleSMSError && err.code === 'recipient_opted_out') {
return { blocked: 'This customer opted out. They can text START to resume.' };
}
throw err;
}
}The same receiver in Python:
python
import os
from flask import Flask, request
from joinsimplesms import SimpleSMS, SimpleSMSError, verify_webhook
app = Flask(__name__)
client = SimpleSMS() # reads SIMPLESMS_API_KEY
@app.post("/webhooks/simplesms")
def inbound():
try:
event = verify_webhook(
request.get_data(),
request.headers,
os.environ["SIMPLESMS_WEBHOOK_SECRET"],
)
except SimpleSMSError:
return "", 400
if event["type"] == "message.received":
data = event["data"]
upsert_ticket(thread=data["conversation"], customer=data["from"],
support_number=data["to"], text=data["body"])
return "", 200
def reply_to_customer(ticket, text: str):
return client.messages.send(ticket.customer, text, from_=ticket.support_number)Reply, read a thread, and rehearse an inbound text with curl (the last call is test keys only):
bash
curl -X POST https://api.joinsimplesms.com/v1/messages \
-H "Authorization: Bearer $SIMPLESMS_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "from": "+15005550100", "to": "+15005550006", "body": "Acme Support: thanks, Dana is looking at your order now." }'
curl "https://api.joinsimplesms.com/v1/messages?number=%2B15005550100&limit=25" \
-H "Authorization: Bearer $SIMPLESMS_API_KEY"
curl -X POST https://api.joinsimplesms.com/v1/test/inbound \
-H "Authorization: Bearer $SIMPLESMS_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "to": "+15005550100", "from": "+14155550132", "body": "Hi, where is my order?" }'The simulated inbound goes through the real pipeline: it threads, emits message.received, and triggers the auto-reply if one is on, marked auto_reply: true on its event.
Compliance notes
- Opt-out detection reads ordinary language, carefully. The exact keywords (
STOP,STOPALL,UNSUBSCRIBE,CANCEL,END,QUIT,OPTOUT,REVOKE,ARRET) must be the whole message. Plain-language revocations aimed at messaging, such as "stop texting me" or "remove me from your list", also count. "Please stop by tomorrow" is an ordinary message. This matters in support, where customers write full sentences. - Every detection is reported.
message.opted_outcarries amethodofkeyword,phraseorai. One confirmation is sent, then nothing until the person opts back in withSTART,UNSTOPorYES. - HELP is answered for you.
HELP,INFOandAIDEget a help reply that names your business, not SimpleSMS. A number linked to a registration sends the HELP message that registration filed.message.help_requestedtells your team it happened. - Agents cannot text past an opt-out. A reply to an opted-out customer answers
403 recipient_opted_out, whether it comes from the API or the console. The opt-out is account-wide across your numbers and pools. - Auto-replies have fixed guardrails. They never answer STOP, START or HELP, never answer a message that looks like a verification code, never go to an opted-out number, and fire at most once per conversation per 4 hours.
- A reply is not a campaign. Answering someone who texted you is the
customer_careuse case. Starting unrelated promotional threads from the support number is a different kind of traffic and needs its own registration and consent. - Before approval, a live US local number can text only your verified numbers, so test the full loop with your own phone while the registration is in review.
What it costs
- $0.009 per outbound reply, any length, carrier fees included.
- Inbound texts: the rate table lists inbound SMS at $0.004 per message, and it is not metered yet, so receiving is free for now.
- Not billed: auto-replies, and the STOP, START and HELP confirmations sent for you.
- $0.95 per number, per month.
- Worked example: 3,000 conversations a month with four agent replies each is 12,000 outbound messages: $108.95 with one support number.
There is no per-seat line in the rate table. Details are on the pricing page.
Limits and caveats
- Inbound MMS attachments are stored on the message (a
mediaarray) and shown in the thread. Outbound MMS returnsmms_not_enabled, so agents can receive a photo but cannot send one. - Auto-replies are configured per number in the console; the public API reference has no endpoint for them.
- The auto-reply cooldown (one per conversation per 4 hours) and its guardrails are not configurable.
- Your webhook must answer 2xx within 5 seconds. Failures are retried after 1 minute, 5 minutes, 30 minutes, 2 hours and 12 hours, then parked under Undelivered events for 30 days, where they can be replayed.
- Deliveries can arrive out of order and occasionally twice. Deduplicate on the event
id. - An account can have up to 5 webhook endpoints.
- Team roles: members work the inbox, viewers read it without changing anything.
- US and Canadian numbers only. Free-tier logs are retained for 30 days.
- Live traffic needs live-access review and registration. In the sandbox the whole loop, including auto-replies and opt-outs, works with simulated messages.
Related docs
- Inbox and conversations: threading, unread counts, attribution
- Webhooks:
message.received, signatures, retries, replay - Auto-replies and office hours
- Opt-out, consent and TCPA: keywords, phrase detection, events
- Teams: roles, invitations and signatures
- Messages: sending replies and listing history
- Number porting: bring your existing support number
- Contacts: names in the inbox