An A2P SMS gateway for Cameroon — OTP and notification delivery over MTN and Orange, with a provider abstraction that covers both HTTP APIs and SMPP.
Status (snapshot, 2026-08-13 — docs/roadmap.md is the maintained version, and GitHub Issues is the real tracker): foundations and delivery are done. Schema, migrations, encoding, and MSISDN parsing; private_key_jwt machine auth and an authorization_code + PKCE human login; a real worker — leader election, optimistic-CAS claim loops, the message and job state machines, DLR ingestion, a job queue; and signed, at-least-once webhooks with retries and circuit breaking. Retention, audit anchoring, backup with a real restore drill, and alerting are done. The admin console is partly built (messages, dashboard, jobs, workers, providers, routes); MTN, the routing rules engine, and failover have landed.
One thing gates real traffic, and it is not code: #36 — no message from this system has ever reached a real handset. That needs a human with a real Orange account and a real phone. Everything automated around it proves how this system behaves under faults; none of it proves Orange behaves the way this code assumes.
You can run all of it locally, with no carrier account, in one command — see local development.
Which door you want depends on what you're doing:
| You want to… | Start here |
|---|---|
| Send SMS through vsms from your own application | docs/integrating.md — credentials, the token exchange, sending, message states, webhooks |
| Run the whole thing locally, no carrier account | docs/runbooks/local-development.adoc — just demo and what it gives you |
| Work on vsms itself | docs/runbooks/getting-started.adoc, then CONTRIBUTING.md |
| Deploy or operate it | docs/runbooks/deployment.adoc and docs/runbooks/ |
The short version, for a developer who just wants it running:
git clone https://github.com/vymalo/vsms.git
cd vsms
just demoThat brings up a scratch Postgres, the gateway, the worker, a fake Orange, and the admin console, wired together with a provisioned client — and a message reaches delivered without a single real SMS being sent.
| Path | What it is |
|---|---|
docs/integrating.md |
For a developer sending SMS through vsms. Credentials, the token exchange, request and response shapes, message states, status codes, webhooks. |
docs/architecture.md |
The full design. Data model, provider abstraction, worker topology, security, compliance, and every framework constraint that shaped a decision. |
docs/roadmap.md |
Sequencing: what order the work happens in, what genuinely blocks what, and what has to be true before real traffic. |
OPEN_QUESTIONS.md |
What this system does not know the answer to — human decisions, unverified claims, accepted limitations. |
docs/runbooks/ |
Step-by-step operational procedures: local development, deployment, backup and restore, alerting, and the real-handset acceptance gate. |
AGENTS.md |
Current project status in detail: what's built, what's still open, and every non-obvious thing found by actually running the toolchain. |
CONTRIBUTING.md |
The three rules (R1/R2/R3) the codebase is written against, and the workflow for changing the schema. |
schemas/vsms.cstack |
22 models, 16 enums, 13 procedures. Parses, emits Postgres DDL, and expands through include_server_schema!. |
backends/migrations/postgres/0001_init/ |
Generated by cratestack migrate diff. Do not hand-edit — regenerated wholesale on every schema change; see AGENTS.md. |
backends/migrations/postgres/0002_bootstrap/ |
Everything the emitter doesn't produce: id and timestamp defaults, the updated_at trigger, the state machines, partial indexes, foreign keys. Generated from §2.10 of the design doc. |
backends/crates/sms-encoding/ |
GSM 03.38 vs UCS-2 analysis, segment counting, normalisation, transliteration. |
backends/crates/sms-msisdn/ |
E.164 +237 parsing, line-type classification, operator-prefix lookup. |
backends/crates/sms-api/ |
Where include_server_schema! expands: generated models, policies, the REST router, the procedure registry, DLR ingestion, and the webhook subscribers. |
backends/crates/sms-auth/ |
authkestra-op glue: the machine private_key_jwt path, the human authorization_code + PKCE login, and RS256 signing-key rotation. |
backends/crates/sms-worker/ |
The worker as a library: role selection, advisory-lock leader election, the CAS claim loop, and each role's real body (dispatch, scheduler, jobs, drain, hooks). |
backends/crates/sms-webhook/ |
The outbound webhook signature scheme — HMAC-SHA256, rotation overlap, cross-checked against an independent Node implementation in CI. |
backends/crates/sms-provider/ |
The SmsProvider trait every adapter implements — capabilities, submit, DLR parsing, health — framework-free. |
backends/crates/sms-provider-orange-cm/, backends/crates/sms-provider-mtn/ |
The Orange Cameroon and MTN adapters. |
backends/crates/sms-fake-orange/ |
A fault-injecting fake of Orange's API — a participant, not a response stub. Development and testing only. |
backends/apps/sms-gateway/ |
The API server binary. serve binds HTTP and mounts the OP; provision-client, provision-user, rotate-signing-key, and seed-provider are the operator actions; routes prints the route table and needs no database. |
backends/apps/sms-worker/ |
The role-selectable worker binary (--roles dispatch,scheduler,jobs,...) — see its own module doc for why the package is sms-worker-bin but the binary is sms-worker. |
frontends/apps/admin/, frontends/packages/ |
The Next.js admin console and its shared TypeScript packages. |
sdks/rust/, examples/ |
The Rust SDK, and runnable Rust and Node integration examples including a reference webhook receiver. |
deploy/ |
Compose stack, Caddy edge, migration job, backup and restore. |
ci/ |
The R1 lint, the R2 state-machine parity check, the migration runner, the bootstrap-SQL generator, and the state-machine SQL test. |
crates/ is libraries, app/ is binaries, and nothing in crates/ depends on anything in app/.
- Rust for the gateway and worker, on CrateStack
=0.8.3— schema-first,.cstackgenerates the model layer, policies, audit, events and REST surface. Pinned exactly, and the installed CLI must match the pin; seeAGENTS.md. - Authkestra
=0.3.3as the OIDC provider. Machine callers use OAuthclient_credentialswithprivate_key_jwt— no shared secrets anywhere in the system. Humans sign in withauthorization_code+ PKCE against a local Argon2id-backed user store. - PostgreSQL 16 as the only coordination mechanism — system of record, claim loops via optimistic CAS on
@version(SKIP LOCKEDisn't expressible through the framework), leader election via advisory locks, and the state machines enforced by triggers. No broker, no Redis, no consensus library. - TypeScript / Next.js 15 for the admin console — messages and state timelines, an operator dashboard, jobs, workers, providers, routes, and a route simulator. Partly built; see the roadmap.
Three findings shaped the schema more than any product requirement, and each is a property of the toolchain rather than of the problem:
- Scalar list fields panic the server macro.
String[]parses fine and the migration emitter writes a cheerfulTEXT[], butinclude_server_schema!dies withunsupported SQLx value type for this slice. Every multi-value column here is a sentinel-delimited string instead. - Any
@default(...)excludes a field from the create input — literals included. A@defaulton something a caller must set makes it unwritable. It's kept only where being unsettable is the point, such asMessage.state, which is why no client can create a message that is alreadydelivered. @server_onlyexcludes a field from create and update, so it can never be populated at all. It is for columns the database owns, not for secrets you write.
None of these are documented upstream. Every framework constraint found this way — not just these three — is recorded in §2.0 with the exact error each one produces, and in AGENTS.md with the story of how it was found.
MIT — see LICENSE.