first commit

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
chelsea
2026-07-04 04:39:53 +00:00
commit db5bde0a70
13 changed files with 1095 additions and 0 deletions

18
.gitignore vendored Normal file
View File

@@ -0,0 +1,18 @@
# Secrets — never commit
.env
.env.*
!.env.example
# Python
__pycache__/
*.py[cod]
*.egg-info/
.venv/
venv/
# Editor / OS
.DS_Store
*.swp
# Claude Code local state
.claude/

0
README.md Normal file
View File

91
sms/README.md Normal file
View File

@@ -0,0 +1,91 @@
# 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 |
| `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=...
# 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.)
## 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.
## 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 |
| --- | --- | --- |
| 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}`.

25
sms/__init__.py Normal file
View File

@@ -0,0 +1,25 @@
"""voip.ms SMS/MMS API façade.
Exposes the typed client and settings for direct import, plus the FastAPI
app for serving the same operations over HTTP.
"""
from .client import VoipMsSMSClient
from .config import Settings
from .exceptions import (
VoipMsApiError,
VoipMsAuthError,
VoipMsError,
VoipMsRateLimitError,
)
from .guard import DailySendCounter
__all__ = [
"VoipMsSMSClient",
"Settings",
"DailySendCounter",
"VoipMsError",
"VoipMsAuthError",
"VoipMsApiError",
"VoipMsRateLimitError",
]

203
sms/app.py Normal file
View File

