SMS Router Process Flows¶
This page describes the main processes run by sms-router-service. See the service architecture, configuration reference, and API reference for related details.
Campaign lifecycle¶
stateDiagram-v2
[*] --> DRAFT: inline create begins
[*] --> BUILDING: CSV accepted
DRAFT --> QUEUED: instant charge succeeds
DRAFT --> SCHEDULED: scheduled charge succeeds
DRAFT --> FAILED: create/charge fails
BUILDING --> QUEUED: build + charge succeeds (instant)
BUILDING --> SCHEDULED: build + charge succeeds (scheduled)
BUILDING --> FAILED: invalid, uncharged, or stale build
SCHEDULED --> DISPATCHING: time reached
SCHEDULED --> PAUSED: pause
QUEUED --> DISPATCHING: claim
QUEUED --> PAUSED: pause
DISPATCHING --> PAUSED: pause
PAUSED --> SCHEDULED: resume paused-from-scheduled
PAUSED --> QUEUED: resume other active state
DISPATCHING --> QUEUED: retry-ready or stale recovery
DISPATCHING --> SCHEDULED: promotional window closes
DISPATCHING --> COMPLETED: all delivered
DISPATCHING --> PARTIAL: terminal mixed result
DISPATCHING --> FAILED: all failed
SCHEDULED --> EXPIRED: deadline missed
QUEUED --> CANCELLING: cancel
SCHEDULED --> CANCELLING: cancel
PAUSED --> CANCELLING: cancel
CANCELLING --> CANCELLED: refund settles
PARTIAL --> QUEUED: resend failed
FAILED --> QUEUED: resend failed
DRAFT usually lasts only a short time, but recovery jobs can find it. Clients see BUILDING while the service reads a CSV file. COMPLETED, PARTIAL, FAILED, CANCELLED, and EXPIRED are final states. However, resend-failed can reopen a PARTIAL or FAILED campaign.
Inline campaign creation¶
POST /api/campaigns accepts 1–5,000 inline recipients.
sequenceDiagram
participant C as Client
participant G as Gateway
participant R as CampaignService
participant I as Idempotency store
participant P as Pricing/routing
participant DB as PostgreSQL
participant B as client-service
C->>G: POST + JWT + optional Idempotency-Key
G->>R: Trusted identity headers + body
R->>I: Reserve (username, key, body SHA-256)
alt completed matching key
I-->>R: Stored response
R-->>C: 200 replay
else same key in progress
R-->>C: 409 + Retry-After: 2
else caller owns key
R->>R: Validate language, campaign ID, schedule, window
R->>P: Render, segment, route, price all recipients
P-->>R: Frozen snapshots + total
R->>DB: Commit DRAFT campaign and recipients
R->>B: Deduct key campaign:{uuid}
alt deduct succeeds
R->>DB: Activate QUEUED or SCHEDULED
R->>I: Store canonical 201 response
R-->>C: 201 Created
else failure or ambiguous outcome
R->>B: Best-effort reverse by key
R->>DB: Mark FAILED or leave DRAFT for recovery
R->>I: Release reservation
R-->>C: 402/502
end
end
The service follows these steps:
- Reserve the optional idempotency key before changing the balance or campaign state.
- Require
PROMOTIONALto uselang=bnand provide operator-approvedcampaignId. - Require
scheduledAtonly forSCHEDULED. By default, treat it as local time in Dhaka. - Enforce the minimum lead, maximum future date, and promotional window.
- Read the exact
{{tag}}names.GENERICallows no tags;PERSONALIZEDrequires at least one. - Require exactly the tags used by the template. Reject extra or missing tags, values longer than 100 characters, template braces, control characters, and non-GSM-7 values when
lang=en. - Normalize MSISDNs; resolve operator, active sender/channel/MNO-user, and billing rate.
- Build each personalized message, calculate its segments, and apply the ten-segment limit.
- Save the final content, route, rate, segment count, and cost for each recipient.
- Deduct the total once and activate as
QUEUEDorSCHEDULED.
POST /api/campaigns/preview validates the request, builds the messages, selects routes, and calculates prices. It does not save the campaign, charge the balance, reserve an idempotency key, or queue work.
Bulk CSV campaign creation¶
POST /api/campaigns/bulk accepts meta JSON and a UTF-8 CSV file as multipart data. Bulk campaigns are promotional only, always use lang=bn, and accept up to campaign.bulk.max-recipients valid rows.
The API stores the CSV data in campaign_upload, creates a BUILDING campaign with no recipients, and returns 202. A worker reads the file and charges the campaign later. The idempotency fingerprint includes the metadata and file data.
Generic CSV¶
The first row can be a header. If its first value is not a valid MSISDN, the service treats the row as a header. It reads only the first column.
Personalized CSV¶
The header must contain lowercase msisdn and every exact template tag. For Hello {{name}}, your balance is {{balance}} BDT:
msisdn,name,balance
01712345678,Amina,250.00
8801812345678,Rahim,125.50
+8801912345678,Nusrat,500.00
CampaignBuildWorker¶
flowchart TD
Poll[Poll on fixed delay] --> Claim[Claim PENDING or stale IN_PROGRESS with SKIP LOCKED]
Claim --> Fresh{BUILDING and zero staged recipients?}
Fresh -- no --> SafeFail[Fail safe without recharging]
Fresh -- yes --> Parse[Strip UTF-8 BOM and parse]
Parse --> Header{Personalized header complete?}
Header -- no --> Fail[Clear rows; campaign/upload FAILED]
Header -- yes --> Rows[Normalize and de-duplicate]
Rows --> Validate[Validate tags and render]
Validate --> Batch[Price and append batches]
Batch --> Any{Accepted recipients?}
Any -- no --> Fail
Any -- yes --> Deduct[One total idempotent deduction]
Deduct -->|success| Activate[QUEUED/SCHEDULED; upload DONE]
Deduct -->|failure| Clear[Clear recipients; mark FAILED]
The worker skips invalid MSISDNs, duplicates in the file, and rows with invalid tags. It adds them to rejectedCount. A missing personalized header fails the whole build. If a recovered build already has saved recipients, it fails instead of risking a second charge.
Campaign dispatch¶
The campaignDispatcher ShedLock lease allows only one service instance to run CampaignDispatcher at a time.
- Claim ready
QUEUEDorSCHEDULEDcampaigns using row locks andSKIP LOCKED. - Mark each claimed campaign
DISPATCHING. - If promotional traffic is outside the BTRC window, move it to
SCHEDULEDat the next opening. - Claim a limited batch of recipients.
- Group recipients by saved content, language, sender CLI, and route. Every recipient in an internal submission must use the same values.
- Call
SmsSubmitService.submitCorewithskipDeduct=true; the campaign was charged upfront. - Link the campaign recipients to the SMS request and increase the attempt count after the actual submission.
- Continue on later scheduler runs until no recipients remain to send.
CampaignStaleRecoverer, not this dispatcher, handles expired deadlines.
The default claim batch is 999 recipients. submitCore also applies MNPSP limits: a promotional chunk has no more than 999 recipients, and a transactional chunk has one.
Outbox publication¶
OutboxPublisher claims PENDING records by priority and age. It sends several Kafka messages at the same time, waits up to the configured limit, and saves each result.
- On success, it stores the Kafka partition and offset and marks the record
SENT. - Failure increments
retry_count, stores an error, and returns toPENDINGbelow five attempts. - The fifth failure marks
FAILED. OutboxRecoveryPollerrequeues eligible failures after a cooldown and incrementsrecovery_count.- After the recovery-round limit, an event that keeps failing stays failed for investigation.
SMS events use priorities 1–3. sms.reporting uses priority 5, so reporting events do not block SMS dispatch.
DLR roll-up and completion¶
The aggregator updates sms_recipient. CampaignDlrRollupPoller copies the results to the related campaign recipients.
flowchart LR
Agg[Aggregator updates sms_recipient] --> Rollup[CampaignDlrRollupPoller]
Rollup --> Map{Terminal result}
Map -->|DELIVERED| Delivered[DELIVERED; actualCost += cost]
Map -->|FAILED/EXPIRED/REJECTED| Classify[Classify failure]
Map -->|NOT_AVAILABLE| NA[FAILED; unconfirmed and not refunded]
Classify -->|transient, retries remain| Retry[RETRY_PENDING + nextRetryAt]
Classify -->|terminal/exhausted| Refund[FAILED + refund]
Delivered --> Finalize[Recount campaign]
NA --> Finalize
Refund --> Finalize
Finalize -->|all delivered| Completed[COMPLETED]
Finalize -->|all failed| Failed[FAILED]
Finalize -->|terminal mix| Partial[PARTIAL]
The roll-up handles duplicate MSISDNs and does not treat a phone number as unique. actualCost includes only delivered messages. totalCost remains the full amount charged.
Retry and refund processes¶
CampaignFailureClassifier¶
Timeouts, connection errors, downstream 5xx responses, Kafka send errors, and configured retryable MNPSP codes are temporary failures. If attempts remain, the recipient becomes RETRY_PENDING. nextRetryAt is the current time plus retryMinutes and a small random delay. Final failures and recipients with no attempts left become FAILED and require a refund.
The attempt count increases only when the dispatcher submits the message. A status update by a poller does not increase it.
CampaignRetryDispatcher¶
Claims ready RETRY_PENDING recipients and changes them to PENDING. It also changes their DISPATCHING campaigns back to QUEUED, because the main dispatcher claims only QUEUED or ready SCHEDULED campaigns.
CampaignRefundRetryPoller¶
After a wait period, claims failed recipients that have not been refunded and calls client-service again with the same key. On success, it saves refundedAt and the reason. The key is recipient:{recipientId}:{resendAttempt}, so each resend has its own charge and refund cycle.
Campaign controls¶
Pause and resume¶
A SCHEDULED, QUEUED, or DISPATCHING campaign can be paused. The service saves its previous state in pausedFromStatus, stops the expiry timer, and prevents new recipient claims. Work already published to Kafka cannot be recalled, and pausing does not issue a refund. A campaign paused from SCHEDULED resumes as SCHEDULED; other campaigns resume as QUEUED.
Reschedule¶
A SCHEDULED campaign, or one paused from SCHEDULED, can be rescheduled. The service validates the new Dhaka-local time and calculates a new dispatchDeadline.
Cancel¶
Cancellation changes an eligible campaign to CANCELLING, prevents new claims, marks unsent recipients CANCELLED, and refunds their saved costs with a stable cancellation key. Work already published may still finish. A recovery job completes cancellation records that become stuck.
Resend failed recipients¶
A PARTIAL or FAILED campaign can resend its failed recipients. The service calculates the cost of only those recipients, charges with a new resend key, increases resendAttempt, clears their final status and refund fields, changes them to PENDING, and reopens the same campaign as QUEUED.
Stale campaign recovery¶
CampaignStaleRecoverer handles each stuck campaign separately:
| State | Recovery |
|---|---|
DRAFT after the allowed time |
Reverse the charge with the campaign charge key, then mark it FAILED |
BUILDING after the allowed time |
If the campaign was not charged and has no saved recipients, mark it FAILED and require another upload |
DISPATCHING after the allowed time |
Queue unfinished work when safe, or let the DLR roll-up finish work that already has a final status |
CANCELLING after the allowed time |
Finish the refund and cancellation records, then change the campaign to CANCELLED |
Each campaign uses its own transaction, so one bad campaign does not roll back the rest of the recovery batch.
Scheduler leases and health¶
Campaign and recovery pollers use named ShedLock leases based on PostgreSQL time. OutboxPublisher uses row claims instead, so it does not need a ShedLock lease.
SchedulerHealthIndicator checks leases outside the scheduler thread pool. After the startup wait period, it reports DOWN when a required lease is missing or too old. This check is not part of liveness because restarting the service cannot fix a bad database lease. Monitor the admin-only GET /actuator/health endpoint. If it reports a problem, check /actuator/scheduledtasks, the lease-age health details, and sms_router.shedlock.
Poll intervals, batches, cooldowns, and recovery limits are listed in SMS router configuration.