Save 44% with yearly billing.View Plans
textbee Logotextbee.dev
Documentation

Python SDK

Send SMS from Python with the official textbee package, a zero dependency, fully typed wrapper around the textbee.dev REST API.

Updated

textbee is the official Python client for textbee.dev. It wraps the same REST API documented elsewhere in these docs, so anything you can do with the SDK you can also do with plain HTTP.

It has zero dependencies and is fully typed. It runs on Python 3.9 or newer. The client is synchronous.

If you would rather call the API directly, see Sending SMS.

Install

Install the package from PyPI with pip:

Shell
pip install textbee

Or add it to a uv project:

Shell
uv add textbee

Quickstart

Generate an API key in your dashboard, then send a message:

Python
import os

from textbee import Textbee

textbee = Textbee(api_key=os.environ["TEXTBEE_API_KEY"])

textbee.send_sms(
    recipients=["+12025550123"],
    message="Hello from textbee!",
)

Never hardcode your API key. Read it from an environment variable so it stays out of your source control.

Send options

send_sms requires only message and recipients. The rest are optional.

OptionTypePurpose
device_idstrWhich phone sends the message
sim_subscription_idintWhich SIM sends it on a multi-SIM phone
scheduled_atstr or datetimeSend later instead of now

This call uses all three options:

Python
from datetime import datetime, timedelta, timezone

textbee.send_sms(
    recipients=["+12025550123"],
    message="Your appointment is tomorrow at 9am",
    device_id="65f0000000000000000000aa",
    sim_subscription_id=2,
    scheduled_at=datetime.now(timezone.utc) + timedelta(hours=1),
)

A successful call means textbee accepted the message. It does not mean the message was delivered. See Delivery status and message states.

The send response

The response shape depends on how the message was dispatched. A queued send carries smsBatchId and recipientCount. Check with in, not .get(), so your type checker knows which shape you have:

Python
result = textbee.send_sms(recipients=recipients, message=message)
if "smsBatchId" in result:
    print(result["smsBatchId"], result["recipientCount"])

# Present when the phone last reported a problem, such as the SMS permission off
if "warning" in result:
    print(result["warning"]["code"], result["warning"]["message"])

A large send that goes out in several waves also carries estimatedCompletionAt, the time the last wave should finish. A warning does not stop the send. It tells you that the send will probably fail, for example because the SMS permission is off on the phone.

device_id

Leave it out and textbee chooses the sender for you: your default device first, and otherwise the enabled device with the most recent heartbeat. Pass one to force a specific phone. You can list your device ids with get_devices(). See Managing devices.

A malformed id fails the request rather than falling back to another phone, so a typo can never send from the wrong device.

sim_subscription_id

Find this value in the textbee Android app under Dashboard, in the SIM Cards section. Each SIM shows its subscription id next to it with a copy button. The id can change when a SIM is removed and reinserted or swapped, so check it in the app again after any change to the SIMs.

Leave it out and the phone decides: it uses the preferred SIM set in the app's settings, or the system default if none is set.

This value is not validated. If the id does not match a SIM in the phone, the phone ignores it and sends from the preferred or default SIM. Nothing fails and no error is returned. If the SIM matters, confirm which number the message arrived from. See Choosing a SIM.

scheduled_at

Accepts an ISO 8601 string or a timezone-aware datetime. It must be in the future. Omit it to send immediately. A naive datetime (one with no tzinfo) raises ValueError, because the SDK cannot know which timezone you meant. See Scheduled messages.

Devices

These calls read your devices and change the default one:

Python
devices = textbee.get_devices()
device = textbee.get_device(device_id)

# Change which device handles sends that omit device_id
textbee.set_default_device(device_id)

Message history and delivery status

History is account-level. get_messages covers every device on the account, and it is paginated, filterable and searchable:

Python
page = textbee.get_messages(
    direction="received",  # "all" | "sent" | "received"
    page=1,
    limit=50,
    search="invoice",
)
messages, meta = page["data"], page["meta"]

# Page mode carries the totals; cursor mode skips them
print(meta.get("total"), meta.get("totalPages"))

Narrow it with any combination of filters:

Python
# Specific devices, instead of the whole account
textbee.get_messages(device_ids=[device_id])