@@ -0,0 +1,203 @@
"""FastAPI façade exposing the voip.ms SMS/MMS operations over HTTP.
Run:
export VOIPMS_API_USERNAME=... VOIPMS_API_PASSWORD=...
uvicorn sms.app:app --reload
Then open http://127.0.0.1:8000/docs
"""
from __future__ import annotations
import logging
from contextlib import asynccontextmanager
from typing import Annotated
from fastapi import FastAPI, Query, Request, status
from fastapi.responses import JSONResponse, PlainTextResponse
from .client import VoipMsSMSClient
from .config import Settings
from .exceptions import VoipMsApiError, VoipMsAuthError, VoipMsError, VoipMsRateLimitError
from .guard import DailySendCounter
from .models import (
DeleteResult,
MediaResult,
MmsRecord,
SendMmsRequest,
SendResult,
SendSmsRequest,
SmsRecord,
)
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s %(message)s")
@asynccontextmanager
async def lifespan(app: FastAPI):
settings = Settings() # validates env creds; raises clearly if missing
app.state.settings = settings
app.state.client = VoipMsSMSClient(settings)
app.state.guard = DailySendCounter(limit=settings.daily_limit)
try:
yield
finally:
await app.state.client.aclose()
app = FastAPI(
title="voip.ms SMS/MMS API façade",
description="Thin typed proxy over the voip.ms SMS/MMS REST API.",
version="0.1.0",
lifespan=lifespan,
)
def _client(request: Request) -> VoipMsSMSClient:
return request.app.state.client
def _guard(request: Request) -> DailySendCounter:
return request.app.state.guard
def _map_error(exc: VoipMsError) -> JSONResponse:
if isinstance(exc, VoipMsRateLimitError):
return JSONResponse(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
content={"detail": str(exc), "limit": exc.limit, "used_today": exc.used_today},
)
if isinstance(exc, VoipMsAuthError):
# 500 — don't leak auth context to callers; log it server-side.
logging.getLogger("voipms.sms.app").error("auth error: %s", exc)
return JSONResponse(status_code=500, content={"detail": "upstream authentication failure"})
return JSONResponse(
status_code=status.HTTP_502_BAD_GATEWAY,
content={"detail": str(exc)},
)
# --- outbound (guarded) ----------------------------------------------------
@app.post("/sms/send", response_model=SendResult)
async def send_sms(body: SendSmsRequest, request: Request) -> SendResult:
guard = _guard(request)
client = _client(request)
try:
guard.check_and_increment()
result = await client.send_sms(did=body.did, dst=body.dst, message=body.message)
except VoipMsError as exc:
return _map_error(exc)
guard.log_send(kind="sms", did=body.did, dst=body.dst, chars=len(body.message))
return result
@app.post("/mms/send", response_model=SendResult)
async def send_mms(body: SendMmsRequest, request: Request) -> SendResult:
guard = _guard(request)
client = _client(request)
try:
guard.check_and_increment()
result = await client.send_mms(
did=body.did,
dst=body.dst,
message=body.message,
media1=body.media1,
media2=body.media2,
media3=body.media3,
)
except VoipMsError as exc:
return _map_error(exc)
guard.log_send(kind="mms", did=body.did, dst=body.dst, chars=len(body.message))
return result
# --- history / retrieval ---------------------------------------------------
@app.get("/sms", response_model=list[SmsRecord])
async def list_sms(
request: Request,
sms: Annotated[int | None, Query(description="Specific SMS id")] = None,
date_from: Annotated[str | None, Query(alias="from", description="YYYY-MM-DD")] = None,
date_to: Annotated[str | None, Query(alias="to", description="YYYY-MM-DD")] = None,
type: Annotated[int | None, Query(description="1=received, 0=sent")] = None,
did: Annotated[str | None, Query()] = None,
contact: Annotated[str | None, Query()] = None,
limit: Annotated[int | None, Query(ge=1)] = None,
timezone: Annotated[int | None, Query(ge=-12, le=13)] = None,
all_messages: Annotated[int | None, Query(description="1=SMS+MMS, 0=SMS only")] = None,
) -> list[SmsRecord]:
client = _client(request)
try:
return await client.get_sms(
sms=sms, from_=date_from, to=date_to, type=type, did=did,
contact=contact, limit=limit, timezone=timezone, all_messages=all_messages,
)
except VoipMsError as exc:
return _map_error(exc)
@app.get("/mms", response_model=list[MmsRecord])
async def list_mms(
request: Request,
id: Annotated[int | None, Query(description="Specific MMS id")] = None,
date_from: Annotated[str | None, Query(alias="from")] = None,
date_to: Annotated[str | None, Query(alias="to")] = None,
type: Annotated[int | None, Query()] = None,
did: Annotated[str | None, Query()] = None,
contact: Annotated[str | None, Query()] = None,
limit: Annotated[int | None, Query(ge=1)] = None,
timezone: Annotated[int | None, Query(ge=-12, le=13)] = None,
all_messages: Annotated[int | None, Query()] = None,
) -> list[MmsRecord]:
client = _client(request)
try:
return await client.get_mms(
id=id, from_=date_from, to=date_to, type=type, did=did,
contact=contact, limit=limit, timezone=timezone, all_messages=all_messages,
)
except VoipMsError as exc:
return _map_error(exc)
@app.get("/mms/{id}/media", response_model=MediaResult)
async def get_mms_media(
request: Request,
id: int,
media_as_array: Annotated[bool, Query()] = False,
) -> MediaResult:
client = _client(request)
try:
return await client.get_media_mms(id=id, media_as_array=media_as_array)
except VoipMsError as exc:
return _map_error(exc)
# --- delete ----------------------------------------------------------------
@app.delete("/sms/{id}", response_model=DeleteResult)
async def delete_sms(request: Request, id: int) -> DeleteResult:
client = _client(request)
try:
return await client.delete_sms(id=id)
except VoipMsError as exc:
return _map_error(exc)
@app.delete("/mms/{id}", response_model=DeleteResult)
async def delete_mms(request: Request, id: int) -> DeleteResult:
client = _client(request)
try:
return await client.delete_mms(id=id)
except VoipMsError as exc:
return _map_error(exc)
# --- liveness --------------------------------------------------------------
@app.get("/health", response_class=PlainTextResponse)
async def health() -> str:
return "ok"

243
sms/client.py Normal file
View File

