Add static API-key auth, dockerize service, publish façade docs
- sms/auth.py: verify_api_key dependency (Bearer/X-API-Key, SHA-256 hash compare via secrets.compare_digest, fail-closed 503 if no hash). All routes gated via a protected router; /health stays open for probes. - config.py: new sms_api_key_hash setting (VOIPMS_SMS_API_KEY_HASH). - Dockerfile + .dockerignore + docker-compose.yml: lean python:3.13-slim image; secrets injected via env_file, never baked in; host-localhost-only port mapping (SMS_PORT override); /health healthcheck. - .env.example: committed template (placeholders only, no secrets). - sms/docs/sms-facade.dokuwiki.txt: abstraction API reference (published to the apidoc wiki at voipms:sms-facade). - README: Authentication section, Docker section, 401/503 error rows. Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
15
.dockerignore
Normal file
15
.dockerignore
Normal file
@@ -0,0 +1,15 @@
|
||||
# Keep secrets and bloat out of the image build context.
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
.venv/
|
||||
venv/
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
.git/
|
||||
.claude/
|
||||
# Reference docs aren't needed at runtime.
|
||||
sms/docs/
|
||||
Dockerfile
|
||||
docker-compose.yml
|
||||
.dockerignore
|
||||
14
.env.example
Normal file
14
.env.example
Normal file
@@ -0,0 +1,14 @@
|
||||
# Template — copy to .env (chmod 600, gitignored) and fill in real values.
|
||||
# Used by sms/config.py (Settings, env_prefix=voipms_).
|
||||
|
||||
# voip.ms REST API credentials (portal login email + API password from API Security)
|
||||
VOIPMS_API_USERNAME=you@example.com
|
||||
VOIPMS_API_PASSWORD=your-api-password
|
||||
|
||||
# SHA-256 hash of the accepted client API key (raw key is NOT stored here).
|
||||
# Generate: python3 -c "import secrets,hashlib; k=secrets.token_urlsafe(32); print('KEY:',k); print('HASH:',hashlib.sha256(k.encode()).hexdigest())"
|
||||
VOIPMS_SMS_API_KEY_HASH=
|
||||
|
||||
# Optional overrides
|
||||
VOIPMS_DIALING_MODE=nanpa
|
||||
VOIPMS_DAILY_LIMIT=100
|
||||
20
Dockerfile
Normal file
20
Dockerfile
Normal file
@@ -0,0 +1,20 @@
|
||||
# voip.ms SMS/MMS API façade — container image.
|
||||
# Secrets are injected at runtime via env_file (docker-compose) / -e; they are
|
||||
# NEVER copied into the image (see .dockerignore).
|
||||
FROM python:3.13-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install deps first for better layer caching.
|
||||
COPY sms/requirements.txt ./requirements.txt
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# Application package only.
|
||||
COPY sms/ ./sms/
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
# Bind 0.0.0.0 inside the container; the compose port mapping restricts host
|
||||
# exposure to 127.0.0.1. Adjust the mapping if you need broader exposure
|
||||
# (the API key in VOIPMS_SMS_API_KEY_HASH gates access either way).
|
||||
CMD ["uvicorn", "sms.app:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
22
docker-compose.yml
Normal file
22
docker-compose.yml
Normal file
@@ -0,0 +1,22 @@
|
||||
# voip.ms SMS/MMS API façade.
|
||||
# Secrets live in the host .env (gitignored) and are injected here via env_file;
|
||||
# they are never baked into the image.
|
||||
services:
|
||||
sms:
|
||||
build: .
|
||||
image: sms-api-wrapper:latest
|
||||
container_name: sms-api
|
||||
# Host-localhost only. To expose on the LAN, change to "8000:8000"
|
||||
# (access is still gated by the VOIPMS_SMS_API_KEY_HASH API key).
|
||||
# Override the host port with SMS_PORT (e.g. SMS_PORT=8001 in .env) if 8000 is taken.
|
||||
ports:
|
||||
- "127.0.0.1:${SMS_PORT:-8000}:8000"
|
||||
env_file:
|
||||
- .env
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health').read()"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 10s
|
||||
@@ -13,6 +13,7 @@ with a per-day outbound send guard.
|
||||
| `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 |
|
||||
|
||||
@@ -30,6 +31,7 @@ with a per-day outbound send guard.
|
||||
```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
|
||||
@@ -39,6 +41,32 @@ export VOIPMS_API_PASSWORD=...
|
||||
|
||||
(`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 <your-api-key>
|
||||
X-API-Key: <your-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
|
||||
@@ -48,6 +76,29 @@ 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 |
|
||||
@@ -72,6 +123,9 @@ type; the message id must be 0 when set). For `/sms` the id query param is
|
||||
|
||||
| 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) |
|
||||
|
||||
24
sms/app.py
24
sms/app.py
@@ -12,9 +12,10 @@ import logging
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import FastAPI, Query, Request, status
|
||||
from fastapi import APIRouter, Depends, FastAPI, Query, Request, status
|
||||
from fastapi.responses import JSONResponse, PlainTextResponse
|
||||
|
||||
from .auth import verify_api_key
|
||||
from .client import VoipMsSMSClient
|
||||
from .config import Settings
|
||||
from .exceptions import VoipMsApiError, VoipMsAuthError, VoipMsError, VoipMsRateLimitError
|
||||
@@ -51,6 +52,10 @@ app = FastAPI(
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
# Every route on this router requires a valid static API key. /health is the
|
||||
# only route kept on `app` directly so liveness probes stay unauthenticated.
|
||||
router = APIRouter(dependencies=[Depends(verify_api_key)])
|
||||
|
||||
|
||||
def _client(request: Request) -> VoipMsSMSClient:
|
||||
return request.app.state.client
|
||||
@@ -79,7 +84,7 @@ def _map_error(exc: VoipMsError) -> JSONResponse:
|
||||
# --- outbound (guarded) ----------------------------------------------------
|
||||
|
||||
|
||||
@app.post("/sms/send", response_model=SendResult)
|
||||
@router.post("/sms/send", response_model=SendResult)
|
||||
async def send_sms(body: SendSmsRequest, request: Request) -> SendResult:
|
||||
guard = _guard(request)
|
||||
client = _client(request)
|
||||
@@ -92,7 +97,7 @@ async def send_sms(body: SendSmsRequest, request: Request) -> SendResult:
|
||||
return result
|
||||
|
||||
|
||||
@app.post("/mms/send", response_model=SendResult)
|
||||
@router.post("/mms/send", response_model=SendResult)
|
||||
async def send_mms(body: SendMmsRequest, request: Request) -> SendResult:
|
||||
guard = _guard(request)
|
||||
client = _client(request)
|
||||
@@ -115,7 +120,7 @@ async def send_mms(body: SendMmsRequest, request: Request) -> SendResult:
|
||||
# --- history / retrieval ---------------------------------------------------
|
||||
|
||||
|
||||
@app.get("/sms", response_model=list[SmsRecord])
|
||||
@router.get("/sms", response_model=list[SmsRecord])
|
||||
async def list_sms(
|
||||
request: Request,
|
||||
sms: Annotated[int | None, Query(description="Specific SMS id")] = None,
|
||||
@@ -138,7 +143,7 @@ async def list_sms(
|
||||
return _map_error(exc)
|
||||
|
||||
|
||||
@app.get("/mms", response_model=list[MmsRecord])
|
||||
@router.get("/mms", response_model=list[MmsRecord])
|
||||
async def list_mms(
|
||||
request: Request,
|
||||
id: Annotated[int | None, Query(description="Specific MMS id")] = None,
|
||||
@@ -161,7 +166,7 @@ async def list_mms(
|
||||
return _map_error(exc)
|
||||
|
||||
|
||||
@app.get("/mms/{id}/media", response_model=MediaResult)
|
||||
@router.get("/mms/{id}/media", response_model=MediaResult)
|
||||
async def get_mms_media(
|
||||
request: Request,
|
||||
id: int,
|
||||
@@ -177,7 +182,7 @@ async def get_mms_media(
|
||||
# --- delete ----------------------------------------------------------------
|
||||
|
||||
|
||||
@app.delete("/sms/{id}", response_model=DeleteResult)
|
||||
@router.delete("/sms/{id}", response_model=DeleteResult)
|
||||
async def delete_sms(request: Request, id: int) -> DeleteResult:
|
||||
client = _client(request)
|
||||
try:
|
||||
@@ -186,7 +191,7 @@ async def delete_sms(request: Request, id: int) -> DeleteResult:
|
||||
return _map_error(exc)
|
||||
|
||||
|
||||
@app.delete("/mms/{id}", response_model=DeleteResult)
|
||||
@router.delete("/mms/{id}", response_model=DeleteResult)
|
||||
async def delete_mms(request: Request, id: int) -> DeleteResult:
|
||||
client = _client(request)
|
||||
try:
|
||||
@@ -201,3 +206,6 @@ async def delete_mms(request: Request, id: int) -> DeleteResult:
|
||||
@app.get("/health", response_class=PlainTextResponse)
|
||||
async def health() -> str:
|
||||
return "ok"
|
||||
|
||||
|
||||
app.include_router(router)
|
||||
69
sms/auth.py
Normal file
69
sms/auth.py
Normal file
@@ -0,0 +1,69 @@
|
||||
"""Static API-key authentication for the sms façade.
|
||||
|
||||
Clients present a key in one of two headers:
|
||||
|
||||
Authorization: Bearer <raw-api-key>
|
||||
X-API-Key: <raw-api-key>
|
||||
|
||||
The server stores only the **SHA-256 hex hash(es)** of accepted keys in the
|
||||
``SMS_API_KEY_HASH`` setting (comma-separated). The raw key is never persisted;
|
||||
losing it means generating a new one and updating the hash.
|
||||
|
||||
Fail-closed: if no hash is configured, every protected route returns 503 rather
|
||||
than silently admitting traffic. ``/health`` is exempt (see ``app.py``).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import logging
|
||||
import secrets
|
||||
|
||||
from fastapi import HTTPException, Request, status
|
||||
|
||||
logger = logging.getLogger("voipms.sms.auth")
|
||||
|
||||
|
||||
def _hash_key(raw: str) -> str:
|
||||
return hashlib.sha256(raw.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def _allowed_hashes(request: Request) -> set[str]:
|
||||
raw = request.app.state.settings.sms_api_key_hash or ""
|
||||
return {h.strip().lower() for h in raw.split(",") if h.strip()}
|
||||
|
||||
|
||||
def _extract_token(request: Request) -> str | None:
|
||||
auth = request.headers.get("authorization")
|
||||
if auth:
|
||||
parts = auth.split(None, 1)
|
||||
if len(parts) == 2 and parts[0].lower() == "bearer":
|
||||
return parts[1].strip() or None
|
||||
return None
|
||||
return request.headers.get("x-api-key")
|
||||
|
||||
|
||||
def verify_api_key(request: Request) -> None:
|
||||
"""FastAPI dependency gating every protected route."""
|
||||
allowed = _allowed_hashes(request)
|
||||
if not allowed:
|
||||
logger.error("SMS_API_KEY_HASH not configured; refusing request (fail-closed).")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="api key auth not configured",
|
||||
)
|
||||
token = _extract_token(request)
|
||||
if not token:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="missing api key",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
digest = _hash_key(token)
|
||||
if not any(secrets.compare_digest(digest, h) for h in allowed):
|
||||
logger.warning("rejected api key from %s", request.client.host if request.client else "?")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="invalid api key",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
@@ -44,4 +44,13 @@ class Settings(BaseSettings):
|
||||
ge=0,
|
||||
)
|
||||
|
||||
sms_api_key_hash: str = Field(
|
||||
"",
|
||||
description=(
|
||||
"Comma-separated SHA-256 hex hashes of accepted client API keys. "
|
||||
"Generate a key, hash it (sha256), and store only the hash here — "
|
||||
"the raw key is never persisted. Empty = fail-closed (503)."
|
||||
),
|
||||
)
|
||||
|
||||
timeout: float = Field(30.0, description="HTTP timeout in seconds.", ge=1.0)
|
||||
139
sms/docs/sms-facade.dokuwiki.txt
Normal file
139
sms/docs/sms-facade.dokuwiki.txt
Normal file
@@ -0,0 +1,139 @@
|
||||
====== apifrontend SMS façade ======
|
||||
|
||||
A small **FastAPI service** that wraps the [[voipms:sms|voip.ms SMS/MMS REST API]] behind typed Python + clean REST endpoints, with a per-day outbound send guard and static API-key auth. This page documents the **façade's own API** (what callers see); the upstream method reference lives at [[voipms:sms]].
|
||||
|
||||
Source: private Gitea repo ''sms-api-wrapper''. Sanitized — no account credentials, phone numbers, or API keys appear here; those are supplied at runtime via environment variables and kept local.
|
||||
|
||||
===== Architecture =====
|
||||
|
||||
^ File ^ Purpose ^
|
||||
| ''config.py'' | ''Settings'' — env-loaded creds, dialing mode, daily limit, API-key hash, timeout |
|
||||
| ''exceptions.py'' | ''VoipMsError'' hierarchy: ''Auth'', ''Api'', ''RateLimit'' |
|
||||
| ''models.py'' | Pydantic request/response models |
|
||||
| ''client.py'' | ''VoipMsSMSClient'' — async ''httpx'' wrappers for the 7 upstream 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 + router exposing the operations as REST endpoints |
|
||||
|
||||
The service is single-process: one shared ''VoipMsSMSClient'' and one ''DailySendCounter'' live in ''app.state'' for the lifetime of the app.
|
||||
|
||||
===== Configuration =====
|
||||
|
||||
Loaded by ''Settings'' (pydantic-settings, env prefix ''voipms_'', also reads a ''.env'' file in the working directory).
|
||||
|
||||
^ Env var ^ Req ^ Notes ^
|
||||
| ''VOIPMS_API_USERNAME'' | yes | voip.ms portal login **email** (not the account ID) |
|
||||
| ''VOIPMS_API_PASSWORD'' | yes | API password from **API Security** (distinct from portal login password) |
|
||||
| ''VOIPMS_SMS_API_KEY_HASH'' | yes* | SHA-256 hex hash of the accepted client API key; comma-separated list allowed for rotation. Empty = **fail-closed** (503) |
|
||||
| ''VOIPMS_DIALING_MODE'' | no | ''nanpa'' (default, 10 digits) or ''e164'' (''+1'' + 10 digits) |
|
||||
| ''VOIPMS_DAILY_LIMIT'' | no | Per-UTC-day outbound cap, default ''100'' (matches upstream API limit) |
|
||||
| ''VOIPMS_TIMEOUT'' | no | HTTP timeout seconds, default ''30'' |
|
||||
| ''VOIPMS_BASE_URL'' | no | Default ''https://voip.ms/api/v1/rest.php'' (avoid ''www.voip.ms'') |
|
||||
|
||||
* ''VOIPMS_SMS_API_KEY_HASH'' is optional in code but the service is fail-closed without it — set it for any real deployment.
|
||||
|
||||
Upstream prerequisites (on the voip.ms side): enable the REST API + set an API password, **whitelist this machine's public IP** under API Security, and **enable SMS on the sending DID**.
|
||||
|
||||
===== Authentication =====
|
||||
|
||||
Every endpoint except ''/health'' requires a static API key. Send it as either:
|
||||
|
||||
* ''Authorization: Bearer <your-api-key>''
|
||||
* ''X-API-Key: <your-api-key>''
|
||||
|
||||
The server stores only the **SHA-256 hash** of accepted keys (''VOIPMS_SMS_API_KEY_HASH''); the raw key is never persisted. Comparison is constant-time (''secrets.compare_digest'').
|
||||
|
||||
Generate a key + hash:
|
||||
|
||||
<code sh>
|
||||
python3 -c "import secrets,hashlib; k=secrets.token_urlsafe(32); print('KEY:',k); print('HASH:',hashlib.sha256(k.encode()).hexdigest())"
|
||||
</code>
|
||||
|
||||
Put the ''HASH:'' value in ''VOIPMS_SMS_API_KEY_HASH''; hand the ''KEY:'' value to your calling services. **Rotation:** add the new hash alongside the old, switch callers, then remove the old hash.
|
||||
|
||||
If ''VOIPMS_SMS_API_KEY_HASH'' is empty/unset, protected routes return ''503'' (fail-closed). ''/health'' stays open for liveness probes.
|
||||
|
||||
===== Endpoints =====
|
||||
|
||||
^ Method ^ Path ^ Upstream ^ Send-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'' | — | — (no auth) |
|
||||
|
||||
==== POST /sms/send ====
|
||||
|
||||
^ Field ^ Req ^ Notes ^
|
||||
| did | yes | Sender DID |
|
||||
| dst | yes | Destination number |
|
||||
| message | yes | Max **160** chars |
|
||||
|
||||
Returns ''{"id": <new sms id>}''. ''did''/''dst'' are normalized per ''VOIPMS_DIALING_MODE'' before sending.
|
||||
|
||||
==== POST /mms/send ====
|
||||
|
||||
^ Field ^ 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 |
|
||||
|
||||
Returns ''{"id": <new mms id>}''. See [[voipms:sms|upstream reference]] for media type/size limits.
|
||||
|
||||
==== GET /sms ====
|
||||
|
||||
Query params: ''sms'' (specific id), ''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 id must be ''0'' when set).
|
||||
|
||||
Returns a list of SMS records. Empty upstream results (''status: no_sms'') are normalized to ''[]'' — not an error.
|
||||
|
||||
==== GET /mms ====
|
||||
|
||||
Same shape as ''/sms'' but the id query param is ''id''. ''all_messages=1'' returns MMS+SMS combined (id must be ''0'').
|
||||
|
||||
==== GET /mms/{id}/media ====
|
||||
|
||||
Query param: ''media_as_array'' (bool, default false). Returns ''{"id","date","media":[url,...]}''.
|
||||
|
||||
==== DELETE /sms/{id} · DELETE /mms/{id} ====
|
||||
|
||||
Returns ''{"status": "success"}'' on success.
|
||||
|
||||
==== GET /health ====
|
||||
|
||||
Unauthenticated. Returns the literal text ''ok'' (200). Use for liveness probes.
|
||||
|
||||
===== 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 send limit exceeded | 429 | ''{detail, limit, used_today}'' |
|
||||
| Upstream returns non-success | 502 | upstream message |
|
||||
| Upstream auth / IP-whitelist failure | 500 | ''upstream authentication failure'' (details logged server-side, never leaked to caller) |
|
||||
|
||||
===== Outbound send guard =====
|
||||
|
||||
''DailySendCounter'' caps outbound sends (''/sms/send'', ''/mms/send'') at ''VOIPMS_DAILY_LIMIT'' per **UTC day**. The counter is checked **before** the upstream call and incremented in-process; every successful send is logged. Resets automatically on UTC-day rollover.
|
||||
|
||||
**Per-process only:** for multiple instances, replace ''DailySendCounter'' with a shared store (Redis, etc.).
|
||||
|
||||
===== Dialing mode =====
|
||||
|
||||
''nanpa'' → numbers sent as 10 digits. ''e164'' → ''+1'' + 10 digits. Applied to ''did''/''dst'' on send. Pick the mode that matches what your DID/carrier expects; normalize on your side for inbound.
|
||||
|
||||
===== Notes & limitations =====
|
||||
|
||||
* No ''markSMSRead'' exists upstream; read state is not manageable via this façade.
|
||||
* Use ''https://voip.ms'' (not ''www.voip.ms'') — the ''www'' host 302-redirects and drops POST bodies.
|
||||
* Inbound URL Callback receiver is **not** included; it can be added as a separate route set that replies with the literal ''ok'' and persists ''{TO}/{FROM}/{MESSAGE}/{ID}/{TIMESTAMP}/{MEDIA}''.
|
||||
* No automated test suite; verify manually per the README.
|
||||
|
||||
----
|
||||
//Façade over the [[voipms:sms|voip.ms SMS/MMS REST API]]. Upstream behavior, limits, and gotchas are documented there.//
|
||||
Reference in New Issue
Block a user