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.
1. Main API — arc.semalink.africa
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
| Module | Responsibility |
|---|---|
auth | JWT issuance, refresh tokens, session management |
users | Registration, profiles, password reset, roles |
messaging | SMS submission, message history, campaign management |
contacts | Contact lists, groups, import/export |
pricing | Per-account rate tables, volume tier lookup |
billing | Credit balance, M-Pesa top-up, invoice generation |
dlr | DLR record updates, delivery status queries |
accounting | Internal 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:
UPDATE campaigns
SET status = 'queued'
WHERE send_at <= NOW()
AND status = 'scheduled'
RETURNING id, account_id, contact_list_id, message, sender_idThe 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:
| Lane | Use Case |
|---|---|
sms.priority | OTP / transactional messages — processed first |
sms.normal | All 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
failedand refund reserved credits
Flow Diagram
3. DLR Webhook Service — dlr.semalink.africa
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
messageIdprevents 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
- E.164 format — must match
+[country code][subscriber number] - Country code — must be a known ITU country code
- MNO prefix registry — prefix must match a known MNO for that country, sourced from the
mno_prefixestable maintained by the Sema Link team
| Prefix (Kenya) | MNO |
|---|---|
| 0700–0729 | Safaricom |
| 0720–0729 | Safaricom |
| 0757–0759 | Safaricom |
| 0740–0756 | Airtel |
| 0768–0769 | Airtel |
| 0790–0799 | Airtel |
| 0110–0119 | Airtel |
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
| Check | Description |
|---|---|
| Vulgar / offensive language | Word list match against the content_blocklist table |
| Disallowed characters | Characters outside GSM-7 and Unicode SMS encoding |
| Encoding detection | Determines single-part vs multi-part — affects cost estimate |
| Spam indicators | Phishing and unsolicited commercial message patterns |
| URL shortener abuse | Flagged short URL domains |
The blocklist and rules are managed by the Sema Link team via the Internal Admin App.