@@ -0,0 +1,243 @@
"""Async client for the voip.ms SMS/MMS REST API.
Wraps the seven documented methods against https://voip.ms/api/v1/rest.php:
sendSMS, sendMMS, getSMS, getMMS, getMediaMMS, deleteSMS, deleteMMS
"""
from __future__ import annotations
import re
from typing import Any
import httpx
from .config import Settings
from .exceptions import VoipMsApiError, VoipMsAuthError, VoipMsError
from .models import DeleteResult, MediaResult, MmsRecord, SendResult, SmsRecord
_DIGITS = re.compile(r"\d+")
def _digits(value: str) -> str:
return "".join(_DIGITS.findall(value or ""))
def normalize_number(value: str, mode: str) -> str:
"""Render a US/Canada number in the configured dialing mode.
nanpa -> 10 digits (NPANXXXXXX)
e164 -> +1 then 10 digits
"""
digits = _digits(value)
# Tolerate an 11-digit number with leading 1.
if len(digits) == 11 and digits.startswith("1"):
digits = digits[1:]
if len(digits) != 10:
# Don't silently mangle unexpected shapes; pass through normalized digits.
return digits
if mode == "e164":
return f"+1{digits}"
return digits
# voip.ms returns one of these `status` values (with a "There are no ... messages"
# message) when a getSMS/getMMS query matches nothing. Treat as an empty list,
# not an error.
_EMPTY_STATUSES = {"no_sms", "no_mms"}
def _is_empty(payload: dict[str, Any]) -> bool:
status = str(payload.get("status", "")).lower()
if status in _EMPTY_STATUSES:
return True
message = str(payload.get("message") or "").lower()
return "there are no" in message and "message" in message
_AUTH_HINTS = (
"invalid api", "incorrect api", "api_username", "api_password",
"ip", "authorized", "whitelist",
"incorrect", "invalid_credentials", "username or password", "credentials",
)
class VoipMsSMSClient:
"""Thin async wrapper over the voip.ms SMS/MMS REST API."""
def __init__(self, settings: Settings) -> None:
self._settings = settings
self._http = httpx.AsyncClient(
base_url=settings.base_url,
timeout=settings.timeout,
)
async def aclose(self) -> None:
await self._http.aclose()
# --- internals --------------------------------------------------------
async def _call(self, method: str, params: dict[str, Any] | None = None) -> dict[str, Any]:
"""Perform one GET (or POST for base64 media) and return parsed JSON."""
query: dict[str, Any] = {
"api_username": self._settings.api_username,
"api_password": self._settings.api_password,
"method": method,
}
if params:
query.update({k: v for k, v in params.items() if v is not None})
try:
response = await self._http.get("", params=query)
except httpx.HTTPError as exc:
raise VoipMsError(f"transport error calling {method}: {exc}") from exc
if response.status_code >= 400:
raise VoipMsError(f"HTTP {response.status_code} from voip.ms calling {method}")
try:
payload = response.json()
except ValueError as exc:
raise VoipMsError(f"non-JSON response from {method}: {response.text[:200]}") from exc
return payload
@staticmethod
def _check_status(payload: dict[str, Any], method: str) -> None:
status = str(payload.get("status", "")).lower()
if status == "success":
return
message = str(payload.get("message") or payload or "unknown error")
# Match against both the message and the status string (e.g.
# "invalid_credentials") so auth failures classify as VoipMsAuthError.
haystack = f"{status} {message}".lower()
if any(hint in haystack for hint in _AUTH_HINTS):
raise VoipMsAuthError(f"{method}: {message}")
raise VoipMsApiError(f"{method}: {message}", code=payload.get("code"))
# --- outbound ---------------------------------------------------------
async def send_sms(self, *, did: str, dst: str, message: str) -> SendResult:
if len(message) > 160:
raise VoipMsError("SMS message exceeds 160 characters")
mode = self._settings.dialing_mode
payload = await self._call("sendSMS", {
"did": normalize_number(did, mode),
"dst": normalize_number(dst, mode),
"message": message,
})
self._check_status(payload, "sendSMS")
return SendResult(id=int(payload["sms"]))
async def send_mms(
self,
*,
did: str,
dst: str,
message: str,
media1: str | None = None,
media2: str | None = None,
media3: str | None = None,
) -> SendResult:
if len(message) > 2048:
raise VoipMsError("MMS message exceeds 2048 characters")
mode = self._settings.dialing_mode
payload = await self._call("sendMMS", {
"did": normalize_number(did, mode),
"dst": normalize_number(dst, mode),
"message": message,
"media1": media1,
"media2": media2,
"media3": media3,
})
self._check_status(payload, "sendMMS")
# sendMMS returns the new id under the "sms" key per the docs examples.
return SendResult(id=int(payload.get("sms", payload.get("mms", 0))))
# --- inbound / history ------------------------------------------------
async def get_sms(
self,
*,
sms: int | None = None,
from_: str | None = None,
to: str | None = None,
type: int | None = None,
did: str | None = None,
contact: str | None = None,
limit: int | None = None,
timezone: int | None = None,
all_messages: int | None = None,
) -> list[SmsRecord]:
payload = await self._call("getSMS", {
"sms": sms,
"from": from_,
"to": to,
"type": type,
"did": did,
"contact": contact,
"limit": limit,
"timezone": timezone,
"all_messages": all_messages,
})
if _is_empty(payload):
return []
self._check_status(payload, "getSMS")
rows = payload.get("sms") or []
return [SmsRecord.model_validate(row) for row in rows]
async def get_mms(
self,
*,
id: int | None = None,
from_: str | None = None,
to: str | None = None,
type: int | None = None,
did: str | None = None,
contact: str | None = None,
limit: int | None = None,
timezone: int | None = None,
all_messages: int | None = None,
) -> list[MmsRecord]:
payload = await self._call("getMMS", {
"mms": id,
"from": from_,
"to": to,
"type": type,
"did": did,
"contact": contact,
"limit": limit,
"timezone": timezone,
"all_messages": all_messages,
})
if _is_empty(payload):
return []
self._check_status(payload, "getMMS")
rows = payload.get("mms") or []
return [MmsRecord.model_validate(row) for row in rows]
async def get_media_mms(self, *, id: int, media_as_array: bool = False) -> MediaResult:
payload = await self._call("getMediaMMS", {
"id": id,
"media_as_array": 1 if media_as_array else 0,
})
self._check_status(payload, "getMediaMMS")
media = payload.get("media")
if isinstance(media, dict):
media_list = [v for v in media.values() if v]
elif isinstance(media, list):
media_list = [v for v in media if v]
else:
media_list = []
return MediaResult(id=payload.get("id", id), date=payload.get("date"), media=media_list)
# --- delete -----------------------------------------------------------
async def delete_sms(self, *, id: int) -> DeleteResult:
payload = await self._call("deleteSMS", {"id": id})
self._check_status(payload, "deleteSMS")
return DeleteResult()
async def delete_mms(self, *, id: int) -> DeleteResult:
payload = await self._call("deleteMMS", {"id": id})
self._check_status(payload, "deleteMMS")
return DeleteResult()

