Learning Goals
3 minBy the end of this lesson you can:
- Explain when SMS is the right channel (and when it's overkill).
- Understand the Twilio account model: SID, auth token, from-number.
- Send an SMS with the Twilio API (and via raw
requests). - Add SMS as a channel in your notifier, gated to high-severity only.
Warm-Up · Choosing the Channel
5 minChannel Speed Cost Use for log file instant free everything (the record) Slack seconds free team-visible status, most alerts email minutes free reports, daily digests SMS instant ~cents CRITICAL only — server down at 3am
SMS is the loudest, most intrusive, and only paid channel — so it's reserved for alerts that genuinely need someone to wake up. The mechanics are the same as every API you've seen (credentials from the environment, a POST, error handling). The discipline is restraint: route only CRITICAL events to SMS, or you'll spend money and train people to ignore it.
New Concept · Sending SMS with Twilio
14 minThe account model
Twilio (the common SMS API; alternatives include Vonage, MessageBird) gives you three things stored in your .env:
TWILIO_ACCOUNT_SID your account id (like a username) TWILIO_AUTH_TOKEN your secret token (like a password) TWILIO_FROM a Twilio phone number you send FROM
A free trial gives credit and a sandbox number that can text verified phones — enough to learn without spending real money.
With the official library
import os from twilio.rest import Client # pip install twilio from dotenv import load_dotenv load_dotenv() client = Client(os.environ["TWILIO_ACCOUNT_SID"], os.environ["TWILIO_AUTH_TOKEN"]) message = client.messages.create( body="🚨 CRITICAL: web server is down", from_=os.environ["TWILIO_FROM"], to="+60123456789", # E.164 format: +<country><number> ) print("sent, sid:", message.sid)
The library wraps the HTTP calls. Phone numbers must be in E.164 format (+ country code, no spaces). Keep messages short — SMS bills per 160-character segment.
The same thing with raw requests
Under the hood it's just an authenticated POST — useful to see, and avoids the dependency:
import os, requests def send_sms(to: str, body: str) -> bool: sid = os.environ["TWILIO_ACCOUNT_SID"] url = f"https://api.twilio.com/2010-04-01/Accounts/{sid}/Messages.json" resp = requests.post( url, auth=(sid, os.environ["TWILIO_AUTH_TOKEN"]), # HTTP basic auth data={"From": os.environ["TWILIO_FROM"], "To": to, "Body": body[:1500]}, timeout=15, ) return resp.status_code == 201 # 201 Created on success
It's the OAuth/credentials pattern from Lesson 25: secrets from the environment, HTTP basic auth (auth=(sid, token)), a form-encoded POST, and a status-code check. Every paid API looks roughly like this.
Each message costs real cents and can't be unsent. Guard against loops that fire hundreds of texts (rate-limit + the "alert once" pattern from Lesson 31). Sending marketing/unsolicited SMS is illegal in many places — only text people who've consented (e.g. your own on-call phone). Treat the auth token like a credit card.
Worked Example · SMS as a Gated Channel
12 minGoal: add SMS to the unified notifier from Lesson 32, but only for CRITICAL alerts — with a rate guard so a runaway loop can't empty your wallet.
import os, time, json, logging, requests from pathlib import Path from dotenv import load_dotenv load_dotenv() logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s") log = logging.getLogger("sms") RATE_FILE = Path(".sms_rate.json") MAX_PER_HOUR = 5 # hard cap to protect your bill def _sent_last_hour() -> int: if not RATE_FILE.exists(): return 0 times = json.loads(RATE_FILE.read_text()) recent = [t for t in times if t > time.time() - 3600] RATE_FILE.write_text(json.dumps(recent)) # prune old entries return len(recent) def _record_send() -> None: times = json.loads(RATE_FILE.read_text()) if RATE_FILE.exists() else [] times.append(time.time()) RATE_FILE.write_text(json.dumps(times)) def send_sms(to: str, body: str) -> bool: if _sent_last_hour() >= MAX_PER_HOUR: log.error("SMS rate cap (%d/hr) hit — refusing to send", MAX_PER_HOUR) return False sid = os.environ["TWILIO_ACCOUNT_SID"] try: r = requests.post( f"https://api.twilio.com/2010-04-01/Accounts/{sid}/Messages.json", auth=(sid, os.environ["TWILIO_AUTH_TOKEN"]), data={"From": os.environ["TWILIO_FROM"], "To": to, "Body": body[:300]}, timeout=15) if r.status_code == 201: _record_send() log.info("SMS sent to %s", to) return True log.error("SMS failed: %s %s", r.status_code, r.text[:120]) except requests.RequestException as e: log.error("SMS error: %s", e) return False def alert(text: str, level: str = "info") -> None: # …Slack/Discord for all levels (Lesson 32)… if level == "critical": to = os.getenv("ONCALL_PHONE") if to: send_sms(to, f"CRITICAL: {text}") else: log.warning("critical alert but no ONCALL_PHONE set") alert("Disk at 88%", "warning") # Slack/Discord only alert("Database unreachable", "critical") # also fires SMS (if under cap)
INFO SMS sent to +60123456789 # A 6th critical within the hour: ERROR SMS rate cap (5/hr) hit — refusing to send
Read the code
Two safeguards make this safe to deploy: SMS only fires for level == "critical" (everything else stays on free channels), and a persisted rate guard refuses to send more than MAX_PER_HOUR — so a buggy loop costs you 5 texts, not 5,000. The send itself is the familiar credentials-from-env, basic-auth POST. This is the SMS channel that plugs into Lesson 34's multi-channel alert system, where routing rules decide what goes where.
Try It Yourself
13 minA Twilio free trial is enough to try this on your own verified phone. If you'd rather not sign up, simulate it: write the same functions but have send_sms just log.info("SMS → %s: %s") instead of calling the API — the surrounding logic (gating, rate cap) is what matters.
Write send_sms(to, body) that (for now) logs the message instead of sending. Confirm your code reads SID/token/from from .env even in simulation.
Implement the per-hour rate cap. Call send_sms in a loop more than the cap and confirm it refuses after the limit, then allows again after the window.
Hint
for i in range(8): ok = send_sms("+60123456789", f"test {i}") print(i, "sent" if ok else "BLOCKED")
Write route(text, level) that sends info/warning to a (simulated) Slack log, error to Slack + email, and critical to all three including SMS — proving each level reaches the right channels.
Hint
def route(text, level): slack_log(text, level) # always if level in ("error", "critical"): email_alert(text, level) if level == "critical": send_sms(os.getenv("ONCALL_PHONE"), text)
Mini-Challenge · The Escalation Ladder
8 minBuild an escalation policy: an issue first posts to Slack; if it's still unresolved after N minutes (simulate with state), escalate to email; if still unresolved after another window, escalate to SMS. This is how real on-call systems avoid both missing incidents and over-paging.
Show a sample solution
import json, time from pathlib import Path STATE = Path("incident.json") def escalate(incident_id: str, text: str) -> None: state = json.loads(STATE.read_text()) if STATE.exists() else {} inc = state.get(incident_id, {"opened": time.time(), "stage": 0}) age_min = (time.time() - inc["opened"]) / 60 if inc["stage"] == 0: slack_alert(text); inc["stage"] = 1 elif inc["stage"] == 1 and age_min >= 10: email_alert(text); inc["stage"] = 2 elif inc["stage"] == 2 and age_min >= 20: send_sms(os.getenv("ONCALL_PHONE"), text); inc["stage"] = 3 state[incident_id] = inc STATE.write_text(json.dumps(state)) def resolve(incident_id: str) -> None: state = json.loads(STATE.read_text()) if STATE.exists() else {} state.pop(incident_id, None) STATE.write_text(json.dumps(state))
Non-negotiables: staged escalation Slack→email→SMS by elapsed time, resolve clears it, SMS only at the top stage.
Recap
3 minSMS (via Twilio or similar) is the instant, intrusive, paid channel — reserve it for CRITICAL alerts. Technically it's the same API pattern you know: SID/auth-token/from-number from the environment, an authenticated POST, numbers in E.164 format, short bodies. The discipline is what's new: gate SMS to high severity, add a hard rate cap so a bug can't drain your account, only text people who consented, and consider an escalation ladder (Slack → email → SMS). SMS becomes one more adapter in your notifier — used sparingly. Next lesson assembles all channels into a routed alert system.
Vocabulary Card
- E.164
- The international phone format:
+country code + number, no spaces. - SID / auth token
- Twilio's account id and secret, used as HTTP basic-auth credentials.
- severity gating
- Routing only high-priority events to expensive/intrusive channels.
- escalation
- Increasing alert urgency over time if an issue stays unresolved.
Homework
4 minAdd an SMS adapter (real Twilio trial or simulated) to your notifier from Lesson 32, complete with a per-hour rate cap and critical-only gating. Write a short policy note: which event levels go to which channels in your system, and why SMS is restricted as it is. If you used the real API, confirm with git status that no credentials are committed.
Sample · notification policy
Routing policy: info / good → log + Slack (free, low-noise) warning → log + Slack (team sees it) error → log + Slack + email (someone investigates) critical → all of the above + SMS (wake up on-call) SMS is restricted because it costs money, is intrusive, and loses meaning if overused. Hard rate cap: 5/hour. Only the verified on-call number (ONCALL_PHONE) is ever texted, and only for level == "critical". Credentials (SID, token) live in .env (git-ignored); git status confirms nothing secret is staged.
Non-negotiables: SMS adapter with rate cap + critical gating, a written routing policy, no committed secrets.