Message Flows
Outbound SMS — Single Send
A customer submits a single SMS via the dashboard or REST API. Content is checked synchronously before the job is queued.
Customer / API Client
│
│ POST /v1/messages (bearer token)
▼
Main API (arc.semalink.africa)
│
├─ 1. Authenticate JWT
├─ 2. Validate phone number format + MNO prefix
├─ 3. Synchronous content check (blocklist, encoding, spam patterns)
│ └─ Fail → 422 Unprocessable Entity, reason returned to caller
├─ 4. Validate sender ID is registered for account
├─ 5. Resolve pricing tier (per-account rate)
├─ 6. Check credit balance ≥ estimated cost
├─ 7. Reserve credits (deducted, not finalised)
├─ 8. Insert message row → status: submitted
├─ 9. Publish job to RabbitMQ sms.priority | sms.normal
│
└─ 202 Accepted { messageId, status: "submitted" }
SMS Worker
│
├─ 10. Dequeue job from RabbitMQ
├─ 11. Update message → status: queued
├─ 12. POST to Celcom Africa REST API { to, from, text, messageId }
│
├─ 13a. Success → status: sent → ACK
└─ 13b. Failure → retry (exponential backoff, max 3x)
→ status: failed → refund reserved creditsOutbound SMS — Bulk Campaign
A customer submits a campaign (message + contact list + optional send_at). Content is checked asynchronously. Scheduled campaigns are held in PostgreSQL until due.
Customer (Customer Web App)
│
│ POST /v1/campaigns
▼
Main API
│
├─ 1. Authenticate JWT
├─ 2. Validate campaign params (sender ID, contact list exists, send_at valid)
├─ 3. Check credit balance covers estimated campaign cost
├─ 4. Reserve credits for full campaign
├─ 5. Insert campaign row → status: pending_content_check
├─ 6. Publish to sms.content queue
└─ 202 Accepted { campaignId, status: "pending_content_check" }
SMS Content Worker
│
├─ 7. Dequeue campaign job
├─ 8. Run content checks (blocklist, encoding, spam patterns)
│
├─ 9a. Rejected → campaign status: rejected, reason saved
│ → refund reserved credits → notify customer
│
└─ 9b. Approved → check send_at
│
├─ No send_at (immediate):
│ → expand contact list → publish jobs to sms.normal
│ → campaign status: queued
│
└─ Future send_at:
→ campaign status: scheduled
→ nothing published to RabbitMQ yet
→ customer can cancel via PATCH /v1/campaigns/:id/cancel
Scheduler (runs every 60s inside Main API)
│
├─ UPDATE campaigns SET status = 'queued'
│ WHERE send_at <= NOW() AND status = 'scheduled'
│ RETURNING ...
│
└─ For each claimed campaign:
→ expand contact list → publish jobs to sms.normal
SMS Worker
│
├─ Dequeue individual message jobs from sms.normal
└─ Submit to Celcom Africa (same as single send flow)Cancellation
A scheduled campaign (status = scheduled) can be cancelled at any time before send_at:
PATCH /v1/campaigns/:id/cancel
→ status: cancelled
→ reserved credits refunded
→ nothing to remove from RabbitMQ (jobs were never published)Contact List Validation
When a customer uploads a contact list, an async validation job checks all numbers before the list can be used in a campaign.
Customer uploads CSV / pastes numbers
│
▼
Main API
├─ Save raw numbers to contacts table (status: pending_validation)
├─ Publish validation job to contacts.validate queue
└─ Return { jobId, status: "validating" }
Phone Validation Worker
│
├─ Dequeue job
├─ For each number:
│ ├─ E.164 format check
│ ├─ Country code validity
│ └─ MNO prefix registry check (from mno_prefixes table)
├─ Write result per number: valid | invalid + reason
└─ Mark job complete → notify customer
Customer reviews results
├─ Proceed with valid numbers only
├─ Fix invalid numbers and re-validate
└─ AbortInbound DLR — Delivery Report Flow
After Celcom Africa delivers the SMS, the MNO returns a delivery receipt. Celcom forwards it to Sema Link as an HTTP POST.
MNO (Safaricom / Airtel / etc.)
│ Delivery Report
▼
Celcom Africa
│ POST /callback (API key auth)
▼
DLR Webhook Service (dlr.semalink.africa)
│
├─ 1. Validate API key / payload signature
├─ 2. Publish raw DLR event → dlr.inbound queue
└─ 3. Return 200 OK immediately
DLR Processor Worker
│
├─ 4. Continuously consume from dlr.inbound
├─ 5. Buffer events (up to 100 or 500ms, whichever comes first)
├─ 6. Batch UPDATE messages table (status, delivered_at)
├─ 7. Finalise credit charge per message
└─ 8. Trigger customer webhook callbacks (if configured)Why Micro-Batching?
High-volume campaigns produce a surge of DLR callbacks. Writing each one individually would cause lock contention on the messages table. The DLR Processor buffers events and flushes in batches of up to 100, reducing DB round-trips significantly while still delivering near-real-time status updates.
DLR Statuses
| Status | Meaning |
|---|---|
delivered | MNO confirmed delivery to handset |
failed | MNO returned permanent failure (invalid number, etc.) |
expired | MNO could not deliver within validity window |
Customer Webhooks
Customers can configure a webhook URL in account settings. After step 8, the DLR Processor POSTs the event to that URL with the message ID, status, and timestamp.