SMS bridge
How threads reach phone numbers that aren't on Muetext, and what your bot can and can't do there.
What the bridge is
A person in Muetext can start a thread with any phone number. If that number isn't on Muetext, it joins the thread as an SMS participant: messages from people in the thread go out as texts, and texted replies come back into the thread. When the person later signs up with that number, the thread moves into their app with full history.
Bots never text
Your bot posts to SMS threads with the normal messages endpoint, exactly like any other thread. Those messages are shown to the people in the app but are never sent as SMS: only messages written by people go out as texts. This keeps automated messages off people's phones unless a human sent them. There is no bot endpoint that sends an SMS.
curl -X POST $BASE/api/public/v1/conversations/$CONV/messages \
-H "Authorization: Bearer $KEY" \
-H "Idempotency-Key: 6f1c2e0a-8b7d-4c1e-9a55-2d0f1b3c4e5f" \
-H "content-type: application/json" \
-d '{"body":"Draft reply for Ada: Thursday 3pm works."}'
# 201 — posted (visible in the app, not texted)
{ "status": "sent", "data": { "id": "…", "seq": 42, "body": "Draft reply for Ada: Thursday 3pm works.", "created_at": "…" } }
# 200 — same Idempotency-Key retried: the original message, no duplicate
{ "data": { "id": "…", "seq": 42, "body": "…", "created_at": "…" }, "idempotent_replay": true }
# 202 — needs a person to approve first
{ "status": "pending_approval", "draft_id": "…", "reasons": [{ "code": "…", "message": "…" }] }
# 400 — missing header
{ "error": { "code": "idempotency_key_required", "message": "Send an Idempotency-Key header (a UUID per message; reuse it when retrying)" } }First contact: the invite
The first message a person sends to a new number goes out as an invite, once per thread and number:
[Name] invited you to chat on Muetext: [link] . Reply STOP to opt out. "[first 120 characters of the message]"
The link is signed and bound to that one thread. Later messages are texted as "Name: message". If the number already belongs to a Muetext account, no SMS is used at all; they're added to the thread directly.
Replies coming back in
The SMS provider posts replies to /api/channels/sms/inbound. Every request is signature-checked (forged requests get 401). A reply goes to the thread that most recently texted that number, and is saved as a normal text message with no sender_user_id and no sender_bot_id.
Texted replies don't trigger bot webhooks. To see them, read the thread with ?after_seq= (or ?since=) and look for text messages where both sender fields are null.
curl "$BASE/api/public/v1/conversations/$CONV/messages?after_seq=42" \
-H "Authorization: Bearer $KEY"
{ "data": [ { "id": "…", "seq": 43, "kind": "text", "body": "Sounds good, see you then",
"sender_user_id": null, "sender_bot_id": null, "created_at": "…" } ],
"next_after_seq": 43 }STOP and opt-out
STOP, STOPALL, UNSUBSCRIBE, CANCEL, END or QUIT (any capitalization) stops all texts to that number from every thread, immediately. They get exactly one confirmation; a repeated STOP gets none. START, UNSTOP or YES opts back in. Both changes post a system message in the thread and are written to the consent ledger. Numbers that text in without any thread get one help reply per day.
Limits
Checked before every text, and every decision is written to the ledger:
Workspace: 100 texts a day. Over the limit, the person's send is refused before the message is saved (they see how long to wait).
Per number: at most 10 texts an hour, and at most 3 texts in a row with no reply.
Per sender: at most 20 new invites a day.
Quiet hours: nothing goes out from 9pm to 8am in the recipient's time zone; texts wait and go at 8am.
Workspace admins can switch texting off; suspended workspaces can't text. Your bot's own REST calls have separate limits and return 429 with Retry-After.
Delivery states
Each text moves through queued → sent → delivered, or failed. People see "SMS sent" then "SMS delivered" under their message in the app. Texts held by a rule are logged as deferred (quiet hours) or blocked (STOP, limit, switch off). Delivery states aren't exposed in the bot API.
Demo mode vs live
Today Muetext runs in demo mode: no real texts are sent and nothing costs money. Texts are recorded in a demo outbox and marked delivered a few seconds later, so the whole flow (invite, "SMS sent" → "SMS delivered", limits, quiet hours) behaves as it will live. Live texting is turned on by the Muetext owner on the server; your bot code doesn't change.
To test: in the app, start a thread with a made-up number such as +1 555 555 0199, add your bot, send a message as yourself and watch the status. Your bot's messages in that thread stay in the app. Demo replies need the server's secret, so they can't be faked from outside.
Verify your webhook
Webhooks for SMS threads are signed like all others. HMAC-SHA256 the string timestamp + "." + raw body with your webhook secret, compare in constant time, and reject anything older than five minutes.
import { createHmac, timingSafeEqual } from "node:crypto";
export function verify(rawBody: string, headers: Headers, secret: string) {
const ts = headers.get("x-muetext-timestamp") ?? "";
const sig = headers.get("x-muetext-signature") ?? ""; // "sha256=<hex>"
if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false;
const expected = "sha256=" + createHmac("sha256", secret).update(ts + "." + rawBody).digest("hex");
const a = Buffer.from(sig), b = Buffer.from(expected);
return a.length === b.length && timingSafeEqual(a, b);
}
// Dedupe on x-muetext-event-id; deliveries can repeat after retries.