dj

SMSTunnel for Django

Your Django app's texts, from your own phone and number

PyPI package · Python 3.10+ · Django 5.2, 6.0, 6.1 · MIT
pip install django-smstunnel

Settings, then send_sms()

Your app hands each 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. A key in your settings, then send_sms() wherever a text should go out, and django-two-factor-auth's sign-in codes too. You can cut your app off at any time by deleting its API key in your dashboard.

What it does

send_sms()
One text to one number, up to 1600 characters, from the phone in your settings or one of yours.
Sign-in codes
send_sign_in_code(): a code of 4 to 10 digits, on its own daily allowance.
django-two-factor-auth
Its Text message method sends the codes from your own phone, in the user's language.
Setup checks
System checks for the SMSTUNNEL_* settings, and manage.py smstunnel_test.
Async views
asend_sms() and asend_sign_in_code(), the same functions as coroutines.
Safe retries
An idempotency key per text, for Celery, RQ, Dramatiq, Huey or Django's tasks.
Requirements
Python 3.10 or newer, Django 5.2, 6.0 or 6.1. One dependency: the smstunnel client.

1. Install

pip install django-smstunnel

For django-two-factor-auth sign-in codes, install it with the extra (it brings django-two-factor-auth and a phone number library):

pip install "django-smstunnel[two-factor]"

