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.received creates or updates a ticket in your helpdesk, and an agent's answer goes out through POST /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

  1. Buy a number, or port the support number you already publish. Register it with the customer_care use case, which covers support conversations.
  2. 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.
  3. A customer texts in. The message is stored, the conversation's unread counter goes up, and message.received is delivered with from, to, body, message_id and conversation.
  4. 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.
  5. Reply with POST /v1/messages, setting from to the number the customer texted (the event's to). 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.
  6. Configure the auto-reply on the number in the console: enabled, a message of up to 320 characters, and optional office hours (an IANA time zone, open days, start and end times) with mode always or after_hours.
  7. 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_out carries a method of keyword, phrase or ai. One confirmation is sent, then nothing until the person opts back in with START, UNSTOP or YES.
  • HELP is answered for you. HELP, INFO and AIDE get a help reply that names your business, not SimpleSMS. A number linked to a registration sends the HELP message that registration filed. message.help_requested tells 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_care use 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 media array) and shown in the thread. Outbound MMS returns mms_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.

More in use cases