47
sms/config.py Normal file
View File

@@ -0,0 +1,47 @@
"""Configuration for the voip.ms SMS client, loaded from environment.
Set credentials before running:
export VOIPMS_API_USERNAME=...
export VOIPMS_API_PASSWORD=...
On the voip.ms side you must also whitelist this machine's public IP under
Main Menu -> SOAP / REST API -> API Security, and the sending DID must have
SMS enabled.
"""
from __future__ import annotations
from typing import Literal
from pydantic import Field
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(
env_prefix="voipms_",
env_file=".env",
env_file_encoding="utf-8",
extra="ignore",
)
api_username: str = Field(..., description="voip.ms API username")
api_password: str = Field(..., description="voip.ms API password (set in API Security)")
base_url: str = Field(
"https://voip.ms/api/v1/rest.php",
description="voip.ms REST endpoint. Avoid www.voip.ms (302 drops POST bodies).",
)
dialing_mode: Literal["nanpa", "e164"] = Field(
"nanpa",
description="Number format. nanpa=10 digits; e164=+1 then 10 digits.",
)
daily_limit: int = Field(
100,
description="Per-UTC-day cap on outbound sends (matches the upstream API limit).",
ge=0,
)
timeout: float = Field(30.0, description="HTTP timeout in seconds.", ge=1.0)

View File

