# sms — voip.ms SMS/MMS API façade A small FastAPI service that wraps the [voip.ms](https://voip.ms) SMS/MMS REST API (`https://voip.ms/api/v1/rest.php`) behind typed Python + REST endpoints, with a per-day outbound send guard. ## Files | File | Purpose | | --- | --- | | `config.py` | `Settings` (env-loaded creds, dialing mode, daily limit, timeout) | | `exceptions.py` | `VoipMsError` hierarchy (`Auth`, `Api`, `RateLimit`) | | `models.py` | Pydantic request/response models | | `client.py` | `VoipMsSMSClient` — async `httpx` wrappers for the 7 methods | | `guard.py` | `DailySendCounter` — UTC-day counter + send logging | | `auth.py` | Static API-key dependency (SHA-256 hash compare, fail-closed) | | `app.py` | FastAPI app exposing the operations as REST endpoints | | `requirements.txt` | Dependencies | ## voip.ms prerequisites 1. Enable the REST API and set an API password in the portal: **Main Menu → SOAP / REST API → API Security**. 2. **Whitelist this machine's public IP** in the same API Security page. 3. The DID you send from must have **SMS enabled** (Manage DIDs → Edit DID). 4. Sending is capped at **100 SMS/MMS per day** via the API. Pricing: $0.0075/SMS, $0.02/MMS, each direction. ## Configure ```sh export VOIPMS_API_USERNAME=... export VOIPMS_API_PASSWORD=... export VOIPMS_SMS_API_KEY_HASH=$(python3 -c "import secrets,hashlib;print(hashlib.sha256(secrets.token_urlsafe(32).encode()).hexdigest())") # optional overrides: # export VOIPMS_BASE_URL=https://voip.ms/api/v1/rest.php # export VOIPMS_DIALING_MODE=nanpa # or e164 # export VOIPMS_DAILY_LIMIT=100 # export VOIPMS_TIMEOUT=30 ``` (`Settings` also reads a `.env` file in the working directory.) ## Authentication Every endpoint except `/health` requires a static API key. Send it as either: ``` Authorization: Bearer X-API-Key: ``` The server stores only the **SHA-256 hash** of accepted keys in `VOIPMS_SMS_API_KEY_HASH` (comma-separated list allowed, for rotation). The raw key is never persisted — generate one, hash it, and keep the raw key wherever your clients live: ```sh python3 -c "import secrets,hashlib; k=secrets.token_urlsafe(32); print('KEY:',k); print('HASH:',hashlib.sha256(k.encode()).hexdigest())" ``` Put the `HASH:` value in `VOIPMS_SMS_API_KEY_HASH`; hand the `KEY:` value to your calling services. To rotate, add the new hash alongside the old, switch callers over, then remove the old hash. If `VOIPMS_SMS_API_KEY_HASH` is empty/unset, the service is **fail-closed**: protected routes return `503` until a hash is configured. `/health` stays open for liveness probes. ## Run ```sh pip install -r sms/requirements.txt uvicorn sms.app:app --reload ``` Open `http://127.0.0.1:8000/docs` for the Swagger UI. ## Run with Docker A `Dockerfile` and `docker-compose.yml` live at the repo root. The compose stack builds the image and runs it, injecting secrets from the local `.env` via `env_file` — **secrets are never baked into the image** (`.dockerignore` excludes `.env`, `.venv`, `.git`, `sms/docs/`). ```sh docker compose up -d # build + start docker compose logs -f sms # follow logs docker compose ps # status / healthcheck docker compose down # stop ``` The container binds `0.0.0.0:8000` internally; compose maps it to **`127.0.0.1:${SMS_PORT:-8000}`** on the host — localhost-only by default. To expose on the LAN, set the mapping to `8000:8000` (access is still gated by the `VOIPMS_SMS_API_KEY_HASH` API key). If host port 8000 is taken, set `SMS_PORT=8001` (or any free port) in `.env`. A healthcheck polls `/health` every 30s. The image is lean — reference docs and the virtualenv are excluded; only `sms/` + dependencies are inside. ## Endpoints | Method | Path | Upstream | Guarded | | --- | --- | --- | --- | | POST | `/sms/send` | `sendSMS` | ✅ | | POST | `/mms/send` | `sendMMS` | ✅ | | GET | `/sms` | `getSMS` | — | | GET | `/mms` | `getMMS` | — | | GET | `/mms/{id}/media` | `getMediaMMS` | — | | DELETE | `/sms/{id}` | `deleteSMS` | — | | DELETE | `/mms/{id}` | `deleteMMS` | — | | GET | `/health` | — | — | ### Query params for `GET /sms` / `GET /mms` `from`, `to` (YYYY-MM-DD), `type` (1=received / 0=sent), `did`, `contact`, `limit`, `timezone` (-12..13), `all_messages` (1=SMS+MMS combined, 0=single type; the message id must be 0 when set). For `/sms` the id query param is `sms`; for `/mms` it is `id`. ## Error mapping | Condition | HTTP | Body | | --- | --- | --- | | Missing API key | 401 | `{"detail":"missing api key"}` | | Invalid API key | 401 | `{"detail":"invalid api key"}` | | No API key hash configured | 503 | `{"detail":"api key auth not configured"}` | | Daily limit exceeded | 429 | `{detail, limit, used_today}` | | voip.ms returns non-success status | 502 | upstream message | | Auth / IP-whitelist failure | 500 | `upstream authentication failure` (details logged server-side) | ## Notes & limitations - The daily counter is **per-process**. For multiple instances, swap `DailySendCounter` for a shared store (Redis, etc.). - Use `https://voip.ms` (not `www.voip.ms`) — the `www` host 302-redirects and drops POST bodies, producing `missing_method` errors. - Dialing mode normalizes `did`/`dst` on send: `nanpa` → 10 digits, `e164` → `+1` + 10 digits. - No `markSMSRead` method exists in the voip.ms API; read state is not manageable via REST. - Inbound URL Callback receiver is **not** included here; it can be added as a separate route set that replies with the literal `ok` and persists `{TO}/{FROM}/{MESSAGE}/{ID}/{TIMESTAMP}/{MEDIA}`.