SMSTunnel for Python

Send texts from your own Python apps, from your own number

Python package · Python 3.10+ · MIT
pip install smstunnel

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. 1. Install the SMSTunnel app from Google Play on your Android phone and sign in.
  2. 2. In your dashboard, open Apps → Add an app, pick Python, name it (for example "My Python app") and click Create the key.
  3. 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, pass default_country_code: it always takes precedence (+49, 49 and 0049 all work).
  • Offline phone: the text waits on your account (queued is True) 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.

No phone paired yet?

Install the Android app and sign in with your account, and you're ready in about a minute.

Get it on Google Play