Send and receive SMS in Python with a REST API
Published and updated
You need an API key and one Android phone with a SIM registered as a device. From Python you POST to /gateway/send-sms with the x-api-key header, receive replies through a signed webhook or by polling /gateway/messages with a cursor, and the phone sends from its own number. The Pro plan, up to 5,000 messages for $9.99 a month, with no per message fee.
Prerequisites
- A textbee account with an API key from the dashboard, kept in the TEXTBEE_API_KEY environment variable and never in source control.
- An Android phone with a SIM, registered as a device on that account. The API picks your default device, so the code below names none.
- Python 3.7 or newer. The samples use only the standard library (urllib, json, hmac, http.server), so there is nothing to install; swap in requests or httpx if your project already has one.
Send an SMS
One POST to the account-level endpoint. The phone registered on your account sends the message over its SIM and the response carries the batch id to follow.
import json
import os
import urllib.request
BASE_URL = os.environ.get("TEXTBEE_BASE_URL", "https://api.textbee.dev/api/v1")
API_KEY = os.environ["TEXTBEE_API_KEY"]
def send_sms(recipients, message):
body = json.dumps({"recipients": recipients, "message": message}).encode()
request = urllib.request.Request(
f"{BASE_URL}/gateway/send-sms",
data=body,
headers={"x-api-key": API_KEY, "Content-Type": "application/json"},
method="POST",
)
with urllib.request.urlopen(request, timeout=30) as response:
return json.load(response)["data"]
result = send_sms(["+12015550123"], "Hello from textbee")
print(result["smsBatchId"], result["recipientCount"])Receive SMS with a webhook
textbee POSTs each event to your URL and signs the raw body with HMAC-SHA256 using the secret you set, sent in the X-Signature header. Verify over the exact bytes, deduplicate on idempotencyKey, and answer 200 quickly.
import hashlib
import hmac
import json
import os
from http.server import BaseHTTPRequestHandler, HTTPServer
SECRET = os.environ["TEXTBEE_WEBHOOK_SECRET"].encode()
seen = set() # use your database in production
class WebhookHandler(BaseHTTPRequestHandler):
def do_POST(self):
raw_body = self.rfile.read(int(self.headers.get("Content-Length", 0)))
expected = hmac.new(SECRET, raw_body, hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, self.headers.get("X-Signature", "")):
self.send_response(401)
self.end_headers()
return
event = json.loads(raw_body)
if event["idempotencyKey"] not in seen:
seen.add(event["idempotencyKey"])
if event["webhookEvent"] == "MESSAGE_RECEIVED":
print(f"{event['sender']}: {event['message']}", flush=True)
self.send_response(200)
self.end_headers()
HTTPServer(("", int(os.environ.get("PORT", 3000))), WebhookHandler).serve_forever()Poll for new messages with a cursor
The pull option. Ask for received messages in ascending order from a start time, then follow meta.nextCursor until meta.hasMore is false. Store the last cursor and resume from it on the next poll, so nothing is missed or read twice.
import json
import os
import urllib.parse
import urllib.request
BASE_URL = os.environ.get("TEXTBEE_BASE_URL", "https://api.textbee.dev/api/v1")
API_KEY = os.environ["TEXTBEE_API_KEY"]
def fetch_page(params):
url = f"{BASE_URL}/gateway/messages?{urllib.parse.urlencode(params)}"
request = urllib.request.Request(url, headers={"x-api-key": API_KEY})
with urllib.request.urlopen(request, timeout=30) as response:
return json.load(response)
params = {"direction": "received", "order": "asc", "limit": 50, "from": "2026-09-01T00:00:00Z"}
while True:
page = fetch_page(params)
for message in page["data"]:
print(message["_id"], message["sender"], message["message"])
if not page["meta"]["hasMore"]:
break
params["cursor"] = page["meta"]["nextCursor"] # store this to resume the next pollHandle errors
A 401 means the key is missing or revoked, a 400 means the request or the device state was rejected, and a 429 means a plan or batch limit was hit. The body carries a message field that says which. Retry only the 429, once, after a pause.
import json
import os
import time
import urllib.error
import urllib.request
BASE_URL = os.environ.get("TEXTBEE_BASE_URL", "https://api.textbee.dev/api/v1")
API_KEY = os.environ["TEXTBEE_API_KEY"]
def send_sms(recipients, message, retried=False):
body = json.dumps({"recipients": recipients, "message": message}).encode()
request = urllib.request.Request(
f"{BASE_URL}/gateway/send-sms",
data=body,
headers={"x-api-key": API_KEY, "Content-Type": "application/json"},
method="POST",
)
try:
with urllib.request.urlopen(request, timeout=30) as response:
return json.load(response)["data"]
except urllib.error.HTTPError as error:
detail = json.loads(error.read() or b"{}").get("message", error.reason)
if error.code == 429 and not retried:
time.sleep(2) # a plan limit or a burst; one retry after a pause
return send_sms(recipients, message, retried=True)
if error.code == 401:
raise SystemExit(f"API key rejected: {detail}")
if error.code == 400:
raise SystemExit(f"Request rejected: {detail}")
raise SystemExit(f"HTTP {error.code}: {detail}")
print(send_sms(["+12015550123"], "Hello from textbee"))Using it in Django
Put send_sms in a module your views and management commands import, read the API key from settings, and call it from the view that needs it or from a Celery task when the send should not block the request. The webhook handler becomes a view decorated with csrf_exempt that reads request.body, verifies the signature and returns HttpResponse(status=200).
Using it in Flask
Register the webhook as a route that reads request.get_data() for the raw body, verifies the signature before touching request.json, and returns an empty 200. Sending fits in a helper module called from your route or from a background worker.
Using it in FastAPI
Read the raw body with await request.body() in the webhook path operation so the signature covers the exact bytes, then parse it. Use httpx.AsyncClient for the send call so the event loop is not blocked, and keep the key in a pydantic settings object.
Honest limits
One phone sends roughly 10 to 15 messages a minute, so a large blast takes time to drain. Carriers can filter bulk patterns on consumer SIMs. Marketing messages still need consent from the recipient under the local rules.
Frequently asked questions
Do I need a Python SDK for textbee?
No. There is no official Python package, and none is needed. The API is four HTTP calls, and the samples above use only the standard library.
Can I send SMS from a Python script without a phone?
No. The message leaves from an Android phone with a SIM that you register in your account. Your script only talks to the API; the phone does the sending.
Which Python versions do the samples run on?
Python 3.7 and newer. They avoid third party packages so they run in a bare virtual environment, a cron job or a Lambda function.
How do I test without sending a real message?
Point TEXTBEE_BASE_URL at a local stub that returns the documented response shape. That is how these samples are checked before they are published.
Read next
- How to Send an SMS from Your Android Phone with Python
- Send SMS in Django and Flask: textbee Integration Guide
- How to Receive SMS and Process Webhooks with textbee
- Python SMS guides
- API reference
- OTP and verification guides
- SMS gateway for the United States
- Webhook, defined
- E.164 phone number format, defined
- Send and receive SMS in JavaScript and TypeScript with a REST API
- Send and receive SMS in PHP with a REST API
- All languages