@@ -0,0 +1,135 @@
====== voip.ms SMS/MMS REST API ======
Reference for the voip.ms SMS/MMS REST API. Sanitized — no account credentials or phone numbers. Account-specific values (API username/password, DID numbers) are supplied at runtime via environment variables and kept local.
===== Endpoint & authentication =====
* **Base URL:** ''https://voip.ms/api/v1/rest.php'' (use ''voip.ms'', **not** ''www.voip.ms'' — the ''www'' host 302-redirects and drops POST bodies, producing ''missing_method'').
* **Auth (every request):** ''api_username'', ''api_password'', ''method''.
- ''api_username'' is the **portal login email** — //not// the 6-digit account ID.
- ''api_password'' is set under **Main Menu → SOAP / REST API → API Security** and is distinct from the portal login password.
- The calling server's public IP must be **whitelisted** on the same API Security page.
* **Transport:** GET with query params for SMS; POST ''multipart/form-data'' for MMS with base64 images.
* A separate **bearer token / API key** exists for the 3CX webhook flow — it is **not** used by ''rest.php''.
===== Limits & pricing =====
* **API sending cap: 100 SMS/MMS per day** (portal is unlimited). Raise via support ticket.
* **SMS:** $0.0075 each way. **MMS:** $0.02 each way.
* US/Canada 10-digit DIDs only; short codes and 2FA codes aren't guaranteed; public URL shorteners (bit.ly etc.) may be blocked.
* MMS attachments: types ''JPG, GIF, JPEG, PNG, MP3, WAV, MIDI, MP4, 3GP''; ≤1300 KB each; up to 3 files; text ≤2048 chars.
===== Methods =====
==== sendSMS — send a text ====
^ Param ^ Req ^ Notes ^
| did | yes | Sender DID, e.g. ''5551234567'' |
| dst | yes | Destination number |
| message | yes | Max **160** chars |
Returns ''{"status":"success","sms":<new id>}''. Requires SMS enabled on the DID (see ''setSMS'' below), else ''{"status":"sms_failed","message":"The SMS message was not sent"}''.
==== sendMMS — send media/text ====
^ Param ^ Req ^ Notes ^
| did | yes | Sender DID |
| dst | yes | Destination |
| message | yes | Max **2048** chars |
| media1 | no | URL to a media file |
| media2 | no | Base64 image (''data:image/png;base64,...'') |
| media3 | no | Reserved/empty |
* POST + base64 → up to **1.2 MB/file**; GET + base64 → ~160 KB (~512-char URL limit). File-URL submissions have no size limit either way.
==== getSMS — list/retrieve texts ====
^ Param ^ Req ^ Notes ^
| sms | no | ID for a specific SMS |
| from | no | Start date ''YYYY-MM-DD'' (default: today) |
| to | no | End date (default: today) |
| type | no | ''1''=received, ''0''=sent |
| did | no | Filter by DID |
| contact | no | Filter by contact number |
| limit | no | Records to return (default **50**) |
| timezone | no | Numeric -12 to 13 |
| all_messages | no | ''1'' = SMS + MMS combined, ''0'' = SMS only (id must be ''0'' when set) |
Returns ''{"status":"success","sms":[ ...rows... ]}''. **Empty result returns ''{"status":"no_sms","message":"There are no SMS messages"}''** — treat as ''[]'', not an error.
==== getMMS — list/retrieve media messages ====
Same shape as ''getSMS'' but the id param is ''mms''. ''all_messages=1'' returns MMS+SMS combined (id must be ''0''). Empty result also reports ''status: no_sms''.
==== getMediaMMS — fetch attachments for a message ====
^ Param ^ Req ^ Notes ^
| id | yes | MMS id |
| media_as_array | no | ''1''=array, ''0''=JSON object (default) |
Returns ''{"status":"success","id":...,"date":...,"media":[url,...]}'' (up to 3 slots).
==== deleteSMS / deleteMMS — delete one message ====
^ Param ^ Req ^ Notes ^
| id | yes | Message id |
Returns ''{"status":"success"}''.
==== setSMS — enable/disable SMS on a DID ====
^ Param ^ Req ^ Notes ^
| did | yes | DID to update |
| enable | yes | ''1'' to enable, ''0'' to disable |
| email_enabled | no | bool — forward inbound SMS to email |
| email_address | no | email address(es), comma-separated |
| sms_forward_enable | no | bool — forward inbound SMS to another number |
| sms_forward | no | forwarding number |
| url_callback_enable | no | bool — GET callback on inbound |
| url_callback | no | callback URL (variables below) |
| url_callback_retry | no | bool — require ''ok'' response, retry every 30 min |
SMS must be enabled on a DID (''enable=1'') before ''sendSMS'' works.
==== getDIDsInfo — list DIDs on the account ====
Returns ''{"status":"success","dids":[ ... ]}''. Each DID includes ''sms_available'', ''sms_enabled'', ''mms_available'', and the full SMS config fields — use this to find a SMS-capable DID and check whether SMS is enabled.
Note: ''getDIDs'', ''getDIDInfo'', ''getDID'' are **not** valid methods — use ''getDIDsInfo''.
===== Inbound via URL Callback (per-DID) =====
Configure on the DID (portal Manage DID, or ''setSMS''):
<code>
https://your.host/sms?to={TO}&from={FROM}&message={MESSAGE}&id={ID}&date={TIMESTAMP}&media={MEDIA}
</code>
Variables: ''{ID}'', ''{TIMESTAMP}'', ''{FROM}'', ''{TO}'', ''{MESSAGE}'', ''{MEDIA}'' (comma-separated media list, for MMS).
**Retry:** if "URL Callback Retry" is enabled, your endpoint must respond with the literal body ''ok''. Without it, voip.ms re-sends the same message **every 30 minutes**.
===== Dialing mode (per-DID, for 3CX/API) =====
Choose NANPA (10-digit, no country code) or E164 (''+1'' + 10 digits). Affects what you send to the API and what arrives in callbacks — normalize on your side.
===== Gotchas =====
* ''api_username'' is the portal **email**, not the account ID.
* Empty ''getSMS''/''getMMS'' results report ''status: no_sms'' (even for MMS) — return ''[]''.
* SMS must be enabled per-DID (''setSMS enable=1'') before sending, else ''sms_failed''.
* ''sendSMS''/''sendMMS'' reuse the ''sms'' response key for the new message id (int); ''getSMS'' uses ''sms'' for the row list; ''getMMS'' uses ''mms''.
* No ''markSMSRead'' method exists.
* SIP/SMS over SIP MESSAGE is text-only (no MMS) and uses a separate SIP sub-account, not the REST API.
* DID **POP must match** the registered SIP server or inbound calls won't ring.
* Transient transport errors occur; clients should retry with backoff.
===== Other ingestion channels (non-REST) =====
* **Email to SMS:** mail ''sms@voip.ms'', subject = recipient 10-digit number; security-code / from-DID overrides via dot syntax ''dst.seccode.fromdid''.
* **SMPP:** ''smpp.voip.ms:2775'' (plain) / '':3550'' (TLS, scheme ''ssmpp''); bind as transceiver; ''source_addr'' = SMS-enabled DID. Bypasses the 100/day REST cap.
* **SIP MESSAGE (RFC 3428):** SMS only, user/pass-auth trunks; original destination in ''X-Sms-To'' header on receive.
----
//Source: voip.ms official API docs (''/m/apidocs.php'') + SMS/MMS wiki article, verified live.//

