====== 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.//