Python Twilio SMS — Deep Dive
System-level framing
At scale, Twilio SMS integration is less about the API call and more about the system around it: webhook reliability, opt-out compliance, cost tracking, rate limiting, and failure recovery. The Twilio Python SDK handles the easy part — the engineering challenge is everything else.
Sending messages: beyond the basics
Standard send
from twilio.rest import Client
import os
client = Client(
os.environ['TWILIO_ACCOUNT_SID'],
os.environ['TWILIO_AUTH_TOKEN']
)
message = client.messages.create(
body="Your verification code is 847293. It expires in 10 minutes.",
from_=os.environ['TWILIO_PHONE_NUMBER'],
to="+14155551234",
status_callback="https://myapp.com/webhooks/sms-status"
)
print(f"SID: {message.sid}, Status: {message.status}")
Using a Messaging Service (recommended for production)
message = client.messages.create(
body="Your order #12345 has shipped!",
messaging_service_sid=os.environ['TWILIO_MESSAGING_SERVICE_SID'],
to="+14155551234",
status_callback="https://myapp.com/webhooks/sms-status"
)
Messaging Services handle number rotation, sticky sender (same recipient always gets messages from the same number), and compliance checks automatically.
Webhook handling
Delivery status webhooks
from flask import Flask, request
from twilio.request_validator import RequestValidator
app = Flask(__name__)
validator = RequestValidator(os.environ['TWILIO_AUTH_TOKEN'])
@app.route('/webhooks/sms-status', methods=['POST'])
def sms_status():
# Validate the request actually came from Twilio
url = request.url
params = request.form.to_dict()
signature = request.headers.get('X-Twilio-Signature', '')
if not validator.validate(url, params, signature):
return 'Unauthorized', 403
message_sid = params['MessageSid']
status = params['MessageStatus']
error_code = params.get('ErrorCode')
# Update your database
update_message_status(message_sid, status, error_code)
return '', 204
Always validate the X-Twilio-Signature header. Without this, anyone can POST fake status updates to your webhook endpoint.
Receiving inbound SMS
from twilio.twiml.messaging_response import MessagingResponse
@app.route('/webhooks/sms-inbound', methods=['POST'])
def inbound_sms():
from_number = request.form['From']
body = request.form['Body'].strip().upper()
resp = MessagingResponse()
if body == 'STOP':
add_to_opt_out_list(from_number)
resp.message("You've been unsubscribed. Reply START to re-subscribe.")
elif body == 'STATUS':
order = get_latest_order(from_number)
resp.message(f"Order #{order.id}: {order.status}")
else:
resp.message("Reply STATUS to check your order, or STOP to unsubscribe.")
return str(resp), 200, {'Content-Type': 'text/xml'}
Rate limiting and queuing
Carrier rate limits
Carriers impose throughput limits that vary by number type:
| Number type | Messages per second | Daily limit |
|---|---|---|
| Local (10DLC) | 1-75 MPS (varies by trust score) | No hard limit |
| Toll-free | 3 MPS | No hard limit |
| Short code | 100+ MPS | No hard limit |
Exceeding these limits causes message queuing on Twilio’s side (adding latency) or carrier filtering (messages silently dropped).
Application-side rate limiting
import time
from collections import deque
from threading import Lock
class SMSRateLimiter:
def __init__(self, max_per_second: float = 10):
self.max_per_second = max_per_second
self.timestamps = deque()
self.lock = Lock()
def wait_if_needed(self):
with self.lock:
now = time.monotonic()
# Remove timestamps older than 1 second
while self.timestamps and now - self.timestamps[0] > 1.0:
self.timestamps.popleft()
if len(self.timestamps) >= self.max_per_second:
sleep_time = 1.0 - (now - self.timestamps[0])
if sleep_time > 0:
time.sleep(sleep_time)
self.timestamps.append(time.monotonic())
limiter = SMSRateLimiter(max_per_second=10)
def send_sms(to: str, body: str):
limiter.wait_if_needed()
return client.messages.create(
body=body,
messaging_service_sid=os.environ['TWILIO_MESSAGING_SERVICE_SID'],
to=to
)
For production bulk sending, use Celery with rate limiting:
from celery import Celery
from celery.utils.log import get_task_logger
app = Celery('sms')
logger = get_task_logger(__name__)
@app.task(
bind=True,
rate_limit='10/s',
max_retries=3,
default_retry_delay=60
)
def send_sms_task(self, to: str, body: str):
try:
message = client.messages.create(
body=body,
messaging_service_sid=os.environ['TWILIO_MESSAGING_SERVICE_SID'],
to=to
)
logger.info(f"Sent {message.sid} to {to}")
return message.sid
except TwilioRestException as e:
if e.status == 429: # Rate limited
raise self.retry(exc=e, countdown=30)
raise
Cost optimization
Track spending programmatically
from datetime import datetime, timedelta
# Get today's message count and cost
today = datetime.utcnow().date()
messages = client.messages.list(
date_sent_after=datetime(today.year, today.month, today.day),
limit=1000
)
total_cost = sum(float(m.price or 0) for m in messages)
total_segments = sum(m.num_segments for m in messages if m.num_segments)
print(f"Today: {len(messages)} messages, {total_segments} segments, ${abs(total_cost):.2f}")
Cost reduction strategies
- Keep messages under 160 characters — One segment instead of two or three.
- Use Twilio’s Content API for templated messages — carriers give these higher trust scores.
- Batch non-urgent messages — Send appointment reminders in a scheduled batch rather than on-demand.
- Set spending alerts in the Twilio console to catch runaway costs early.
- Use short URLs — Replace long tracking URLs with a shortener to stay under 160 characters.
Compliance patterns
TCPA and opt-out handling
US law (Telephone Consumer Protection Act) requires:
- Prior express consent before sending marketing SMS
- Immediate opt-out processing when a user replies STOP
- Clear identification of who is sending the message
Twilio’s Advanced Opt-Out feature handles STOP/START/HELP keywords automatically for Messaging Services. For custom handling:
# Maintain an opt-out list
def is_opted_out(phone_number: str) -> bool:
return db.opt_outs.exists(phone=phone_number)
def send_if_allowed(to: str, body: str) -> str | None:
if is_opted_out(to):
logger.info(f"Skipping opted-out number: {to}")
return None
return send_sms(to, body)
A2P 10DLC registration flow
- Register your brand with Twilio (company name, EIN, website).
- Create a messaging campaign (use case, sample messages, opt-in flow).
- Twilio submits to The Campaign Registry (TCR).
- Wait for approval (hours to days).
- Associate your Twilio phone numbers with the approved campaign.
- Send messages — carriers now trust your traffic.
Without this, US carriers will throttle or block your messages. Trust scores determine your throughput: higher scores (based on brand reputation) allow more messages per second.
Error handling taxonomy
| Error code | Meaning | Action |
|---|---|---|
| 21211 | Invalid phone number | Remove from contact list |
| 21608 | Number not owned by your account | Check from_ parameter |
| 21610 | Recipient opted out | Honor opt-out, do not retry |
| 21614 | Number incapable of receiving SMS | Try voice or email instead |
| 30003 | Unreachable handset | Retry later (phone may be off) |
| 30004 | Message blocked by carrier | Check content for spam triggers |
| 30006 | Landline or unreachable | Remove from SMS list |
| 30007 | Message filtering | Improve 10DLC trust score |
from twilio.base.exceptions import TwilioRestException
PERMANENT_ERRORS = {21211, 21610, 21614, 30006}
def send_with_error_handling(to: str, body: str):
try:
return client.messages.create(
body=body,
messaging_service_sid=os.environ['TWILIO_MESSAGING_SERVICE_SID'],
to=to
)
except TwilioRestException as e:
if e.code in PERMANENT_ERRORS:
mark_number_invalid(to, e.code)
return None
raise # Transient errors bubble up for retry
The one thing to remember: Production SMS with Twilio is 10% API integration and 90% operational infrastructure — rate limiting, compliance registration, cost tracking, and graceful error handling determine whether your system scales or gets blocked by carriers.
See Also
- Python Discord Bot Development Learn how Python creates Discord bots that moderate servers, play music, and respond to commands — explained for total beginners.
- Python Email Templating Jinja Discover how Jinja templates let Python create personalized emails for thousands of people without writing each one by hand.
- Python Imap Reading Emails See how Python reads your inbox using IMAP — explained with a mailbox-and-key analogy anyone can follow.
- Python Push Notifications How Python sends those buzzing alerts to your phone and browser — explained for anyone who has ever wondered where notifications come from.
- Python Slack Bot Development Find out how Python builds Slack bots that read messages, reply to commands, and automate team workflows — no Slack expertise needed.