SMS registration is guided in the dashboard, and the same steps are also available through the
/sms API with the sms:read and sms:write scopes. Carrier registration (TCR) is
compliance-critical — the guided flow validates your details, runs a free compliance review, and
walks you through approval so a submission can’t be wrong before you pay the activation fee.
Sending stays gated until your campaign is approved.Set it up
- Open SMS in the dashboard.
- Enter your business details — the platform auto-generates compliant campaign text (sample messages and opt-in language).
- Run the free compliance review (green / yellow / red, with specific fixes).
- Submit once it’s green. This pays the one-time activation fee and registers your brand and campaign with the carrier registry; status then updates as the registry approves it.
- Enable SMS on the numbers you want to text from.
After approval
Once your campaign is approved, your agent can send and receive texts on its enabled numbers. Opt-outs (STOP) are honored automatically, and the dashboard keeps a do-not-text suppression list — re-enabling a number requires fresh, documented consent.Send a text from the API
Your backend can text a customer directly — an appointment confirmation, a “your order is ready”, a follow-up — withPOST /sms/messages. No agent composes it and no teammate needs to be signed
in. You need a secret key with the sms:send scope and a from number your organization owns
that is SMS-enabled and assigned to an agent.
201:
text that is empty after that conversion is
refused (400), even when the message carries pictures (leave text out to send only the
pictures). The result is capped at two segments. The receipt’s text is exactly what was sent and
segments is its SMS segment count. billing is what you were billed for: "unit": "sms" with
quantity equal to segments for a text, or one MMS for a message with pictures (see
Send and receive pictures). If the customer replies, the reply
joins the same conversation and is handled like any other inbound text to that number.
kind tells you how the message was classified. It is reply when the person texted one of your
numbers within the last 24 hours, and initiated otherwise. A reply is not held to quiet hours
(see the table below), exactly like a reply your teammates send. The one exception is a workspace
that uses the strict posture (quietHoursRepliesExempt: false on the SMS registration), which holds
replies to the window as well. Being a reply changes nothing else: consent, opt-outs, the send
limit and the brand and opt-out disclosure all apply the same way.
What is checked before anything is sent
Every rule below is enforced server-side, and a send that didn’t happen is always an explicit error — never a201 with delivered: false.
A send refused at send time by a compliance check — an opt-out, quiet hours, an inactive
registration or sending number — is also recorded as an
sms_blocked event in your workspace’s
compliance trail, so the refusal is on record even though nothing was sent.
Retries and idempotency
client_message_id is required and names one message — use a fresh UUID for each. Sending
the same request again returns the original receipt and never texts the person twice. Reusing an
id for a different message is 409 client_message_id_reused.
If the carrier can’t confirm whether the message went out (a timeout, for example), you get
409 send_state_unknown. That is the only refusal that doesn’t guarantee the recipient’s phone
stayed silent, so don’t retry with a new id — replay the same client_message_id, which
returns the same answer (or the receipt once the outcome settles) and never sends a second text.
If the carrier rejected the message outright, you get 502 upstream_error: nothing was sent and
the id is released, so a corrected retry is safe.
Two refusals are temporary: 503 opt_out_check_unavailable (the recipient’s opt-out status
couldn’t be confirmed — it is never reported as an opt-out) and 503 try_again (the platform is
busy). Nothing was sent and the id isn’t used up, so retry the same request shortly. A message with
pictures adds two more temporary refusals, 429 media_fetch_busy and 429 rate_limited — see
When an attachment is refused.
Send and receive pictures (MMS)
A text can carry up to 10 pictures or short videos. There are two ways to attach one, and you can mix them in one message:- Upload it with
POST /sms/media, then send itsidinmediaIds. Use this for pictures and videos that aren’t on a public web address, which is where most of your own photos live. - Give its address in
mediaUrls. The platform downloads it, checks it and sends its own copy, so the carrier never fetches anything from your server.
text is optional when a message has attachments, so a picture-only message is fine.
Every rule in What is checked before anything is sent
applies to a message with attachments too. On a first contact, the brand name and opt-out
disclosure are added even when the message is only a picture: its text is then your brand name on
one line and the disclosure on the next (the receipt’s
text shows it). A message with attachments is billed
as one picture message (MMS), however many attachments it carries: the receipt’s billing is
{ "unit": "mms", "quantity": 1 }, and segments still counts the SMS segments of the caption.
Upload a picture, then send it
POST /sms/media (scope sms:write) takes one file per call as multipart/form-data, in the
file field. filename is optional and is only a label. Uploading and sending are separate
permissions, so a key that does both needs sms:write and sms:send.
expiresAt) by putting the id in mediaIds:
media:
Send a picture from a web address
Put publichttps:// addresses in mediaUrls (each up to 2,048 characters, on the standard port
443 or on 8443). The key only needs sms:send:
client_message_id
you already used is answered from its receipt without downloading anything. Then the cheap checks run
in this order: from (owned, SMS-enabled, assigned to an agent), to (not one of your own numbers),
the recipient’s consent, and your daily send limit. A refusal there downloads nothing. If the send is
refused after the download — quiet hours, an opt-out, a busy conversation — the copies just stored
are deleted straight away, so a refused send leaves nothing behind.
The receipt’s media lists each attachment under the id of the copy the platform stored, and you
can send that id again in mediaIds later.
When an attachment is refused
One refused attachment refuses the whole message, and nothing is sent.
Reusing a
client_message_id with different attachments is 409 client_message_id_reused, like
reusing it with different text.
Receive pictures
When a customer sends a picture, yoursms.message.received webhook (see
Subscribe to incoming texts) lists the files in media, each with
a download link:
id is guaranteed: if a file’s details or link couldn’t be
produced for a delivery, the entry still arrives with what is known, and the event is never held
back for it. Either way you can get the details and a fresh link at any time with the entry’s id. Customers’ phones can also send PDF
and WebP files and videos of up to 8 MiB. You receive all of them, but you can forward a received
file (by putting its id in mediaIds) only when it is a type and size you can send.
Download a file
GET /sms/media/{id} (scope sms:read) returns a fresh link for any file sent or received by text
in your workspace:
url with a plain GET and no Authorization header. The link expires after about 5
minutes, on purpose: anyone who holds it can read the file until then. Store the id, not the
link, and call this endpoint again whenever you need the file. An unknown id, an upload left unsent
for 24 hours, or a file a visitor uploaded in a web chat (only text attachments are served here)
returns 404.
A conversation’s transcript (GET /conversations/{id}/transcript) lists the files on each turn in
attachments, for the customer’s messages and for yours.
Consent
Texting a person first requires a recorded consent basis for their number. There are two:- Conversational — the person texted one of your numbers first. This is recorded automatically; you don’t need to do anything.
- Express — the person agreed to receive your texts. It’s recorded automatically when they agree during a call or chat with your agent. Consent you collected anywhere else — a booking or web form, at the counter, on paper, in your own system — has to be recorded before you can text them.
PUT /sms/contacts/{e164}/consent (scope sms:write):
source is one of web_form, point_of_sale, verbal, paper, import or other. Include the
exact disclosureText the person agreed to and a link or reference to your own proof — the record
appears in the contacts consent export, and you are attesting that your business holds the consent.
In the contacts list and the export, a record made this way shows its source as api:<source>
(for example api:web_form), so it is clear the consent was attested through the API.
DELETE /sms/contacts/{e164}/consent withdraws the express consent your workspace recorded
through this API — for example a record entered in error, or a customer who withdrew the permission
they gave you on your own form. It never touches a STOP and never erases the fact that a customer
texted you first: a contact who texted you first keeps conversational consent, and anyone else
drops to none (the response’s consent tells you which). revokedRecords counts only the
records you made through the API. A contact left at none is refused consent_required until
they text you or you record their consent again.
Let your own system handle a conversation
Sometimes your own software should answer a customer instead of the AI: a billing dispute your system of record owns, or a VIP your ops team handles. You can pause the AI for one contact, handle the conversation through the API, and turn the AI back on when you’re done. While the pause is on:- Every text the customer sends is still received, recorded on its conversation and shown in Observe. The AI just doesn’t reply.
- Each text is delivered to your webhook as an
sms.message.receivedevent, so your system knows to answer. - You answer with
POST /sms/messages. - The pause holds until you clear it. It never expires on its own.
1. Subscribe to incoming texts
Create a webhook forsms.message.received (scope webhooks:write). Save the secret in the
response, because it is shown only once.
data:
The event fires for every text a customer sends you, not only for paused contacts. Filter on
aiPaused (or handledBy: "api") to find the texts that are yours to answer. These texts don’t
send the event:
- Keywords and opt-outs. STOP and the other opt-out keywords, START, HELP, and plain-language
opt-outs such as “please stop texting me” are handled before the conversation. An opt-out is
recorded, and a later
POST /sms/messagesto that person returns409 sms_suppressed. - Texts that aren’t accepted. Texts beyond the per-contact daily limit on incoming texts, and texts that arrive while your workspace is out of credits, aren’t recorded or answered.
- Agent Bridge conversations. A conversation handed to an external agent through the Agent
Bridge sends its texts as
escalation.message.createdinstead. - Unclaimed group texts. A group text that no teammate has taken over yet is recorded (Observe shows it) and emailed to your team’s SMS support contact.
id, which is also the
Flowyte-Delivery header. To verify a delivery, recompute
hex(HMAC_SHA256(secret, "{Flowyte-Delivery}.{Flowyte-Timestamp}.{rawBody}")) and compare it to
the Flowyte-Signature header in constant time. Reject any timestamp more than 5 minutes old.
2. Pause the AI for the contact
PUT /sms/contacts/{e164}/ai-pause (scope sms:write). The body is optional. A note of up to
500 characters is stored with the pause and shown with it:
webhookSubscribed. It is true when your workspace has an active webhook subscribed to
sms.message.received. If it is false, nothing tells your system that the person texted: their
texts are still recorded and shown in Observe, but no one answers them. Create the webhook from
step 1, or set a disabled webhook back to active, before you rely on the pause.
The call is safe to repeat. Pausing a contact who is already paused keeps the original pausedAt
and pausedBy (whoever paused them first). A new note replaces the stored one; leaving it out
keeps it. You can also pause a number that hasn’t texted you yet, and the pause will be in place
for their first text. If the person has an open conversation, Observe shows an AI paused
marker at the moment the pause began, and conversationId is their most recent open
conversation.
If the AI is already writing a reply to the person when you pause, that reply isn’t sent. Their
text is delivered to your webhook with "handledBy": "api", like every text during the pause.
The races either way are only a few milliseconds wide: a reply already being sent at the instant
you pause still goes out, and a text that arrives at the instant you resume can still reach you as
paused.
3. Answer with the send API
Reply withPOST /sms/messages, using the webhook’s to as your
from so the reply stays on the same conversation. That number is always one an agent answers,
because the pause only applies there. A message to someone who texted you in the
last 24 hours is a reply ("kind": "reply" on the receipt), so it isn’t held to quiet hours
unless you use the strict posture. Every other check still applies, including consent, STOP and
the send limit. Your messages appear in Observe labelled as sent through the API.
4. Turn the AI back on
"wasPaused": true, or false if the contact wasn’t paused. The call is
safe to repeat. The AI doesn’t reply to texts that arrived during the pause. It answers the
customer’s next text, and it has the whole conversation as context, including your messages.
Observe shows an AI resumed marker.
Check a contact
GET /sms/contacts/{e164} (scope sms:read) returns one contact’s current state:
- their consent tier and whether they have opted out
- whether the AI is paused for them, and when, by whom and why
- when you first and last texted them, and when they last texted you
- their most recent open conversation, if they have one
404. That means it has never texted you or been
texted, and has no recorded consent, pause or opt-out. To list every contact the AI is paused for,
use GET /sms/contacts?aiPaused=true.
See the API Reference (SMS group) for every field and error code.