Skip to navigation

Send and receive messages

View as MarkdownOpen in Claude

Text your customers from your SignalWire number, and answer the texts they send back. This guide shows how to send SMS and MMS, handle an incoming message, and track whether each message was delivered with SWML, Relay, or the REST API.

How messaging works

Sending a message only takes a request from your code. The number you send from must be a SignalWire phone number with messaging enabled; a verified caller ID can’t send messages. Each outbound message moves through these states:

StateMeaning
queuedSignalWire accepted the message and queued it for delivery
initiatedDelivery has started
sentThe message left SignalWire for the carrier network
deliveredThe carrier confirmed delivery to the recipient
readThe recipient opened the message. Only WhatsApp messages reach this state
undeliveredThe carrier couldn’t deliver the message
failedThe message never left SignalWire. error_code and error_message say why

delivered, undelivered, and failed are final for SMS and MMS. A WhatsApp message can still move from delivered to read. Messages sent faster than your throughput allows wait in your Space’s backlog and go out when they can, as Default limits describes.

Receiving a message takes a little setup first, because SignalWire has to know where to send it. Every phone number has one message handler, separate from its call handler: a Resource you assign under the number’s Inbound Message Settings. When someone texts the number, SignalWire hands the message to that Resource:

ApproachResource on the numberWhat happens when a message arrives
SWMLSWML Script used for messagingSignalWire runs the script’s document, first fetching it from your server when the script points at a URL
WebSocket (Relay)Relay ApplicationSignalWire delivers the message to the Relay client connected on the application’s topic

When a number has no message handler, nothing happens when a text arrives.

Choose an approach

Each approach has its own code, and the sections below don’t build on each other, so pick one and follow its section:

  • SWML is a document of instructions that SignalWire runs. A messaging document answers incoming texts on its own, either hosted in SignalWire or served from your server. A calling document can also text someone during a call.
  • WebSocket (Relay) is a Relay client in the Server SDKs. It holds a WebSocket open from your server, sends messages, and receives incoming ones and every delivery update on that connection, so it needs no public URL.
  • REST sends a message with one HTTP request from any process, and reports delivery to a webhook you choose. It can’t receive messages: pair it with SWML or Relay for that.

WhatsApp messages use the same REST endpoint as SMS and MMS; see Send WhatsApp messages.

Prepare for messaging

Have these values ready:

  • Your Space URL, such as <YOUR_SPACE>.signalwire.com.
  • Your Project ID and API token from the Dashboard’s API credentials page. Enable the token’s Messaging permission, and Numbers if you assign a phone number in code.
  • A phone number in your Space with messaging enabled, and a mobile phone you can text it from.
US numbers need registration before they can text

New US 10-digit long code (10DLC) numbers don’t have messaging enabled. To send from one, register a brand and campaign with The Campaign Registry and assign the number to the approved campaign. A toll-free number needs toll-free verification instead. To receive on a number you only use inbound, ask Support to enable inbound messaging.

The free trial only texts one verified number, and doesn't run SWML

Until you register, Platform Free Trial lets one SignalWire number text the mobile number you verified, and only that number, up to 200 messages a day. Each message starts with [SignalWire Free Trial], and SWML message handling doesn’t work during the trial: test the SWML section after registration, or use Relay or REST. Outside a trial, enable international messaging before you text another country.

You're responsible for opt-outs

SignalWire doesn’t manage opt-outs for you. When a recipient replies STOP, stop texting them until they opt back in. Optimize delivery rates lists the content and practices carriers expect.

Replace these values in the samples you run:

ValueReplace with
<YOUR_SPACE>Your Space’s subdomain in <YOUR_SPACE>.signalwire.com
<YOUR_PROJECT_ID>Your Project ID
<YOUR_API_TOKEN>Your API token, used only in server code
<YOUR_SIGNALWIRE_NUMBER>Your messaging-enabled SignalWire phone number, in E.164 format such as +12025550123
<YOUR_DESTINATION>The mobile number you’ll text, in E.164 format
<YOUR_PHONE_NUMBER_ID>Your phone number’s ID from its Dashboard page, when you assign it in code
<YOUR_BASIC_AUTH_PASSWORD>A password you choose for your SWML server
<YOUR_PUBLIC_HOST>The public hostname of your SWML server or tunnel
<YOUR_RIDES_API_URL>An HTTPS endpoint you control that looks up a ride by phone number
<YOUR_STATUS_WEBHOOK_URL>An HTTPS endpoint you control that receives delivery updates

