SMSTunnel for Django
django-smstunnel: send texts from your Django app through your own Android phone, from your own number, with SMSTunnel. It also sends django-two-factor-auth's sign-in codes.
Your app hands each text to your SMSTunnel account and your phone sends it from its own SIM, so recipients see a number they know and can reply to. The phone is linked to your account when you sign in to the SMSTunnel app, and you can cut your app off at any time by deleting its API key in your dashboard.
Your phone needs the SMSTunnel app from Google Play:
SMSTunnel for Django is made by NARBOWEB SRL. It is not affiliated with, endorsed by, or sponsored by the Django Software Foundation or the django-two-factor-auth project (Jazzband). Django is a registered trademark of the Django Software Foundation.
Contents
- Requirements
- Installation
- Get your key
- Setup
- Send a text
- Settings
- Sign-in codes with django-two-factor-auth
- Errors
- Background tasks and retries
- Status, phones and usage
- Notes and limits
- Build it with an AI assistant
- Responsible use
Requirements
- Python 3.10 or newer
- Django 5.2, 6.0 or 6.1
- One dependency besides Django:
smstunnel, the SMSTunnel client for Python, which needs nothing else.
Installation
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]"
Get your key
- Install the SMSTunnel app from Google Play on your Android phone and sign in. Sign in at smstunnel.io on your computer with the same account: the phone appears under Phones (you can also link it with the QR code from Phones → Add phone).
- In your SMSTunnel dashboard, open Apps.
- Click Add an app and copy its key. The key can send texts and look up whether they went out (
sms:sendandsms:read).
Keep the key out of your code: put it in an environment variable such as SMSTUNNEL_API_KEY.
Setup
# 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.
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.
Settings
| Setting | Default | What it does |
|---|---|---|
SMSTUNNEL_API_KEY |
(required) | The key from Apps → Add an app. Read it from the environment. |
SMSTUNNEL_DEVICE_ID |
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 |
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 |
"https://smstunnel.io" |
Change it only for a test server. |
SMSTUNNEL_TIMEOUT |
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). The SMSTUNNEL_DEVICE_ID and SMSTUNNEL_DEFAULT_COUNTRY_CODE settings 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 TWO_FACTOR_SMS_GATEWAY at your class.
Errors
Every function raises a subclass of django_smstunnel.SmsTunnelError when the text was not accepted:
| Exception | When |
|---|---|
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/422 (an idempotency key that is still in use or was 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.
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); see client.get_received_messages() in the smstunnel README. You can also have them forwarded to a URL of your app: Apps → the key → Forward received texts.
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.")
Notes and limits
- Rate limit: 30 requests a minute per API key. A 429 carries
Retry-After. - Length: up to 1600 characters (10 joined SMS); sign-in codes up to 320.
- Numbers: use the international format (
+14155550123). For local numbers, setSMSTUNNEL_DEFAULT_COUNTRY_CODEor passdefault_country_code. - Offline phone: the text waits on your account and goes out when the phone is back online.
- Plans: the free plan has a daily limit; see smstunnel.io/pricing.
- Documentation: smstunnel.io/docs. Keys: dashboard.smstunnel.io/apps.
Build it with an AI assistant
Paste this into Claude, ChatGPT, Cursor or Copilot, and replace the part in brackets. Never paste your real API key into a prompt: the prompt doesn't need it.
I want my Django app to send texts with SMSTunnel. SMSTunnel sends each text from my own Android phone, from my own number: the phone runs the SMSTunnel app and is linked to my SMSTunnel account.
Use the django-smstunnel package: `pip install django-smstunnel`, add "django_smstunnel" to INSTALLED_APPS and set SMSTUNNEL_API_KEY = os.environ.get("SMSTUNNEL_API_KEY", "") in settings.py. Then `from django_smstunnel import send_sms`.
API facts:
- send_sms(to, message, device_id=None, default_country_code=None, idempotency_key=None) calls POST https://smstunnel.io/api/v1/sms/send with the header X-API-Key and the JSON body {"to", "message", "deviceId"?, "defaultCountryCode"?}. It returns result.message_id and result.queued.
- One recipient per call. To text several people, call it once per number.
- send_sign_in_code(...) (POST /api/v1/sms/send-2fa) is only for sign-in codes: the text must contain a code of 4 to 10 digits standing on its own (e.g. 482913 or 123-456), at most 320 characters; it uses a separate quota. For django-two-factor-auth, set TWO_FACTOR_SMS_GATEWAY = "django_smstunnel.two_factor.SmsTunnel".
- Numbers in international format (+14155550123). default_country_code or the setting SMSTUNNEL_DEFAULT_COUNTRY_CODE (e.g. "+40") completes numbers written without + or 00.
- Limits: 30 requests a minute per API key, 1600 characters a text.
- idempotency_key is sent as the Idempotency-Key header: a retry with the same key within 24 hours gets the first answer instead of sending a second text. Never retry a send after a timeout, a 5xx or a 429 unless it carries a key.
- Errors (all subclasses of SmsTunnelError): 401 AuthenticationError (bad key), 403 PermissionDeniedError (missing permission or plan; error_code such as PERMISSION_REQUIRED), 429 RateLimitError (retry_after seconds from the Retry-After header), other 4xx/5xx ApiError (status_code, error_code, message), TransportError when SMSTunnel can't be reached. is_retryable(error) and retry_after(error) say whether and when a retry may work.
- If the phone is offline, the text waits on the account (result.queued is True) and goes out when the phone is back.
What I want: [describe what should send a text, to whom, and when]
Rules:
- Read the API key from an environment variable or the platform's secret store. Never put it in source code, in the repository or in client-side code.
- Validate phone numbers before sending, and handle every error above: log it clearly, don't retry 400/401/403, and retry 429/5xx/timeouts only with an idempotency key, after the wait SMSTunnel asks for.
- Send after the database transaction commits (transaction.on_commit) or from a background task.
- Only send texts people expect (alerts to myself, reminders, sign-in codes, order updates).
Responsible use
Send only texts that people expect from you: alerts to yourself, appointment reminders, sign-in codes, order updates. Unsolicited messages from a personal SIM break most carriers' terms of service. Please make sure your usage complies with your carrier's terms and with local regulations (in the EU, that includes GDPR and ePrivacy rules on electronic communications).
Development
python -m venv .venv && . .venv/bin/activate
pip install -e . --group dev # pip 25.1 or newer
pytest # unit tests (HTTP is faked)
ruff check . && ruff format --check .
pip install --group typing && mypy # strict, with the Django stubs (Python 3.11 or newer)
The end-to-end tests run only against a test server, never production. They read the server address and a test key from a JSON file ({"serverUrl": "http://127.0.0.1:3141", "apiKey": "...", "deviceId": "..."}) and, when SMSTUNNEL_E2E_MONGO_URL is set, check each text in the server's database:
pip install --group e2e
SMSTUNNEL_E2E_KEY_FILE=/path/to/test-key.json SMSTUNNEL_E2E_MONGO_URL=mongodb://127.0.0.1:27018/smstunnel_test pytest tests/e2e
Changelog
See CHANGELOG.md.
License
MIT, © NARBOWEB SRL. See LICENSE.
Metadata
Release files for django-smstunnel 1.0.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | Actions |
|---|---|---|---|---|
| django_smstunnel-1.0.0.tar.gz | 26.5 kB | With provenance | Details for django_smstunnel-1.0.0.tar.gz |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Actions Reset |
|---|---|---|---|---|
| django_smstunnel-1.0.0-py3-none-any.whl | Python 3 | none | any | Details for django_smstunnel-1.0.0-py3-none-any.whl |
Total release size: 46.5 kB
Release files / django_smstunnel-1.0.0.tar.gz
| Download URL | django_smstunnel-1.0.0.tar.gz |
|---|---|
| Size | 26.5 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
812fcc9c8b77db2c04c50fa00c1f65023fbb0eb7814545ab84ba26084bc30bde
|
|
BLAKE2b-256 checksum How to use checksums |
8c455ac08e8d1a7234181c6065a9b1f541d045041e4b985003b1c0429248576d
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.16
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Nov 4, 2026.
Transparency logRelease files / django_smstunnel-1.0.0-py3-none-any.whl
| Download URL | django_smstunnel-1.0.0-py3-none-any.whl |
|---|---|
| Size | 20.0 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
0a4bf37922d6abf1bc1e70a445e1e2779a7e2ee9c6e88971b29c58a6b1068f20
|
|
BLAKE2b-256 checksum How to use checksums |
5c508339dd80045bf19bdcc24ccc4b1dbcad5701fe3e24ec97e642bc4af7d670
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.16
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Nov 4, 2026.
Transparency logSecurity for django-smstunnel 1.0.0
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
Full provenance
Both files in this release have been uploaded with provenance metadata.
Attestation for django_smstunnel-1.0.0.tar.gz
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Nov 4, 2026.
Transparency logA note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.
To learn more about protecting yourself from malicious actors, visit our package security documentation.