RelaySMS Publisher
Publishes to online platforms, such as Gmail, from content people send by SMS, so they can post without an internet connection.
How it works
---
config:
flowchart:
curve: linear
---
flowchart TB
app["RelaySMS app"]
gateway["Gateway client"]
mailbox["Mailbox"]
platform["Online platforms"]
subgraph publisher["Publisher"]
grpc["gRPC API"]
rest["REST API"]
smtp["Email listener"]
db[("Database")]
worker["Celery worker"]
adapter["Platform adapter"]
%% Keeps the title clear of the arrows entering the box.
spacer[" "]:::hidden
spacer ~~~ worker
end
app -- "link an account, online" --> grpc
app -- "encrypted SMS" --> gateway
gateway -- "HTTPS" --> rest
gateway -- "email" --> mailbox
mailbox -- "IMAP" --> smtp
grpc -- "token, keys" --> db
db --> worker
rest --> worker
smtp --> worker
grpc --> adapter
worker --> adapter
adapter --> platform
classDef hidden fill:none,stroke:none
- While online, a user links an account through the gRPC API. The Publisher stores its token encrypted.
- Offline, the app encrypts the content and sends it by SMS to a gateway client, a phone number that relays SMS.
- The gateway client posts the payload to the REST API, or emails it to a mailbox the Publisher reads.
- A Celery worker decrypts the payload and publishes it through the platform’s adapter.
Key terms
- Gateway client: a phone number that relays SMS to the Publisher, such as an Android phone running Deku SMS. See Connecting a relay.
- Adapter (platform adapter): the plugin that publishes to one platform, such as Gmail, installed from its own repository. See Managing adapters.
- Linked account: a user’s account on a platform, authorized once while online. The Publisher stores its token encrypted.
- OAuth2 and PNBA: how accounts link. OAuth2 goes through the platform’s consent page, as for Gmail. Phone number-based authentication (PNBA) uses a code sent to the user’s phone, as for Telegram. The provider guides give each platform’s protocol.
- Offline payload: content sent without a linked account, published through RelaySMS Mail. See Offline publishing.
The glossary defines the rest, and Architecture shows every component.
Where to start
| To | Read |
|---|---|
| Try the Publisher on your machine | Quick start |
| Run it in production | Requirements, then Install on a server |
| Offer a platform | Managing adapters |
| Relay SMS to the Publisher | Managing gateway clients |
| Build a client | gRPC API and REST API |
| Understand the protocol | Security model and the RelaySMS whitepaper |
Getting help
- Ask questions and report bugs in the issue tracker; CONTRIBUTING.md says what a useful report has.
- Report vulnerabilities privately, as SECURITY.md describes.
Quick start
Runs the Publisher on your machine with Docker, to try it before installing it on a server.
Requirements
-
Docker with the Compose plugin: Docker Engine on Linux, or Docker Desktop, which includes Compose, on macOS and Windows. On Linux, add yourself to the
dockergroup or prefix thedockercommands withsudo. -
Git,
makeandopenssl: on macOS,xcode-select --installprovides Git andmake, andopensslships with the system. On Windows, run the steps in WSL. On Ubuntu or Debian:sudo apt-get install -y git make openssl
Check them:
docker compose version
git --version
make --version
openssl version
Steps
-
Clone the repository with its submodule:
git clone --recurse-submodules https://github.com/smswithoutborders/RelaySMS-Publisher.git cd RelaySMS-Publisher -
Create
.envwith fresh encryption keys:make .env -
Build and start. The first build compiles the payload library in Rust and takes several minutes:
docker compose up -d --build -
Check that it’s up:
curl http://localhost:16000/healthYou should see
{"status":"ok"}. If the connection fails, the container is still applying migrations;docker compose logs -fshows its progress. -
Create an administrator credential:
docker compose exec publisher \ python -m publisher creds create --username admin --administratorImportant
The password is printed only once.
-
Use it on the REST API:
curl -u admin:<PASSWORD> http://localhost:16000/v1/auth/meYou should see
"username":"admin"and"administrator":true.
To stop the Publisher, run docker compose down. To start again from scratch, also run sudo rm -rf data .env.
Next steps
The pages below run the admin CLI as sudo ./publisher.sh; with Docker, use docker compose exec publisher python -m publisher instead.
- Add a platform
- Register a gateway client
- Install on a server for production
Requirements
- Operating system: Ubuntu 24.04 or Debian 13, or any Linux host with Docker.
- Python: 3.12 or newer. The installer installs it, with the other system packages and Rust.
- Database: SQLite (the default) or a database server. See Database for the supported ones.
- Domain: a DNS name pointing at the server, for the TLS certificate. See Reverse proxy and TLS.
- Inbound ports (firewall): 80 and 443 for nginx. On a server install, the services listen on
127.0.0.1only. Docker publishesPORT(16000) andGRPC_PORT(6000). - Outbound access:
github.com,pypi.org, the Rust installer and the system’s package mirrors to install the Publisher and its adapters; Let’s Encrypt for certificates; each platform’s API to publish. - Platform credentials: from each platform you offer, such as a Google Cloud OAuth client for Gmail. See Provider guides.
- Gateway client: at least one phone number that relays SMS, such as an Android phone running Deku SMS. See Connecting a relay.
Install on a server
The installer sets up the Publisher on Ubuntu 24.04 or Debian 13 under systemd, in /opt/relaysms/relaysms-publisher. Check the requirements first.
Run the installer
Tip
For a dedicated service user with no login, run the installer from a root shell (
sudo -i). The service user can’t be changed later without reinstalling.
curl -fsSL https://raw.githubusercontent.com/smswithoutborders/RelaySMS-Publisher/main/install.sh \
| sudo bash
To read the script before running it:
curl -fsSLO https://raw.githubusercontent.com/smswithoutborders/RelaySMS-Publisher/main/install.sh
less install.sh
sudo bash install.sh
It asks about each option you don’t pass as a flag. To choose up front, pass flags after bash -s --:
curl -fsSL https://raw.githubusercontent.com/smswithoutborders/RelaySMS-Publisher/main/install.sh \
| sudo bash -s -- --site-name publisher.example.com --setup-db postgres
| Flag | Does |
|---|---|
--site-name <DOMAIN> | Puts nginx with a Let’s Encrypt certificate in front (details) |
--letsencrypt-email <EMAIL> | Email for Let’s Encrypt renewal notices |
--docs-site-name <DOMAIN> | Serves this manual on its own domain (details) |
--skip-nginx | Skips nginx and certificates entirely |
--setup-db mysql|postgres | Uses MySQL or PostgreSQL instead of SQLite (details) |
--setup-broker rabbitmq | Uses RabbitMQ instead of SQLite as the Celery broker (details) |
--setup-observability | Adds SigNoz and Uptime Kuma (details) |
--install-dir <PATH>, --instance-name <NAME> | Installs elsewhere, or a second copy on the same host (details) |
--branch <BRANCH> | Installs another branch than main |
install.sh --help lists every flag, including those for an existing database or broker.
The installer:
- installs the system packages and Rust;
- picks the service user: the user who ran
sudo, or a newrelaysmsuser when run from a root shell; - clones the repository, builds it and creates
.envwith fresh encryption keys; - sets up the database and broker you chose, and applies the database migrations;
- installs and starts the systemd services;
- sets up nginx and the certificate, if you gave a domain.
Running it again updates the install in place and keeps its .env. Run it as the same user as the first time; it refuses another service user.
After installing
-
Go to the install directory and check that the services are up:
cd /opt/relaysms/relaysms-publisher sudo ./manage.sh status curl http://127.0.0.1:16000/healthYou should see
{"status":"ok"}, also athttps://<DOMAIN>/healthonce nginx is set up. -
The defaults suit one server behind nginx. Change
.envonly for what they don’t cover, such as a MySQL or PostgreSQL database (settings reference), then check it and restart:sudo ./manage.sh check sudo ./manage.sh restart -
Create an administrator credential:
sudo ./publisher.sh creds create --username ops --administratorImportant
The password is printed only once.
-
Back up
.env: its encryption keys are the only way to read the data.
Serving the manual
--docs-site-name builds this manual into book/ and serves it with nginx on its own domain and certificate. manage.sh update rebuilds it.
curl -fsSL https://raw.githubusercontent.com/smswithoutborders/RelaySMS-Publisher/main/install.sh \
| sudo bash -s -- --site-name publisher.example.com --docs-site-name docs.publisher.example.com
On an install that’s already running, add it from the install directory:
sudo ./manage.sh docs docs.publisher.example.com
Next steps
- Add a platform adapter, then follow its provider guide.
- Register a gateway client and connect its relay.
Install with Docker
docker-compose.yml runs every service in one container, with its data in ./data and its settings in .env (see the settings reference). It needs the tools in the quick start’s requirements.
git clone --recurse-submodules https://github.com/smswithoutborders/RelaySMS-Publisher.git
cd RelaySMS-Publisher
make .env
docker compose up -d --build
The container applies pending migrations, then starts every service. It publishes PORT (16000) and GRPC_PORT (6000) on every interface. To serve it publicly, bind them to 127.0.0.1 in docker-compose.yml and put nginx with TLS in front.
Run the admin CLI with docker compose exec publisher python -m publisher <COMMAND>.
Back up .env and ./data: its encryption keys are the only way to read the data.
To update, see Upgrading.
Next steps
Continue with After installing from step 3, running the CLI as docker compose exec publisher python -m publisher.
Install by hand
These are the steps the installer runs, for when you need to change one. Run them in a root shell (sudo -i).
-
Install the system packages:
apt-get update apt-get install -y python3 python3-pip python3-venv python3-dev \ build-essential pkg-config libsqlcipher-dev libmagic1 openssl git make curl -
Install Rust, which builds the payload library:
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y source "$HOME/.cargo/env" -
Create the service user:
useradd --system --no-create-home --shell /usr/sbin/nologin relaysms -
Clone the repository with its submodule:
git clone --recurse-submodules \ https://github.com/smswithoutborders/RelaySMS-Publisher.git \ /opt/relaysms/relaysms-publisher cd /opt/relaysms/relaysms-publisher -
Create the virtualenv and build the gRPC code and payload library:
python3 -m venv venv venv/bin/pip install --upgrade pip venv/bin/pip install -r requirements.txt make build -
Create
.envwith fresh encryption keys, then set what you need (see the settings reference):make .env chown root:relaysms .env chmod 640 .env -
Create the data directories. By default the SQLite database, Celery files and adapters all live under
data/:mkdir -p data/platforms/{adapters,venvs,config,state} chown -R relaysms: data chmod -R 750 dataIf
.envmoves any of these paths out ofdata/(SQLITE_DATABASE_PATH,CELERY_BROKER_DB_PATH,CELERY_RESULT_DB_PATH,CELERY_BEAT_SCHEDULE_PATHand the fourPLATFORMS_ADAPTERS_*directories), create its parent directory and add it toRW_PATHSin step 9. Everything else is read-only to the services.For MySQL, PostgreSQL or RabbitMQ, run its setup script now (Database, Celery broker).
-
Check the configuration and apply the database migrations, as the service user:
sudo -u relaysms venv/bin/python -m publisher config check sudo -u relaysms venv/bin/python -m alembic upgrade head -
Install and start the systemd units.
RW_PATHSlists the directories the services may write to:RW_PATHS="$(pwd)/data" for unit in deploy/systemd/*; do sed -e "s|/opt/relaysms/relaysms-publisher|$(pwd)|g" \ -e "s|__RW_PATHS__|$RW_PATHS|" \ "$unit" > "/etc/systemd/system/$(basename "$unit")" done systemctl daemon-reload systemctl enable --now relaysms-publisher.target
Next steps
Set up nginx and TLS, then continue with After installing.
Database
| Dialect | Use it for | Encryption at rest |
|---|---|---|
| SQLite (default) | One server with light load | SQLCipher, with DATABASE_ENCRYPTION_KEY |
| MySQL 8.0.28+ or MariaDB | Heavier load, or a database on another host | The server’s own (TDE) |
| PostgreSQL 12+ | Heavier load, or a database on another host | The disk’s |
In every dialect, the server’s private keys are encrypted, and so are platform tokens while field encryption is on; see Encryption keys.
Database servers
A setup script installs the server, creates the database and user, and writes the connection details to .env. Running it again without --db-password gives the user a new password. On Debian, install MariaDB yourself and pass --db-existing.
During install:
sudo ./install.sh --setup-db postgres
On an existing install, from the install directory:
sudo ./scripts/setup-postgres.sh --db-name relaysms --db-user relaysms
For MySQL or MariaDB, use --setup-db mysql or scripts/setup-mysql.sh the same way.
--db-password <PASSWORD> sets the password instead of generating one.
To use a server you already have, pass its details. The script only checks the connection and writes .env; it never changes the server:
sudo ./install.sh --setup-db postgres --db-existing \
--db-host db.example.com --db-port 5432 \
--db-name relaysms --db-user relaysms --db-password '<PASSWORD>'
On an existing install:
sudo ./scripts/setup-postgres.sh --existing \
--db-host db.example.com --db-name relaysms --db-user relaysms --db-password '<PASSWORD>'
Or set it by hand in .env. For PostgreSQL:
DATABASE_DIALECT=postgres
POSTGRES_HOST=127.0.0.1
POSTGRES_PORT=5432
POSTGRES_USER=relaysms
POSTGRES_PASSWORD=<PASSWORD>
POSTGRES_DATABASE=relaysms
For MySQL or MariaDB:
DATABASE_DIALECT=mysql
MYSQL_HOST=127.0.0.1
MYSQL_PORT=3306
MYSQL_USER=relaysms
MYSQL_PASSWORD=<PASSWORD>
MYSQL_DATABASE=relaysms
Then apply the migrations and restart:
sudo ./manage.sh migrate
sudo ./manage.sh restart
The new database starts empty: switching doesn’t copy the data from SQLite.
Celery broker
The REST API and the email listener queue publications for the Celery worker through a broker.
CELERY_BROKER_TYPE | Use it for | Worker concurrency by default |
|---|---|---|
sqlite (default) | Light load. Needs no server. | 1 |
rabbitmq | Heavier load | 4 |
redis | Heavier load, with a Redis server you already run | 4 |
CELERY_WORKER_CONCURRENCY overrides the concurrency.
RabbitMQ
A setup script installs RabbitMQ, creates a vhost and user, and writes the broker settings to .env.
During install:
sudo ./install.sh --setup-broker rabbitmq
On an existing install, from the install directory:
sudo ./scripts/setup-rabbitmq.sh --broker-vhost relaysms --broker-user relaysms
--broker-password <PASSWORD> sets the password instead of generating one. To use a broker you already have, add --existing (--broker-existing on install.sh) with --broker-host. The script checks the credentials against the management API (port 15672, or --broker-mgmt-port) and changes nothing on the broker.
By hand
For RabbitMQ, in .env:
CELERY_BROKER_TYPE=rabbitmq
CELERY_RABBITMQ_URL=amqp://relaysms:<PASSWORD>@localhost:5672/relaysms
For Redis:
CELERY_BROKER_TYPE=redis
CELERY_REDIS_URL=redis://localhost:6379/0
Then restart the services with sudo ./manage.sh restart.
Reverse proxy and TLS
The services listen on 127.0.0.1. nginx serves them on port 443 with a Let’s Encrypt certificate, from the site template. It sends /publisher.v3.Publisher and /grpc.health.v1.Health to gRPC on GRPC_PORT, and everything else to REST on PORT. It rate-limits logins and HTTP Basic requests (limits).
With the installer
Pass --site-name <DOMAIN> to the installer. Running it again leaves an existing site file alone, and skips Certbot when the domain already has a certificate; use manage.sh nginx to render the site again.
On an existing install
manage.sh nginx renders the site from the template, obtains or reattaches its certificate, and turns on HTTP/2, which gRPC needs. Run it to add nginx later, or after an update that changes the template. It keeps the previous file as <SITE>.conf.bak and restores it if the new one fails.
sudo apt-get install -y nginx certbot python3-certbot-nginx
sudo ./manage.sh nginx publisher.example.com
By hand
With nginx and Certbot installed as above:
sudo sed -e "s/__SERVER_NAME__/publisher.example.com/g" \
-e "s/__REST_PORT__/16000/g" -e "s/__GRPC_PORT__/6000/g" \
deploy/nginx/relaysms-publisher-nginx.conf.template |
sudo tee /etc/nginx/sites-available/publisher.example.com.conf >/dev/null
sudo ln -s /etc/nginx/sites-available/publisher.example.com.conf /etc/nginx/sites-enabled/
sudo nginx -t && sudo systemctl reload nginx
sudo certbot --nginx -d publisher.example.com --redirect
Then turn on HTTP/2 in the HTTPS block Certbot added: add http2 on; after listen 443 ssl; (nginx 1.25.1 or newer), or change it to listen 443 ssl http2;. Reload nginx.
gRPC TLS without nginx
The gRPC server can serve TLS itself. The REST API can’t, so it still needs a reverse proxy.
GRPC_HOST=0.0.0.0
GRPC_TLS_ENABLED=true
GRPC_TLS_CERT_FILE=/path/to/fullchain.pem
GRPC_TLS_KEY_FILE=/path/to/privkey.pem
Multiple instances
To run a second Publisher on the same host, give it its own install directory, instance name and ports:
-
Install it without nginx. When it shares a database or RabbitMQ server with the first instance, give it its own user: pass
--db-name acme --db-user acmeor--broker-vhost acme --broker-user acme.sudo ./install.sh --install-dir /srv/relaysms-acme --instance-name acme --skip-nginx -
Set
PORTandGRPC_PORTin/srv/relaysms-acme/.envto ports no other instance uses. -
Restart it, then put nginx in front:
cd /srv/relaysms-acme sudo ./manage.sh restart sudo ./manage.sh nginx acme.example.com
Its units are named after the instance (relaysms-publisher-acme.target, relaysms-publisher-acme-rest.service, …), and the manage.sh in each install directory manages only its own instance. Running install.sh again on that directory reuses the name; a different --instance-name is refused.
Settings reference
Settings live in .env in the install directory. template.env lists each one with its default.
sudo ./manage.sh checkreports missing or invalid settings; each service refuses to start on an error in the settings it uses.- Restart the services after a change:
sudo ./manage.sh restart. - After an update,
sudo ./sync-env.shadds the settings fromtemplate.envthat.envis missing, and shows the change before writing it. - On Docker, check with
docker compose exec publisher python -m publisher config checkand apply a change withdocker compose up -d.
Server
| Setting | Default | Description |
|---|---|---|
HOST | 127.0.0.1 | REST API address. Docker always binds 0.0.0.0. |
PORT | 16000 | REST API port |
WORKERS | None | REST API worker processes: 1 by default under scripts/run.sh, 4 on Docker and server installs |
GRPC_HOST | 127.0.0.1 | gRPC address. Docker always binds 0.0.0.0. |
GRPC_PORT | 6000 | gRPC port |
GRPC_MAX_WORKERS | 10 | gRPC worker threads |
GRPC_TLS_ENABLED | false | Serves TLS on GRPC_PORT itself; leave off behind nginx (details) |
GRPC_TLS_CERT_FILE, GRPC_TLS_KEY_FILE | None | Certificate and key. Required when GRPC_TLS_ENABLED=true. |
NONCE_TTL_SECONDS | 600 | How far a gRPC request’s timestamp may be from the server’s clock; nonces are remembered this long to refuse replays |
Database
See Database and Encryption keys.
| Setting | Default | Description |
|---|---|---|
DATABASE_DIALECT | sqlite | sqlite, mysql (MySQL and MariaDB) or postgres |
SQLITE_DATABASE_PATH | data/relaysms.db | SQLite database file |
MYSQL_HOST | 127.0.0.1 | MySQL server |
MYSQL_PORT | 3306 | MySQL port |
MYSQL_USER, MYSQL_PASSWORD | None | Required for mysql |
MYSQL_DATABASE | relaysms | Required for mysql; created if it doesn’t exist |
POSTGRES_HOST | 127.0.0.1 | PostgreSQL server |
POSTGRES_PORT | 5432 | PostgreSQL port |
POSTGRES_USER, POSTGRES_PASSWORD | None | Required for postgres |
POSTGRES_DATABASE | relaysms | Required for postgres; created if it doesn’t exist |
DATABASE_ENCRYPTION_ENABLED | true (false if unset) | Encrypts the SQLite file with SQLCipher. No effect on MySQL or PostgreSQL. |
DATABASE_ENCRYPTION_KEY | None | Required for SQLite when encryption is on |
DATABASE_FIELD_ENCRYPTION_ENABLED | true (false if unset) | Encrypts linked accounts’ platform tokens |
DATABASE_FIELD_ENCRYPTION_KEY | None | Required when field encryption is on |
DATA_ENCRYPTION_KEY | None | Required. Encrypts the server’s private keys, and keys the hash that replaces each sender’s address. |
Celery
See Celery broker.
| Setting | Default | Description |
|---|---|---|
CELERY_BROKER_TYPE | sqlite | sqlite, redis or rabbitmq |
CELERY_WORKER_CONCURRENCY | 1 for sqlite, else 4 | Tasks the worker runs at once |
CELERY_BROKER_DB_PATH | data/celery_broker.db | Broker file when CELERY_BROKER_TYPE=sqlite |
CELERY_RESULT_DB_PATH | data/celery_results.db | Result file when CELERY_BROKER_TYPE=sqlite |
CELERY_REDIS_URL | redis://localhost:6379/0 | Broker and result backend when CELERY_BROKER_TYPE=redis |
CELERY_RABBITMQ_URL | amqp://guest:guest@localhost:5672// | Broker when CELERY_BROKER_TYPE=rabbitmq |
CELERY_BEAT_SCHEDULE_PATH | data/celerybeat-schedule | Beat’s schedule file |
CELERY_CLEANUP_CRON | 0 */3 * * * | When to clean up expired data (below) |
CELERY_TOKEN_CLEANUP_CRON | 0 3 * * * | When to remove idle tokens (below) |
Cleanup jobs
Both schedules are 5-field cron expressions in UTC.
CELERY_CLEANUP_CRONdeletes incomplete payloads, ended web sessions, and audit events and adapter jobs past retention, and fails abandoned adapter jobs.CELERY_TOKEN_CLEANUP_CRONdeletes tokens idle pastTOKEN_IDLE_MAX_AGE_DAYSand revokes them on their platform.
Retention
| Setting | Default | Description |
|---|---|---|
PAYLOAD_SESSION_MAX_AGE_HOURS | 3 | Hours before an incomplete multi-part payload is discarded |
TOKEN_IDLE_MAX_AGE_DAYS | 90 | Days without use before a linked account’s token is deleted and revoked on its platform |
AUDIT_RETENTION_DAYS | 365 | Days to keep audit events and adapter install and update jobs |
Offline publishing
See Offline publishing.
| Setting | Default | Description |
|---|---|---|
OFFLINE_PUBLISH_ALLOWED_PROTOCOLS | smtp (empty if unset) | Comma-separated protocols whose offline payloads publish: https, smtp. Empty allows both. |
OFFLINE_PUBLISH_SHARED_SECRET | None | 64 hex characters. Required when https is allowed. |
SMTP transport
See SMTP transport.
| Setting | Default | Description |
|---|---|---|
SMTP_TRANSPORT_ENABLED | false | Runs the email listener |
SMTP_IMAP_SERVER, SMTP_IMAP_USERNAME, SMTP_IMAP_PASSWORD | None | IMAP host and login. Required when the transport is enabled. |
SMTP_IMAP_PORT | 993 | IMAP port |
SMTP_IMAP_MAIL_FOLDER | INBOX | Comma-separated folders to watch, such as INBOX,Spam |
SMTP_TLS_CLIENT_CERTIFICATE, SMTP_TLS_CLIENT_KEY | None | mTLS client certificate and key, if the provider requires one. Set both or neither. |
SMTP_ALLOWED_SENDERS | None | Comma-separated addresses and domains allowed to send. Empty allows nothing. |
SMTP_TRUSTED_AUTHSERV_ID | None | authserv-id whose Authentication-Results verdicts are trusted, such as mx.google.com. Required when the transport is enabled. |
SMTP_REQUIRE_DKIM, SMTP_REQUIRE_SPF | true | Require dkim=pass and spf=pass |
SMTP_VERIFY_DKIM_INDEPENDENTLY | false | Also re-verifies the DKIM signature against DNS |
Platform adapters
See Managing adapters. Relative paths are from the install directory.
| Setting | Default | Description |
|---|---|---|
PLATFORMS_ADAPTERS_DIR | data/platforms/adapters | Directory for adapters’ code |
PLATFORMS_ADAPTERS_VENV_DIR | data/platforms/venvs | Directory for adapters’ virtualenvs |
PLATFORMS_ADAPTERS_CONFIG_DIR | data/platforms/config | Directory for adapters’ credentials.json files |
PLATFORMS_ADAPTERS_STATE_DIR | data/platforms/state | Directory for files adapters write, such as databases |
PLATFORMS_GITHUB_ORGS | None | Comma-separated GitHub orgs that administrators may install and update adapters from over the REST API. Empty refuses REST installs; publisher.sh platforms is unaffected. |
REST API access
| Setting | Default | Description |
|---|---|---|
AUTH_WEB_ORIGINS | None | Comma-separated web client origins allowed to use a session cookie, such as https://console.example.com |
AUTH_SESSION_COOKIE_SECURE | true | Sends the session cookie over HTTPS only. Set false only for local HTTP development. |
AUTH_SESSION_IDLE_MINUTES | 30 | Minutes of inactivity that end a web session |
AUTH_SESSION_MAX_HOURS | 12 | Hours after login when a web session ends, even if it’s active |
API_DOCS_ENABLED | false | Serves interactive API docs at /docs and the schema at /openapi.json. For development. |
AUTH_WEB_ORIGINS origins must be on the same site as the API. Leave it empty when the web client is served from the API’s own origin.
Logging and monitoring
See Observability.
| Setting | Default | Description |
|---|---|---|
LOG_LEVEL | info | debug, info, warning, error or critical |
OTEL_EXPORTER_OTLP_ENDPOINT | None | OTLP collector URL for traces, metrics and logs, such as http://localhost:4317. Needs requirements-observability.txt. Blank turns all three off. |
OTEL_EXPORTER_OTLP_PROTOCOL | grpc | grpc or http/protobuf |
OTEL_TRACES_SAMPLER | parentbased_traceidratio | Which traces to keep |
OTEL_TRACES_SAMPLER_ARG | 1.0 | Fraction of traces kept, 0 to 1 |
OTEL_LOGS_EXPORTER | otlp | otlp sends logs to the collector; none doesn’t |
OTEL_PYTHON_LOG_CORRELATION | true | Adds trace and span IDs to log records |
UPTIME_KUMA_WORKER_PUSH_URL, UPTIME_KUMA_SMTP_PUSH_URL | None | Uptime Kuma push URLs for the worker and email listener heartbeats. Blank turns them off. |
Encryption keys
Three keys in .env protect the data. The installer and make .env generate them. Each is 64 hex characters; openssl rand -hex 32 makes one, and manage.sh check reports a missing or malformed key.
| Setting | Protects | Used |
|---|---|---|
DATA_ENCRYPTION_KEY | The server’s private keys, and the keyed hash that replaces each sender’s address | Always |
DATABASE_FIELD_ENCRYPTION_KEY | Linked accounts’ platform tokens | When field encryption is on |
DATABASE_ENCRYPTION_KEY | The whole SQLite database file (SQLCipher) | When encryption is on and the database is SQLite |
Caution
Back up
.envwhenever you back up the database, store it apart (Backup and restore), and never change a key once the install holds data. Data encrypted with a lost or changed key can’t be read again, and a newDATA_ENCRYPTION_KEYalso gives every sender a new hash.
Offline publishing
Offline payloads let people send email through RelaySMS Mail without linking an account; linked accounts need none of this (settings). The app encrypts an offline payload to the server’s identity key, and the Publisher publishes it through the rmail adapter, which must be installed. Anyone who can reach an allowed protocol can send one.
Each payload is tagged with the protocol it arrived on:
-
https: RESTPOST /v1/publications -
smtp: the SMTP transport -
An offline payload from a protocol not in
OFFLINE_PUBLISH_ALLOWED_PROTOCOLSis discarded. -
An offline payload over
httpsmust carryOFFLINE_PUBLISH_SHARED_SECRETin the request’stagfield, or it’s discarded. -
Leaving
OFFLINE_PUBLISH_ALLOWED_PROTOCOLSempty, or listinghttps, without the secret failsmanage.sh check, and the services refuse to start. -
A discarded payload is logged and recorded as
failedin the publication stats, with reasonprotocol_not_allowed,offline_tag_missingoroffline_tag_invalid.POST /v1/publicationsstill returns success, since the check runs in the queue.
Allowing https
-
Generate a secret:
openssl rand -hex 32 -
Set
OFFLINE_PUBLISH_SHARED_SECRETto it, and addhttpstoOFFLINE_PUBLISH_ALLOWED_PROTOCOLS, such assmtp,https. -
Check and restart:
sudo ./manage.sh check sudo ./manage.sh restart
Next steps
Connect each relay that posts over https with the secret as its tag (FORWARD_TAG for Twilio).
SMTP transport
Use this when gateway clients relay payloads by email instead of HTTPS. The listener watches a mailbox over IMAP, checks each sender, and queues the payload for publication.
Set it up
-
Create a mailbox that the gateway clients email, with IMAP access.
-
In
.env, setSMTP_IMAP_SERVER,SMTP_IMAP_USERNAMEandSMTP_IMAP_PASSWORD. To also watch a spam folder, setSMTP_IMAP_MAIL_FOLDER, such asINBOX,Spam. -
Set
SMTP_ALLOWED_SENDERSto the addresses the gateway clients send from (allowlist). -
Set
SMTP_TRUSTED_AUTHSERV_IDto theauthserv-idthe mailbox’s server writes: the first word of theAuthentication-Resultsheader in a received email, such asmx.google.com(authentication). -
Set
SMTP_TRANSPORT_ENABLED=true, then check and restart:sudo ./manage.sh check sudo ./manage.sh restart
Settings reference lists every SMTP_* setting.
Check it
sudo ./manage.sh logs --unit smtp --no-follow --lines 20
You should see Connected to mailbox <SMTP_IMAP_SERVER>.
How it works
- Waits for unseen mail in
SMTP_IMAP_MAIL_FOLDERwith IMAP IDLE. - Rejects each email unless the sender is allowlisted and authenticated.
- Parses the body as JSON and validates the payload.
- Queues the payload for publication, tagged with protocol
smtp. Whether offline payloads from email publish depends on offline publishing. - Deletes each handled email. An email that hits an unexpected error stays in the folder and is retried on the next cycle.
Allowlist
SMTP_ALLOWED_SENDERS lists exact addresses and domains, comma-separated. Empty allows nothing.
Warning
A domain entry trusts every authenticated sender on that domain, since DKIM vouches for the domain, not the mailbox. List an exact address when only one sender should be trusted.
Authentication
The listener can’t re-check SPF, which needs the sender’s IP. It reads the Authentication-Results header (RFC 8601) that the mailbox’s own server added, and only from the authserv-id in SMTP_TRUSTED_AUTHSERV_ID, since a sender can forge such a header from anyone else.
Message format
The body of each email must be a JSON object:
{
"address": "5447c1f50558292bd9df723f9fdc0b06b892199c7dcfaad5164f2d94dfd3470a",
"dialing_code": "237",
"text": "<base64-encoded serialized payload>"
}
| Field | Type | Required | Description |
|---|---|---|---|
address | string | Yes | Sender ID from the gateway client, or an E.164 phone number |
dialing_code | string | No | Sender’s country calling code, for publication stats |
text | string | Yes | Base64-encoded serialized payload |
Next steps
To publish offline payloads that arrive by email, allow smtp in Offline publishing.
Managing adapters
Each platform, such as Gmail, is served by an adapter installed from its own GitHub repository.
Run these commands from the install directory with publisher.sh, which runs them as the service user.
Commands
| Command | Does |
|---|---|
platforms add <GITHUB_URL> [--tag <TAG>] | Installs the newest version tag, or <TAG> |
platforms update [<NAME>] [--tag <TAG>] | Moves one adapter, or all, to a version tag |
platforms list | Lists every adapter, disabled ones included |
platforms disable <NAME> | Hides it from users; linked accounts can still be unlinked |
platforms enable <NAME> | Offers it again |
platforms remove <NAME> [--force] | Uninstalls it (details) |
platforms config show <NAME> | Lists its settings, without secret values |
platforms config set <NAME> <KEY>=<VALUE>|<KEY> | Changes settings; a bare <KEY> asks for its value |
platforms setup <NAME> [--input <KEY>=<VALUE>] | Runs the adapter’s setup and saves its settings |
platforms import | Registers adapter directories on disk that the database lacks, and moves their files out of the code (details) |
platforms exec <NAME> -- <COMMAND> | Runs a command the adapter installs |
Prefix each with sudo ./publisher.sh, for example:
sudo ./publisher.sh platforms add https://github.com/smswithoutborders/gmail-oauth2-adapter
Commands taking a name also take --proto-id and --cat-id, for when a name matches more than one adapter. Administrators can do the same over the REST API, but only from GitHub orgs listed in PLATFORMS_GITHUB_ORGS.
Versions
Adapters are installed and updated by version tag, such as v1.2.0, so a repository needs one before it can be added.
An update builds the new version and its virtualenv beside the running one, then switches over, so a failed update leaves the running version in place. An update is refused if the installed tag now points to a different commit: publish a new tag instead of moving one.
Removing
remove is refused while accounts are linked through the adapter, since their tokens can only be revoked through it. Disable the adapter instead.
Warning
--forceremoves it anyway and leaves those tokens unrevocable.
Files
| Directory | Holds |
|---|---|
PLATFORMS_ADAPTERS_DIR/<ID> | The adapter’s code at its tag, replaced on every update |
PLATFORMS_ADAPTERS_VENV_DIR/<ID> | Its virtualenv |
PLATFORMS_ADAPTERS_CONFIG_DIR/<ID> | Its credentials.json |
PLATFORMS_ADAPTERS_STATE_DIR/<ID> | What it writes, such as databases |
<ID> is the ID column of platforms list.
Settings
An adapter reads its settings, such as API keys, from credentials.json in its config directory. Its adapter.toml declares each one. Lists are comma-separated.
sudo ./publisher.sh platforms config set rmail smtp_host=smtp.example.com smtp_port=465
sudo ./publisher.sh platforms config set rmail smtp_password
sudo ./publisher.sh platforms config set rmail --unset alias_prefix
config set changes only the keys given. setup is for adapters whose settings come from the platform, such as a registered app for Mastodon or a downloaded client file for Gmail, and replaces all of them. --input <KEY>=@<PATH> reads a file as the service user, from the install directory, so give an absolute path it can read:
sudo ./publisher.sh platforms setup gmail --input client_file=@/tmp/client_secret.json
Redirect URIs
OAuth2 adapters, such as Gmail, send the user back to a redirect URI after they authorize. The client app chooses it and sends it to the Publisher when it links an account; the adapter uses the first of its redirect_uris setting when the app sends none.
- Run an HTTPS page that receives the authorization
codeandstateand hands them back to the app. - Configure the apps to send that page’s URI.
- Register the same URI, character for character, with the platform. A trailing slash counts.
- Set it in the adapter’s
redirect_uris, with every other URI your apps send.
Provider guides
| Platform | Adapter | Protocol |
|---|---|---|
| Gmail | gmail-oauth2-adapter | OAuth2 |
| Mastodon | mastodon-oauth2-adapter | OAuth2 |
| Bluesky | bluesky-oauth2-adapter | OAuth2 |
| Telegram | telegram-pnba-adapter | Phone number |
| RelaySMS Mail | relaysms-mail-pnba-adapter | Phone number |
An adapter’s own commands
An adapter may install admin commands. exec runs one from the adapter’s virtualenv with its config and state directories; put -- before it:
sudo ./publisher.sh platforms exec <NAME> -- <COMMAND> --help
Next steps
- Follow the provider guide for each platform you offer, then link a test account from the RelaySMS app.
- To support another platform, see Writing an adapter.
Gmail
Lets users send email from their Gmail account.
Last checked against Google Cloud Console: 2026-10-22.
Requirements
- A Google Cloud account
- An HTTPS redirect URI
Create the OAuth client
- Create a project, or pick one.
- Enable the Gmail API.
- In Google Auth Platform > Branding, click Get started.
- Enter the app name, support email and contact email. Choose External as the audience, unless every user belongs to your Google Workspace organization.
- In Data Access, add these scopes and save:
openidhttps://www.googleapis.com/auth/userinfo.emailhttps://www.googleapis.com/auth/userinfo.profilehttps://www.googleapis.com/auth/gmail.send
- In Clients, create a client of type Web application.
- Under Authorized redirect URIs, add every redirect URI your apps send, the default first, and create the client.
- Download the client’s JSON from the dialog that confirms it.
Important
Google shows the client secret only at creation. If you close the dialog without downloading the JSON, add a new secret to the client.
Configure the adapter
Copy the JSON to the server, then:
sudo ./publisher.sh platforms add \
https://github.com/smswithoutborders/gmail-oauth2-adapter
sudo ./publisher.sh platforms setup gmail \
--input client_file=@/tmp/client_secret_<ID>.apps.googleusercontent.com.json
setup copies the client ID, secret and redirect URIs from the file.
To add a redirect URI later, add it to the client in the console, then set the whole list:
sudo ./publisher.sh platforms config set gmail \
redirect_uris=https://a.example.com/callback,https://b.example.com/callback
Check it
sudo ./publisher.sh platforms config show gmail
You should see redirect_uris in the client’s order, and client_secret as (secret, set).
Testing and production
| Publishing status | Who can link | Limits |
|---|---|---|
| Testing | Test users listed under Audience | Links expire after 7 days |
| In production, unverified | Anyone, after a warning | 100 users in all |
| In production, verified | Anyone | None |
gmail.send is a sensitive scope, so verification needs:
- the redirect and home page domains verified in Google Search Console;
- a public home page and a privacy policy on that domain, describing how the app uses Gmail;
- a justification for each scope;
- an unlisted YouTube video showing the consent screen and how the app sends email.
Publish the app under Audience, then submit it in the Verification Center.
Troubleshooting
setup fails with redirect_uris is required
The client has no authorized redirect URIs. Add them in the console, then set them with config set gmail redirect_uris=....
redirect_uri_mismatch
The URI the app sent isn’t on the client. Add it exactly, trailing slash included.
Linking fails with Access wasn't granted to ...
The user unticked a permission on the consent screen. They should link again and allow every permission.
Sending fails with Google refused the refresh token
Links made while the app is in Testing expire after 7 days. Publish the app, and have users link again.
The client disappeared
Google deletes clients unused for 6 months. Restore it within 30 days, or create a new one.
Next steps
Register a gateway client to relay SMS to the Publisher.
Mastodon
Lets users post to Mastodon. The adapter’s setup registers the app on a Mastodon server, so there’s no console to visit.
Last checked against the Mastodon API: 2026-10-22.
Requirements
- A Mastodon server running 4.4 or newer: see
versionathttps://<SERVER>/api/v2/instance - An HTTPS redirect URI
The adapter serves one Mastodon server: only accounts on that server can link.
Configure the adapter
sudo ./publisher.sh platforms add \
https://github.com/smswithoutborders/mastodon-oauth2-adapter
sudo ./publisher.sh platforms setup mastodon \
--input client_name=RelaySMS \
--input redirect_uris=https://example.com/callback \
--input website=https://example.com \
--input base_url=https://mastodon.social
| Input | Required | Description |
|---|---|---|
client_name | Yes | App name on the server’s consent page |
redirect_uris | Yes | Comma-separated redirect URIs your apps send, the default first |
website | No | Home page on the consent page |
base_url | No | The server. Default: https://mastodon.social |
setup registers the app with the scopes profile, write:statuses and write:media, and saves its client ID and secret. Run it again to change the redirect URIs or the server; it registers a new app and replaces every setting. After changing the server, users must link again.
Check it
sudo ./publisher.sh platforms config show mastodon
You should see client_secret as (secret, set).
Troubleshooting
A user can’t link
Their account is on another server than base_url.
Linking fails after authorizing
The server runs Mastodon older than 4.4.
“The redirect URI included is not valid”
The app sent a URI the app registration doesn’t have. Run setup again with it.
Next steps
Register a gateway client to relay SMS to the Publisher.
Bluesky
Lets users post to Bluesky. Bluesky has no app registration: the Publisher serves the client’s metadata at a public URL, and that URL is the client ID.
Last checked against the AT Protocol OAuth spec: 2026-10-22.
Requirements
- The REST API is public over HTTPS on port 443, behind the reverse proxy.
https://<HOST>/v1/platforms/bluesky/oauth/client-metadata.jsonanswers200with JSON, with no redirect on the way. It does while the adapter is enabled.- The redirect URIs use HTTPS.
Configure the adapter
sudo ./publisher.sh platforms add \
https://github.com/smswithoutborders/bluesky-oauth2-adapter
sudo ./publisher.sh platforms config set bluesky \
client_id=https://publisher.example.com/v1/platforms/bluesky/oauth/client-metadata.json \
client_uri=https://publisher.example.com \
client_name=RelaySMS \
redirect_uris=https://example.com/callback \
"scope=atproto transition:generic"
| Setting | Required | Description |
|---|---|---|
client_id | Yes | The metadata URL above, exactly as the Publisher serves it |
client_uri | Yes | Home page on the Publisher’s host, the same host as client_id |
client_name | Yes | Name on Bluesky’s consent page |
redirect_uris | Yes | Comma-separated redirect URIs your apps send, the default first |
scope | Yes | atproto transition:generic |
pds_url | No | Auth server. Default: https://bsky.social |
Check it
curl -i https://publisher.example.com/v1/platforms/bluesky/oauth/client-metadata.json
You should see 200 and JSON whose client_id equals the URL you fetched.
Troubleshooting
Bluesky says the client metadata is invalid
client_id must match the served URL exactly: https, /v1, no trailing slash.
The metadata URL redirects or needs a login
Bluesky refuses redirects. Serve the URL directly over HTTPS.
Access wasn't granted to every scope.
Set scope to exactly atproto transition:generic.
The account uses a different auth server.
The account isn’t on pds_url’s auth server, such as one on a self-hosted PDS. Only accounts there can link.
A changed redirect URI isn’t accepted yet
Bluesky’s auth server caches the metadata. Try again later.
Next steps
Register a gateway client to relay SMS to the Publisher.
Telegram
Lets users send Telegram messages. Users link with their phone number, a login code and, if they set one, their two-step verification password.
Last checked against my.telegram.org: 2026-10-22.
Requirements
- Agreement to Telegram’s API terms of service
- A Telegram account made with an official app, on a phone number you’ll keep. Each number can create only one API ID.
Get the API ID and hash
- Sign in at my.telegram.org with the number in international format. The login code arrives in the Telegram app, not by SMS.
- Open API development tools.
- Fill in the app title and a short name (5 to 32 letters and digits), and create the application.
- Copy the App api_id and App api_hash.
The hash can’t be changed: replacing a leaked one takes a new app on another number.
Configure the adapter
sudo ./publisher.sh platforms add \
https://github.com/smswithoutborders/telegram-pnba-adapter
sudo ./publisher.sh platforms config set telegram api_id=<API_ID> api_hash
Check it
sudo ./publisher.sh platforms list --name telegram
You should see ✓ under Configured.
Troubleshooting
The form at my.telegram.org says only “ERROR”
Try again later from a home connection without a VPN, in a private window.
A user’s account is limited or banned
Heavy or spam-like sending gets accounts banned. Appeals go to recover@telegram.org.
Next steps
Register a gateway client to relay SMS to the Publisher.
RelaySMS Mail
The rmail adapter gives each user an email alias tied to their phone number, and sends email from it through SimpleLogin. Users link with a code sent over a messaging app by Shortmesh Authy. It’s the adapter that publishes offline payloads, each from a new random alias.
Last checked against SimpleLogin, Shortmesh Authy and Google: 2026-10-22.
Requirements
- A SimpleLogin Premium account, which custom domains and sending to new contacts require
- A domain whose DNS you control
- An SMTP account allowed to send as your SimpleLogin mailbox address
- A self-hosted Shortmesh Authy, to send the login codes
SimpleLogin
- Sign up at app.simplelogin.io. The signup address becomes your mailbox; to use another, add and verify it under Mailboxes.
- Add your domain, or a subdomain such as
mail.example.com, under Custom domains. - Create the DNS records the domain’s page lists: ownership TXT, MX, SPF, DKIM and DMARC. SimpleLogin marks the last three optional; without them, mail sent from aliases doesn’t arrive.
- Create an API key.
SMTP
The adapter sends each message from your mailbox address over SMTP, and SimpleLogin relays it from the user’s alias.
For a Gmail mailbox, use smtp.gmail.com, port 465, the Gmail address, and an app password, which needs 2-Step Verification on the account.
Shortmesh Authy
Run Authy-API with its Interface-API, then in the Interface-API’s admin UI:
- Under Devices, link the phone that sends the codes.
- Under Tokens, create a token.
Configure the adapter
This example uses a Gmail mailbox:
sudo ./publisher.sh platforms add \
https://github.com/smswithoutborders/relaysms-mail-pnba-adapter
sudo ./publisher.sh platforms config set rmail \
sl_primary_email=you@gmail.com sl_primary_domain=mail.example.com \
smtp_host=smtp.gmail.com smtp_port=465 smtp_username=you@gmail.com \
authy_base_url=https://authy.example.com authy_sender=+237600000000
sudo ./publisher.sh platforms config set rmail sl_api_key smtp_password authy_token
sudo ./publisher.sh platforms config show rmail lists the optional settings, such as alias prefixes.
Check it
sudo ./publisher.sh platforms list --name rmail
You should see ✓ under Configured.
curl https://authy.example.com/api/v1/platforms
You should see the channels Authy can send codes on, such as signal.
Troubleshooting
No mailbox for <EMAIL>
sl_primary_email isn’t a verified SimpleLogin mailbox.
No suffix available for hostname '<DOMAIN>'
sl_primary_domain isn’t a verified custom domain in SimpleLogin.
Sending fails with an SMTP authentication error
For Gmail, use an app password, not the account password. A changed account password revokes app passwords.
Recipients don’t get the email
Check the domain’s SPF, DKIM and DMARC records on its SimpleLogin page.
SimpleLogin refuses to create a contact
The account isn’t Premium.
Users don’t get a login code
Check that the sender device is linked and online, and that the channel the app sends, such as signal, appears at <AUTHY_BASE_URL>/api/v1/platforms.
Next steps
Register a gateway client to relay SMS to the Publisher.
Managing gateway clients
Register each number that relays SMS to this server, so the RelaySMS app lists it, then connect its relay. Credentials with gc:read and gc:write can also manage them over the REST API; every change is recorded in the audit log.
Commands
| Command | Does |
|---|---|
gateway-clients suggest <MSISDN> | Shows the number’s details and PLMN candidates; saves nothing |
gateway-clients create --msisdn <MSISDN> --protocols <PROTOCOLS> | Registers a number; also takes --country, --operator and --operator-code |
gateway-clients list | Lists them; filter with --msisdn, --country or --operator |
gateway-clients update <MSISDN> | Changes --country, --operator, --operator-code or --protocols |
gateway-clients disable <MSISDN> | Hides it from the public list |
gateway-clients enable <MSISDN> | Lists it publicly again |
gateway-clients delete <MSISDN> | Removes it |
gateway-clients countries | Lists the countries that have a gateway client |
gateway-clients operators --country <COUNTRY> | Lists the operators of that country’s gateway clients |
gateway-clients mcc-mnc list | Searches the PLMN table; filter with --country-code, --network or --iso |
Prefix each with sudo ./publisher.sh. <PROTOCOLS> is how the relay reaches the Publisher, comma-separated: https for one that posts to the REST API, smtp for one that emails. For example:
sudo ./publisher.sh gateway-clients create --msisdn +237600000000 --protocols https
create fills in any of --country, --operator and --operator-code you leave out with what suggest finds. It fills the PLMN code (MCC+MNC) only when every candidate shares it; otherwise run suggest, pick a candidate and pass it with --operator-code.
How suggest works
phonenumbers gives the number’s country, region and carrier name. The carrier is matched against that region’s operators in publisher/gateway_clients/mcc_mnc_table.json, a vendored snapshot of musalbas/mcc-mnc-table. A territory without rows of its own, like Guernsey, uses its calling code’s main region.
Names are compared word by word, ignoring case, accents, the country’s name and words such as “Telecom” or “Ltd”.
match | Candidates |
|---|---|
carrier | Networks matching the carrier, best first. The PLMN code is filled in only when they all share one. |
region | Every operator in the country, because the carrier is unknown (common for US and Canadian numbers) or matched nothing |
none | None: the number couldn’t be placed. Enter the details yourself. |
When suggest can’t place the operator, as for many US, Canadian and Twilio numbers, pass --country and --operator yourself, or leave the operator out. The public list shows each number’s country and operator.
Connecting a relay
A gateway client receives the SMS that people send and relays each payload to the Publisher’s POST /v1/publications. Pick one:
| Gateway client | Runs on | Use it for |
|---|---|---|
| Deku SMS | An Android phone with a SIM card | A local number, with no SMS provider |
| RelaySMS-GatewayClient-Twilio | A server, behind a Twilio number | A number that doesn’t depend on a phone staying online |
Requirements
- The Publisher’s REST API reachable over HTTPS, behind the reverse proxy
- The number registered as a gateway client, so the RelaySMS app lists it
Deku SMS on Android
- Install Deku SMS on the phone and make it the default SMS app.
- In its gateway settings, forward incoming SMS to
https://<HOST>/v1/publications. - Keep the phone powered, with mobile data or Wi-Fi.
Twilio
- Install RelaySMS-GatewayClient-Twilio as its README describes. It can run on the Publisher’s server, on its own subdomain such as
twilio.example.com. SetTWILIO_AUTH_TOKENfrom the Twilio console, and keep a copy of the generatedSENDER_HASH_KEY: a new key gives every sender a new address. - In its
.env, setFORWARD_URLS_JSON=https://<HOST>/v1/publications. - If the Publisher allows offline publishing over
https, setFORWARD_TAGto itsOFFLINE_PUBLISH_SHARED_SECRET. - In the Twilio console, under the number’s Messaging > A message comes in, set
https://<RELAY_HOST>/twilio-smswithHTTP POST.
Check it
Send an SMS from the RelaySMS app to the number, then follow the worker’s log on the Publisher:
sudo ./manage.sh logs --unit worker
You should see Published via '<PLATFORM>'. To test without a phone, the reference client posts a payload the way a gateway client does.
Next steps
- Back up
.envanddata/. - Harden the install before announcing the number.
Services
The Publisher runs as systemd services, grouped under relaysms-publisher.target:
| Service | Runs |
|---|---|
relaysms-publisher-rest | REST API |
relaysms-publisher-grpc | gRPC API |
relaysms-publisher-worker | Celery worker: publishes queued payloads |
relaysms-publisher-beat | Celery Beat: schedules cleanups, and the worker heartbeat with Uptime Kuma |
relaysms-publisher-smtp | Email listener; exits at once while SMTP_TRANSPORT_ENABLED is off |
The REST API listens on HOST:PORT (127.0.0.1:16000) and gRPC on GRPC_HOST:GRPC_PORT (127.0.0.1:6000).
Is it working?
sudo ./manage.sh status
curl http://127.0.0.1:16000/health
You should see every service active, and {"status":"ok"}. To follow publications as they happen:
sudo ./manage.sh logs --unit worker
Each published payload logs Published via '<PLATFORM>'., and each failure a line starting Failed to (what each means). Credentials with stats:publications:read can also query the counts at GET /v1/stats/publications.
manage.sh
Run it from the install directory with sudo:
| Command | Does |
|---|---|
status | Shows each service’s state |
logs | Follows the service logs |
start, stop, restart | Controls every service together |
enable, disable | Starts the services on boot, or stops doing so |
update [--migrate] | Pulls the latest code, rebuilds and restarts the services (details) |
migrate | Applies database migrations |
check | Reports every missing or invalid setting |
nginx [<DOMAIN>] | Renders the nginx site again and reattaches or obtains its certificate (details) |
docs [<DOMAIN>] | Builds this manual and serves it on its own domain (details) |
uninstall | Removes the services and the install directory, with .env and data/ |
uninstall keeps the database and broker servers and the service user.
logs takes:
-u,--unit:rest,grpc,worker,beatorsmtp; repeatable-n,--lines: how many lines to show-s,--since: a time, such as"1 hour ago"--no-follow: print and exit
For example:
sudo ./manage.sh logs --unit rest --unit grpc --since "1 hour ago" --lines 200
publisher.sh
./publisher.sh runs the admin CLI as the service user, with the virtualenv and .env:
sudo ./publisher.sh --help # every command
sudo ./publisher.sh env # install directory, service user and the .env values the CLI uses
sudo ./publisher.sh shell # a shell as the service user, with .env loaded
Use it instead of python3 -m publisher: run as another user, the CLI can leave adapter files the services can’t read.
Files
| Path | Holds |
|---|---|
/opt/relaysms/relaysms-publisher/ | The install |
.env | Settings (root:<SERVICE_USER>, mode 640) |
data/ | SQLite database, Celery files and adapters (owned by the service user, mode 750) |
/etc/systemd/system/relaysms-publisher* | Service units |
/etc/nginx/sites-available/<DOMAIN>.conf | nginx sites |
Credentials and access
Credentials log in to the REST API’s admin endpoints, each with its own scopes. One holding every scope is an administrator. The RelaySMS app and gateway clients need none.
Create a credential
sudo ./publisher.sh creds create --username ops --administrator
sudo ./publisher.sh creds create --username analyst --scope stats:publications:read
Important
The password is printed only once.
Usernames are 3 to 32 lowercase letters, digits, ., _ or -, starting with a letter or digit.
Scopes
| Scope | Allows |
|---|---|
stats:publications:read | Read publication stats, without failure reasons |
stats:publications:reasons | See failure reasons in publication stats |
gc:read | List gateway clients |
gc:write | Add, change and remove gateway clients |
platforms:read | List platform adapters |
platforms:write | Change and remove platform adapters |
creds:read | List credentials |
creds:write | Add, change and remove credentials |
audit:read | Read the audit log, for the areas your other scopes cover |
sudo ./publisher.sh creds scopes prints the same list. Each :write scope needs its :read scope, and stats:publications:reasons needs stats:publications:read. Installing, updating and configuring adapters over the API also needs an administrator.
Commands
| Command | Does |
|---|---|
creds list | Lists credentials |
creds set-scopes --username <NAME> --scope <SCOPE> | Replaces a credential’s scopes; repeat --scope, or pass --administrator |
creds reset-password --username <NAME> | Prints a new password and ends the credential’s web sessions |
creds disable --username <NAME> | Blocks it and ends its web sessions |
creds enable --username <NAME> | Lets it log in again |
creds revoke-sessions --username <NAME> | Logs it out of every web session; HTTP Basic still works |
creds delete --username <NAME> | Deletes it and its sessions, after asking; --yes skips the question |
Prefix each with sudo ./publisher.sh. Credentials with creds:write can do the same over the REST API, within their own scopes.
Logging in
- Web session:
POST /v1/auth/login, then send the session cookie with every request (credentials: "include"infetch). Logins and writes (POST,PATCH,DELETE) with the cookie must come from the API’s own origin or one listed inAUTH_WEB_ORIGINS, on the same site as the API. A session ends afterAUTH_SESSION_IDLE_MINUTESidle (30) orAUTH_SESSION_MAX_HOURS(12). - HTTP Basic: the username and password on every request, such as
curl -u analyst:<PASSWORD> https://<HOST>/v1/stats/publications.
Scope changes apply on the credential’s next request. nginx allows 5 logins a minute per IP address, and 30 HTTP Basic requests a minute per IP address.
Settings: REST API access.
Upgrading
A server install follows the branch it was installed from, main by default.
Before you upgrade
-
Back up. Migrations can’t be undone, so going back to older code needs the backup.
-
From the install directory, note the current commit, to roll back to:
sudo git rev-parse HEAD
Update a server install
From the install directory:
sudo ./manage.sh update --migrate
sudo ./sync-env.sh
sudo ./manage.sh check
sudo ./manage.sh restart
update --migratestops the services, pulls the code, reinstalls the dependencies, rebuilds, applies pending migrations and starts the services again. When the install serves this manual, it rebuilds it too.sync-env.shadds the settings the newtemplate.envhas and.envlacks, showing the change before writing it.checkreports any setting still missing or invalid; fill it in.env.restartloads the new settings.
When .env has an invalid setting, update still restarts every service, then exits with an error (fix).
If the update changed the nginx template, which sudo git diff --stat ORIG_HEAD HEAD -- deploy/nginx lists, render the site again:
sudo ./manage.sh nginx
If you edited tracked files that the update changes, update refuses to pull. Discard the edits or git stash them, then run update again.
Roll back
-
Take the commit you noted before upgrading.
-
If the update applied migrations, restore the backup taken before it; older code can’t use the migrated database.
-
Check out that commit and rebuild.
--pulledskips the pull that would undo the checkout:sudo git checkout <COMMIT> sudo ./manage.sh update --pulled
To follow the branch again later, run sudo git checkout <BRANCH>, then sudo ./manage.sh update --migrate.
Update with Docker
git pull --recurse-submodules
docker compose up -d --build
The container applies new migrations and runs platforms import when it starts.
Update adapters
Adapters update separately, by version tag:
sudo ./publisher.sh platforms update
See Versions.
Upgrade notes
Steps a particular upgrade needs once, newest first.
Gateway client registry import (migration 019)
For installs from before 2026-10-07.
update --migrate applies migration 019, which imports data/gateway_clients/registry.json (or GATEWAY_CLIENTS_REGISTRY_FILE, if .env sets it). It logs the entries in mcc_mnc_overrides.json without importing them. Delete both files afterwards.
Adapter registry import
For installs from before 2026-10-06.
update --migrate and manage.sh migrate register adapter directories on disk that the database lacks, and move their settings and files out of the code. The Docker container does it at every start.
Backup and restore
A backup is:
.env: its encryption keys are the only way to read the data.data/: the SQLite database, Celery files, and the adapters with their settings, state and virtualenvs.- A dump of the database, with MySQL or PostgreSQL.
- Any file
.envpoints to outsidedata/, such asGRPC_TLS_CERT_FILEorSMTP_TLS_CLIENT_KEY.
The installer recreates the service units, and the nginx sites and certificates for the domains you pass it, so /etc needs no backup. If you run the Twilio relay, back up its .env too: its SENDER_HASH_KEY keeps senders’ hashes the same.
Copy the archives off the host, and test a restore on a spare machine now and then.
Caution
Store the
.envarchive apart from the data. Anyone with both can read the linked accounts’ tokens.
Back up
From the install directory, stop the services so the data is consistent, then archive .env and data/ separately:
sudo ./manage.sh stop
sudo tar -czf /root/relaysms-env-$(date +%F).tar.gz .env
sudo tar -czf /root/relaysms-data-$(date +%F).tar.gz data
With PostgreSQL, dump the database before starting the services again:
sudo sh -c 'sudo -u postgres pg_dump relaysms > /root/relaysms-db-$(date +%F).sql'
With MySQL or MariaDB:
sudo sh -c 'mysqldump relaysms > /root/relaysms-db-$(date +%F).sql'
Then start the services:
sudo ./manage.sh start
For a database on another host, or named differently in .env, connect with the details from .env, such as pg_dump -h <DB_HOST> -U <DB_USER> <DB_NAME>. With Docker, use docker compose stop and docker compose start in place of manage.sh.
Restore
-
Install the Publisher in the same directory, with the same
--instance-name. With MySQL or PostgreSQL, pass--setup-db,--db-name,--db-userand--db-passwordwith the values from the backed-up.env(MYSQL_*orPOSTGRES_*); with RabbitMQ, the same for--broker-password. Otherwise the servers refuse the restored.env’s logins. -
Stop the services:
sudo ./manage.sh stop -
From the install directory, replace the new
data/and.envwith the backed-up ones:sudo rm -rf data sudo tar -xzf /root/relaysms-env-<DATE>.tar.gz sudo tar -xzf /root/relaysms-data-<DATE>.tar.gz -
With PostgreSQL, replace the new database with the dump:
sudo -u postgres dropdb relaysms sudo -u postgres createdb -O relaysms relaysms sudo sh -c 'sudo -u postgres psql -v ON_ERROR_STOP=1 relaysms < /root/relaysms-db-<DATE>.sql'With MySQL or MariaDB:
sudo mysql -e 'DROP DATABASE relaysms; CREATE DATABASE relaysms CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci' sudo sh -c 'mysql relaysms < /root/relaysms-db-<DATE>.sql' -
Apply any migrations newer than the backup, then start the services and check them:
sudo ./manage.sh migrate sudo ./manage.sh start sudo ./manage.sh check
tar keeps the files’ owners. If the service user’s name or ID differs on the new host, give the files back to it: sudo chown -R <SERVICE_USER>: data and sudo chown root:<SERVICE_USER> .env.
Next steps
After a restore, check that the services are up.
Observability
Self-hosted tracing, metrics and log export (SigNoz) and uptime monitoring (Uptime Kuma) for the Publisher’s services. Both are optional; tracing, metrics and log export stay off while OTEL_EXPORTER_OTLP_ENDPOINT is blank.
Set up
During install:
sudo ./install.sh --setup-observability
On an existing install, from the install directory:
sudo ./scripts/setup-observability.sh
install.sh | setup-observability.sh | Does |
|---|---|---|
--observability-site-name <DOMAIN> | --site-name <DOMAIN> | Puts nginx and a Let’s Encrypt certificate in front of SigNoz |
--observability-kuma-site-name <DOMAIN> | --kuma-site-name <DOMAIN> | The same for Uptime Kuma, on a different domain |
| None | --signoz-email, --signoz-password | SigNoz admin account |
| None | --signoz-smtp-host, --signoz-smtp-port, --signoz-smtp-username, --signoz-smtp-password, --signoz-smtp-from | SMTP relay for SigNoz’s email alerts |
| None | --skip-tracing | Leaves tracing off |
scripts/setup-observability.sh --help lists every flag. The script:
- installs Docker if it’s missing;
- writes
observability/signoz/casting.yamlfrom its template, binds the SigNoz UI and OTLP ports to127.0.0.1, starts SigNoz and creates the admin account; - starts Uptime Kuma on
127.0.0.1:3001, or the port you choose; - installs
requirements-observability.txtand setsOTEL_EXPORTER_OTLP_ENDPOINT, unless given--skip-tracing; - with
--site-nameor--kuma-site-name, writes and enables the nginx sites and runs Certbot.
Set retention in the SigNoz UI, under Settings > Workspace > Retention Controls.
Important
casting.yamlis generated only once. To change a running install, editobservability/signoz/casting.yamland runfoundryctl cast -f observability/signoz/casting.yamlagain. It recreates only thesignozcontainer; Postgres and ClickHouse keep their data.
Uptime Kuma monitors
Open Uptime Kuma through its domain, or through an SSH tunnel at http://localhost:3001:
ssh -L 3001:127.0.0.1:3001 <USER>@<HOST>
Then add a monitor for each service.
REST
Monitor Type “HTTP(s)”, URL https://<DOMAIN>/health.
gRPC
- Choose Monitor Type “GRPC(S) - Keyword”.
- Set gRPC URL to
<DOMAIN>:443and turn on Enable TLS. - Set Service Name to
grpc.health.v1.Healthand Method tocheck. - Set gRPC Body to
{"service":"publisher.v3.Publisher"}and Keyword toSERVING. - Paste this into Protobuf:
Health check protobuf
syntax = "proto3";
package grpc.health.v1;
message HealthCheckRequest {
string service = 1;
}
message HealthCheckResponse {
enum ServingStatus {
UNKNOWN = 0;
SERVING = 1;
NOT_SERVING = 2;
SERVICE_UNKNOWN = 3;
}
ServingStatus status = 1;
}
service Health {
rpc Check(HealthCheckRequest) returns (HealthCheckResponse);
}
Worker
Monitor Type “Push”, interval just over 60s. Set UPTIME_KUMA_WORKER_PUSH_URL in .env to the push URL Kuma shows, then restart the services. Beat schedules this heartbeat, so the monitor also goes down when Beat stops.
SMTP listener
As for the worker, with an interval just over 20s and UPTIME_KUMA_SMTP_PUSH_URL.
Alerts
In the SigNoz UI, go to Alerts > New Alert > Logs, paste a filter below into the search bar, set the threshold to count > 0 over a rolling window, and attach a notification channel. Keep the parentheses: AND binds tighter than OR.
Publish failures, adapter errors included:
service.name = 'relaysms-publisher-worker' AND (body CONTAINS 'Failed to process payload' OR body CONTAINS 'Failed to publish message' OR body CONTAINS 'An unexpected error occurred during task processing')
Key management failures:
body CONTAINS 'Failed to generate server identity keys' OR body CONTAINS 'Token hash missing'
Turning tracing on and off
To turn tracing on by hand, from the install directory:
sudo venv/bin/pip install --quiet -r requirements-observability.txt
sudo sed -i 's|^OTEL_EXPORTER_OTLP_ENDPOINT=.*|OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317|' .env
sudo ./manage.sh restart
To turn it off, blank OTEL_EXPORTER_OTLP_ENDPOINT and restart.
Setting up by hand
SigNoz
Needs Docker Engine with the Compose plugin. Set <PASSWORD> to 12 or more characters, with upper and lower case, a number and a symbol.
curl -fsSL https://signoz.io/foundry.sh | FOUNDRY_INSTALL_DIR=/usr/local/bin bash
sed \
-e "s/__POSTGRES_DB__/signoz/" -e "s/__POSTGRES_USER__/signoz/" \
-e "s/__POSTGRES_PASSWORD__/$(openssl rand -hex 24)/" \
-e "s/__SIGNOZ_PORT__/8090/" -e "s/__OTLP_GRPC_PORT__/4317/" -e "s/__OTLP_HTTP_PORT__/4318/" \
-e "s/__SMTP_SMARTHOST__//" -e "s/__SMTP_USERNAME__//" -e "s/__SMTP_PASSWORD__//" -e "s/__SMTP_FROM__//" \
-e "s|__SIGNOZ_EXTERNAL_URL__|http://localhost:8090|" \
observability/signoz/casting.yaml.template > observability/signoz/casting.yaml
foundryctl cast -f observability/signoz/casting.yaml
docker compose \
-f pours/deployment/compose.yaml \
-f observability/signoz/docker-compose.override.yml \
up -d
until curl -fs http://127.0.0.1:8090/api/v1/version | grep -q setupCompleted; do sleep 2; done
curl -X POST http://127.0.0.1:8090/api/v1/register \
-H "Content-Type: application/json" \
-d '{"name":"Admin","orgId":"","orgName":"RelaySMS","email":"admin@example.com","password":"<PASSWORD>"}'
The template stores SigNoz’s metadata in Postgres (metastore.kind: postgres); set sqlite for a single node.
Uptime Kuma
docker compose -f observability/uptime-kuma/docker-compose.yml up -d
Reverse proxy
sed -e "s/__SERVER_NAME__/<OPS_DOMAIN>/g" \
observability/nginx-observability.conf.template |
sudo tee /etc/nginx/sites-available/<OPS_DOMAIN>.conf >/dev/null
sudo ln -s /etc/nginx/sites-available/<OPS_DOMAIN>.conf /etc/nginx/sites-enabled/
sed -e "s/__SERVER_NAME__/<STATUS_DOMAIN>/g" \
observability/nginx-uptime-kuma.conf.template |
sudo tee /etc/nginx/sites-available/<STATUS_DOMAIN>.conf >/dev/null
sudo ln -s /etc/nginx/sites-available/<STATUS_DOMAIN>.conf /etc/nginx/sites-enabled/
sudo nginx -t && sudo systemctl reload nginx
sudo certbot --nginx -d <OPS_DOMAIN>
sudo certbot --nginx -d <STATUS_DOMAIN>
<OPS_DOMAIN> serves SigNoz and <STATUS_DOMAIN> serves Uptime Kuma.
Audit log
The Publisher records every change to credentials, adapters and gateway clients, every web login and every refused login, whether made over the REST API or the CLI. Read it at GET /v1/audit-events:
curl -u ops:<PASSWORD> https://<HOST>/v1/audit-events
It needs audit:read, plus the read scope of each area: creds:read for auth.* and creds.* events, platforms:read for platforms.*, gc:read for gateway_clients.*. Events from areas you can’t read are left out. Filter with action, actor, target, since and until, such as ?action=auth.login&since=2026-10-01. Results are paginated.
Events
| Action | Recorded when |
|---|---|
creds.create, creds.update, creds.reset_password, creds.revoke_sessions, creds.delete | A credential changes. details says what changed, never secrets. |
auth.login | A web session starts, or a web or HTTP Basic login for an existing username is refused |
auth.logout | A web session ends |
platforms.add, platforms.update, platforms.remove | An adapter is installed, updated or removed. details has the source URL and commits. |
platforms.enable, platforms.disable | An adapter is offered to users again, or hidden from them |
platforms.configure, platforms.setup | An adapter’s settings change. details has the keys saved. |
gateway_clients.create, gateway_clients.update, gateway_clients.delete | A gateway client changes. details has its fields, or what changed. |
gateway_clients.enable, gateway_clients.disable | A gateway client is listed publicly again, or hidden |
Fields
outcome:success,denied(the change went beyond the actor’s scopes) orfailed(a refused login for an existing username: wrong password, or disabled).actor: the username at the time; null for the CLI or a failed login.details: what changed, never secrets; forauth.login,methodissessionorbasic.
Successful HTTP Basic requests aren’t recorded. Events are kept for AUDIT_RETENTION_DAYS (Retention).
Security hardening
A server install listens on 127.0.0.1 only, runs each service under systemd sandboxing with everything read-only except its data directories, and sits behind nginx with TLS and rate limits. These steps close what’s left to you.
Network and firewall
-
Allow only SSH, HTTP and HTTPS in:
sudo ufw allow OpenSSH sudo ufw allow 'Nginx Full' sudo ufw enable -
Keep
HOSTandGRPC_HOSTat127.0.0.1, so only nginx reaches the services. -
On Docker, bind
PORTandGRPC_PORTto127.0.0.1indocker-compose.yml, since Docker’s published ports bypass ufw, and put nginx in front (details).
Service user
The services run as the user who ran the installer with sudo, or as a new relaysms user when it ran from a root shell. For a dedicated user with no login or sudo rights, run the installer from a root shell (sudo -i). Changing it on an existing install means reinstalling.
Settings
| Setting | Keep it at |
|---|---|
API_DOCS_ENABLED | false in production |
AUTH_SESSION_COOKIE_SECURE | true |
AUTH_WEB_ORIGINS | Only the web client origins you run |
PLATFORMS_GITHUB_ORGS | Only orgs you trust: installing an adapter runs its code on the server |
SMTP_ALLOWED_SENDERS | Exact addresses; a domain trusts every sender on it (details) |
DATABASE_ENCRYPTION_ENABLED, DATABASE_FIELD_ENCRYPTION_ENABLED | true |
Credentials
Give each person their own credential, with only the scopes they need. Review the audit log for denied events (a change beyond the actor’s scopes) and failed ones (a refused login).
Troubleshooting
Start with the logs of the service involved:
sudo ./manage.sh logs --unit <SERVICE> --since "1 hour ago"
<SERVICE> is one of the units logs takes.
Services
A service won’t start
The service refuses to start on an invalid setting it uses. Run sudo ./manage.sh check, fix what it reports in .env, then sudo ./manage.sh restart.
update ends with .env has errors
The new version needs a setting .env lacks. Run sudo ./sync-env.sh, fill in what sudo ./manage.sh check reports, then sudo ./manage.sh restart.
/health returns 500
The REST API can’t open the database: the database server is down, its settings in .env are wrong, or, with SQLite, the service user can’t read data/. See Database.
The email listener service is inactive
It exits at once while SMTP_TRANSPORT_ENABLED is off. See SMTP transport.
Docker refuses connections right after starting
The container applies migrations before the services start. Wait, or follow docker compose logs -f.
Publishing
A payload is accepted but never published
POST /v1/publications only queues a payload; the worker decides later. Follow sudo ./manage.sh logs --unit worker and match the line:
| Log line | Cause |
|---|---|
Published via '<PLATFORM>'. | It worked |
Failed to process payload: ... | The payload didn’t decrypt or validate, its keys are unknown or used up, or offline publishing refused it (Offline publishing) |
Failed to publish message: Adapter error: ... | The platform refused it; the rest of the line is the adapter’s reason. Fix it with the platform’s provider guide, then the user sends the same SMS again. |
Session <ID> can't be assembled from <N> segment(s): ... | A multi-part payload’s segments don’t fit together |
An unexpected error occurred during task processing. | A bug; the traceback follows. Report it. |
| No line at all | The worker isn’t running (sudo ./manage.sh status), or a multi-part payload is still waiting for its other segments, for up to PAYLOAD_SESSION_MAX_AGE_HOURS |
GET /v1/stats/publications lists each failure with a reason code, for credentials with stats:publications:reasons.
Linking an account fails
Check the adapter shows ✓ under Configured in sudo ./publisher.sh platforms list, then see its provider guide’s troubleshooting.
Relay
An SMS was sent, but the worker logs nothing
Check that the relay received it:
- With Deku SMS, the phone is online and Deku SMS is its default SMS app.
- With Twilio, follow the relay’s log (
sudo journalctl -u relaysms-gatewayclient-twilio) and Twilio’s error log, under Monitor > Logs > Errors in the Twilio console.
The Twilio relay answers 403 Invalid Twilio signature.
The webhook URL in the Twilio console doesn’t match the URL the relay sees, scheme and host included. Set it to the relay’s exact https:// URL.
The Twilio relay answers 502 Forwarding failed.
A FORWARD_URLS_JSON URL refused the payload or couldn’t be reached; the relay’s log names it. Check that https://<HOST>/v1/publications answers from the relay’s host.
REST API
Invalid username or password.
The credential doesn’t exist, the password is wrong, or it’s disabled (sudo ./publisher.sh creds list).
Missing scope: ...
Add the scope with sudo ./publisher.sh creds set-scopes (Scopes).
Session expired or invalid. Please log in again.
The session idled past AUTH_SESSION_IDLE_MINUTES, reached AUTH_SESSION_MAX_HOURS, or was ended by reset-password, disable or revoke-sessions. Log in again.
Origin not allowed.
The web client’s origin is neither the API’s own nor listed in AUTH_WEB_ORIGINS; logins and writes with a session cookie both check it. Add the origin, which must be on the same site as the API, then sudo ./manage.sh restart.
Too many requests. Please try again shortly.
nginx rate-limits logins and HTTP Basic requests (limits). Wait a minute and try again.
nginx
gRPC calls fail through nginx
gRPC needs HTTP/2 on the HTTPS block. Run sudo ./manage.sh nginx to render the site again with it.
nginx -t fails after an update
Run sudo ./manage.sh nginx: it renders the site from the current template, and restores the previous file if the new one fails.
Command-line tools
| Tool | Runs | Documented in |
|---|---|---|
install.sh | Installs or updates a server install | Install on a server |
manage.sh | Controls, updates and checks the services | Services |
publisher.sh | The admin CLI, as the service user | Below |
sync-env.sh | Adds to .env the settings template.env has and it lacks | Upgrading |
scripts/setup-postgres.sh, scripts/setup-mysql.sh | Sets up a database server | Database |
scripts/setup-rabbitmq.sh | Sets up a RabbitMQ broker | Celery broker |
scripts/setup-observability.sh | Sets up SigNoz and Uptime Kuma | Observability |
Run them with sudo; all but install.sh from the install directory. Each takes --help.
manage.sh
See Services.
publisher.sh
Runs python3 -m publisher as the service user. sudo ./publisher.sh <GROUP> <COMMAND> --help describes each command.
| Group | Commands | Documented in |
|---|---|---|
creds | create, list, scopes, set-scopes, reset-password, disable, enable, revoke-sessions, delete | Credentials and access |
platforms | add, update, list, disable, enable, remove, config show, config set, setup, import, exec | Managing adapters |
gateway-clients | suggest, create, list, update, disable, enable, delete, countries, operators, mcc-mnc list | Managing gateway clients |
config | check | Settings reference |
seed | stats, creds, platforms, gateway-clients | None; fills a development database with sample data |
It also takes env and shell (Services).
REST API
The REST API accepts payloads from gateway clients, serves public lists, and has the admin endpoints.
The endpoint reference is openapi.json, generated from the app. Open it in any OpenAPI viewer, or set API_DOCS_ENABLED=true on a development server and browse /docs.
Base URL
https://<HOST>/v1
/health is the only endpoint outside /v1.
Authentication
Admin endpoints take a web session cookie or HTTP Basic credentials; see Logging in. Each endpoint’s description names the scopes it needs.
Managing credentials
A credential can only grant scopes it holds, can’t change itself, and can’t change a credential holding scopes it lacks. These return 403.
GET /v1/creds/{username} returns an opaque ETag. Send it back unchanged as If-Match to change, reset or delete that credential: a missing header gets 428, a stale one 412. Responses that change a credential return its new ETag. Generated passwords appear only in the response that creates them.
Managing platform adapters
/v1/platforms/adapters lists every installed adapter, with its source, version tag, commit and who last changed it, and takes the same ETag and If-Match rules as credentials. A disabled adapter disappears from /v1/platforms and can’t publish or link accounts, but still revokes tokens so users can unlink. Uninstalling is refused with 409 while accounts are linked through the adapter; see Removing.
Installing (POST /v1/platforms/adapters) and updating (POST /v1/platforms/adapters/{id}/update) run code from the repository on the server, so they need an administrator and a repository from an org in PLATFORMS_GITHUB_ORGS. Both take an optional tag, defaulting to the newest version tag, and return 202 with the job’s URL in Location. Poll the job until its state is succeeded or failed; its log has the end of the git and pip output. An adapter has one job at a time; another gets 409.
An adapter’s settings are at /v1/platforms/adapters/{id}/config, for administrators only. GET lists each setting, with secrets only marked is_set, and the inputs its setup takes. PATCH changes the settings sent and keeps the rest; POST .../setup runs the adapter’s setup and replaces them all, with a file input sent as the file’s text. configured in the adapter list says whether every required setting has a value.
Managing gateway clients
/v1/gateway-clients/registry lists every gateway client, with who last changed it, and takes the same ETag and If-Match rules as credentials (scopes gc:read and gc:write). GET /v1/gateway-clients/registry/suggest?msisdn= returns the details and PLMN candidates a create would use, to prefill a form for the administrator to confirm; see how suggest works. A disabled client disappears from the public /v1/gateway-clients.
Pagination
List endpoints return {"data": [...], "next": ..., "prev": ...}, newest first. Follow next and prev as they are: they keep your filters and limit, and are null on the last and first page. Don’t build the cursor yourself.
since and until take ISO-8601 times, UTC if there’s no offset. since is inclusive and until exclusive.
Audit events
GET /v1/audit-events returns the audit log.
Publishing
POST /v1/publications takes JSON and tags payloads with protocol https. It needs no credentials: a payload publishes only if it decrypts with the keys of a linked account or, for an offline payload, passes the offline publishing rules.
It only checks the payload’s structure before queueing it. Decryption, payload type and adapter errors happen later, so they’re logged on the server, not returned.
Errors
Error bodies are {"error": "<MESSAGE>"}. The message is meant to be shown to users; the server logs more detail.
| Status | Meaning |
|---|---|
400 Bad Request | Invalid parameters or payload, invalid cursor, or invalid time window |
401 Unauthorized | Missing or invalid credentials |
403 Forbidden | Origin not allowed, missing scope, or a credential change beyond your scopes |
404 Not Found | The resource doesn’t exist |
409 Conflict | Already exists, in use, or changed by another request at the same moment; retry |
412 Precondition Failed | If-Match doesn’t match the current ETag; reload and retry |
422 Unprocessable Entity | Validation error |
428 Precondition Required | If-Match header missing |
429 Too Many Requests | Rate limited by nginx (limits) |
500 Internal Server Error | Unexpected server error |
gRPC API
The gRPC API links and unlinks platform accounts and syncs ephemeral key pools. The RelaySMS app calls it while online.
Protocol buffer file
curl -O -L https://raw.githubusercontent.com/smswithoutborders/RelaySMS-Publisher/main/protos/v3/publisher.proto
Package publisher.v3, service Publisher.
Generate a client
Generate stubs with protoc for your language. For Python:
pip install grpcio-tools
python -m grpc_tools.protoc -I . --python_out=. --grpc_python_out=. publisher.proto
Request headers
Every RPC needs these headers. Headers ending in -bin carry raw binary.
| Header | Type | Required | Description |
|---|---|---|---|
x-payload-bin | bytes | Yes | Encrypted request payload ciphertext |
x-public-key-bin | bytes | Yes | Client ephemeral public key ($eC_{pk}$, 32 bytes) |
x-key-id | string | Yes | ID of the server identity key used to encrypt the payload |
x-nonce-bin | bytes | Yes | Request nonce (16 bytes) |
x-timestamp | string | Yes | Unix timestamp in seconds, within NONCE_TTL_SECONDS of the server’s clock |
The examples use -plaintext against a local server; drop it for a server behind TLS on port 443.
Methods
Get OAuth2 authorization URL
Returns the URL that starts the OAuth2 flow.
Request: GetOAuth2AuthorizationUrlRequest
| Field | Type | Required | Description |
|---|---|---|---|
| platform | string | Yes | Platform identifier, such as gmail |
| state | string | No | OAuth2 state, returned unchanged |
| code_verifier | string | No | PKCE code verifier |
| autogenerate_code_verifier | bool | No | Generate a code verifier when none is sent |
| redirect_url | string | No | Redirect URL for OAuth2 application |
| request_identifier | string | No | Request identifier for tracking |
Response: GetOAuth2AuthorizationUrlResponse
| Field | Type | Description |
|---|---|---|
| authorization_url | string | Generated authorization URL |
| state | string | The state sent |
| code_verifier | string | PKCE code verifier used |
| message | string | Human-readable result |
| scope | string | Authorization request scope |
| client_id | string | OAuth2 application client ID |
| redirect_url | string | OAuth2 application redirect URL |
Example:
grpcurl -plaintext \
-H 'x-payload-bin: <ENCRYPTED_PAYLOAD>' \
-H 'x-public-key-bin: <CLIENT_PUBLIC_KEY>' \
-H 'x-key-id: <KEY_ID>' \
-H 'x-nonce-bin: <NONCE>' \
-H 'x-timestamp: <TIMESTAMP>' \
-d '{
"platform": "gmail",
"autogenerate_code_verifier": true
}' \
-proto publisher.proto \
<HOST>:<PORT> publisher.v3.Publisher/GetOAuth2AuthorizationUrl
Exchange OAuth2 code and store token
Exchanges an OAuth2 authorization code for tokens and stores them. Returns encrypted token data and a set of server ephemeral keys.
Request: ExchangeOAuth2CodeAndStoreRequest
| Field | Type | Required | Description |
|---|---|---|---|
| platform | string | Yes | Platform identifier |
| authorization_code | string | Yes | OAuth2 authorization code |
| code_verifier | string | No | Code verifier for PKCE |
| redirect_url | string | No | Redirect URL for OAuth2 application |
| request_identifier | string | No | Request identifier for tracking |
| client_ephemeral_public_keys | list[PublicKey] | Yes | Exactly 256 ephemeral public keys generated by the client, with key_id 0-255 each used once |
PublicKey message:
| Field | Type | Description |
|---|---|---|
| key_id | uint32 | Key index (0-255) |
| public_key | bytes | 32-byte public key |
Response: ExchangeOAuth2CodeAndStoreResponse
| Field | Type | Description |
|---|---|---|
| success | bool | true if the call succeeded |
| message | string | Human-readable result |
| account_identifier | string | Account identifier, such as mail@example.com |
| token_ciphertext | bytes | Encrypted token ciphertext |
| token_id | uint32 | Unique identifier for the stored token |
| server_ephemeral_public_keys | list[PublicKey] | Exactly 256 ephemeral public keys generated by the server |
Example:
grpcurl -plaintext \
-H 'x-payload-bin: <ENCRYPTED_PAYLOAD>' \
-H 'x-public-key-bin: <CLIENT_PUBLIC_KEY>' \
-H 'x-key-id: <KEY_ID>' \
-H 'x-nonce-bin: <NONCE>' \
-H 'x-timestamp: <TIMESTAMP>' \
-d @ \
-proto publisher.proto \
<HOST>:<PORT> publisher.v3.Publisher/ExchangeOAuth2CodeAndStore <<EOF
{
"platform": "gmail",
"authorization_code": "auth_code",
"client_ephemeral_public_keys": [
{"key_id": 0, "public_key": "..."},
... 254 more keys ...
{"key_id": 255, "public_key": "..."}
]
}
EOF
Revoke and delete OAuth2 token
Revokes and deletes a stored OAuth2 token, identified by its token ID and the key ID that encrypted it.
Request: RevokeOAuth2TokenRequest
| Field | Type | Required | Description |
|---|---|---|---|
| token_id | uint32 | Yes | Unique identifier for the stored token |
| key_id | uint32 | Yes | Key index used for encrypting the token |
Response: RevokeOAuth2TokenResponse
| Field | Type | Description |
|---|---|---|
| success | bool | true if the call succeeded |
| message | string | Human-readable result |
Example:
grpcurl -plaintext \
-H 'x-payload-bin: <ENCRYPTED_PAYLOAD>' \
-H 'x-public-key-bin: <CLIENT_PUBLIC_KEY>' \
-H 'x-key-id: <KEY_ID>' \
-H 'x-nonce-bin: <NONCE>' \
-H 'x-timestamp: <TIMESTAMP>' \
-d '{
"token_id": 2941967295,
"key_id": 123
}' \
-proto publisher.proto \
<HOST>:<PORT> publisher.v3.Publisher/RevokeOAuth2Token
Get PNBA code
Requests a one-time passcode (OTP) for phone number-based authentication (PNBA).
Request: GetPNBACodeRequest
| Field | Type | Required | Description |
|---|---|---|---|
| platform | string | Yes | Platform identifier |
| phone_number | string | Yes | Phone number to receive OTP |
| request_identifier | string | No | Request identifier for tracking |
| channel | string | No | Channel to send the code over, such as signal |
Response: GetPNBACodeResponse
| Field | Type | Description |
|---|---|---|
| success | bool | true if the call succeeded |
| message | string | Human-readable result |
| expires_at | int64 | Optional. Code expiry, Unix epoch seconds |
Example:
grpcurl -plaintext \
-H 'x-payload-bin: <ENCRYPTED_PAYLOAD>' \
-H 'x-public-key-bin: <CLIENT_PUBLIC_KEY>' \
-H 'x-key-id: <KEY_ID>' \
-H 'x-nonce-bin: <NONCE>' \
-H 'x-timestamp: <TIMESTAMP>' \
-d '{
"platform": "telegram",
"phone_number": "+237600000000"
}' \
-proto publisher.proto \
<HOST>:<PORT> publisher.v3.Publisher/GetPNBACode
Exchange PNBA code and store token
Exchanges a one-time passcode (OTP) for a token, stores it, and returns encrypted token data.
Request: ExchangePNBACodeAndStoreRequest
| Field | Type | Required | Description |
|---|---|---|---|
| platform | string | Yes | Platform identifier |
| phone_number | string | Yes | Phone number that received OTP |
| authorization_code | string | Yes | PNBA authorization code |
| password | string | No | Password for two-step verification |
| request_identifier | string | No | Request identifier for tracking |
| client_ephemeral_public_keys | list[PublicKey] | Yes | Exactly 256 ephemeral public keys generated by the client, with key_id 0-255 each used once |
| channel | string | No | Channel the code was sent over, such as signal |
Response: ExchangePNBACodeAndStoreResponse
| Field | Type | Description |
|---|---|---|
| success | bool | true if the call succeeded |
| message | string | Human-readable result |
| two_step_verification_enabled | bool | Two-step verification status |
| account_identifier | string | Account identifier |
| token_ciphertext | bytes | Encrypted token ciphertext |
| token_id | uint32 | Unique identifier for the stored token |
| server_ephemeral_public_keys | list[PublicKey] | Exactly 256 ephemeral public keys generated by the server |
| key_id | uint32 | Key ID used for encrypting token |
Example:
grpcurl -plaintext \
-H 'x-payload-bin: <ENCRYPTED_PAYLOAD>' \
-H 'x-public-key-bin: <CLIENT_PUBLIC_KEY>' \
-H 'x-key-id: <KEY_ID>' \
-H 'x-nonce-bin: <NONCE>' \
-H 'x-timestamp: <TIMESTAMP>' \
-d @ \
-proto publisher.proto \
<HOST>:<PORT> publisher.v3.Publisher/ExchangePNBACodeAndStore <<EOF
{
"platform": "telegram",
"phone_number": "+237600000000",
"authorization_code": "auth_code",
"client_ephemeral_public_keys": [
{"key_id": 0, "public_key": "..."},
... 254 more keys ...
{"key_id": 255, "public_key": "..."}
]
}
EOF
Revoke and delete PNBA token
Revokes and deletes a stored PNBA token.
Request: RevokePNBATokenRequest
| Field | Type | Required | Description |
|---|---|---|---|
| token_id | uint32 | Yes | Unique identifier for the stored token |
| key_id | uint32 | Yes | Key index used for encrypting the token |
Response: RevokePNBATokenResponse
| Field | Type | Description |
|---|---|---|
| success | bool | true if the call succeeded |
| message | string | Human-readable result |
Example:
grpcurl -plaintext \
-H 'x-payload-bin: <ENCRYPTED_PAYLOAD>' \
-H 'x-public-key-bin: <CLIENT_PUBLIC_KEY>' \
-H 'x-key-id: <KEY_ID>' \
-H 'x-nonce-bin: <NONCE>' \
-H 'x-timestamp: <TIMESTAMP>' \
-d '{
"token_id": 2941967295,
"key_id": 123
}' \
-proto publisher.proto \
<HOST>:<PORT> publisher.v3.Publisher/RevokePNBAToken
Sync keys
Replaces a token’s key pools: the client sends 256 new ephemeral public keys and the server returns 256 of its own.
Request: SyncKeysRequest
| Field | Type | Required | Description |
|---|---|---|---|
| token_id | uint32 | Yes | Unique identifier for the stored token |
| key_id | uint32 | Yes | The current key index used for encrypting the token |
| client_ephemeral_public_keys | list[PublicKey] | Yes | Exactly 256 new ephemeral public keys generated by the client, with key_id 0-255 each used once |
Response: SyncKeysResponse
| Field | Type | Description |
|---|---|---|
| success | bool | true if the call succeeded |
| message | string | Human-readable result |
| server_ephemeral_public_keys | list[PublicKey] | Exactly 256 ephemeral public keys generated by the server |
Example:
grpcurl -plaintext \
-H 'x-payload-bin: <ENCRYPTED_PAYLOAD>' \
-H 'x-public-key-bin: <CLIENT_PUBLIC_KEY>' \
-H 'x-key-id: <KEY_ID>' \
-H 'x-nonce-bin: <NONCE>' \
-H 'x-timestamp: <TIMESTAMP>' \
-d @ \
-proto publisher.proto \
<HOST>:<PORT> publisher.v3.Publisher/SyncKeys <<EOF
{
"token_id": 2941967295,
"key_id": 123,
"client_ephemeral_public_keys": [
{"key_id": 0, "public_key": "..."},
... 254 more keys ...
{"key_id": 255, "public_key": "..."}
]
}
EOF
Every RPC returns a standard gRPC status code; 0 OK is success.
Glossary
- Adapter (platform adapter): the plugin that publishes to one platform, such as Gmail, installed from its own repository. See Managing adapters.
- Administrator: a credential holding every scope.
- Audit event: a record of a change or login. See Audit log.
- Category: what an adapter publishes, such as email (
--cat-id). - Credential: a username and password that logs in to the REST API’s admin endpoints, with its own scopes. See Credentials and access.
- Ephemeral key pool: the client public keys and server key pairs each linked account holds, up to 256 of each; each pair is used once, with an identity key, by a payload or a token check. See Security model.
- Gateway client: a phone number that relays SMS to the Publisher, such as an Android phone running Deku SMS. See Connecting a relay.
- Identity key: one of the server’s 256 long-term X25519 keys, used in every gRPC request and payload, and alone for offline payloads.
- Linked account: a user’s account on a platform, authorized once while online. The Publisher stores its token encrypted.
- MSISDN: a phone number in international format, such as
+237600000000. - OAuth2: linking through the platform’s consent page, as for Gmail.
- Offline payload: content sent without a linked account, published through RelaySMS Mail. See Offline publishing.
- Payload: the encrypted content the RelaySMS app sends by SMS; one carrying an attachment arrives in several segments.
- PLMN: a mobile network, identified by its MCC (country code) and MNC (network code). See How suggest works.
- PNBA (phone number-based authentication): linking with a code sent to the user’s phone, as for Telegram.
- Protocol: how an adapter links accounts, such as
oauth2(--proto-id). For a payload, how it reached the Publisher, such ashttps(the REST API); see Offline publishing. - Redirect URI: where an OAuth2 platform sends the user back after they authorize. See Redirect URIs.
- rmail: the name of the RelaySMS Mail adapter. See RelaySMS Mail.
- Scope: a permission a credential holds, such as
gc:write. See Scopes. - Sender hash: the keyed hash that replaces a sender’s phone number before anything is stored.
- Token: what the Publisher stores for a linked account to act on the platform, encrypted.
- Web session: a REST API login kept in a cookie, for a web client.
Architecture
The Publisher is a set of services sharing one database and one Celery broker. Each platform is reached through an adapter that runs as its own process.
---
config:
flowchart:
curve: linear
---
flowchart TB
app["RelaySMS app"]
gateway["Gateway client"]
mailbox["Mailbox"]
platform["Online platforms"]
subgraph publisher["Publisher"]
grpc["gRPC API"]
rest["REST API"]
smtp["Email listener"]
beat["Celery Beat"]
db[("Database")]
broker[("Celery broker")]
worker["Celery worker"]
adapter["Adapter process"]
end
app -- "link an account" --> grpc
grpc --> adapter
app -- "SMS" --> gateway
gateway -- "HTTPS" --> rest
gateway -- "email" --> mailbox
mailbox -- "IMAP" --> smtp
rest -- "queue" --> broker
smtp -- "queue" --> broker
beat -- "schedule cleanups" --> broker
broker --> worker
grpc -- "token, keys" --> db
db --> worker
worker --> adapter
adapter --> platform
Every service in the Publisher also reads and writes the database.
Services
| Service | Does |
|---|---|
| gRPC API | Links and unlinks accounts through their adapters, stores their tokens encrypted, and exchanges ephemeral public keys with the app (SyncKeys) |
| REST API | Takes payloads from gateway clients, and serves the admin endpoints, publication stats and public lists |
| Email listener | Reads payloads that gateway clients email to a mailbox |
| Celery worker | Decrypts each queued payload and publishes it through the platform’s adapter; also installs and updates adapters |
| Celery Beat | Schedules the cleanup jobs and, when set, the worker heartbeat |
The database holds linked accounts, keys, credentials, adapters, gateway clients, the audit log and publication stats. The broker carries the queue between the services and the worker; with the default SQLite broker it’s two files in data/.
Adapters
Each adapter is a Python package installed from its own repository into its own virtualenv. The Publisher runs it as a separate process for each call, with a 60-second limit, and talks to it over JSON-RPC on standard input and output. It gets its own config and state directories under data/platforms/ and a few environment variables, not the Publisher’s settings. It runs as the service user (security model). See Writing an adapter.
A publication, step by step
- A gateway client posts a payload to
POST /v1/publications, or emails it. - The REST API or the email listener checks its structure, replaces the sender’s address with a keyed hash, and queues it.
- A multi-part payload waits until all its segments arrive.
- The worker finds the payload’s keys, decrypts it, and calls the platform’s adapter to publish it.
- Only a successful publication uses up the keys; a failure leaves them usable.
- The outcome is recorded in the publication stats.
The security model covers the keys and encryption.
Security model
The RelaySMS protocol is described in the technical whitepaper. This page covers what the Publisher holds and who can read it.
Keys
| Key | Where | Used for |
|---|---|---|
| Server identity keys | 256 X25519 key pairs in the database | Every gRPC request and every payload; the only key for payloads sent without a linked account |
| Ephemeral key pools | Up to 256 client public keys and 256 server key pairs per linked account | One use each, by a payload or a token check; a successful use deletes the pair |
| Encryption keys | .env | Encrypting the private keys, the platform tokens and, with SQLite, the database file, and keying the sender hash |
The app refills a linked account’s pools with the gRPC SyncKeys call. A failed publication leaves its key pair usable, so the same SMS can publish when it’s sent again. A client must never encrypt different content with a key pair it has already used.
What travels encrypted
- Payloads from a linked account are encrypted by the app with a pool key pair combined with an identity key, so the gateway client and the network carry only ciphertext.
- Payloads sent without a linked account are encrypted to an identity key alone, without forward secrecy: anyone who later obtains the identity private keys can decrypt recorded ones.
- gRPC requests carry their payload (
x-payload-bin) encrypted to an identity key. A request is refused if its nonce was already seen, or if its timestamp is more thanNONCE_TTL_SECONDSfrom the server’s clock. The other request fields, such as phone numbers, login codes and two-step passwords, rely on TLS, from nginx orGRPC_TLS_ENABLED. - The REST API takes payloads without credentials, and the email listener only from allowed senders. Either way, a payload only publishes if it decrypts with a linked account’s keys, or passes the offline publishing rules.
What the operator can see
| Data | Stored as |
|---|---|
| Platform tokens | Encrypted with DATABASE_FIELD_ENCRYPTION_KEY while DATABASE_FIELD_ENCRYPTION_ENABLED is on, as template.env sets it |
| Private keys | Encrypted with DATA_ENCRYPTION_KEY |
| Sender phone numbers | Replaced with a keyed hash before anything is stored |
| Message content | Ciphertext while queued, and while a multi-part payload waits for its segments; decrypted only in memory, in the worker and the adapter process it hands it to |
| Publication stats | Platform, protocol, status, country code, failure reason and time |
Adapters
An adapter runs as the service user, so it can read .env and the database. PLATFORMS_GITHUB_ORGS limits which orgs administrators can install from over the REST API.
Who can read what
Anyone holding both .env and the database can decrypt the tokens, publish as the linked accounts, and decrypt recorded payloads sent without a linked account. With field encryption off, the database alone exposes the tokens. Protect them as Backup and restore and Security hardening describe.
Data and retention
The cleanup jobs run on the CELERY_CLEANUP_CRON and CELERY_TOKEN_CLEANUP_CRON schedules, and delete data past its limit at the next run.
| Data | Kept until |
|---|---|
| Linked accounts’ tokens | The user unlinks, or the account has no publication or key sync for TOKEN_IDLE_MAX_AGE_DAYS (90); then the Publisher also tries to revoke it on its platform |
| Ephemeral key pools | Each pair is deleted when a publication or token check uses it; the pools go with their token |
| Incomplete multi-part payloads | All segments arrive, or PAYLOAD_SESSION_MAX_AGE_HOURS (3) passes |
| Publication stats | Kept |
| Audit events and adapter jobs | AUDIT_RETENTION_DAYS (365) |
| Web sessions | Logout, AUTH_SESSION_IDLE_MINUTES (30) idle, or AUTH_SESSION_MAX_HOURS (12) |
| Credentials, adapters and gateway clients | You remove them |
| Server identity keys | Kept |
| Adapters’ own files | Up to each adapter; they live in its state directory |
| Logs | The system journal’s retention, and SigNoz’s when OTEL_EXPORTER_OTLP_ENDPOINT is set |
See the security model for what each record holds.
Writing an adapter
An adapter publishes to one platform. It’s a Python package in its own repository, built with the RelaySMS Adapter SDK, which the Publisher runs in its own virtualenv and calls over JSON-RPC.
Requirements
- Python 3.12 or newer
- A GitHub repository
- A developer account and API credentials for the platform
Steps
-
Copy the SDK’s echo adapter into a new repository named after the platform and protocol, such as
example-oauth2-adapter. -
Subclass the SDK’s base class for its protocol, such as
OAuth2Adapter, and implement its methods (Write an adapter). -
Describe it in
adapter.toml: its name, protocol, category, and a[config]table for each setting (Describe it). -
Try it with the
relaysms-adapterconsole, which runs it the way the Publisher does (Try it). -
Tag a release, such as
v1.0.0(Release an adapter). -
Install it on a Publisher:
sudo ./publisher.sh platforms add https://github.com/<ORG>/<REPOSITORY> -
Set its settings with
sudo ./publisher.sh platforms config set <NAME> <KEY>=<VALUE>, orplatforms setup <NAME>if it has a setup (Settings).
Check it
sudo ./publisher.sh platforms list shows the adapter with its tag, and ✓ under Configured once its settings are set. Then link a test account from the RelaySMS app.
The adapters in the provider guides, such as gmail-oauth2-adapter, are complete examples.
Reference client
tools/client.py drives the gRPC and REST flows by hand, the way a phone app would. It keeps tokens and key pools in tools/db.json between runs.
pip install -r requirements.txt
python -m tools.client <COMMAND> [ARGS]
Connection options
| Option | Default | Does |
|---|---|---|
--host, --port | 127.0.0.1, 6000 | gRPC server |
--tls | off | Use TLS for gRPC |
--rest-api | http://localhost:16000 | REST API base URL |
--platform, -p | None | Platform name, such as gmail |
--phone-number | None | Phone number, for phone number-based authentication (PNBA), such as for Telegram |
--request-identifier | None | Optional request identifier |
Linking accounts
| Command | Does |
|---|---|
get-oauth2-url | Prints the OAuth2 authorization URL |
exchange-oauth2-code --code <CODE> | Exchanges the code and stores the token |
revoke-oauth2-token | Revokes a stored OAuth2 token |
get-pnba-code [--auth-channel signal] | Requests a PNBA one-time code |
exchange-pnba-code --code <CODE> [--password <PASSWORD>] | Exchanges the code and stores the token; --password for two-step verification |
revoke-pnba-token | Revokes a stored PNBA token |
sync-keys | Replaces the token’s 256 client and 256 server ephemeral keys |
python -m tools.client get-oauth2-url --platform gmail
python -m tools.client exchange-oauth2-code --platform gmail --code <AUTH_CODE>
python -m tools.client get-pnba-code --platform telegram --phone-number +237600000000
python -m tools.client exchange-pnba-code --platform telegram --phone-number +237600000000 --code <OTP>
With several stored tokens, sync-keys and the revoke commands ask which one to use. Pass --token to choose up front.
Publishing
send encrypts a message and posts it to POST /v1/publications with --address as the sender. With an attachment, the payload is split into SMS-sized segments sent --interval seconds apart.
python -m tools.client send --platform gmail --address +237600000000 \
--to friend@example.com --subject "Hello" --body "Test message"
python -m tools.client send --platform gmail --address +237600000000 \
--to friend@example.com --subject "Hello" --body "See attached" --attachment ./file.pdf --interval 2.5 --shuffle
python -m tools.client send --offline --platform rmail --address +237600000000 \
--to friend@example.com --subject "Hello" --body "No token needed" --tag <SECRET>
| Option | Does |
|---|---|
--address | Sender’s number in E.164 format (required) |
--body | Message body (required) |
--to | Recipient email or number, for email and messaging platforms |
--subject | Subject, for email platforms |
--attachment | File to attach, sent as several segments |
--interval | Seconds between segments (default 1.0) |
--shuffle | Sends segments out of order |
--dry-run | Prints the segments and their order without sending |
--token | Raw token (base64), instead of choosing interactively |
--offline | Uses offline-first encryption, which needs no linked token (rmail only) |
--tag | The server’s OFFLINE_PUBLISH_SHARED_SECRET, when it sets one |
Each online send uses up one ephemeral key pair. Run sync-keys when the pool runs low. Offline sends use none.
--shuffle --dry-run shows the segment order before a live send, to check the server reassembles out-of-order segments.