152
sms/docs/voipms-sms-api.md Normal file
View File

@@ -0,0 +1,152 @@
# voip.ms SMS/MMS REST API
Reference for the voip.ms SMS/MMS REST API. Sanitized — no account credentials or
phone numbers. Account-specific values (API username/password, DID numbers, etc.)
are kept local and supplied at runtime via environment variables.
## Endpoint & authentication
- **Base URL:** `https://voip.ms/api/v1/rest.php` (use `voip.ms`, **not** `www.voip.ms`
the `www` host 302-redirects and drops POST bodies, producing `missing_method`).
- **Auth (every request):** `api_username`, `api_password`, `method`.
- `api_username` is the **portal login email***not* the 6-digit account ID.
- `api_password` is set under **Main Menu → SOAP / REST API → API Security** and is
distinct from the portal login password.
- The calling server's public IP must be **whitelisted** on the same API Security page.
- **Transport:** GET with query params for SMS; POST `multipart/form-data` for MMS with
base64 images.
- A separate **bearer token / API key** exists for the 3CX webhook flow — it is **not**
used by `rest.php`.
## Limits & pricing
- **API sending cap: 100 SMS/MMS per day** (portal is unlimited). Raise via support ticket.
- **SMS:** $0.0075 each way. **MMS:** $0.02 each way.
- US/Canada 10-digit DIDs only; short codes and 2FA codes aren't guaranteed; public URL
shorteners (bit.ly etc.) may be blocked.
- MMS attachments: types `JPG, GIF, JPEG, PNG, MP3, WAV, MIDI, MP4, 3GP`; ≤1300 KB each;
up to 3 files; text ≤2048 chars.
## Methods
### `sendSMS` — send a text
| Param | Req | Notes |
|---|---|---|
| `did` | yes | Sender DID, e.g. `5551234567` |
| `dst` | yes | Destination number |
| `message` | yes | Max **160** chars |
Returns `{"status":"success","sms":<new id>}`. Requires SMS enabled on the DID
(see `setSMS` below), else `{"status":"sms_failed","message":"The SMS message was not sent"}`.
### `sendMMS` — send media/text
| Param | Req | Notes |
|---|---|---|
| `did` | yes | Sender DID |
| `dst` | yes | Destination |
| `message` | yes | Max **2048** chars |
| `media1` | no | URL to a media file |
| `media2` | no | Base64 image (`data:image/png;base64,...`) |
| `media3` | no | Reserved/empty |
- POST + base64 → up to **1.2 MB/file**; GET + base64 → ~160 KB (~512-char URL limit).
File-URL submissions have no size limit either way.
### `getSMS` — list/retrieve texts
| Param | Req | Notes |
|---|---|---|
| `sms` | no | ID for a specific SMS |
| `from` | no | Start date `YYYY-MM-DD` (default: today) |
| `to` | no | End date (default: today) |
| `type` | no | `1`=received, `0`=sent |
| `did` | no | Filter by DID |
| `contact` | no | Filter by contact number |
| `limit` | no | Records to return (default **50**) |
| `timezone` | no | Numeric -12 to 13 |
| `all_messages` | no | `1` = SMS + MMS combined, `0` = SMS only (id must be `0` when set) |
Returns `{"status":"success","sms":[ ...rows... ]}`. **Empty result returns
`{"status":"no_sms","message":"There are no SMS messages"}`** — treat as `[]`, not an error.
### `getMMS` — list/retrieve media messages
Same shape as `getSMS` but the id param is `mms`. `all_messages=1` returns MMS+SMS
combined (id must be `0`). Empty result also reports `status: no_sms`.
### `getMediaMMS` — fetch attachments for a message
| Param | Req | Notes |
|---|---|---|
| `id` | yes | MMS id |
| `media_as_array` | no | `1`=array, `0`=JSON object (default) |
Returns `{"status":"success","id":...,"date":...,"media":[url,...]}` (up to 3 slots).
### `deleteSMS` / `deleteMMS` — delete one message
| Param | Req | Notes |
|---|---|---|
| `id` | yes | Message id |
Returns `{"status":"success"}`.
### `setSMS` — enable/disable SMS on a DID
| Param | Req | Notes |
|---|---|---|
| `did` | yes | DID to update |
| `enable` | yes | `1` to enable, `0` to disable |
| `email_enabled` | no | bool — forward inbound SMS to email |
| `email_address` | no | email address(es), comma-separated |
| `sms_forward_enable` | no | bool — forward inbound SMS to another number |
| `sms_forward` | no | forwarding number |
| `url_callback_enable` | no | bool — GET callback on inbound |
| `url_callback` | no | callback URL (variables below) |
| `url_callback_retry` | no | bool — require `ok` response, retry every 30 min |
SMS must be enabled on a DID (`enable=1`) before `sendSMS` works.
### `getDIDsInfo` — list DIDs on the account
Returns `{"status":"success","dids":[ ... ]}`. Each DID includes `sms_available`,
`sms_enabled`, `mms_available`, and the full SMS config fields — use this to find a
SMS-capable DID and check whether SMS is enabled.
> Note: `getDIDs`, `getDIDInfo`, `getDID` are **not** valid methods — use `getDIDsInfo`.
## Inbound via URL Callback (per-DID)
Configure on the DID (portal Manage DID, or `setSMS`):
```
https://your.host/sms?to={TO}&from={FROM}&message={MESSAGE}&id={ID}&date={TIMESTAMP}&media={MEDIA}
```
Variables: `{ID}`, `{TIMESTAMP}`, `{FROM}`, `{TO}`, `{MESSAGE}`, `{MEDIA}` (comma-separated
media list, for MMS).
**Retry:** if "URL Callback Retry" is enabled, your endpoint must respond with the literal
body `ok`. Without it, voip.ms re-sends the same message **every 30 minutes**.
## Dialing mode (per-DID, for 3CX/API)
Choose NANPA (10-digit, no country code) or E164 (`+1` + 10 digits). Affects what you send
to the API and what arrives in callbacks — normalize on your side.
## Gotchas
- `api_username` is the portal **email**, not the account ID.
- Empty `getSMS`/`getMMS` results report `status: no_sms` (even for MMS) — return `[]`.
- SMS must be enabled per-DID (`setSMS enable=1`) before sending, else `sms_failed`.
- `sendSMS`/`sendMMS` reuse the `sms` response key for the new message id (int); `getSMS`
uses `sms` for the row list; `getMMS` uses `mms`.
- No `markSMSRead` method exists.
- SIP/SMS over SIP MESSAGE is text-only (no MMS) and uses a separate SIP sub-account, not
the REST API.
- DID **POP must match** the registered SIP server or inbound calls won't ring.
- Transient transport errors occur; clients should retry with backoff.
## Other ingestion channels (non-REST)
- **Email to SMS:** mail `sms@voip.ms`, subject = recipient 10-digit number; security-code
/ from-DID overrides via dot syntax `dst.seccode.fromdid`.
- **SMPP:** `smpp.voip.ms:2775` (plain) / `:3550` (TLS, scheme `ssmpp`); bind as
transceiver; `source_addr` = SMS-enabled DID. Bypasses the 100/day REST cap.
- **SIP MESSAGE (RFC 3428):** SMS only, user/pass-auth trunks; original destination in
`X-Sms-To` header on receive.
---
*Source: voip.ms official API docs (`/m/apidocs.php`) + SMS/MMS wiki article, verified live.*