Send and receive messages via SWML

A messaging SWML document lists what to do with an incoming text, such as replying, routing by keyword, or calling your API. SignalWire runs it when a message reaches your number. Messaging documents use their own messaging methods, such as reply, separate from the calling methods.

Text a caller during a call via SWML

A calling document sends a text with send_sms. Run either program below: it calls the destination, says the ride is confirmed, texts the details to the same phone, and hangs up. The REST dial request carries the document, as in Place a call via SWML.

# Install: python -m pip install signalwire-sdk==3.4.1
# Save as text_during_call.py and run: python text_during_call.py
from signalwire import SWMLBuilder, SWMLService
from signalwire.rest import RestClient
client = RestClient(
project="<YOUR_PROJECT_ID>",
token="<YOUR_API_TOKEN>",
host="<YOUR_SPACE>.signalwire.com",
)
swml = (
SWMLBuilder(SWMLService(name="text-during-call"))
.say("Your Bayview Taxi ride is confirmed. We're texting you the details now.")
.send_sms(
from_number="<YOUR_SIGNALWIRE_NUMBER>",
to_number="<YOUR_DESTINATION>",
body="Bayview Taxi: your ride is confirmed for 8 AM tomorrow. Reply STATUS for your pickup time.",
)
.hangup()
.build()
)
call = client.calling.dial(
from_="<YOUR_SIGNALWIRE_NUMBER>",
to="<YOUR_DESTINATION>",
swml=swml,
)
print(call["id"])

dial() signature depends on the SDK version

@signalwire/sdk@2.0.5 takes a single options object. Newer releases take the positional dial(from, to, options) form.

Answer the call: you hear the confirmation, and the text arrives a few seconds later. To text whoever called your number instead, put to_number: "%{call.from}" in a document that answers inbound calls. send_sms sets send_sms_result to success or failed, so later steps can branch on whether SignalWire accepted the message.

Reply to incoming messages via SWML

When someone texts your number, SignalWire runs the messaging SWML Script assigned to it. The script either holds the document itself or fetches it from your server for each message.

1

Create the messaging SWML Script

SignalWire must reach your server over the internet, so this tab has four parts: run the server, give it a public URL, check the URL, then create the script that points at it.

Run the server below. It answers requests on port 3000 at /swml, but only when they carry the username signalwire and the password you set. The TypeScript SDK can’t build messaging documents yet, so this server is Python only; to skip running a server, use the Hosted in SignalWire tab.

Python — SWML service
# Install: python -m pip install signalwire-sdk==3.4.1
# Save as inbound_message_server.py and run: python inbound_message_server.py
from signalwire import SWMLService
# The SWML builder has no messaging methods yet, so add `reply` with add_verb.
# Schema validation only knows calling methods, so this service turns it off.
service = SWMLService(
name="inbound-message-server",
route="/swml",
port=3000,
basic_auth=("signalwire", "<YOUR_BASIC_AUTH_PASSWORD>"),
schema_validation=False,
)
service.add_verb("reply", {
"body": "Thanks for texting Bayview Taxi! We got your message: %{message.body}",
})
# Serves the document at /swml. Put the credentials in the URL you give
# SignalWire: https://signalwire:<YOUR_BASIC_AUTH_PASSWORD>@<YOUR_PUBLIC_HOST>/swml
service.serve()

SignalWire fills in %{message.body} before it sends the reply. Each request to your server carries the incoming message as message, with fields such as from, to, body, and media, so your code can also read them and build a different document for each sender.

While you develop, give the server a public HTTPS URL with a tunnel such as ngrok. In a second terminal, run:

ngrok http 3000

ngrok prints an https:// Forwarding URL. Its hostname is your <YOUR_PUBLIC_HOST>. Keep both the server and ngrok running while you test.

Check that the document is reachable before you point a number at it:

curl -u "signalwire:<YOUR_BASIC_AUTH_PASSWORD>" "https://<YOUR_PUBLIC_HOST>/swml"