# Which recipients of a bulk send failed
result = textbee.send_sms(recipients=recipients, message=message)
if "smsBatchId" in result:
    textbee.get_messages(sms_batch_id=result["smsBatchId"], status="failed")

# A date window. from_ is inclusive, to is exclusive, so back to back
# windows never count a message twice. Datetimes need an explicit
# timezone; a bare date is read as UTC midnight.
textbee.get_messages(from_="2026-08-01", to="2026-09-01")

The filter is named from_ because from is a Python keyword. The SDK sends it to the API as from.

The direction field on each message is lowercase, so you can pass it straight back as a filter. Responses are plain dicts typed as TypedDicts, so keys keep the API's names (smsBatchId, createdAt) and your editor completes them. Message history and polling lists every filter.

Polling without duplicates

Page numbers drift when new messages arrive mid-read. iterate_messages follows the pagination cursor instead, so it yields every message exactly once:

Python
for message in textbee.iterate_messages(
    direction="received",
    order="asc",
    from_=last_run_timestamp,
    page_size=100,
):
    handle_message(message)

page_size is the number of messages each request fetches, not a cap on the total. Break out of the loop and it stops fetching. from_ is inclusive, so a poll that resumes from the last createdAt sees that message again. Skip ids you have already handled. To resume on your next run, keep meta.get("nextCursor") from get_messages and pass it back as cursor.

One message or one batch

get_sms returns one message with its current status. get_sms_batch returns a batch and its messages, using the smsBatchId that send_sms returned:

Python
# One message and its current status
sms = textbee.get_sms(device_id, sms_id)

# A batch, using the smsBatchId returned by send_sms
batch_result = textbee.get_sms_batch(device_id, sms_batch_id)
batch, batch_messages = batch_result["batch"], batch_result["messages"]

get_sms_batch needs the id of the device that sent the batch. When textbee picked the device for you, use get_messages(sms_batch_id=...) instead.

get_sms and get_sms_batch return the Sms type. It differs from the Message type that get_messages returns. In Sms, device is the device id string, not an object with the phone's name and model. Sms has no direction field, so read type instead.

Delivery status and message states explains each status and timestamp on the result.

Verifying webhooks

textbee signs every webhook delivery with HMAC-SHA256 and sends the hex digest in the X-Signature header. verify_webhook_signature checks it with a constant-time compare and returns True or False.

Pass the raw request body rather than a re-serialized dict whenever your framework exposes it. This Flask route reads the raw body with request.get_data():

Python
import json
import os

from flask import Flask, request
from textbee import verify_webhook_signature

app = Flask(__name__)


@app.post("/webhooks/textbee")
def textbee_webhook():
    valid = verify_webhook_signature(
        payload=request.get_data(),
        signature=request.headers.get("X-Signature"),
        signing_secret=os.environ["TEXTBEE_WEBHOOK_SECRET"],
    )
    if not valid:
        return "", 401

    event = json.loads(request.get_data())
    # handle the event
    return "", 200

payload accepts bytes, str or a dict. The SDK re-serializes a dict the way the API does. That only matches while key order and number formatting survive the round trip, so prefer the raw body. Use the signing secret of the subscription that sent the request. See Webhooks for retries and idempotency, and the Webhook events reference for every payload.

SMS utilities

The SDK exports pure helpers for SMS text and phone numbers. They need no API key and make no network calls.

Segments and encoding

A message in the 7-bit GSM alphabet fits 160 characters in one segment. One character outside that alphabet, such as one emoji or one curly quote, switches the whole message to UCS-2. The limit then drops to 70 characters.

Python
from textbee import count_sms_segments, find_non_gsm7_characters, get_sms_encoding

count_sms_segments("Your code is 123456")
# SmsSegmentInfo(encoding='gsm-7', length=19, segments=1, remaining_in_segment=141)

count_sms_segments("Your code is 123456 🎉")
# SmsSegmentInfo(encoding='ucs-2', length=22, segments=1, remaining_in_segment=48)

get_sms_encoding("plain ascii")  # 'gsm-7'
find_non_gsm7_characters("Hi 🎉")  # ['🎉']

