Skip to content

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
Hold "Ctrl" to enable pan & zoom

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
Hold "Ctrl" to enable pan & zoom

The service follows these steps:

  1. Reserve the optional idempotency key before changing the balance or campaign state.
  2. Require PROMOTIONAL to use lang=bn and provide operator-approved campaignId.
  3. Require scheduledAt only for SCHEDULED. By default, treat it as local time in Dhaka.
  4. Enforce the minimum lead, maximum future date, and promotional window.
  5. Read the exact {{tag}} names. GENERIC allows no tags; PERSONALIZED requires at least one.
  6. 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.
  7. Normalize MSISDNs; resolve operator, active sender/channel/MNO-user, and billing rate.
  8. Build each personalized message, calculate its segments, and apply the ten-segment limit.
  9. Save the final content, route, rate, segment count, and cost for each recipient.
  10. Deduct the total once and activate as QUEUED or SCHEDULED.

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.

msisdn
01712345678
8801812345678
+8801912345678

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]
Hold "Ctrl" to enable pan & zoom

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.

  1. Claim ready QUEUED or SCHEDULED campaigns using row locks and SKIP LOCKED.
  2. Mark each claimed campaign DISPATCHING.
  3. If promotional traffic is outside the BTRC window, move it to SCHEDULED at the next opening.
  4. Claim a limited batch of recipients.
  5. Group recipients by saved content, language, sender CLI, and route. Every recipient in an internal submission must use the same values.
  6. Call SmsSubmitService.submitCore with skipDeduct=true; the campaign was charged upfront.
  7. Link the campaign recipients to the SMS request and increase the attempt count after the actual submission.
  8. 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 to PENDING below five attempts.
  • The fifth failure marks FAILED.
  • OutboxRecoveryPoller requeues eligible failures after a cooldown and increments recovery_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]
Hold "Ctrl" to enable pan & zoom

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.