It needs Python 3.10 or newer and Django 5.2, 6.0 or 6.1, and brings one dependency: smstunnel, the SMSTunnel client for Python, which needs nothing else.

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 Django, name it (for example "My Django 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 Django app can't send texts anymore.

Get the steps with your key in them

It opens Add an app with Django picked. Once the key is made, the dashboard shows every step and command with the key already filled in, ready to copy.

Keep the key out of your code: put it in an environment variable such as SMSTUNNEL_API_KEY, and read it in your settings.

3. Add it to your settings

# settings.py
import os

INSTALLED_APPS = [
    # ...
    "django_smstunnel",
]

SMSTUNNEL_API_KEY = os.environ.get("SMSTUNNEL_API_KEY", "")

Adding the app to INSTALLED_APPS turns on the system checks and the smstunnel_test command: python manage.py check warns when the key is missing or a setting has a value SMSTunnel can't use. Check the setup from the command line. Without --to it only lists your phones; with it, it sends one test text:

python manage.py smstunnel_test
python manage.py smstunnel_test --to +14155550123
The API key works (https://smstunnel.io).
  Pixel 8 (6740f1c2a8b9c0d1e2f3a4b5): online
Test text accepted, message id 6740f2d0a8b9c0d1e2f3a4c7.

4. Send a text

from django_smstunnel import send_sms

result = send_sms("+14155550123", "Your order #1042 has shipped.")

result.message_id  # keep it to look up the status later
result.queued      # True when the phone was offline: it sends the text as soon as it is back

All the arguments:

send_sms(
    "+14155550123",                       # one number, in international format
    "See you at 10:30.",                  # up to 1600 characters
    device_id=None,                       # a phone's ID; default SMSTUNNEL_DEVICE_ID, else SMSTunnel picks one of yours
    default_country_code=None,            # e.g. "+40": completes numbers written without + or 00
    idempotency_key="booking-881-remind", # optional: a retry with the same key never sends a second text
)

One call sends one text to one number. To text several people, call send_sms() once for each. Send after the data is saved, so a failed save never texts anyone:

from django.db import transaction

transaction.on_commit(lambda: send_sms(order.phone, f"Your order #{order.pk} has shipped."))

In async views use asend_sms() and asend_sign_in_code(), the same functions as coroutines.

Sign-in and verification codes

from django_smstunnel import send_sign_in_code

send_sign_in_code("+14155550123", "Your sign-in code is 482913")

The text must contain the code, 4 to 10 digits standing on their own (for example 482913 or 123-456); SMSTunnel refuses it otherwise. Codes count against a separate quota, and the text can be up to 320 characters. Use it for sign-in and verification codes only. What counts as a code

Settings

SMSTUNNEL_API_KEY
Required. The key from Apps → Add an app. Read it from the environment.
SMSTUNNEL_DEVICE_ID
Default None. The phone that sends when a call doesn't name one: copy the ID from Phones in your dashboard. Without it, SMSTunnel picks one of your phones.
SMSTUNNEL_DEFAULT_COUNTRY_CODE
Default None. Completes numbers written without + or 00, e.g. "+40" ("40" and "0040" work too). It always wins over the phone's own country.
SMSTUNNEL_BASE_URL
Default "https://smstunnel.io". Change it only for a test server.
SMSTUNNEL_TIMEOUT
Default 30. Seconds for each request.

Changing a setting in a test with override_settings takes effect at once.

Sign-in codes with django-two-factor-auth

django-two-factor-auth can send sign-in codes by text; SMSTunnel sends them from your own phone. Set up django-two-factor-auth as its installation guide says, then add its phone number plugin and point it at SMSTunnel:

# settings.py
INSTALLED_APPS = [
    # ... django_otp, its plugins and two_factor, as django-two-factor-auth's guide says
    "two_factor.plugins.phonenumber",  # the "Text message" method
    "django_smstunnel",
]

TWO_FACTOR_SMS_GATEWAY = "django_smstunnel.two_factor.SmsTunnel"

That is all: when a user picks Text message in django-two-factor-auth's setup, and at every sign-in, the code goes out through your phone. The number comes from the user's phone device, and the text from the template django_smstunnel/two_factor/sms_message.txt:

Your OTP token is 482913

It is translated with django-two-factor-auth's own translations, in the user's language. To change the words, put your own templates/django_smstunnel/two_factor/sms_message.txt in your project. Keep the code on its own; for example, with the line browsers use to fill in the code automatically:

{% load i18n %}{% autoescape off %}{% blocktrans trimmed %}Your OTP token is {{ token }}{% endblocktrans %}

@example.com #{{ token }}{% endautoescape %}

Phone calls are not supported: leave TWO_FACTOR_CALL_GATEWAY unset (the system check warns when it points at SMSTunnel). SMSTUNNEL_DEVICE_ID and SMSTUNNEL_DEFAULT_COUNTRY_CODE apply to these codes too. Every request for a code sends a text; if a double click should not send the same code twice, subclass SmsTunnel, return a key from idempotency_key(self, device, token) (for example f"sign-in-{device.persistent_id}-{token}") and point the setting at your class.

Errors

Every function raises a subclass of django_smstunnel.SmsTunnelError when the text was not accepted:

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), or the plan does not include the feature.
RateLimitError (429)
Too many requests. retry_after gives the seconds to wait (SMSTunnel's Retry-After header).
ApiError
Any other error answer: 400 (a number it can't read, a sign-in text without a code), 404, 409 and 422 (an idempotency key still in use, or used for another text), 5xx. The three above extend it; status_code, error_code and message say what happened.
TransportError
SMSTunnel could not be reached (DNS, TLS, timeout).

A missing SMSTUNNEL_API_KEY raises ImproperlyConfigured, and an empty number or text raises ValueError, before anything is sent. An offline phone is not an error: the text waits on your account (result.queued is True) and goes out when the phone is back online, within 3 days.

import logging

from django_smstunnel import ApiError, RateLimitError, TransportError, send_sms

log = logging.getLogger(__name__)

try:
    send_sms(customer.phone, "Your table is ready.")
except RateLimitError as error:
    log.warning("SMSTunnel: too many texts, try again in %s s", error.retry_after)
except ApiError as error:
    log.warning("SMSTunnel %s %s: %s", error.status_code, error.error_code or "-", error.message)
except TransportError:
    log.warning("SMSTunnel could not be reached")

Background tasks and retries

send_sms() never retries on its own: after a timeout or a server error, the first attempt may already have sent the text. To retry safely, give the text an idempotency_key of your own, the same on every attempt. SMSTunnel remembers it for 24 hours and answers a repeat with the first result instead of sending a second text. With Celery:

# shop/tasks.py
from celery import shared_task

from django_smstunnel import SmsTunnelError, is_retryable, retry_after, send_sms

from .models import Order


@shared_task(bind=True, max_retries=5)
def text_order_shipped(self, order_id: int) -> None:
    order = Order.objects.get(pk=order_id)
    try:
        send_sms(
            order.phone,
            f"Your order #{order.pk} has shipped.",
            idempotency_key=f"order-{order.pk}-shipped",  # the same key on every attempt
        )
    except SmsTunnelError as error:
        if is_retryable(error):  # 429, 5xx, a request still running, or no answer
            raise self.retry(exc=error, countdown=retry_after(error) or 60) from error
        raise  # 400, 401, 403: fix the number, the key or the plan instead
transaction.on_commit(lambda: text_order_shipped.delay(order.pk))

The same applies to RQ, Dramatiq, Huey or Django's task framework: one key per text, kept across retries. A key is 1 to 255 printable ASCII characters; a UUID stored with the record works too.

Status, phones and usage

get_client() returns the project's smstunnel.Client, made from the settings, for everything else:

from django_smstunnel import get_client

client = get_client()

status = client.get_message_status(result.message_id)
status.status       # "pending", "sent", "delivered" or "failed"
status.is_final     # True once it will not change any more

for phone in client.get_devices():
    print(phone.name, "online" if phone.is_online else "offline")

client.get_usage().remaining  # today's quota left

Reading the texts your phone received needs a key you allowed to (Can also read received texts) and syncing turned on in the app: see client.get_received_messages() on the Python page. SMSTunnel can also post each received text to a URL of your app: in your dashboard, Apps → Forward received texts (how it works).

In your own tests

Patch the function where your code uses it, so tests never send a text:

from unittest import mock


def test_shipping_texts_the_customer(client, order):
    with mock.patch("shop.views.send_sms") as send_sms:
        client.post(f"/orders/{order.pk}/ship/")

    send_sms.assert_called_once_with(order.phone, f"Your order #{order.pk} has shipped.")

Limits worth knowing

  • 30 send requests a minute per key; the keys of one account together get three times that. Over it, a RateLimitError with retry_after.
  • Plans: Free, Starter and Standard have a daily and a monthly allowance; Professional and Enterprise have no daily cap. See Pricing.
  • Length: up to 1600 characters (10 joined SMS); sign-in codes up to 320.
  • Numbers: use the international format (+14155550123). For local numbers, set SMSTUNNEL_DEFAULT_COUNTRY_CODE or pass default_country_code.
  • Offline phone: the text waits on your account 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 Django 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 django-smstunnel package: pip install django-smstunnel (Python 3.10+, Django 5.2, 6.0 or 6.1; it brings the smstunnel client). Add "django_smstunnel" to INSTALLED_APPS and set SMSTUNNEL_API_KEY = os.environ.get("SMSTUNNEL_API_KEY", "") in settings.py. Optional settings: SMSTUNNEL_DEVICE_ID, SMSTUNNEL_DEFAULT_COUNTRY_CODE, SMSTUNNEL_BASE_URL, SMSTUNNEL_TIMEOUT (seconds). python manage.py smstunnel_test --to +14155550123 checks the key and sends one test text.
- from django_smstunnel import send_sms; send_sms(to, message, device_id=None, default_country_code=None, idempotency_key=None) returns result.message_id and result.queued (True: the phone is offline and the text goes out when it is back). One call sends one text to one number: loop for several. In async views: await asend_sms(...).
- Sign-in codes: send_sign_in_code("+14155550123", "Your sign-in code is 482913") (asend_sign_in_code in async code); the text must contain the code as 4 to 10 digits standing on their own (or 123-456) and be at most 320 characters. Codes have their own daily allowance. For django-two-factor-auth: pip install "django-smstunnel[two-factor]", "two_factor.plugins.phonenumber" in INSTALLED_APPS and TWO_FACTOR_SMS_GATEWAY = "django_smstunnel.two_factor.SmsTunnel"; leave TWO_FACTOR_CALL_GATEWAY unset (no phone calls).
- Errors, all SmsTunnelError (importable from django_smstunnel): AuthenticationError (401, bad key), PermissionDeniedError (403; error_code such as PERMISSION_REQUIRED, permission), RateLimitError (429; retry_after seconds), ApiError for the other answers (400, 404, 409, 422, 5xx; status_code, error_code, message), TransportError when SMSTunnel can't be reached. A missing SMSTUNNEL_API_KEY raises ImproperlyConfigured, an empty number or text ValueError, before anything is sent.
- send_sms() never retries by itself. Retry only with the same idempotency_key on every attempt (e.g. f"order-{order.pk}-shipped"): a repeat within 24 hours gets the first answer back instead of a second text. In a Celery task: if is_retryable(error): raise self.retry(exc=error, countdown=retry_after(error) or 60); don't retry 400, 401 or 403.
- Send after the data is saved: transaction.on_commit(lambda: send_sms(...)), or a background task queued on commit, so a failed save never texts anyone and a slow network never holds up a request.
- get_client() returns the project's smstunnel.Client for get_message_status(message_id) (status: pending, sent, delivered or failed; is_final), get_devices() and get_usage().
- Numbers in international format. default_country_code or SMSTUNNEL_DEFAULT_COUNTRY_CODE (e.g. "+40") completes numbers written without + or 00 and always wins over the phone's own country. Texts up to 1600 characters; 30 send requests a minute per key.
- 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.

Django is a registered trademark of the Django Software Foundation. SMSTunnel is a third-party integration and is not affiliated with, endorsed by, or sponsored by the Django Software Foundation or the django-two-factor-auth project (Jazzband).

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