Multi-user backend for the SMS Forwarder app
Receives SMS uploaded by the phone client, authenticates the user, and fans each message out to that user's configured channels (Telegram). A single Go binary with an embedded SQLite store and a built-in operator console — no external services.
📱 Client: SMSForwarder · 🌐 SMS Forwarder — forward SMS to Telegram
smsforwarder-api is the server half of SMS Forwarder. The phone uploads each incoming
SMS to POST /api/sms; the server authenticates the device, stores the message briefly,
and delivers it to the user's channels. Channel configuration lives here (not in the app),
so channels can be added or changed without rebuilding the client.
It is relay-and-forget: an SMS body lives in the outbox only until it is delivered (or
finally given up), then it is deleted. Only metadata is kept in the delivery log, visible
to the operator. Modeled on the sibling secretary project — one binary, pure-Go SQLite
(no CGO), no message queue, no ORM.
The official instance is operated by ShuttleLab at smsforwarder-api.shuttlelab.org, and
the released app talks to it out of the box. Because the code is open source (AGPL-3.0),
anyone can also run their own instance.
- Multi-user — open registration; every user's data is isolated (all queries scoped by
user_id). A message can never be delivered to the wrong user. - Device-token auth — sign in once with username + password; the server issues a long-lived, per-device token (stored only as a SHA-256 hash) that the app sends as a Bearer credential. Tokens are individually revocable.
- Telegram channel — one shared bot handles every user. Users bind by tapping a deep link and pressing Start (
/bindflow); the bot writes theirchat_idstraight into the store. No tokens to copy. - Durable delivery — an outbox with an exponential-backoff retry engine survives restarts; long messages are auto-split under Telegram's 4096-char limit; delivery is idempotent via a per-message key.
- Self-healing bindings — when Telegram reports a recipient is unreachable (403: bot blocked / can't initiate), the binding is cleared automatically so the app prompts the user to re-bind. There is also a one-click "reset all Telegram bindings" for when the shared bot is replaced.
- Operator admin console — an embedded web UI (session-cookie login) for delivery logs, user management (ban / revoke devices / delete), per-table data maintenance, and bot settings. Binds to localhost by default.
- No cloud dependencies — no GMS/FCM, no external DB. A single binary plus one SQLite file.
- Language: Go 1.25
- Storage:
modernc.org/sqlite(pure Go, no CGO), WAL mode - Telegram:
github.com/go-telegram/bot(long polling) - HTTP / admin UI: standard library
net/http+html/template - Passwords: bcrypt (
golang.org/x/crypto)
- Go 1.25+
cp .env.example .env # optional; sane defaults otherwise
go run . # API on HTTP_ADDR (default 0.0.0.0:8090)Configuration (env, see .env.example): DB_PATH, HTTP_ADDR, RATE_LIMIT_PER_MIN,
BOT_TOKEN, BOT_USERNAME, ADMIN_ADDR, ADMIN_USER, ADMIN_PASSWORD. The Telegram bot
token/username may also be set in the admin console (DB overrides env; a changed token
needs a restart).
| Method | Path | Auth | Notes |
|---|---|---|---|
| GET | /healthz |
– | liveness |
| POST | /api/register |
– | {username, password} |
| POST | /api/login |
– | {username, password, device_label?} → {device_token} |
| POST | /api/logout |
Bearer | revoke current device |
| GET | /api/channels |
Bearer | list |
| POST | /api/channels |
Bearer | {type, config, enabled?} |
| GET/PATCH/DELETE | /api/channels/{id} |
Bearer | read / edit / delete |
| POST | /api/channels/{id}/test |
Bearer | send a test message now |
| POST | /api/channels/{id}/bind-code |
Bearer | Telegram bind code + deep link |
| POST | /api/sms |
Bearer | {sender, content, timestamp(ms), client_msg_id} |
Send Authorization: Bearer <device_token> (returned once at login).
Channel config per type: telegram → {"chat_id":<int>} (filled by /bind).
Enabled when ADMIN_USER + ADMIN_PASSWORD are set; served on ADMIN_ADDR
(default 127.0.0.1:8091). Keep it on localhost and reach it over an SSH tunnel:
ssh -L 8091:127.0.0.1:8091 <server> # then open http://127.0.0.1:8091/adminSingle binary + SQLite, same host as secretary. Expose it with a Cloudflare Tunnel
(no public inbound port): add an ingress rule mapping smsforwarder-api.shuttlelab.org
to http://127.0.0.1:8090, then run behind systemd.
go build -o smsforwarder-api .
# HTTP_ADDR=127.0.0.1:8090 (localhost only; the tunnel reaches it)
sudo systemctl restart smsforwarder-api
curl -s https://smsforwarder-api.shuttlelab.org/healthz # {"ok":true}Licensed under the GNU Affero General Public License v3.0 (AGPL-3.0) — see LICENSE. You may use, study, modify and redistribute it, but any distributed or network-served derivative (including a hosted instance) must also be released under AGPL-3.0.