SMSTunnel for Python
Send texts from your own Python apps, from your own number
Your app writes, your phone sends
Your app hands the text to your SMSTunnel account and your own Android phone sends it from its own SIM, so people see a number they know and can reply. You can cut an app off at any time by deleting its API key in your dashboard.
What it does
- Send a text
- One text to one number, up to 1600 characters, from the phone you choose or one of yours.
- Sign-in codes
- A code of 4 to 10 digits, on its own daily allowance.
- Delivery status
- Pending, sent, delivered or failed, and why.
- Received texts
- The texts your phone receives, only with a key you allowed to read them.
- Phones and usage
- Which of your phones are online, your plan and what is left today.
- Sync or async
- A client and an async client with the same methods, with type hints.
- No dependencies
- Python 3.10 or newer and the standard library. httpx is optional.
1. Install
pip install smstunnel Python 3.10 or newer. No dependencies: httpx is optional (see your own HTTP client).
2. Get your key
- 1. Install the SMSTunnel app from Google Play on your Android phone and sign in.
- 2. In your dashboard, open Apps → Add an app, pick Python, name it (for example "My Python app") and click Create the key.
- 3. Copy the key right away: it's shown only once. You can stop or delete the app there anytime, and My Python app can't send texts anymore.
Get the steps with your key in them
It opens Add an app with Python picked. Once the key is made, the dashboard shows every step and command with the key already filled in, ready to copy.
The key can send texts and look up whether they went out. Only if your app reads the texts your phone receives:
also tick Can also read received texts, and turn on syncing of received texts in the SMSTunnel
app. It's off by default, and only texts that arrive after you turn it on are synced. Keep the key out of your
code: put it in an environment variable such as SMSTUNNEL_API_KEY.
3. Send a text
import os
import smstunnel
client = smstunnel.Client(os.environ["SMSTUNNEL_API_KEY"]) # sk_live_...
result = client.send_sms("+14155550123", "Your order #1042 has shipped.")
print(result.message_id) # keep it to look up the status later All the options, and what comes back:
result = client.send_sms(
"+14155550123", # international format
"See you at 10:30.", # up to 1600 characters
device_id=None, # a phone id from get_devices(), or None to let SMSTunnel pick one of yours
default_country_code=None, # e.g. "+40": completes numbers given without + or 00
)
result.message_id # str
result.queued # True when the phone was offline: it sends the text as soon as it is back
result.branded # True when the text carries a short SMSTunnel signature (plans with a free quota)
result.remaining # today's quota left, e.g. {"clean": 95, "total": 95} (clean -1 and total "unlimited": no limit)
One call sends one text to one number. To text several people, call send_sms() once for each.
Sign-in and verification codes
client.send_verification_code("+14155550123", "Your sign-in code is 482913")
The text must contain the code: 4 to 10 digits standing on their own, like 482913, or
123-456. SMSTunnel refuses it otherwise. Codes count against a separate allowance, and the
text can be up to 320 characters.
What counts as a code
Look up a status
from smstunnel import MessageStatus
status = client.get_message_status(result.message_id)
status.status # "pending", "sent", "delivered" or "failed"
status.delivery_status # the same, or "unconfirmed": handed to the SIM but the phone got no confirmation
status.sent_at # a datetime (UTC), or None
status.error_message # why it failed, or None
status.is_final # True once it will not change any more
if status.status == MessageStatus.FAILED:
... Read received texts
Needs a key allowed to read received texts, and syncing turned on in the app.
from datetime import datetime, timedelta, timezone
page = client.get_received_messages(
limit=20, # at most 100
page=1,
since=datetime.now(timezone.utc) - timedelta(days=1), # or an ISO 8601 string
phone="+14155550123", # only from this sender
device_id=None, # only this phone
)
for text in page:
print(text.sender, text.message) # text.received_at, text.device_name, ...
page.total # all texts that match, on every page
page.has_more # ask for the next page Your phones and your usage
for phone in client.get_devices():
print(phone.name, "online" if phone.is_online else "offline") # phone.id, phone.last_seen_at
usage = client.get_usage()
usage.plan # e.g. "standard"
usage.usage # {"smsClean": 15, "smsBranded": 0, "twoFaClean": 4}
usage.remaining # {"smsClean": 1985, "twoFaClean": "unlimited"}
Every response object is a read-only dataclass with a raw field, the whole answer, for
fields this version does not map yet.
Async
smstunnel.AsyncClient has the same methods, as coroutines:
import asyncio
import os
import smstunnel
async def main() -> None:
async with smstunnel.AsyncClient(os.environ["SMSTUNNEL_API_KEY"]) as client:
await client.send_sms("+14155550123", "Your table is ready.")
asyncio.run(main())
It sends through httpx when httpx is installed (pip install "smstunnel[httpx]"); otherwise it
runs the standard-library transport in a worker thread.
Errors
Every call raises a smstunnel.SmsTunnelError when it does not go through:
- AuthenticationError (401)
- The API key is wrong, was deleted, or its account is suspended.
- PermissionDeniedError (403)
- The key lacks a permission (error_code is PERMISSION_REQUIRED, permission names it, e.g. sms:inbox), or the plan does not include the feature.
- RateLimitError (429)
- Too many requests: retry_after gives the seconds to wait. Without retry_after, the plan's limit for the day or month is reached: don't retry until then.
- ApiError
- Any other error answer: 400 (a number it cannot read, a verification text without a code), 404 (no such message or phone), 5xx. The three above extend it.
- TransportError
- SMSTunnel could not be reached (DNS, TLS, timeout). After a timeout the text may still go out: check your dashboard's Messages before you send it again.
import logging
import time
from smstunnel import ApiError, RateLimitError, TransportError
try:
client.send_sms("+14155550123", "Your table is ready.")
except RateLimitError as error:
if error.retry_after:
time.sleep(error.retry_after) # then try again
else:
... # the plan's limit for the day or month is reached: no retry until then
except ApiError as error:
logging.warning("SMSTunnel %s %s: %s", error.status_code, error.error_code or "-", error.message)
except TransportError:
... # no answer: after a timeout the text may still go out, so check before you send it again message (also str(error)) is SMSTunnel's own explanation,
status_code the HTTP status, body the decoded answer. An empty number
or text raises ValueError before anything is sent.
Django
django-smstunnel does this for you:
settings, send_sms(), setup checks and django-two-factor-auth's sign-in codes. With the
client alone:
Read the key from the environment in settings.py:
# settings.py
import os
SMSTUNNEL_API_KEY = os.environ.get("SMSTUNNEL_API_KEY", "") Make one client for the whole app, and send once the order is saved:
# shop/sms.py
from functools import cache
import smstunnel
from django.conf import settings
@cache
def sms() -> smstunnel.Client:
return smstunnel.Client(settings.SMSTUNNEL_API_KEY) # shop/views.py
import logging
from django.db import transaction
from django.shortcuts import get_object_or_404, redirect
from smstunnel import SmsTunnelError
from .models import Order
from .sms import sms
log = logging.getLogger(__name__)
def text_customer(order: Order) -> None:
try:
sms().send_sms(order.phone, f"Your order #{order.pk} has shipped.")
except SmsTunnelError:
log.exception("The shipping text for order %s did not go out", order.pk)
def mark_shipped(request, pk):
order = get_object_or_404(Order, pk=pk)
order.status = "shipped"
order.save()
transaction.on_commit(lambda: text_customer(order))
return redirect("order-detail", pk=pk)
To keep requests fast, you can call text_customer() from a background task (Celery, RQ,
Django's tasks) instead.
Flask
import os
from flask import Flask, abort, request
from smstunnel import Client, SmsTunnelError
app = Flask(__name__)
app.config["SMSTUNNEL_API_KEY"] = os.environ["SMSTUNNEL_API_KEY"]
sms = Client(app.config["SMSTUNNEL_API_KEY"])
@app.post("/appointments/<int:appointment_id>/remind")
def remind(appointment_id: int):
phone = request.form.get("phone", "")
if not phone.startswith("+"):
abort(400, "Give the number in international format, e.g. +14155550123.")
try:
result = sms.send_sms(phone, "Reminder: your appointment is tomorrow at 10:30.")
except SmsTunnelError as error:
app.logger.warning("SMSTunnel: %s", error)
return {"sent": False}, 502
return {"sent": True, "messageId": result.message_id} Your own HTTP client
The default transport uses only the standard library (urllib.request), a 30-second timeout
and never follows redirects, so the API key is never sent to another address. It uses the proxy from the
HTTPS_PROXY environment variable. Change the timeout, or trust your own certificate authority:
import ssl
from smstunnel import Client
from smstunnel.transport import UrllibTransport
client = Client(api_key, timeout=10, transport=UrllibTransport(ssl_context=ssl.create_default_context(cafile="ca.pem"))) Or send through httpx, so SMSTunnel calls share your app's HTTP setup:
import httpx
from smstunnel import AsyncClient, Client
from smstunnel.transport import AsyncHttpxTransport, HttpxTransport
client = Client(api_key, transport=HttpxTransport(httpx.Client(proxy="http://proxy.internal:3128")))
async_client = AsyncClient(api_key, transport=AsyncHttpxTransport(httpx.AsyncClient()))
In your own tests, give the client an object with a send(request) -> HttpResponse method
(see smstunnel.transport.Transport) to answer without a network, or mock
smstunnel.Client.
Limits worth knowing
- 30 send requests a minute. Past that, you get a
RateLimitError. - Length: up to 1600 characters (10 joined SMS); verification code texts up to 320.
- Numbers: use the international format (
+14155550123). For local numbers, passdefault_country_code: it always takes precedence (+49,49and0049all work). - Offline phone: the text waits on your account (
queuedisTrue) and goes out when the phone is back online.
Build it with an AI assistant
Copy this prompt into Claude, ChatGPT, Cursor or Copilot and replace the part in [brackets]. Don't paste your API key into the chat: the prompt doesn't need it.
Help me send text messages from my Python app with SMSTunnel.
About SMSTunnel (https://smstunnel.io): my own apps and tools hand text messages (SMS) to my SMSTunnel account, and my own Android phone sends them from my own number, on my own SIM and plan. The phone runs the SMSTunnel app from Google Play (https://play.google.com/store/apps/details?id=io.smstunnel.app), signed in to my account.
What I want: [describe what should send a text, to whom, and when]
How it works:
- Use the official client: pip install smstunnel (Python 3.10+, no dependencies; httpx is optional: pip install "smstunnel[httpx]"). client = smstunnel.Client(os.environ["SMSTUNNEL_API_KEY"]); smstunnel.AsyncClient has the same methods as coroutines.
- client.send_sms(to, message, device_id=None, default_country_code=None) returns message_id, queued (True: the phone is offline and the text goes out when it is back), branded and remaining. One call sends one text to one number: loop for several.
- Sign-in codes: client.send_verification_code("+14155550123", "Your sign-in code is 482913"); the text must contain the code as 4 to 10 digits standing on their own (or 123-456) and can be up to 320 characters. Codes have their own daily allowance.
- client.get_message_status(message_id): status ("pending", "sent", "delivered" or "failed"), delivery_status ("unconfirmed" = handed to the SIM without a confirmation, probably sent: never resend it automatically), error_message, is_final.
- Exceptions, all extending smstunnel.SmsTunnelError: AuthenticationError (401), PermissionDeniedError (403; error_code is PERMISSION_REQUIRED, permission names it), RateLimitError (429; retry_after), ApiError (other errors: 400 a number it cannot read or a code text without a code, 404, 5xx; status_code, error_code, message), TransportError (network). An empty number or text raises ValueError.
- Numbers in international format. default_country_code (e.g. "+40") completes numbers written without + or 00 and always takes precedence over the phone's own country. Texts up to 1600 characters; 30 send requests a minute.
- In Django or Flask: one client for the whole app, the key from the environment, and the text sent once the database change is committed (transaction.on_commit) or from a background task.
- The API key comes from my SMSTunnel dashboard (https://dashboard.smstunnel.io): Apps → Add an app → Create the key. It is shown only once and can send texts and check whether they went out.
Rules for your answer:
- Keep the API key in an environment variable or the platform's secret store (for example SMSTUNNEL_API_KEY). Never put it in source code, in a repository, or in code that runs in a browser or a mobile app: call SMSTunnel from a server. I will not paste my key in this chat; use a placeholder.
- Validate phone numbers before sending (international format, like +14155550123) and skip or report the invalid ones.
- Handle errors: log or show what went wrong, retry only after 429, 5xx or network errors and only a few times, and never send the same text twice.
- Only send texts people expect from me: alerts to myself, reminders, sign-in codes, updates they asked for. No unsolicited texts. Responsible use
Built for alerts to yourself and your team, and texts people expect from you. One text goes to one number. Sending unsolicited messages from a consumer SIM generally breaches mobile carrier terms of service.
Python is a registered trademark of the Python Software Foundation. Django and Flask are trademarks of their owners. SMSTunnel is a third-party integration and is not affiliated with, endorsed by, or sponsored by the Python Software Foundation or the owners of Django and Flask.