Skip to content

Backend Services

Six Node.js services run on DigitalOcean App Platform. Each is a separate deployable unit with its own process, environment variables, and scaling configuration.


The primary backend service. All frontend apps and external API consumers talk to this endpoint.

Runtime: Node.js
Architecture: Modular monolith — one process, multiple internal modules
Hosting: DigitalOcean App Platform (HTTPS service)

Internal Modules

ModuleResponsibility
authJWT issuance, refresh tokens, session management
usersRegistration, profiles, password reset, roles
messagingSMS submission, message history, campaign management
contactsContact lists, groups, import/export
pricingPer-account rate tables, volume tier lookup
billingCredit balance, M-Pesa top-up, invoice generation
dlrDLR record updates, delivery status queries
accountingInternal ledger, agent commissions, revenue reporting

Key Responsibilities

  • Accept SMS submission requests from the Customer Web App and the REST API
  • Run synchronous content checks on single API sends before queuing
  • Validate sender ID, check credit balance, apply rate for the account
  • Publish outbound SMS jobs to the RabbitMQ SMS queue
  • Expose REST endpoints for all frontend apps

Queue Interaction

The Main API publishes to queues. It does not consume from them directly. After publishing a send job, it returns 202 Accepted to the caller with a message ID.

Scheduler Module

A polling loop inside the Main API runs every 60 seconds and publishes campaigns that are due to send.

Campaigns with a future send_at are stored in PostgreSQL with status = scheduled — nothing enters RabbitMQ until they are due. When the scheduler fires, it claims due campaigns atomically:

sql
UPDATE campaigns
SET status = 'queued'
WHERE send_at <= NOW()
  AND status = 'scheduled'
RETURNING id, account_id, contact_list_id, message, sender_id

The UPDATE ... RETURNING is atomic — if multiple Main API instances are running, only one will receive rows. The winner expands the contact list and publishes individual jobs to sms.normal. No distributed lock needed.

Campaigns can be cancelled (PATCH /v1/campaigns/:id/cancel) any time while status = scheduled. Credits are refunded on cancellation.

Flow Diagram


2. SMS Worker

Consumes SMS jobs from RabbitMQ and submits them to Celcom Africa.

Runtime: Node.js
Hosting: DigitalOcean App Platform (worker — no public HTTP port)

SMS Queue Lanes

Two lanes allow priority-based processing:

LaneUse Case
sms.priorityOTP / transactional messages — processed first
sms.normalAll other sends — standard and campaign jobs

The worker polls priority first, falls back to normal. Scheduled campaigns are held in PostgreSQL and only enter sms.normal when the scheduler determines they are due — the SMS Worker never sees a message before its send_at time.

Key Behaviours

  • Multiple instances can run in parallel — RabbitMQ distributes jobs across them
  • Each job is acknowledged only after successful submission — a crashed worker causes RabbitMQ to redeliver to another instance
  • Failed submissions retry up to 3 times with exponential backoff, then mark failed and refund reserved credits

Flow Diagram


Lightweight public-facing service that receives Delivery Report callbacks from Celcom Africa and publishes them to the DLR queue. Intentionally minimal — no database writes happen here.

Runtime: Node.js
Hosting: DigitalOcean App Platform (HTTPS service — public endpoint)

Why Separate?

  • Celcom Africa's callback URL must never change — a dedicated subdomain decouples this from Main API refactors
  • High DLR volume is fully isolated from Main API response times
  • Keeping it minimal means it is extremely reliable and easy to reason about

Flow Diagram


4. DLR Processor Worker

Consumes from the dlr.inbound queue and writes delivery status updates to PostgreSQL in micro-batches to avoid hammering the database during high-volume periods.

Runtime: Node.js
Hosting: DigitalOcean App Platform (worker — no public HTTP port)

Why Micro-Batching?

High-volume campaigns produce a surge of DLR callbacks in a short window. Writing each one individually causes lock contention on the messages table. The processor buffers up to 100 events (or 500ms, whichever comes first) and flushes them in a single batch query.

Design Notes

  • Runs continuously — near-real-time status updates are preserved
  • If the worker crashes mid-batch, unacknowledged messages are redelivered by RabbitMQ; idempotent upsert on messageId prevents duplicate writes

Flow Diagram


5. Phone Number Validation Worker

Validates phone numbers in contact lists asynchronously before they can be used in a campaign.

Runtime: Node.js
Hosting: DigitalOcean App Platform (worker — no public HTTP port)

Why Async?

Contact lists can contain thousands of numbers. Validating synchronously on upload would time out. The Main API publishes a validation job and returns immediately; the worker processes the list and writes results per number back to the database.

Validation Rules

  1. E.164 format — must match +[country code][subscriber number]
  2. Country code — must be a known ITU country code
  3. MNO prefix registry — prefix must match a known MNO for that country, sourced from the mno_prefixes table maintained by the Sema Link team
Prefix (Kenya)MNO
0700–0729Safaricom
0720–0729Safaricom
0757–0759Safaricom
0740–0756Airtel
0768–0769Airtel
0790–0799Airtel
0110–0119Airtel

Flow Diagram

Customers can proceed with valid numbers only, fix the invalid ones, or abort.


6. SMS Content Worker

Screens message content for policy violations before any SMS job reaches the send queue. Operates in two modes depending on the send type.

Runtime: Node.js
Hosting: DigitalOcean App Platform (worker — no public HTTP port)

Two Modes

Synchronous (single API send): Content is checked inline inside the Main API before queuing. A violation returns 422 Unprocessable Entity immediately with a reason.

Asynchronous (bulk campaign): The campaign is published to sms.content first. The worker approves it (moves to send queue) or rejects it (notifies the customer with a reason).

Content Checks

CheckDescription
Vulgar / offensive languageWord list match against the content_blocklist table
Disallowed charactersCharacters outside GSM-7 and Unicode SMS encoding
Encoding detectionDetermines single-part vs multi-part — affects cost estimate
Spam indicatorsPhishing and unsolicited commercial message patterns
URL shortener abuseFlagged short URL domains

The blocklist and rules are managed by the Sema Link team via the Internal Admin App.

Flow Diagram

Internal use only — Sema Link Engineering