termux-bulk-sms
Send a personalised SMS to a list of people from your own Android phone, through Termux:API, driven from a web page on your local network.
- Paste a CSV, write a message with
[name], set the delay, press Start, watch it go. - Sendings are campaigns: every recipient can be edited, removed or put on the block list.
- Two types: Fixed (a list sent through once) and Ongoing (stays on until you turn it off; whoever you add is texted automatically). Several campaigns can be on at once — messages still leave the phone one at a time.
- Nobody is texted twice: duplicates in the paste, everyone ever texted (history) and a manual block list are all skipped.
- Optional remote lookup: before sending, ask your own backend which numbers it already knows (existing customers, members …) and skip those.
- Python standard library only. No pip, no npm, no build step, no CDN.
It is one process on the phone. The page is served only to the local network and needs a password.
Install (in Termux)
pkg install python termux-api termux-services # plus the Termux:API app, with SMS permission
pip install termux-bulk-sms
termux-bulk-sms init # writes the config file and says where
termux-bulk-sms set-password
termux-bulk-sms run # prints the address to open
Restart Termux once after installing termux-services. Edit the config file (init prints its
path, normally ~/.config/termux-bulk-sms/config.json) before the first real run — see below.
As a service you can switch on and off
termux-bulk-sms service install # once
termux-bulk-sms service enable # on: now and whenever Termux starts
termux-bulk-sms service disable # off: nothing is listening
termux-bulk-sms service status | restart | logs
The config file is read at start, so termux-bulk-sms service restart after editing it.
From a git checkout instead
git clone https://github.com/Logicbloke/termux-bulk-sms && cd termux-bulk-sms
cp config.example.json config.json
python -m termux_bulk_sms set-password
python -m termux_bulk_sms run
./service.sh install && ./service.sh enable
A config.json in the directory you run from is used when there is one; BULKSMS_CONFIG overrides
both locations.
config.json
| key | default | meaning |
|---|---|---|
title |
"Bulk SMS" |
Name shown on the page. |
port |
8742 |
Port the page is served on. |
dry_run |
true |
Nothing is sent while this is true. A rehearsal: rows are marked "Rehearsed", the history is left alone, and when the app is restarted with false those rows go back in the queue. |
lookup.url |
"" |
Remote lookup endpoint. Empty = no lookup. Must be https://. |
lookup.token |
"" |
Sent as Authorization: Bearer …. |
lookup.label |
"our database" |
Shown as "Already in …" on skipped rows. |
country_code |
"46" |
Country that numbers starting with 0 belong to. |
default_delay, default_jitter |
20, 5 |
Seconds to wait between rounds, and a random ± on top. Per campaign on the page. |
default_batch |
1 |
Messages per round: that many go out back to back, then the delay. Per campaign on the page. |
window_start, window_end |
08:00, 21:00 |
Default allowed hours (the phone's clock). Empty = any time. |
sim_slot |
null |
SIM slot for dual-SIM phones (0 or 1). |
max_consecutive_failures |
3 |
A campaign pauses itself after this many failures in a row. |
host, data_dir |
0.0.0.0, data |
Listen address; where the SQLite file lives. |
password_hash is written by set-password. The SQLite file lives in data/ next to the config file.
The remote lookup
Any backend can implement it:
POST <lookup.url>
Authorization: Bearer <lookup.token>
Content-Type: application/x-www-form-urlencoded
numbers=%2B46701234567%0A%2B46735556677 (one E.164 number per line)
200 {"results": ["known", "unknown"]} (same order; "invalid" is the third value)
With a lookup configured, a row is sendable only after the lookup has answered unknown for the
number it has now. The lookup is asked when a number is added or edited, when you press "Check
numbers again", and once more when a campaign is started — never before each message.
The phone does not need a data connection to send. If the lookup cannot be reached, numbers that were already checked keep their answer and are sent; numbers added while offline stay "Not checked" and are not sent until a check succeeds. It never falls back to "probably fine".
How it avoids sending twice
Each row is marked sending in SQLite before termux-sms-send is called and sent after. If
the app or the phone dies in between, the row comes back as Uncertain and the campaign as
Paused; nothing resumes by itself and you settle the row by hand ("It was sent" / "It was not").
The history is kept when a campaign is deleted.
After a restart of the app nothing is on: a fixed campaign waits for Resume and an ongoing one for Turn on.
"Sent" means Android accepted the message from Termux. Delivery reports are not available through Termux:API.
Recipients and placeholders
Comma, semicolon or tab separated; a header row is optional; the phone column is found by content.
Free-form lines (Anna Andersson 070-123 45 67) work too. [name] is the name column; with a
header row every other column is a placeholder as well ([town]). A row whose placeholder would be
empty is held back until you fix it. Placeholders that match nothing are flagged before you start.
Security
- Only loopback and private-network clients (RFC 1918, link-local, ULA) are answered; a request arriving over mobile data gets 403.
- Password login (PBKDF2), throttled; the session cookie is
HttpOnly; SameSite=Strict. - The page is plain HTTP: anyone who can sniff your Wi-Fi can read it. Use it on a network you
trust, and
termux-bulk-sms service disablewhen you are done.
Good to know
- Android's own SMS limit. Android asks for confirmation when an app sends many messages in a
short time (historically 30 per 30 minutes; it varies by version and vendor), and that dialog
stalls an unattended run. Watch the first larger campaign. The limit can be raised over adb:
adb shell settings put global sms_outgoing_check_max_count 500. - Battery. A wake lock is held while a campaign runs — for an ongoing campaign, for as long as it is on. Also exempt Termux and Termux:API from battery optimisation, or Android may suspend them with the screen off.
- The law. In most countries unsolicited marketing texts to private persons need prior consent, and every message must say who it is from and how to decline. The page reminds you when the message has no "stop" wording; the block list is where a "stop" reply belongs. Your carrier's terms may also limit bulk sending from a consumer subscription.
Releasing
- Bump
__version__intermux_bulk_sms/__init__.py, commit, push. - Publish a GitHub release with the tag
v<version>. Thepublishworkflow runs the tests, builds and uploads to PyPI.
One-time setup on pypi.org: Your account → Publishing → Add a pending publisher with owner
Logicbloke, repository termux-bulk-sms, workflow publish.yml, environment pypi.
Tests
python -m unittest discover -s tests
Licence
MIT — see LICENSE.
Metadata
Release files for termux-bulk-sms 0.1.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 |
|---|---|---|---|---|
| termux_bulk_sms-0.1.0.tar.gz | 43.1 kB | With provenance | Details for termux_bulk_sms-0.1.0.tar.gz |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Actions Reset |
|---|---|---|---|---|
| termux_bulk_sms-0.1.0-py3-none-any.whl | Python 3 | none | any | Details for termux_bulk_sms-0.1.0-py3-none-any.whl |
Total release size: 83.8 kB
Release files / termux_bulk_sms-0.1.0.tar.gz
| Download URL | termux_bulk_sms-0.1.0.tar.gz |
|---|---|
| Size | 43.1 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
1538397f4861b39ef2b0901bdfd2530f58ecaa63f2f7186890b595a58474c589
|
|
BLAKE2b-256 checksum How to use checksums |
0de3c0c10d27cc743478bb2ecb0249d647f6bbbbe9a10d0f1e68121ad41bc432
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
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 Oct 11, 2026.
Transparency logRelease files / termux_bulk_sms-0.1.0-py3-none-any.whl
| Download URL | termux_bulk_sms-0.1.0-py3-none-any.whl |
|---|---|
| Size | 40.7 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
092dd6f9b2940bda53a9d53dec814491452a926f56af174648b0098f73bd0ae5
|
|
BLAKE2b-256 checksum How to use checksums |
434bddce3e68ea02406ea164b72d8a1060ccb7554feee15fefc04cc19999c3bd
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
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 Oct 11, 2026.
Transparency logSecurity for termux-bulk-sms 0.1.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
All 2 files in this release have been uploaded with provenance metadata.
Attestation for termux_bulk_sms-0.1.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 Oct 11, 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.