smstunnel 1.0.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,17 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *.egg-info/
5
+ .venv/
6
+ venv/
7
+ build/
8
+ dist/
9
+ .pytest_cache/
10
+ .mypy_cache/
11
+ .ruff_cache/
12
+ .coverage
13
+ htmlcov/
14
+ # Never commit keys
15
+ .env
16
+ *.key.json
17
+ local-test-key.json
@@ -0,0 +1,19 @@
1
+ # Changelog
2
+
3
+ All notable changes to `smstunnel` are documented here. The format follows
4
+ [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the project uses [Semantic Versioning](https://semver.org/).
5
+
6
+ ## [1.0.0] - 2026-11-01
7
+
8
+ ### Added
9
+
10
+ - `smstunnel.Client` with `send_sms()`, `send_verification_code()`, `get_message_status()`,
11
+ `get_received_messages()`, `get_devices()` and `get_usage()`, and `smstunnel.AsyncClient` with the same methods
12
+ as coroutines.
13
+ - Typed, read-only responses: `SendResult`, `MessageStatus`, `ReceivedMessages` / `ReceivedMessage`,
14
+ `DeviceList` / `Device`, `Usage`. Times are timezone-aware `datetime` objects.
15
+ - Exceptions: `ApiError` with the HTTP status, SMSTunnel's error code and message, and the subclasses
16
+ `AuthenticationError` (401), `PermissionDeniedError` (403) and `RateLimitError` (429, with `retry_after`);
17
+ `TransportError` when SMSTunnel cannot be reached. All extend `SmsTunnelError`.
18
+ - A standard-library transport (no dependencies, never follows redirects), and optional httpx transports
19
+ (`pip install "smstunnel[httpx]"`).
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 NARBOWEB SRL
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,377 @@
1
+ Metadata-Version: 2.5
2
+ Name: smstunnel
3
+ Version: 1.0.0
4
+ Summary: Python client for SMSTunnel: send texts from your own apps through your own Android phone, from your own number.
5
+ Project-URL: Homepage, https://smstunnel.io
6
+ Project-URL: Documentation, https://github.com/narcisbodea/smstunnel-python#readme
7
+ Project-URL: Source, https://github.com/narcisbodea/smstunnel-python
8
+ Project-URL: Issues, https://github.com/narcisbodea/smstunnel-python/issues
9
+ Project-URL: Changelog, https://github.com/narcisbodea/smstunnel-python/blob/main/CHANGELOG.md
10
+ Author-email: Narcis Bodea <contact@narboweb.ro>
11
+ License-Expression: MIT
12
+ License-File: LICENSE
13
+ Keywords: 2fa,android,otp,sms,smstunnel,text message,verification code
14
+ Classifier: Development Status :: 5 - Production/Stable
15
+ Classifier: Intended Audience :: Developers
16
+ Classifier: Operating System :: OS Independent
17
+ Classifier: Programming Language :: Python :: 3
18
+ Classifier: Programming Language :: Python :: 3 :: Only
19
+ Classifier: Programming Language :: Python :: 3.10
20
+ Classifier: Programming Language :: Python :: 3.11
21
+ Classifier: Programming Language :: Python :: 3.12
22
+ Classifier: Programming Language :: Python :: 3.13
23
+ Classifier: Programming Language :: Python :: 3.14
24
+ Classifier: Programming Language :: Python :: 3.15
25
+ Classifier: Topic :: Communications :: Telephony
26
+ Classifier: Typing :: Typed
27
+ Requires-Python: >=3.10
28
+ Provides-Extra: httpx
29
+ Requires-Dist: httpx>=0.27; extra == 'httpx'
30
+ Description-Content-Type: text/markdown
31
+
32
+ # SMSTunnel for Python
33
+
34
+ `smstunnel`: a small Python client for **[SMSTunnel](https://smstunnel.io)**. Send texts from your own Python apps through **your own Android phone**, from **your own number**.
35
+
36
+ Your app hands the 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 (or scan the QR code from your dashboard), and you can cut an app off at any time by deleting its API key in your dashboard.
37
+
38
+ Your phone needs the SMSTunnel app from Google Play:
39
+
40
+ <a href="https://play.google.com/store/apps/details?id=io.smstunnel.app"><img src="https://smstunnel.io/google-play-badge.png" alt="Get it on Google Play" height="56"></a>
41
+
42
+ ## Requirements
43
+
44
+ - Python 3.10 or newer
45
+ - No dependencies. [httpx](https://www.python-httpx.org) is optional (see [Your own HTTP client](#your-own-http-client)).
46
+
47
+ ## Installation
48
+
49
+ ```bash
50
+ pip install smstunnel
51
+ ```
52
+
53
+ ## Get an API key
54
+
55
+ 1. Install the [SMSTunnel app from Google Play](https://play.google.com/store/apps/details?id=io.smstunnel.app) on your Android phone and sign in. Sign in at [smstunnel.io](https://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**).
56
+ 2. In your [SMSTunnel dashboard](https://dashboard.smstunnel.io/apps), open **Apps** → **Add an app** and create its key. The key can send texts and look up whether they went out (`sms:send` and `sms:read`).
57
+ 3. Reading received texts only works with a key you have explicitly allowed to (**Can also read received texts**, the `sms:inbox` permission, a separate opt-in per key). Syncing received texts is off by default in the app; when you turn it on, only texts that arrive from then on are synced, and the phone's existing messages are never read.
58
+
59
+ Keep the key out of your code: put it in an environment variable such as `SMSTUNNEL_API_KEY`.
60
+
61
+ ## Usage
62
+
63
+ ```python
64
+ import os
65
+ import smstunnel
66
+
67
+ client = smstunnel.Client(os.environ["SMSTUNNEL_API_KEY"]) # sk_live_...
68
+
69
+ result = client.send_sms("+14155550123", "Your order #1042 has shipped.")
70
+
71
+ print(result.message_id) # keep it to look up the status later
72
+ ```
73
+
74
+ ### Send a text
75
+
76
+ ```python
77
+ result = client.send_sms(
78
+ "+14155550123", # international format
79
+ "See you at 10:30.", # up to 1600 characters
80
+ device_id=None, # a phone id from get_devices(), or None to let SMSTunnel pick one of yours
81
+ default_country_code=None, # e.g. "+40": completes numbers given without + or 00
82
+ )
83
+
84
+ result.message_id # str
85
+ result.queued # True when the phone was offline: it sends the text as soon as it is back
86
+ result.branded # True when the text carries a short SMSTunnel signature (plans with a free quota)
87
+ result.remaining # today's quota left, e.g. {"clean": 95, "total": "unlimited"} (-1 means no limit)
88
+ ```
89
+
90
+ One call sends one text to one number. To text several people, call `send_sms()` once for each.
91
+
92
+ ### Send a sign-in or verification code
93
+
94
+ ```python
95
+ client.send_verification_code("+14155550123", "Your sign-in code is 482913")
96
+ ```
97
+
98
+ 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.
99
+
100
+ ### Look up a status
101
+
102
+ Needs a key with `sms:read`.
103
+
104
+ ```python
105
+ from smstunnel import MessageStatus
106
+
107
+ status = client.get_message_status(result.message_id)
108
+
109
+ status.status # "pending", "sent", "delivered" or "failed"
110
+ status.delivery_status # the same, or "unconfirmed": handed to the SIM but the phone got no confirmation
111
+ status.sent_at # a datetime (UTC), or None
112
+ status.error_message # why it failed, or None
113
+ status.is_final # True once it will not change any more
114
+
115
+ if status.status == MessageStatus.FAILED:
116
+ ...
117
+ ```
118
+
119
+ ### Read received texts
120
+
121
+ Needs a key you allowed to read received texts (`sms:inbox`) and syncing turned on in the app.
122
+
123
+ ```python
124
+ from datetime import datetime, timedelta, timezone
125
+
126
+ page = client.get_received_messages(
127
+ limit=20, # at most 100
128
+ page=1,
129
+ since=datetime.now(timezone.utc) - timedelta(days=1), # or an ISO 8601 string
130
+ phone="+14155550123", # only from this sender
131
+ device_id=None, # only this phone
132
+ )
133
+
134
+ for text in page:
135
+ print(text.sender, text.message) # text.received_at, text.device_name, ...
136
+
137
+ page.total # all texts that match, on every page
138
+ page.has_more # ask for the next page
139
+ ```
140
+
141
+ ### Your phones and your usage
142
+
143
+ ```python
144
+ for phone in client.get_devices():
145
+ print(phone.name, "online" if phone.is_online else "offline") # phone.id, phone.last_seen_at
146
+
147
+ usage = client.get_usage()
148
+ usage.plan # e.g. "pro"
149
+ usage.usage # {"smsClean": 15, "smsBranded": 0, "twoFaClean": 4}
150
+ usage.remaining # {"smsClean": "unlimited", "twoFaClean": 96}
151
+ ```
152
+
153
+ Every response object is a read-only dataclass with a `raw` field, the whole answer, for fields this version does not map yet. The package ships type hints (`py.typed`).
154
+
155
+ ### Async
156
+
157
+ `smstunnel.AsyncClient` has the same methods, as coroutines:
158
+
159
+ ```python
160
+ import asyncio
161
+ import os
162
+
163
+ import smstunnel
164
+
165
+
166
+ async def main() -> None:
167
+ async with smstunnel.AsyncClient(os.environ["SMSTUNNEL_API_KEY"]) as client:
168
+ await client.send_sms("+14155550123", "Your table is ready.")
169
+
170
+
171
+ asyncio.run(main())
172
+ ```
173
+
174
+ It sends through httpx when httpx is installed (`pip install "smstunnel[httpx]"`); otherwise it runs the standard-library transport in a worker thread.
175
+
176
+ ## Errors
177
+
178
+ Every call raises a `smstunnel.SmsTunnelError` when it does not go through:
179
+
180
+ | Exception | When |
181
+ | --- | --- |
182
+ | `AuthenticationError` (401) | The API key is wrong, was deleted, or its account is suspended. |
183
+ | `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. |
184
+ | `RateLimitError` (429) | Too many requests. `retry_after` gives the seconds to wait, when SMSTunnel says. |
185
+ | `ApiError` | Any other error answer: 400 (a number it cannot read, a verification text without a code), 404 (no such message), 5xx. The three above extend it. |
186
+ | `TransportError` | SMSTunnel could not be reached (DNS, TLS, timeout). |
187
+
188
+ ```python
189
+ import logging
190
+ import time
191
+
192
+ from smstunnel import ApiError, RateLimitError, TransportError
193
+
194
+ try:
195
+ client.send_sms("+14155550123", "Your table is ready.")
196
+ except RateLimitError as error:
197
+ time.sleep(error.retry_after or 30) # then try again
198
+ except ApiError as error:
199
+ logging.warning("SMSTunnel %s %s: %s", error.status_code, error.error_code or "-", error.message)
200
+ except TransportError:
201
+ ... # network problem: try again later
202
+ ```
203
+
204
+ `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.
205
+
206
+ ## Django
207
+
208
+ Read the key from the environment in `settings.py`:
209
+
210
+ ```python
211
+ # settings.py
212
+ import os
213
+
214
+ SMSTUNNEL_API_KEY = os.environ.get("SMSTUNNEL_API_KEY", "")
215
+ ```
216
+
217
+ Make one client for the whole app, and send once the order is saved:
218
+
219
+ ```python
220
+ # shop/sms.py
221
+ from functools import cache
222
+
223
+ import smstunnel
224
+ from django.conf import settings
225
+
226
+
227
+ @cache
228
+ def sms() -> smstunnel.Client:
229
+ return smstunnel.Client(settings.SMSTUNNEL_API_KEY)
230
+ ```
231
+
232
+ ```python
233
+ # shop/views.py
234
+ import logging
235
+
236
+ from django.db import transaction
237
+ from django.shortcuts import get_object_or_404, redirect
238
+
239
+ from smstunnel import SmsTunnelError
240
+
241
+ from .models import Order
242
+ from .sms import sms
243
+
244
+ log = logging.getLogger(__name__)
245
+
246
+
247
+ def text_customer(order: Order) -> None:
248
+ try:
249
+ sms().send_sms(order.phone, f"Your order #{order.pk} has shipped.")
250
+ except SmsTunnelError:
251
+ log.exception("The shipping text for order %s did not go out", order.pk)
252
+
253
+
254
+ def mark_shipped(request, pk):
255
+ order = get_object_or_404(Order, pk=pk)
256
+ order.status = "shipped"
257
+ order.save()
258
+ transaction.on_commit(lambda: text_customer(order))
259
+ return redirect("order-detail", pk=pk)
260
+ ```
261
+
262
+ To keep requests fast, you can call `text_customer()` from a background task (Celery, RQ, Django's tasks) instead.
263
+
264
+ ## Flask
265
+
266
+ ```python
267
+ import os
268
+
269
+ from flask import Flask, abort, request
270
+ from smstunnel import Client, SmsTunnelError
271
+
272
+ app = Flask(__name__)
273
+ app.config["SMSTUNNEL_API_KEY"] = os.environ["SMSTUNNEL_API_KEY"]
274
+ sms = Client(app.config["SMSTUNNEL_API_KEY"])
275
+
276
+
277
+ @app.post("/appointments/<int:appointment_id>/remind")
278
+ def remind(appointment_id: int):
279
+ phone = request.form.get("phone", "")
280
+ if not phone.startswith("+"):
281
+ abort(400, "Give the number in international format, e.g. +14155550123.")
282
+ try:
283
+ result = sms.send_sms(phone, "Reminder: your appointment is tomorrow at 10:30.")
284
+ except SmsTunnelError as error:
285
+ app.logger.warning("SMSTunnel: %s", error)
286
+ return {"sent": False}, 502
287
+ return {"sent": True, "messageId": result.message_id}
288
+ ```
289
+
290
+ ## Your own HTTP client
291
+
292
+ 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:
293
+
294
+ ```python
295
+ import ssl
296
+
297
+ from smstunnel import Client
298
+ from smstunnel.transport import UrllibTransport
299
+
300
+ client = Client(api_key, timeout=10, transport=UrllibTransport(ssl_context=ssl.create_default_context(cafile="ca.pem")))
301
+ ```
302
+
303
+ Or send through httpx, so SMSTunnel calls share your app's HTTP setup:
304
+
305
+ ```python
306
+ import httpx
307
+ from smstunnel import AsyncClient, Client
308
+ from smstunnel.transport import AsyncHttpxTransport, HttpxTransport
309
+
310
+ client = Client(api_key, transport=HttpxTransport(httpx.Client(proxy="http://proxy.internal:3128")))
311
+ async_client = AsyncClient(api_key, transport=AsyncHttpxTransport(httpx.AsyncClient()))
312
+ ```
313
+
314
+ 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`.
315
+
316
+ `Client(api_key, "https://your-own-host")` points the client at a self-hosted SMSTunnel; the default is `https://smstunnel.io`.
317
+
318
+ ## Notes and limits
319
+
320
+ - **Rate limit**: sending allows 30 requests a minute.
321
+ - **Length**: up to 1600 characters (10 joined SMS); verification codes up to 320.
322
+ - **Numbers**: use the international format (`+14155550123`). For local numbers, pass `default_country_code`: it always takes precedence (`+49`, `49` and `0049` all work).
323
+ - **Offline phone**: the text waits on your account (`queued` is `True`) and goes out when the phone is back online.
324
+
325
+ ## Build it with an AI assistant
326
+
327
+ Paste this into Claude, ChatGPT, Cursor or Copilot, and replace the part in brackets. Never paste your real API key into a prompt.
328
+
329
+ ```text
330
+ I want my Python 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.
331
+
332
+ Use the SMSTunnel package: `pip install smstunnel`, then `client = smstunnel.Client(os.environ["SMSTUNNEL_API_KEY"])`.
333
+ API facts:
334
+ - client.send_sms(to, message, device_id=None, default_country_code=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 message_id and queued.
335
+ - One recipient per call. To text several people, call it once per number.
336
+ - client.send_verification_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.
337
+ - Numbers in international format (+14155550123). default_country_code (e.g. "+40") completes numbers written without + or 00.
338
+ - Limits: 30 send requests a minute, 1600 characters a text.
339
+ - Errors: 401 AuthenticationError (bad key), 403 PermissionDeniedError (missing permission or plan; error_code such as PERMISSION_REQUIRED), 429 RateLimitError (retry_after seconds), other 4xx/5xx ApiError, TransportError when SMSTunnel can't be reached.
340
+ - If the phone is offline, the text waits on the account (queued is True) and goes out when the phone is back.
341
+
342
+ What I want: [describe what should send a text, to whom, and when]
343
+
344
+ Rules:
345
+ - 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.
346
+ - Validate phone numbers before sending, and handle every error above: log it clearly, don't retry 401/403, wait and retry 429.
347
+ - Only send texts people expect (alerts to myself, reminders, sign-in codes, order updates).
348
+ ```
349
+
350
+ ## Responsible use
351
+
352
+ 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).
353
+
354
+ ## Development
355
+
356
+ ```bash
357
+ python -m venv .venv && . .venv/bin/activate
358
+ pip install -e . --group dev # pip 25.1 or newer
359
+ pytest # unit tests
360
+ ruff check . && ruff format --check .
361
+ mypy # strict
362
+ ```
363
+
364
+ 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:3101", "apiKey": "...", "deviceId": "..."}`) and, when `SMSTUNNEL_E2E_MONGO_URL` is set, check each text in the server's database:
365
+
366
+ ```bash
367
+ pip install --group e2e
368
+ SMSTUNNEL_E2E_KEY_FILE=/path/to/test-key.json SMSTUNNEL_E2E_MONGO_URL=mongodb://127.0.0.1:27018/smstunnel_app_dev pytest tests/e2e
369
+ ```
370
+
371
+ ## Changelog
372
+
373
+ See [CHANGELOG.md](https://github.com/narcisbodea/smstunnel-python/blob/main/CHANGELOG.md).
374
+
375
+ ## License
376
+
377
+ MIT, © NARBOWEB SRL. See [LICENSE](https://github.com/narcisbodea/smstunnel-python/blob/main/LICENSE).