BalloAds
An advertising and messaging platform that speaks SMPP to a mobile network directly
A .NET advertising platform for the Zambian market: campaigns, audience segmentation, purchase-order balances and company-scoped access, sitting on top of an SMS layer that binds straight to a mobile operator's SMSC. The campaign UI was never the hard part. SMPP is a stateful, long-lived protocol, and an HTTP API is neither of those things.

- Built for
- Ballo Innovations
- My role
- Technical strategy and architecture, plus backend implementation across messaging, audiences, billing and access control. I lead the team building it.
- When
- 2025 — present
- .NET
- C#
- Entity Framework Core
- PostgreSQL
- Python
- smpplib
- Docker
- Prometheus
- SMPP
The longer version
The problem
Reaching people in Zambia means SMS, and SMS at any volume means talking to a mobile operator's SMSC over SMPP rather than posting JSON at an aggregator. That protocol assumes something an HTTP service doesn't provide: a long-lived, authenticated TCP session that stays bound, carries submissions out, and receives delivery receipts back asynchronously, sometimes minutes after the message that caused them.
Wrapping that in a request/response handler is the obvious first attempt, and it doesn't survive contact with reality. Either you connect per message, which the operator will not thank you for, or you hold a connection inside a stateless web process that can be recycled out from under it.
What I built
The platform itself: campaigns, brands, audiences, purchase-order balances, company-scoped roles and invites, an OCR path for brand documents, and an integration with our AI service for copy and compliance checks. Plus the messaging layer sitting underneath all of it.
The messaging layer is split on purpose. Outbound SMPP is a small worker service
with one job: own the binds, submit messages, parse delivery receipts, and post
outcomes back to the API. Everything about it is shaped by one fact, that a
session is expensive to establish and cheap to keep. Hence persistent transceiver
binds, registered_delivery on every submit, bounded queueing with explicit
backpressure, and a readiness signal that means "a session is bound" rather than
"the process started".
What I would do differently
Delivery receipts arrive as a semi-structured text body with a status and error code, and the parser accepts both that and the receipted-message-id field, because operators differ in what they actually populate. That tolerance is pragmatic, but it makes the parser the component most likely to mis-classify a delivery quietly. It deserves a conformance suite built from real captured receipts rather than the synthetic ones it has today.
Screens and demos
What it looks like in use, not just what it was designed to do.

Sign-in 
Dashboard 
Ad feed 
Campaign compose 
Email template editor
What made it hard
The problems worth reading about. Everything else in this system was ordinary work.
- 01
SMS routes by destination network. Numbers on the operator we hold a direct bind with go over SMPP, and everything else goes out through a partner's HTTP gateway. The test endpoint and the campaign sender share one implementation on purpose, because the failure I wanted to rule out was a test send behaving differently from a real one.
- 02
SMPP doesn't fit inside a request/response API. It's a persistent TCP bind that receives delivery receipts asynchronously, on the same connection, minutes later. So it lives in a separate worker service that owns the sessions. The API talks to it over the private network and gets receipts back as callbacks.
- 03
That worker refuses work it can't do instead of queueing it forever. The inbound queue is bounded and returns 503 when full, and the readiness probe reports not-ready until at least one session is actually bound. A process that's running but holds no bind isn't ready, and pretending otherwise just moves the failure somewhere harder to see.
- 04
Audience segments get evaluated from a filter AST rather than assembled SQL strings, and recomputed on a job rather than at send time. That makes a campaign's recipient list a resolved artifact you can inspect before you spend money on it.
What it measures
Each figure says where it came from, so you can judge how much weight to give it. Some are measurements and some are chosen thresholds; the basis line tells you which.
- 145
- Schema migrations
- 164
- Test files
- 256
- Queue depth before backpressure
- 4
- Persistent SMSC sessions
EF Core migrations in the repository. Not a quality measure on its own, but evidence the system has been changed repeatedly while running rather than rewritten.
xUnit test files in the test project.
Bounded queue in the SMPP worker. A full queue returns HTTP 503 rather than accepting work it has no capacity to deliver.
Default worker count in the SMPP service. Each worker holds its own transceiver bind for the life of the process rather than connecting per message.