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 campaign UI was never the hard part. SMPP is a stateful, long-lived protocol, and an HTTP API is neither of those things.

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

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 sign-in form beside a preview of the app's welcome screen
    Sign-in
  • BalloAds dashboard: a campaign sidebar, an assistant prompt asking what it can help with, and a row of product announcements
    Dashboard
  • Ad feed filtered by industry, with getting-started guides and a panel of companies to subscribe to
    Ad feed
  • First step of campaign creation: naming an SMS campaign and drafting its message, with a live handset preview alongside
    Campaign compose
  • Email template editor with a palette of content blocks and a rendered newsletter preview
    Email template editor
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, 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.

  2. 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.

  3. 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.

  4. 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.

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 on its own, but 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.