- Industry
- SMS operators · resellers · CPaaS teams
- Type
- Multi-tenant SMS / WhatsApp CPaaS
- Role
- Solo — architecture, FastAPI, Vue, worker
- Keys
- Platform · tenant/reseller · RBAC user
- Stack
- Vue 3 · FastAPI · PostgreSQL · Redis · Kannel
- Proof
- 11 running screens · 65 OpenAPI paths
The problem
Indian enterprises need SMS and WhatsApp with DLT templates, reseller tenancy and prepaid wallets — not a single Twilio account with no isolation between agencies.
Constraints
Delivery stayed inside the client's stack, tenancy, compliance and media-ownership boundaries. Where a choice was forced (suite vs owned plane, BYOC vs CPaaS, local vs cloud models), the architecture section below records the trade-off rather than a marketing rewrite.
Business requirements
Platform operator, tenant/reseller hierarchy, RBAC users, campaign send, DLT registration, wallet top-ups and provider routing (Kannel, Twilio, Gupshup) on one FastAPI + Vue control plane.
Engineering challenges
- Tenant tree — master, reseller and client rows with scoped queries.
- DLT compliance — template and sender IDs before broadcast.
- Provider abstraction — route SMS without forking the product per carrier.
The solution
SMS CPaaS platform CPaaS: landing, JWT auth, platform and tenant dashboards, campaigns, wallet, contacts and OpenAPI-documented REST — captured in eleven running screens on this site.
My role
Solo architecture and full-stack delivery — FastAPI, Vue 3, PostgreSQL, Redis and Kannel integration. Not a voice/PBX product; see CCaaS case studies for FreeSWITCH work.
Technology stack
Production considerations
- JWT expiry, permission codes and tenant_id on every mutating route.
- Seeded demo users must not ship to production builds.
- Marketing stats on the landing page are copy — not telemetry claims.
Product depth
Context. Not a voice / SIP / PBX platform
The case-study chrome on this site often assumes FreeSWITCH, WebRTC and Grafana. This repo is SMS CPaaS. Kannel was chosen because it is an SMPP router, not a media switch. Grafana, Prometheus and SIP are not in the codebase. Voice work is on FreeSWITCH CCaaS and Asterisk 22 UnifiedPBX.
Counts from the running OpenAPI document and Alembic tree — not production traffic or uptime: 65 OpenAPI paths, 15 router tags, 3 SMS providers (Kannel, Twilio, Gupshup), 13 Alembic revisions.
Screenshots
Public door — acquisition, not telemetry
Hero: “India's Enterprise Messaging Control Plane.” CTAs, TRAI / routing / API-first badges. Dual-channel SMS + WhatsApp. GET /cms/landing plus hardcoded Vue sections. The 125.0M / 500+ enterprises / 99.95% uptime / 98.5% delivery tiles are marketing copy. They are not used as results on this page.
Screenshots
One door, three keys
Email + password. POST /auth/login returns a JWT (HS256, default 60 minutes). Then GET /auth/permissions and GET /auth/me. SPA stores permission codes in sessionStorage; Axios sends Bearer. First-tenant register exists. Seeded local users in docs must not ship to production.
Who does what
| Role | Job | Surfaces in this capture |
|---|---|---|
| Platform | tenant_id IS NULL. Tenant lifecycle, routes, flags, audit | Platform Overview, Tenants, Feature Flags, Audit Logs |
| Tenant / reseller | master / reseller / client via parent_tenant_id | Dashboard, Campaigns, Templates, DLT, Wallet, Contacts, Routes |
| RBAC user | Permission codes plus user overrides | Nav gated by can(); missing permission → Settings |
Parent is credited after a client campaign debit. Margin in this build is a hardcoded 10%, not tenants.reseller_margin_percent.
Platform — is anyone sending?
The question this screen answers: “How many tenants are live, and did anything go out today?”
GET /admin/platform/overview behind require_platform_admin. This capture: SMS today 0, delivery 0%, 3 active tenants, 0 active routes, empty top-tenants table. Left rail still shows Platform Overview, Tenants, Routes, Compliance, Feature Flags, Audit Logs. “System healthy” in the top bar is a static label, not a probe of GET /health.
Tenant — spend, delivery, campaign load
GET /dashboard/stats, /dashboard/realtime, /wallet/balance. Wallet ₹0, messages sent 0, delivery 0%, active campaigns 0. Low-wallet alert at ₹500 is a frontend threshold. “Industry avg: 96.5%” is hardcoded UI copy. Badges state the product domains: DLT, PCI payments, SMS + WhatsApp.
Campaigns — create is send
Name, approved template, contact list, DLT sender ID, Create & send. No separate Start button. Channel filter on the table. Pipeline: approved template + list → skip opted-out (and unconsented promotional) → DLT gate → render placeholders → segment billing → SELECT wallet FOR UPDATE → insert messages → enqueue Redis sms:send (2,000 IDs/batch). Pause/cancel patches campaign status; already-queued IDs can still drain.
Tabs: All / SMS / WhatsApp / Compliance. Sync from WhatsApp needs WHATSAPP_BUSINESS_ACCOUNT_ID. Empty in this capture. Campaigns may only use approved templates. WhatsApp send is the exception to the queue: it runs synchronously inside POST /campaigns via Meta Graph v18; wallet cost is recorded as zero in this implementation.
One list in this restore. In-product CSV columns: phone, name, is_opted_out. STOP/START MO webhooks flip that flag. Tenant for inbound currently uses a default tenant id in env, not DID-level mapping.
DLT — TRAI before the queue
PE profiles and sender IDs (header, route type, status). Template regex lives on templates, not a separate DLT-template table (migration 010 merged that model). Toggle DLT_VALIDATION_ENABLED / SettingsService. When the gate is on, mismatched traffic is never billed or queued.
Empty inventory here; UI still exposes Kannel / Twilio / Gupshup. Worker uses provider + smpp_id from the selected route. Campaign create sorts by priority only. LCR and weighted-split helpers exist in routing_service.py and are unused by create. Failover rows can be stored; the worker does not auto-fail over on DLR failure.
Wallet — lock, then enqueue
Copy on the screen: card data never touches these servers. Paytm checksum and Razorpay HMAC credit the ledger; callbacks are idempotent on order status. Concurrent campaigns cannot overdraw: wallet row lock + segmenter (160/153 GSM, 70/67 UCS-2). Insufficient balance → HTTP 400, no enqueue. Failed SMS is not auto-refunded on DLR in this build (a credit helper exists; webhooks do not call it).
| Hop | Sync or async | Failure mode |
|---|---|---|
| Login / CRUD / DLT / wallet lock | Synchronous | HTTP 4xx, no enqueue |
| SMS submit | Async (Redis + worker) | 3 retries, then messages.failed |
| WhatsApp send | Synchronous in create | Row marked failed; wallet cost 0 |
| DLR / MO / payment callback | Synchronous webhook | Idempotent wallet; DLR updates status |
The contract the UI already uses
Swagger at /docs — SMS SaaS API 0.1.0, OAS 3.1, 65 paths, 15 tags. Bearer Authorize. Public: login, register, health, CMS, contact form, payment callbacks, SMS webhooks. Locked: campaigns, wallet, DLT, platform admin. Generated from FastAPI signatures — not a separately maintained YAML file.
Working-system video
The recording is the product walking — not a slide deck. Same stack as these screens. UnifiedPBX.in is not a public SMS CPaaS platform tenant login.
Watch on YouTube → · Dedicated watch page →
How the system flows
Create and send is one action: the API validates DLT and wallet, enqueues Redis batches, and workers submit to Kannel or HTTP SMS providers.
flowchart LR T[Tenant Create and Send] --> V[Template DLT consent check] V --> W[Lock wallet debit] W --> Q[Redis sms:send batches] Q --> K[Worker asyncpg httpx] K --> S[Kannel SMPP or Twilio Gupshup] S --> D[DLR update status]
Architecture
The rule: FastAPI decides; the worker submits; Kannel (or HTTP aggregators) execute.
flowchart TB Vue[Vue 3 landing tenant platform] API[FastAPI 65 paths OpenAPI] PG[(PostgreSQL 16)] Redis[(Redis 7 queue)] W[Worker retries] K[Kannel SMPP] TW[Twilio Gupshup HTTP] Vue --> API API --> PG API --> Redis Redis --> W W --> K W --> TW
WhatsApp is not on that async hop: API → Meta Cloud inside campaign create. arq is in requirements.txt; the queue is a Redis list, not ARQ. Compose runs Postgres and Redis by default; Kannel is an optional profile. API/UI run locally. Kubernetes, Grafana and a CI test suite are not provided.
Engineering challenges
| Problem | Approach | Residual |
|---|---|---|
| HTTP must not wait on SMPP | Redis batches | No DLQ UI; pause is weak |
| TRAI rejects unpaid spam | DLT + consent before queue | Depends on toggle + data quality |
| Concurrent prepaid send | Wallet row lock + ledger | Failed SMS not auto-refunded on DLR |
| Fresh migrate vs ORM seed | 002 uses Tenant columns from 011 | Open migration debt |
- Multi-provider DLR → normalise to delivered/failed + raw
dlr_status - PCI → hosted checkout; PAN never posted to this API
- Tenant leakage →
tenant_idfrom JWT on queries (application layer, not row-level DB policies) - Known hardening items: SMS DLR/MO webhooks unauthenticated;
X-Tenant-IDaccepted without a platform-admin gate
My role
Solo engineer: control plane vs data plane, FastAPI routers and Pydantic, Vue SPA, schema and 13 Alembic revisions, wallet pre-deduct, DLT gate, provider adapters, webhook normalisation, Compose for Postgres/Redis/optional Kannel, backup scripts, OpenAPI.
Architecture FastAPI Vue 3 Kannel SMPP DLT Wallets
Engineering outcomes
No invented delivery-rate SLA. What the running UI and spec actually show:
- One login that fans out by platform vs tenant vs permission codes
- Prepaid wallet with row lock before enqueue
- DLT PE / sender IDs and approved-template campaign create
- Async SMS worker (Kannel, Twilio, Gupshup) and sync WhatsApp via Meta
- Paytm / Razorpay signed, idempotent credit — no PAN
- OpenAPI 3.1 at
/docs(65 paths) - Honest gaps: unused LCR helpers, no Grafana,
scheduled_atnot a running scheduler
Architectural insight
If the HTTP request waits on SMPP, you built a form, not a CPaaS. If DLT is a spreadsheet beside the composer, TRAI will still reject the traffic and the wallet will still have been charged. The product is identity, compliance and prepaid accounting — SMSC is the adapter. Related: watch the demo, vertical products, CCaaS when the same operator also needs queues, SMS CPaaS platform article for the reseller narrative.
FAQ — buyer & architecture questions
Ten common questions about this case study, fit, and engagement.
Building a messaging control plane you own?
Share DLT status, who is platform vs reseller vs sender, which SMSC you already bind, and what already bills or rejects. The first reply is architecture — not a per-SMS quote.
Discuss Your Project