26
sms/exceptions.py Normal file
View File

@@ -0,0 +1,26 @@
"""Exception hierarchy for the voip.ms SMS client."""
class VoipMsError(Exception):
"""Base error for any failure talking to the voip.ms API."""
class VoipMsAuthError(VoipMsError):
"""Authentication / IP-whitelist problem reported by voip.ms."""
class VoipMsApiError(VoipMsError):
"""voip.ms returned a non-success `status` in the response body."""
def __init__(self, message: str, code: str | None = None) -> None:
super().__init__(message)
self.code = code
class VoipMsRateLimitError(VoipMsError):
"""Local daily-send guard refused the request before it was sent."""
def __init__(self, message: str, *, limit: int, used_today: int) -> None:
super().__init__(message)
self.limit = limit
self.used_today = used_today

74
sms/guard.py Normal file
View File

@@ -0,0 +1,74 @@
"""Outbound send guard: per-UTC-day counter + structured logging.
The voip.ms API caps sending at 100 SMS/MMS per day. This guard refuses
sends beyond a configurable limit (default 100) before they hit the network,
and logs every outbound send.
Note: this counter is per-process. If you run multiple instances behind a
load balancer, replace it with a shared store (Redis, etc.).
"""
from __future__ import annotations
import logging
from datetime import datetime, timezone
from .exceptions import VoipMsRateLimitError
logger = logging.getLogger("voipms.sms.guard")
class DailySendCounter:
"""Counts outbound sends per UTC day, refusing sends past the limit."""
def __init__(self, limit: int) -> None:
self._limit = limit
self._date: str | None = None
self._count = 0
@staticmethod
def _today() -> str:
return datetime.now(timezone.utc).strftime("%Y-%m-%d")
@property
def used_today(self) -> int:
if self._date != self._today():
return 0
return self._count
@property
def limit(self) -> int:
return self._limit
def check_and_increment(self) -> int:
"""Raise VoipMsRateLimitError if the daily cap is hit; otherwise increment.
Returns the count after this send.
"""
today = self._today()
if self._date != today:
self._date = today
self._count = 0
if self._count >= self._limit:
raise VoipMsRateLimitError(
f"daily send limit ({self._limit}) reached",
limit=self._limit,
used_today=self._count,
)
self._count += 1
return self._count
def log_send(self, *, kind: str, did: str, dst: str, chars: int) -> None:
remaining = self._limit - self._count
logger.info(
"voipms_send kind=%s did=%s dst=%s chars=%d used_today=%d limit=%d remaining=%d",
kind,
did,
dst,
chars,
self._count,
self._limit,
remaining,
)

