Skip to content
Ballo Innovations2025 — present
In productionarchitecturepaymentsdata

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 hard part is not the campaign UI — it is that SMPP is a stateful, long-lived protocol and an HTTP API is neither.

BalloAds interface
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
01Look & feel

Screens and demos

What it looks like in use — not just what it was designed to do.

Product walkthrough — campaign flow in the web app
  • BalloAds login screen
    Sign-in
  • Multi-company workspace picker
    Company-scoped access
  • WhatsApp campaign template selection
    Campaign templates
  • Composing a WhatsApp campaign message
    Message compose
02The engineering

What made it hard

The problems worth reading about. Everything else in this system was ordinary work.

  1. 01

    SMS routes by destination network: numbers on the operator we hold a direct bind with go over SMPP, everything else goes out over a partner's HTTP gateway. The test endpoint and the campaign sender deliberately share one sender implementation, because the failure I wanted to rule out was a test send behaving differently from a real one.

  2. 02

    SMPP does not fit inside a request/response API. It is 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, and the API talks to it over the private network and receives receipts back as callbacks.

  3. 03

    That worker refuses work it cannot do rather than 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 is running but has no bind is not ready, and saying otherwise just moves the failure somewhere harder to see.

  4. 04

    Audience segments are evaluated from a filter AST rather than assembled SQL strings, and recomputed on a job rather than at send time, so a campaign's recipient list is a resolved artifact you can inspect before spending money on it.

03Numbers

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

EF Core migrations in the repository. Not a quality measure — it is evidence the system has been changed repeatedly while running rather than rewritten.

164
Test files

xUnit test files in the test project.

256
Queue depth before backpressure

Bounded queue in the SMPP worker; a full queue returns HTTP 503 rather than accepting work it has no capacity to deliver.

4
Persistent SMSC sessions

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.

Write-up

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 does not 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 does not 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 underneath 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 underneath it.

The messaging layer is split deliberately. 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 around the fact that a session is expensive to establish and cheap to keep: 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 populate. That tolerance is pragmatic, but it means the parser is the component most likely to silently mis-classify a delivery, and it deserves a conformance test suite built from real captured receipts rather than the synthetic ones it has.