The response is the SWML document, starting with {"version": "1.0.0". A 401 means the password doesn’t match the one in your server. See webhook security to also verify that requests come from SignalWire.

Then create the script in the Dashboard:

  1. Open My Resources, select + Add, then Script, then SWML Script.
  2. Name it Text replies and set Used For to Messaging.
  3. Choose External URL and enter https://signalwire:<YOUR_BASIC_AUTH_PASSWORD>@<YOUR_PUBLIC_HOST>/swml in Primary Script URL.
  4. Select Create and keep the server running.
2

Assign the script to your phone number

Open Phone Numbers, select the number, and select Edit Settings. Under Inbound Message Settings, select Assign Resource, choose Text replies, and save. The number’s call handler doesn’t change.

A phone number's Edit page in the Dashboard showing an Assign Resource button under Inbound Call Settings and another under Inbound Message Settings

A phone number's Edit page, with Inbound Message Settings below Inbound Call Settings

To assign the script in code, use Create a phone number address with handler_type: "messaging". That endpoint is in beta and requires the number to have no message handler yet.

3

Text your number

Text anything to your SignalWire number from your phone. Within a few seconds you get “Thanks for texting Bayview Taxi! We got your message:” followed by your text.

If no reply arrives, check the symptom:

  • Your message doesn’t appear under Logs, then Messaging in the Dashboard. It never reached SignalWire. Check that you texted the right number and that the number can receive messages.
  • Your message is logged, but no reply arrives. The number’s Inbound Message Settings don’t point at Text replies, or the number can’t send yet: see the registration warnings in Prepare for messaging. For a script served from your server, rerun the curl check with the exact URL in the script, and confirm the server and ngrok are still running.

Route replies by keyword via SWML

Branch on what the sender wrote with switch, and call your own API with request when the reply needs data only you have. This document answers STATUS with the rider’s pickup time from your rides API, and every other text with a short menu. The lowercase_trim transform matches status, Status, and STATUS alike.

Your API receives a JSON body such as {"phone": "+12025550123"} and returns JSON such as {"pickup_time": "8:05 AM"}. With save_variables: true, each top-level field becomes a request_response.* variable. Paste the document into the script’s Primary Script, or pass each step to add_verb on your Python server.

version: 1.0.0
sections:
main:
- switch:
variable: message.body
transform: lowercase_trim
case:
status:
- request:
url: "<YOUR_RIDES_API_URL>"
method: POST
body:
phone: "%{message.from}"
save_variables: true
- switch:
variable: request_result
case:
success:
- reply: "Bayview Taxi: your driver arrives at %{request_response.pickup_time}."
default:
- reply: "Bayview Taxi: we couldn't look up your ride. Ada will text you shortly."
default:
- reply: "Bayview Taxi: reply STATUS for your pickup time."

Text STATUS to your number to get the pickup time, and anything else to get the menu. If STATUS always gets the fallback, your API didn’t return a 2xx response within the 5-second limit. A switch with no matching case and no default stops the document without replying, so keep a default. For a short keyword list with no API call, reply also accepts an inline switch; see reply.

Track delivery via SWML

Add status_url to a reply, or status_callback to a send_sms, and SignalWire POSTs a message status callback to that URL each time the message changes state. The payload is the same one REST sends; Track delivery via REST lists its fields.

YAML
version: 1.0.0
sections:
main:
- reply:
body: "Thanks for texting Bayview Taxi! We got your message: %{message.body}"
status_url: "<YOUR_STATUS_WEBHOOK_URL>"

Within the document, reply sets reply_result to queued or failed, and reply_message_id to the new message’s ID, so later steps can branch on whether SignalWire accepted the reply. A queued reply can still end up undelivered; only the status callback reports that. Status callbacks are best-effort, so don’t gate time-critical work on one; see Status callbacks are advisory.

Send and receive messages via WebSocket (Relay)

A Relay client runs on your server and holds a WebSocket connection to SignalWire. It sends messages, receives incoming ones, and gets every state change on that connection, so it needs no public HTTP endpoint.

Send a message via WebSocket (Relay)

Run either program below. It texts the destination, waits until the message reaches a final state, and prints that state.

# Install: python -m pip install signalwire-sdk==3.4.1
# Save as send_message_relay.py and run: python send_message_relay.py
import asyncio
from signalwire.relay import RelayClient
client = RelayClient(
project="<YOUR_PROJECT_ID>",
token="<YOUR_API_TOKEN>",
contexts=["default"],
)
async def main():
async with client:
message = await client.send_message(
from_number="<YOUR_SIGNALWIRE_NUMBER>",
to_number="<YOUR_DESTINATION>",
body="Bayview Taxi: your ride is confirmed for 8 AM tomorrow. Reply STATUS for your pickup time.",
)
print(f"Sent {message.message_id}")
await message.wait(timeout=60)
print(f"Final state: {message.state} {message.reason}")
asyncio.run(main())

The text arrives on your phone, and the program prints Final state: delivered. To send MMS, add media with up to 8 public media URLs; MIME types lists the supported formats.

If something goes wrong, check the symptom:

  • The send raises an error before any state prints. SignalWire rejected the request. Check that the from_number is a SignalWire number in your project, not a verified caller ID, and that the token has the Messaging permission.
  • The final state is failed or undelivered. The number can’t send yet, or a carrier refused the message: see the registration warnings in Prepare for messaging, and the reason printed after the state.
  • No final state arrives within 60 seconds. wait() raises a timeout error. The message may still be in your backlog; look it up under Logs, then Messaging, in the Dashboard.

Receive and reply to messages via WebSocket (Relay)

A Relay handler subscribes to one or more topics, names you choose and list in contexts. A Relay Application is the Resource that ties your phone number’s messages to one topic: texts to the number go to whichever Relay client is subscribed to that topic.

1

Run the message handler

Run either handler below and leave it running while you test. It subscribes to the inbound-messaging topic, then waits. When a text arrives, it prints the sender and body and replies from the number that received it.

# Install: python -m pip install signalwire-sdk==3.4.1
# Save as inbound_message_relay.py and run: python inbound_message_relay.py
from signalwire.relay import RelayClient
client = RelayClient(
project="<YOUR_PROJECT_ID>",
token="<YOUR_API_TOKEN>",
contexts=["inbound-messaging"],
)
@client.on_message
async def handle_message(message):
print(f"From {message.from_number}: {message.body}")
await client.send_message(
from_number=message.to_number,
to_number=message.from_number,
body=f"Thanks for texting Bayview Taxi! We got your message: {message.body}",
)
client.run()

The incoming message also carries media, a list of attachment URLs on an MMS, and segments, the number of SMS segments the text used; Character limits explains segments.

2

Assign the topic to your phone number

Create a Relay Application for the topic:

  1. Open My Resources, select + Add, then Relay Application.
  2. Name it Text replies and enter inbound-messaging as the Topic. It must match the contexts value in your code.
  3. Select Create.

Then open Phone Numbers, select the number, and select Edit Settings. Under Inbound Message Settings, select Assign Resource, choose Text replies, and save.

The Server SDKs point the number’s messages at the topic in one request, replacing the number’s current message handler and leaving its call handler alone.

# Install: python -m pip install signalwire-sdk==3.4.1
# Save as assign_message_topic.py and run: python assign_message_topic.py
from signalwire.rest import RestClient
client = RestClient(
project="<YOUR_PROJECT_ID>",
token="<YOUR_API_TOKEN>",
host="<YOUR_SPACE>.signalwire.com",
)
client.phone_numbers.update(
"<YOUR_PHONE_NUMBER_ID>",
message_handler="relay_topic",
message_relay_topic="inbound-messaging",
)

The cURL example calls Update phone number.

3

Text your number

Text anything to your SignalWire number from your phone. The handler prints your number and text, and within a few seconds you get “Thanks for texting Bayview Taxi! We got your message:” followed by your text.

If it doesn’t work, check the symptom:

  • The handler logs connection errors and keeps retrying. It can’t sign in. Check the Project ID and API token, and that the token has the Messaging permission.
  • The handler prints nothing. The message never reached it. Check that the handler is still running, that its contexts value matches the Relay Application’s Topic exactly, and that the number’s Inbound Message Settings point at that Relay Application.
  • The handler prints your text, but no reply arrives. The number can’t send yet: see the registration warnings in Prepare for messaging.

Track delivery via WebSocket (Relay)

Relay reports each state change on the connection that sent the message, so it needs no status callback URL. This version of the send program registers a listener on the message as soon as the send call returns, before it awaits anything else, and prints each state as it happens:

# Install: python -m pip install signalwire-sdk==3.4.1
# Save as track_message_relay.py and run: python track_message_relay.py
import asyncio
from signalwire.relay import RelayClient
client = RelayClient(
project="<YOUR_PROJECT_ID>",
token="<YOUR_API_TOKEN>",
contexts=["default"],
)
def on_state(event):
print(f"State: {event.params.get('message_state')} {event.params.get('reason', '')}")
async def main():
async with client:
message = await client.send_message(
from_number="<YOUR_SIGNALWIRE_NUMBER>",
to_number="<YOUR_DESTINATION>",
body="Bayview Taxi: your ride is confirmed for 8 AM tomorrow. Reply STATUS for your pickup time.",
)
message.on(on_state)
await message.wait(timeout=60)
print(f"Final state: {message.state} {message.reason}")
asyncio.run(main())

The listener prints each state change, such as sent and delivered, then the program prints the final state. A listener only sees states reported after it’s registered, and states reported before the send call returns never reach it. Register it before any other await, or the states that arrive in between are lost.

The message’s state and reason always hold the latest values, and wait() returns once the state is final. To act on the final state without waiting, pass on_completed to send_message() (onCompleted to sendMessage() in TypeScript). An incoming message arrives already in the received state.

Send messages via REST

REST sends a message from any process with one HTTP request, and reports delivery to a webhook. It can’t receive messages; to handle incoming texts, use the SWML or Relay section.

Send a message via REST

Run either example below. It sends the text and prints the new message’s ID and status.

# Install: python -m pip install signalwire-sdk==3.4.1
# Save as send_message_rest.py and run: python send_message_rest.py
from signalwire.rest import RestClient
client = RestClient(
project="<YOUR_PROJECT_ID>",
token="<YOUR_API_TOKEN>",
host="<YOUR_SPACE>.signalwire.com",
)
message = client.messages.create(
from_="<YOUR_SIGNALWIRE_NUMBER>",
to="<YOUR_DESTINATION>",
body="Bayview Taxi: your ride is confirmed for 8 AM tomorrow. Reply STATUS for your pickup time.",
)
print(message["id"], message["status"])

@signalwire/sdk@2.0.5 has no method for this request. In TypeScript, use the Relay client’s sendMessage() instead, or send the request directly. The request calls Send a message. To send MMS, add media with up to 7 public media URLs; a REST request with 8 or more is rejected with a 422.

SignalWire returns the message with status queued, before it’s delivered. Save the id to match status callbacks and look the message up later:

Response
{
"id": "c2d3e4f5-a6b7-8901-cdef-234567890abc",
"from": "+15559876543",
"to": "+15551234567",
"body": "Your order #12345 has shipped!",
"status": "queued",
"direction": "outbound",
"kind": "sms",
"media": [],
"number_of_segments": 1,
"error_code": null,
"error_message": null,
"created_at": "2024-05-06T12:20:00Z",
"project_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"status_callback_url": null,
"message_uri": "/api/messaging/logs/c2d3e4f5-a6b7-8901-cdef-234567890abc"
}

The text arrives on your phone a few seconds later. A 422 response names the field SignalWire rejected; the most common cause is a from that isn’t a messaging-enabled SignalWire number in your project. A message that is accepted but never arrives usually means the number can’t send yet: see the registration warnings in Prepare for messaging.

Track delivery via REST

Add status_callback to the request, and SignalWire POSTs a message status callback to that URL each time the message changes state. Add custom_variables too, with up to 20 string pairs such as your own ride ID, and SignalWire echoes them in every callback so you can match it to a record in your system.

Python — REST client
message = client.messages.create(
from_="<YOUR_SIGNALWIRE_NUMBER>",
to="<YOUR_DESTINATION>",
body="Bayview Taxi: your ride is confirmed for 8 AM tomorrow. Reply STATUS for your pickup time.",
status_callback="<YOUR_STATUS_WEBHOOK_URL>",
custom_variables={"ride_id": "4821"},
)

The cURL request takes the same two fields in its JSON body. Each callback carries these fields:

Callback fieldValue
idThe message ID returned when you sent it
statusThe new state, from the table in How messaging works
from, to, bodyThe message as sent
number_of_segmentsHow many SMS segments the body used
error_code, error_messageWhy delivery failed, or null
custom_variablesThe pairs you sent, when you sent any
timestampWhen the state changed

Status callbacks are best-effort, so don’t gate time-critical work on one; see Status callbacks are advisory and the webhooks guide for endpoint setup. To check a message without a webhook, look it up by ID with Get message log: client.logs.messages.get(message_id) in either SDK returns its current status.

Next steps