textbee Logotextbee.dev
Plans from $9.99/mo.View Plans

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.

send.py
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.

webhook.py
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.

poll.py
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 poll

Handle 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.

errors.py
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