Skip to main content
SMS lets your business send and receive text messages from numbers you own. In the US, texting from a standard 10-digit number requires A2P 10DLC registration: every organization registers its own brand and campaign with the carrier registry before any message can be sent.
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

  1. Open SMS in the dashboard.
  2. Enter your business details — the platform auto-generates compliant campaign text (sample messages and opt-in language).
  3. Run the free compliance review (green / yellow / red, with specific fixes).
  4. 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.
  5. 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.
Plan the registration step before launch — approval is controlled by the carrier registry and can take time, so start it early.

Send a text from the API

Your backend can text a customer directly — an appointment confirmation, a “your order is ready”, a follow-up — with POST /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.
A successful send returns 201:
Send the message only. On the first text to a person (and again after 30 days without one), the platform adds your registered brand name and the required opt-out disclosure. Every message is converted to plain ASCII: smart punctuation is mapped to its plain equivalent; accented letters, emoji and other non-ASCII characters are removed. A 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 a 201 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 its id in mediaIds. 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.
Send it within 24 hours (expiresAt) by putting the id in mediaIds:
The receipt lists what was sent in media:
An upload can be attached to more than one message. Once a message carries it, it is kept with the conversation instead of expiring.

Send a picture from a web address

Put public https:// addresses in mediaUrls (each up to 2,048 characters, on the standard port 443 or on 8443). The key only needs sms:send:
The platform downloads each picture or video once, before sending, and checks it exactly like an upload. Each address must answer within 10 seconds (20 seconds for all of a message’s addresses together). Up to 3 redirects are followed, each held to the same rules, and an address on a private or internal network, or on another port, is refused. Each address counts as one upload against the upload rate limit (about 20 per 10 seconds per key). Nothing is downloaded until the message is known to be sendable. A replay of a 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, your sms.message.received webhook (see Subscribe to incoming texts) lists the files in media, each with a download link:
The link works for about an hour, without credentials, so download the file promptly if you want a copy, and don’t store the link. Only 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:
Fetch 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. 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.
Record out-of-band consent with 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.
An opt-out always wins. Recording consent does not re-enable a number that replied STOP — the response still succeeds but carries optedOut: true, and sends stay refused until the person texts START or you re-enable them with fresh, documented consent (DELETE /sms/suppressions/{e164}).
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.
A customer asking you to stop texting is an opt-out, not a revoke. Add them to the suppression list with POST /sms/suppressions (or the dashboard), which blocks every kind of text immediately.

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.received event, 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.
The pause applies to one person, across every one of your numbers that an agent answers. It silences the AI, not your team: a number that routes straight to your team keeps ringing your team, and the person’s texts to it reach your team as before. The pause affects texts only: calls from that number still reach your agent.

1. Subscribe to incoming texts

Create a webhook for sms.message.received (scope webhooks:write). Save the secret in the response, because it is shown only once.
Each delivery is a signed envelope. The customer’s text is in 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/messages to that person returns 409 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.created instead.
  • 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.
Deliveries are at least once, so deduplicate on the envelope 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:
Check 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 with POST /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

The response includes "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
A number your workspace has no record of returns 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.
A teammate who takes over the thread outranks the pause. If someone on your team has taken over a customer’s thread, the customer’s texts go to that teammate even while the AI is paused. The event says "handledBy": "seat", and POST /sms/messages to that customer returns 409 conversation_busy from any of your numbers, so your system never talks over a person. Group texts are always handled by your team.
See the API Reference (SMS group) for every field and error code.