Sending SMS and MMS with Twilio in Python
Twilio is a cloud communications platform that lets you send and receive SMS, MMS, and WhatsApp messages programmatically. In Python, the fastest way to send a text is to install the official twilio helper library, create a Client, and call messages.create() with three things: who you are sending to, the Twilio number you are sending from, and the message body.
from twilio.rest import Client
client = Client(account_sid, auth_token)
client.messages.create(
to="+15558675310",
from_="+15017122661",
body="Hello from Twilio + Python!",
)
That is the whole happy path. The rest of this guide covers what production senders actually need in 2026: loading credentials securely from environment variables, sending picture messages (MMS), receiving inbound texts with verified webhooks, tracking delivery status, handling errors, and — critically for any US-bound traffic — A2P 10DLC registration, which is now mandatory. The examples target the modern Twilio Python SDK (v9.x) on Python 3.12+.
At MicroPyramid we have built messaging and notification flows into Python applications for 12+ years across 50+ projects, so the patterns below reflect what holds up in production rather than just what compiles.
1. Set up your Twilio account and number
Before any code runs, you need three things from the Twilio Console:
- An Account SID (starts with
AC...) and an Auth Token — your account credentials. - A phone number capable of messaging. You buy one in the console (Phone Numbers -> Buy a number) and pick a number type appropriate for your use case (see the comparison table below).
- For US traffic, a registered A2P 10DLC brand and campaign, or a verified toll-free number. Trial accounts can only message verified numbers and prepend a trial banner; you must upgrade and register before sending to real customers.
Install the official helper library into your virtual environment:
python -m pip install twilioCheck the version — this guide assumes v9.x of the SDK on Python 3.12 or newer:
python -c "import twilio; print(twilio.__version__)"
# 9.x.x2. Configure credentials securely (never hardcode)
Never put your Account SID and Auth Token in source code. Hardcoded secrets leak through Git history, logs, and screenshots, and your Auth Token can send messages and incur real charges. Load credentials from environment variables instead.
Use API Keys, not the Auth Token
Twilio recommends authenticating with a Standard API Key (an SK... SID plus its secret) rather than the account-wide Auth Token. API Keys can be revoked and rotated independently without changing your Account SID or breaking the Auth Token, which limits the blast radius if a credential leaks. The Client accepts the API Key SID and secret with the Account SID passed as a third argument:
import os
from twilio.rest import Client
# Authenticate with a Standard API Key (recommended) loaded from the environment.
api_key_sid = os.environ["TWILIO_API_KEY_SID"] # starts with "SK..."
api_key_secret = os.environ["TWILIO_API_KEY_SECRET"]
account_sid = os.environ["TWILIO_ACCOUNT_SID"] # starts with "AC..."
client = Client(api_key_sid, api_key_secret, account_sid)If you are still using the Auth Token directly (fine for quick scripts), load it the same way — from os.environ, never a literal:
import os
from twilio.rest import Client
# Auth-token style: both values come from the environment, never the code.
client = Client(os.environ["TWILIO_ACCOUNT_SID"], os.environ["TWILIO_AUTH_TOKEN"])Store the values in your shell, a secrets manager (AWS Secrets Manager, Vault, Doppler), or a .env file that is git-ignored and loaded with python-dotenv:
# .env (add this file to .gitignore -- never commit it)
TWILIO_ACCOUNT_SID=ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
TWILIO_API_KEY_SID=SKxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
TWILIO_API_KEY_SECRET=your_api_key_secret
TWILIO_FROM_NUMBER=+15017122661
TWILIO_MESSAGING_SERVICE_SID=MGxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx3. Send your first SMS
With credentials in the environment, sending is one call. messages.create() queues the message and returns a MessageInstance immediately — delivery is asynchronous, so the returned status is usually queued or accepted, not delivered.
import os
from twilio.rest import Client
client = Client(os.environ["TWILIO_ACCOUNT_SID"], os.environ["TWILIO_AUTH_TOKEN"])
message = client.messages.create(
to="+15558675310", # E.164 format: + and country code
from_=os.environ["TWILIO_FROM_NUMBER"], # your Twilio number, also E.164
body="Your verification code is 481516.",
)
print(message.sid) # e.g. "SMxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
print(message.status) # "queued" / "accepted" -- not yet "delivered"Always use E.164 phone-number formatting (+ followed by country code and number, no spaces or dashes). To check the outcome later, fetch the message back by its SID rather than relying on the initial response:
sent = client.messages(message.sid).fetch()
print(sent.status) # "delivered", "sent", "undelivered", "failed", ...
print(sent.error_code) # None when OK, otherwise a Twilio error code (e.g. 30007)4. Send an MMS (picture message)
MMS is the same messages.create() call with a media_url argument. Pass a list of publicly reachable HTTPS URLs — Twilio fetches each one and attaches it. You can include up to 10 media items, and body is optional for MMS.
import os
from twilio.rest import Client
client = Client(os.environ["TWILIO_ACCOUNT_SID"], os.environ["TWILIO_AUTH_TOKEN"])
message = client.messages.create(
to="+15558675310",
from_=os.environ["TWILIO_FROM_NUMBER"],
body="Here is your receipt and our logo.",
media_url=[
"https://example.com/receipts/inv-1042.png",
"https://example.com/brand/logo.png",
],
)
print(message.sid, message.num_media) # num_media reflects attached itemsNote that MMS is largely a US/Canada feature. In most other countries, sending media to a phone number falls back to an SMS containing a link, or is not supported at all — for rich media to a global audience, WhatsApp (covered later) is usually the better channel.
SMS vs MMS vs WhatsApp via Twilio
| SMS | MMS | ||
|---|---|---|---|
| Content | Text (segments of 160 / 70 chars) | Text + images, audio, video, vCard | Text, media, buttons, lists, templates |
| Geography | Global | Mainly US & Canada | Global |
from_ format |
+1... (E.164) |
+1... (E.164) |
whatsapp:+1... |
| Out-of-session sends | Anytime (with consent) | Anytime (with consent) | Pre-approved template (Content API) required |
| Best for | OTP, alerts, reminders | Receipts, tickets, US promos | 2-way support, rich notifications worldwide |
For US application-to-person SMS/MMS you must additionally complete A2P 10DLC or toll-free verification (next sections) regardless of channel.
Long code vs toll-free vs short code
The number type you send from affects throughput, cost, and which registration applies.
| Number type | Throughput | Registration (US) | Typical use |
|---|---|---|---|
| 10DLC long code | ~ tens of msgs/sec (tier-dependent) | A2P 10DLC brand + campaign | Most app notifications, OTP, alerts |
| Toll-free | Higher than long code | Toll-free verification | Notifications without a registered brand |
| Short code | Highest | Carrier-approved short code application | High-volume marketing/OTP at scale |
A Messaging Service can pool several numbers, pick the best sender per recipient, and carry your A2P registration — recommended once you outgrow a single number.
5. Send at scale with a Messaging Service
When you send to many recipients, do not loop over a list of raw from_ numbers. Create a Messaging Service (a MG... sender pool) in the console, attach your numbers and A2P campaign to it, and send with messaging_service_sid instead of from_. Twilio then handles sender selection, sticky sender, and scaling.
import os
from twilio.rest import Client
client = Client(os.environ["TWILIO_ACCOUNT_SID"], os.environ["TWILIO_AUTH_TOKEN"])
for recipient in ("+15558675310", "+15559994433"):
client.messages.create(
to=recipient,
messaging_service_sid=os.environ["TWILIO_MESSAGING_SERVICE_SID"],
body="Your order has shipped. Reply STOP to opt out.",
)For real bulk sending, queue each message in a background worker (for example Celery) so an API hiccup retries one message instead of failing the whole batch, and so your web request returns immediately. You can also schedule messages with schedule_type="fixed" and send_at (a timezone-aware datetime) when using a Messaging Service.
6. Receive inbound SMS with a verified webhook
When someone texts your Twilio number, Twilio sends an HTTP POST to the webhook URL you configure on the number (or Messaging Service). Your endpoint replies with TwiML — a small XML document — to send an automatic response. The MessagingResponse helper builds that XML for you.
Validate the signature on every inbound request
Your webhook URL is public, so anyone could POST fake messages to it. Always verify the X-Twilio-Signature header with RequestValidator before trusting the payload. It re-computes the request signature with your Auth Token and rejects anything that does not match.
Here is a complete, signature-validated inbound handler in Flask:
import os
from flask import Flask, request, abort
from twilio.request_validator import RequestValidator
from twilio.twiml.messaging_response import MessagingResponse
app = Flask(__name__)
validator = RequestValidator(os.environ["TWILIO_AUTH_TOKEN"])
@app.post("/sms")
def inbound_sms():
# 1. Verify the request really came from Twilio.
signature = request.headers.get("X-Twilio-Signature", "")
if not validator.validate(request.url, request.form, signature):
abort(403)
# 2. Read the inbound message.
body = request.form.get("Body", "").strip()
from_number = request.form.get("From", "")
# 3. Reply with TwiML.
resp = MessagingResponse()
if body.upper() == "STOP":
resp.message("You have been unsubscribed. Reply START to opt back in.")
else:
resp.message(f"Thanks! We received: {body}")
return str(resp), 200, {"Content-Type": "application/xml"}The same pattern works in FastAPI — read the form body, validate, and return XML. If you are building APIs in FastAPI, our FastAPI development and broader Python development practice cover production webhook patterns in depth:
import os
from fastapi import FastAPI, Request, Response, HTTPException
from twilio.request_validator import RequestValidator
from twilio.twiml.messaging_response import MessagingResponse
app = FastAPI()
validator = RequestValidator(os.environ["TWILIO_AUTH_TOKEN"])
@app.post("/sms")
async def inbound_sms(request: Request):
form = await request.form()
signature = request.headers.get("X-Twilio-Signature", "")
# request.url must be the exact public URL Twilio called (mind proxies/HTTPS).
if not validator.validate(str(request.url), dict(form), signature):
raise HTTPException(status_code=403, detail="Invalid Twilio signature")
resp = MessagingResponse()
resp.message(f"Thanks! We received: {form.get('Body', '')}")
return Response(content=str(resp), media_type="application/xml")Behind a load balancer or proxy, the URL your framework sees may differ from the one Twilio signed (HTTP vs HTTPS, or a stripped port). Reconstruct the exact public URL Twilio called when validating, and during local development expose your server with a tunnel (for example, point the number's SMS webhook at an ngrok URL via the Twilio CLI).
7. Track delivery with status callbacks
Outbound delivery is asynchronous. Rather than polling messages.fetch(), pass a status_callback URL and Twilio will POST status updates (queued -> sent -> delivered, or undelivered/failed) as they happen.
message = client.messages.create(
to="+15558675310",
from_=os.environ["TWILIO_FROM_NUMBER"],
body="Your appointment is confirmed.",
status_callback="https://your-app.example.com/sms-status",
)@app.post("/sms-status")
def sms_status():
signature = request.headers.get("X-Twilio-Signature", "")
if not validator.validate(request.url, request.form, signature):
abort(403)
message_sid = request.form.get("MessageSid")
status = request.form.get("MessageStatus") # delivered, undelivered, failed...
error_code = request.form.get("ErrorCode") # set when delivery fails
# Persist status against the message_sid in your database here.
return ("", 204)8. Handle errors and error codes
API failures raise TwilioRestException. Catch it, log the code and msg, and decide whether to retry. Note the difference between API errors (raised synchronously, e.g. bad number, unverified trial recipient) and delivery errors (reported later via status callback or error_code on the message).
from twilio.base.exceptions import TwilioRestException
try:
msg = client.messages.create(
to="+15558675310",
from_=os.environ["TWILIO_FROM_NUMBER"],
body="Hello!",
)
except TwilioRestException as exc:
# exc.code is the Twilio error code; exc.status is the HTTP status.
if exc.code == 21608:
print("Trial accounts can only send to verified numbers.")
elif exc.code == 21211:
print("Invalid 'To' number -- check E.164 formatting.")
else:
print(f"Twilio error {exc.code}: {exc.msg}")Common codes worth handling explicitly:
| Code | Meaning | Action |
|---|---|---|
| 21211 | Invalid To number |
Validate/normalise to E.164 before sending |
| 21608 | Unverified recipient on a trial account | Verify the number or upgrade the account |
| 21610 | Recipient has replied STOP (opted out) | Suppress; do not retry until they opt back in |
| 30007 | Carrier filtered / spam | Check A2P registration and message content |
| 30034 | Unregistered/disallowed 10DLC traffic | Complete A2P 10DLC registration |
Treat 4xx errors as permanent (fix the request), and back off and retry on 429 (rate limit) and 5xx (transient) errors.
9. A2P 10DLC, toll-free verification, and consent
This section is not optional for US traffic. Under the US carriers' A2P 10DLC (Application-to-Person 10-Digit Long Code) framework, unregistered application traffic to US numbers is filtered or blocked, and senders can be charged carrier penalty fees. If you send programmatic SMS/MMS to US phone numbers, you must register.
What registration involves
- Brand registration — register your business identity (legal name, EIN/tax ID, address) with The Campaign Registry (TCR) via the Twilio Console.
- Campaign registration — describe each messaging use case (e.g. "2FA/OTP", "account notifications", "marketing"), provide sample messages, opt-in details, and opt-out language. Your approved throughput depends on your brand vetting and campaign type.
- Attach the registered campaign to the number or Messaging Service you send from.
If you do not want to register a brand, a verified toll-free number is the alternative path for US notifications — toll-free traffic still requires Twilio's toll-free verification, and unverified toll-free traffic is also blocked.
Consent and opt-out are mandatory
Regardless of registration, you must:
- Collect and keep proof of opt-in (consent) before messaging anyone.
- Honour STOP / UNSUBSCRIBE / CANCEL / END / QUIT automatically — Twilio handles standard opt-out keywords for long codes and toll-free by default, and a recipient who has opted out triggers error 21610 on further sends.
- Support START to opt back in, and HELP for help text.
These rules derive from US carrier requirements and regulations such as the TCPA; the specifics evolve, so confirm current requirements in Twilio's messaging-compliance documentation and with your own counsel before launching. The same idea applies elsewhere — many countries require sender ID registration, local opt-out handling, or pre-registered content — so check the rules for every destination country, not just the US.
10. Sending WhatsApp via Twilio (brief)
Twilio sends WhatsApp through the same messages.create() call — you just prefix both numbers with whatsapp:. Outside a 24-hour customer-initiated session, WhatsApp requires a pre-approved message template, which you reference with content_sid (from Twilio's Content API) and fill in with content_variables.
# Free-form reply inside an open 24h session:
client.messages.create(
to="whatsapp:+15558675310",
from_="whatsapp:+14155238886", # your WhatsApp-enabled Twilio sender
body="Thanks for reaching out -- how can we help?",
)
# Business-initiated message outside a session needs an approved template:
client.messages.create(
to="whatsapp:+15558675310",
from_="whatsapp:+14155238886",
content_sid="HXxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
content_variables='{"1": "Asha", "2": "Friday 3pm"}',
)Best practices
- Keep secrets out of code. Load credentials from environment variables or a secrets manager; prefer rotatable API Keys over the Auth Token; git-ignore your
.env. - Validate every webhook with
RequestValidatorand reject requests that fail signature checks. - Register before you send to the US — complete A2P 10DLC or toll-free verification, and attach the campaign to a Messaging Service.
- Always capture consent and honour STOP — store opt-in proof; let Twilio's default opt-out keywords do their job.
- Send asynchronously. Queue messages in a background worker so a transient API error retries one message, not the batch, and your request returns fast.
- Use status callbacks, not polling, to track delivery, and persist the final status against each
MessageSid. - Normalise numbers to E.164 before sending and handle
TwilioRestExceptioncodes explicitly. - Don't send media globally as MMS — fall back to a link or use WhatsApp for rich content outside the US/Canada.
Messaging looks simple until it meets carrier filtering, opt-out law, retries, and delivery reporting. If you want a notification, OTP, or two-way messaging system built into a Python application — or messaging wired into an AI feature such as an agent that texts customers — our team has shipped these flows across web and API stacks; see our Python and REST API work for related patterns, and our guide on sending email with Amazon SES for the email side of notifications.
Frequently Asked Questions
How do I send an SMS with Python and Twilio?
Install the official helper library with pip install twilio, create a client with Client(account_sid, auth_token) (loading both values from environment variables, not hardcoded), then call client.messages.create(to="+1...", from_="+1...", body="..."). The to and from_ numbers must be in E.164 format. The call queues the message and returns a message object whose sid you can use to look up delivery status later.
What is A2P 10DLC and do I need it?
A2P 10DLC is the US carrier framework for application-to-person messaging over standard 10-digit long-code numbers. If you send programmatic SMS or MMS to US phone numbers, yes, you need it — you must register a brand and a campaign through The Campaign Registry (via the Twilio Console) and attach the campaign to your sending number or Messaging Service. Unregistered US traffic gets filtered or blocked and can incur carrier penalty fees. The alternative for US notifications is a verified toll-free number, which still requires Twilio's toll-free verification.
How do I send an MMS with Twilio in Python?
Use the same messages.create() call and add a media_url argument set to a list of publicly accessible HTTPS URLs (up to 10): media_url=["https://example.com/image.png"]. Twilio downloads each URL and attaches it. body is optional for MMS. Note that MMS is mainly supported in the US and Canada; elsewhere it typically falls back to an SMS with a link, so use WhatsApp for rich media to a global audience.
How do I receive SMS messages in my app?
Configure a webhook URL on your Twilio number (or Messaging Service). Twilio sends an HTTP POST to that URL for each inbound message, and your endpoint replies with TwiML (use MessagingResponse) to send an automatic reply. Crucially, verify the X-Twilio-Signature header with RequestValidator on every request before trusting it, because the URL is public. The pattern is the same in Flask and FastAPI.
How do I keep my Twilio credentials secure?
Never hardcode your Account SID or Auth Token in source code — load them from environment variables (os.environ) or a secrets manager, and git-ignore any .env file. Prefer authenticating with a Standard API Key (an SK... SID and secret) over the account-wide Auth Token, because API Keys can be revoked and rotated independently without breaking your account. Rotate any credential that may have leaked, and never log raw tokens.
How do I check whether a message was delivered?
messages.create() returns immediately with a status like queued or accepted because delivery is asynchronous. To learn the final outcome, either pass a status_callback URL so Twilio POSTs status updates (sent, delivered, undelivered, failed) as they occur, or fetch the message later with client.messages(sid).fetch() and read its status and error_code. Status callbacks are preferred over polling at scale.