The phone splits a longer message into segments. Each segment then holds 153 characters (GSM-7) or 67 (UCS-2). In UCS-2, lengths count UTF-16 code units the way phones do, so an emoji counts as 2. remaining_in_segment counts single-unit characters, so a two-unit character such as an emoji or € may not fit even when it reads as 1.

Keeping messages in GSM-7

Text pasted from a word processor or a CMS often has curly quotes, ellipses and non-breaking spaces. sanitize_for_gsm7 replaces them with plain equivalents so the message stays in GSM-7 and needs fewer segments.

Python
from textbee import count_sms_segments, sanitize_for_gsm7

pasted = "“Your order shipped…”"
count_sms_segments(pasted).encoding  # 'ucs-2'

clean = sanitize_for_gsm7(pasted)  # '"Your order shipped..."'
count_sms_segments(clean).encoding  # 'gsm-7'

# Optionally strip accents that GSM-7 does not carry. Letters it does carry,
# like é, ü, and ñ, are always left alone.
sanitize_for_gsm7("naïve", transliterate_accents=True)  # 'naive'

It is best effort: characters with no safe equivalent pass through unchanged. Check the result with get_sms_encoding, and list what is left with find_non_gsm7_characters.

Phone number helpers

is_valid_e164 checks the E.164 format that recipients expects. normalize_phone_number converts common formats to E.164:

Python
from textbee import is_valid_e164, normalize_phone_number

is_valid_e164("+12025550123")  # True
is_valid_e164("202-555-0123")  # False

normalize_phone_number("+1 (202) 555-0123")  # '+12025550123'
normalize_phone_number("0012025550123")  # '+12025550123'
normalize_phone_number("(202) 555-0123", default_country_code="1")  # '+12025550123'
normalize_phone_number("not a number")  # None

These helpers check format only. They know nothing about dialing plans, so a well-formed but unassigned number still passes. Input that cannot be normalized returns None. An unusable default_country_code raises ValueError.

Handling errors

Any non-2xx response raises a TextbeeError. It carries the HTTP status, the message, and the parsed response body. A redirect also raises TextbeeError, because the client never follows one, so your key never goes to another host. So does a response that is not the API's JSON. Both usually mean a wrong base_url.

Network failures raise the underlying error instead: an OSError such as URLError or a socket timeout, or an http.client error when the connection drops. This lets you tell the two apart.

Python
from textbee import TextbeeError

try:
    textbee.send_sms(recipients=["+12025550123"], message="hi")
except TextbeeError as error:
    print(error.status, error.message, error.body)

Common statuses: 400 for a rejected request (for example no enabled device), 401 for a missing or revoked API key, and 429 when a plan limit is used up. See Messages not sending.

Client options

The constructor takes the API key, an optional base URL and an optional timeout:

Python
Textbee(
    api_key="your-api-key",
    base_url="https://api.textbee.dev/api/v1",  # the default
    timeout=30.0,  # seconds for each connect and each read
)

timeout applies to each connect and each read, not to the whole request.

Self-hosted textbee

Set base_url to your own instance. The SDK adds /api/v1 unless the path already contains it, so all of these reach the same API:

Python
Textbee(api_key=api_key, base_url="https://sms.example.com")
Textbee(api_key=api_key, base_url="https://sms.example.com/api/v1")
Textbee(api_key=api_key, base_url="http://localhost:3001")

The SDK reads base_url with these rules:

  • A URL that contains /api/v1 is cut after it, so a pasted endpoint URL still works.
  • A subpath deployment works with its prefix: https://example.com/textbee reaches https://example.com/textbee/api/v1.
  • A value with no scheme gets https://.
  • An invalid URL, a scheme other than http or https, or a URL with a username or password raises ValueError. The SDK does not fall back to textbee.dev, so a typo never sends your key to the public service.

The SDK does not read a TEXTBEE_BASE_URL environment variable. Pass the value to the constructor yourself. See Self-hosting.

What the SDK does not cover yet

The SDK focuses on sending and reading messages. Bulk sending and some device management operations are REST only for now. Use the Sending bulk SMS guide for bulk sends.

The client is synchronous. In async code, call it from a thread with asyncio.to_thread.