76
sms/models.py Normal file
View File

@@ -0,0 +1,76 @@
"""Pydantic models for voip.ms SMS/MMS request and response payloads."""
from __future__ import annotations
from typing import Literal
from pydantic import BaseModel, Field
class SendResult(BaseModel):
"""Result of a successful sendSMS / sendMMS call."""
id: int = Field(..., description="The new SMS/MMS id assigned by voip.ms.")
class SmsRecord(BaseModel):
"""A single SMS row as returned by getSMS.
voip.ms returns a flat object per message; only the commonly-used fields
are typed. Extra fields are ignored so the model stays forward-compatible.
"""
id: str | int
date: str | None = None
type: str | None = Field(None, description="e.g. '1' received / '0' sent")
did: str | None = None
contact: str | None = None
message: str | None = None
model_config = {"extra": "ignore"}
class MmsRecord(BaseModel):
"""A single MMS row as returned by getMMS."""
id: str | int
date: str | None = None
type: str | None = None
did: str | None = None
contact: str | None = None
message: str | None = None
media: str | None = Field(None, description="Comma-separated media file list, if present.")
model_config = {"extra": "ignore"}
class MediaResult(BaseModel):
"""Media attachments for one MMS, from getMediaMMS."""
id: str | int
date: str | None = None
media: list[str] = Field(default_factory=list, description="Up to 3 media URLs/slots.")
class DeleteResult(BaseModel):
"""Result of deleteSMS / deleteMMS."""
status: Literal["success"] = "success"
# --- Request bodies for the FastAPI routes ---------------------------------
class SendSmsRequest(BaseModel):
did: str = Field(..., description="Sender DID (10 digits, or +1... for e164).")
dst: str = Field(..., description="Destination number.")
message: str = Field(..., max_length=160)
class SendMmsRequest(BaseModel):
did: str
dst: str
message: str = Field(..., max_length=2048)
media1: str | None = Field(None, description="URL to a media file.")
media2: str | None = Field(None, description="Base64-encoded image (data:image/...;base64,...).")
media3: str | None = Field(None, description="Reserved; usually empty.")

5
sms/requirements.txt Normal file
View File

@@ -0,0 +1,5 @@
fastapi>=0.110
uvicorn[standard]>=0.27
httpx>=0.27
pydantic>=2.6
pydantic-settings>=2.2