Skip to content

SMS Router Service

sms-router-service prepares SMS requests for delivery. It receives authenticated requests from gateway-service, validates and normalizes them, chooses a route for each operator, calculates the cost, asks client-service to update the balance, saves the delivery plan, and sends work to aggregator-integration-service.

This page describes the current architecture. See SMS router process flows for campaign and recovery flows, and SMS router configuration for runtime settings.

Responsibilities and boundaries

The router owns:

  • direct SMS submission and client-specific status checks;
  • generic, personalized, instant, scheduled, and CSV-backed campaigns;
  • Bangladesh MSISDN normalization and operator resolution;
  • sender, CLI, channel, MNO/IPTSP credentials, fallback routes, and billing rates;
  • GSM-7/UCS-2 segmentation, cost per recipient, and saved route and price details;
  • upfront charges, reversals, and campaign refunds through client-service;
  • outbox records, Kafka publishing, and recovery;
  • campaign state changes, retries, DLR updates, and reporting events.

It does not validate JWTs, store client balances, call an MNO, or request DLRs from an MNO. These tasks belong to gateway-service, client-service, and aggregator-integration-service.

System context

flowchart LR
    Consumer[Client application]
    Operator[Platform operator]
    Gateway[gateway-service]
    Router[sms-router-service]
    Client[client-service]
    DB[(PostgreSQL\nsms_router schema)]
    Kafka[(Kafka)]
    Aggregator[aggregator-integration-service]
    MNO[MNO / IPTSP]

    Consumer -->|Bearer JWT| Gateway
    Operator -->|X-Admin-API-Key| Gateway
    Gateway -->|Trusted identity headers| Router
    Router -->|Deduct / reverse / refund / client config| Client
    Router --> DB
    Router -->|sms.outbound.mnoUserId\nsms.reporting| Kafka
    Kafka --> Aggregator
    Aggregator --> MNO
    Aggregator -->|recipient and chunk state| DB
    Aggregator -->|MESSAGE_FINALIZED| Kafka
Hold "Ctrl" to enable pan & zoom

The database and Kafka separate request acceptance from delivery. A successful submission means the request, recipients, chunks, and outgoing work were saved. It does not mean the operator accepted or delivered the SMS.

Request access and trust

External clients call the gateway, usually on port 8000. The gateway validates the bearer token. It then replaces any identity headers from the caller with trusted X-Client-Id, X-User-Id, X-Username, and X-Status values.

HeaderTrustFilter requires all four headers for /api/sms/** and /api/campaigns/**. Missing or invalid headers return 401. An inactive account returns 403. The router does not validate the JWT, so its HTTP port must remain private.

/admin/**, /internal/**, and restricted actuator paths require X-Admin-API-Key. Only liveness, readiness, and service information are open without authentication. See API access and audiences.

Internal component model

flowchart TB
    Controllers[REST controllers]
    Identity[HeaderTrustFilter / AdminApiKeyFilter]
    Submit[SmsSubmitService]
    Campaign[CampaignService / CampaignSagaTx]
    Price[CampaignPricingService]
    Route[RoutingService]
    Segment[SegmentationService]
    Balance[BalanceDeductClient / RefundClient]
    Workers[Campaign workers and recoverers]
    Outbox[OutboxPublisher]
    Repos[JPA repositories]
    DB[(sms_router)]
    Kafka[(Kafka)]

    Controllers --> Identity
    Controllers --> Submit
    Controllers --> Campaign
    Campaign --> Price
    Price --> Route
    Price --> Segment
    Submit --> Route
    Submit --> Segment
    Submit --> Balance
    Campaign --> Balance
    Campaign --> Repos
    Submit --> Repos
    Workers --> Campaign
    Workers --> Submit
    Workers --> Repos
    Outbox --> Repos
    Repos --> DB
    Outbox --> Kafka
Hold "Ctrl" to enable pan & zoom
Component What it does
SmsSubmitController Reserves an optional idempotency key, submits direct SMS, saves the final response, and returns it again for a matching retry
SmsStatusController / StatusAggregator Checks client ownership and combines recipient states into one message state
CampaignController / CampaignService Creates, previews, reads, cancels, pauses, resumes, reschedules, and resends campaigns, and accepts bulk uploads
CampaignSagaTx Sets transaction boundaries for campaign state changes, recipient records, and safe recovery updates
RoutingService Normalizes and groups recipients, finds the sender, channel, and MNO user, and applies the billing rate
SegmentationService Detects or checks the language and encoding, then calculates the segment count
CampaignPricingService Builds personalized content, saves the route, rate, and content for each recipient, applies the ten-segment limit, and calculates the total cost
SenderConfigAdminService Updates public sender routes and generated fallback records in one transaction
Scheduled workers Build CSV uploads, dispatch campaigns, restart retries, update DLRs, retry refunds, recover stuck work, and delete expired idempotency records
OutboxPublisher Claims outbox records by priority and publishes them to Kafka with time limits and retry tracking

Persistence model

All router-owned tables live in PostgreSQL schema sms_router and are managed by Flyway.

erDiagram
    MNO_CONFIG ||--o{ MNO_USER_CONFIG : contains
    MNO_CONFIG ||--o{ CHANNEL_CONFIG : groups
    CHANNEL_CONFIG ||--o{ SENDER_CONFIG : selected_by
    MNO_USER_CONFIG ||--o{ SENDER_CONFIG : pins
    SENDER_CONFIG ||--o{ SENDER_CONFIG_CLI : authorizes
    SENDER_CONFIG_CLI }o--o| SENDER_CONFIG : fallback_to
    CHANNEL_CONFIG ||--o{ BILLING_RATE : prices
    SMS_REQUEST ||--|{ SMS_REQUEST_CHUNK : splits_into
    SMS_REQUEST_CHUNK ||--|{ SMS_RECIPIENT : contains
    SMS_REQUEST_CHUNK }o--|| SENDER_CONFIG : submitted_route
    CAMPAIGN ||--o{ CAMPAIGN_RECIPIENT : contains
    CAMPAIGN ||--o| CAMPAIGN_UPLOAD : staged_as
Hold "Ctrl" to enable pan & zoom
Table Purpose and rules
mno_config MNO or IPTSP group. mno_type is MNO or IPTSP; is_long shows whether it supports long SMS.
mno_user_config Aggregator credentials and TPS limit. MNO records require bill_msisdn; IPTSP records must not have it. Passwords use the encrypted-string converter.
channel_config Logical lane with MNO group, sms_type, masking_type, and active status.
sender_config / sender_config_cli Client and operator route with allowed CLIs. A CLI may point to a generated fallback sender and select PRIMARY or SECONDARY.
billing_rate Current rate for (system_user_name, channel_id, operator): BDT rate per segment plus a fixed di_price per MSISDN.
sms_request One direct SMS or campaign dispatch request with its public message_id, content, segment count, total cost, and state.
sms_request_chunk A delivery unit for one operator and MNO user. It stores route and channel details and an optional campaign link.
sms_recipient Status, cost, response codes, timestamps, and DLR data for each MSISDN.
campaign Definition, ownership, schedule, totals, lifecycle timestamps, state, and optimistic version.
campaign_recipient Saved content, encoding, segments, route, rate, cost, retry, refund, and resend state for each recipient.
campaign_upload Raw CSV data and build progress. Any service instance can claim the work without shared file storage.
outbox_event Kafka topic, key, JSON data, priority, publish and recovery counts, and broker details.
idempotency_record Client-specific request fingerprint, IN_PROGRESS or COMPLETED state, response, and 24-hour expiry.
shedlock Database locks for scheduled jobs across service instances.

Old chunk records keep links to the routing configuration used at submission time. Because these records provide an audit trail, an admin cannot delete referenced configuration. The delete request returns 409.

Routing decision

Routing follows the configuration available when the request is accepted:

  1. Validate and normalize accepted forms such as 017…, 88017…, and +88017… to 8801….
  2. Resolve the Bangladesh operator from the normalized prefix.
  3. Group recipients by operator and keep their original order within each group.
  4. Resolve an active public sender whose tenant, operator, CLI, and channel SMS type match.
  5. Resolve the billing rate for the same tenant, sender channel, and operator.
  6. Save the sender, MNO, MNO user, channel, CLI, and price with the recipient and chunk records.
Operator Prefixes
GP 88017, 88013
RB 88018, 88016
BL 88019, 88014
TT 88015

Every operator in the recipient list must have an active sender. If any route is missing, the whole request fails before it is charged or saved.

Sender CLI fallback routing

A public sender can allow several exact CLIs. Each CLI may have a secondary route with a different MNO group, channel, MNO user, and fallback CLI. A wildcard * fallback rule is allowed, but clients cannot use it as a sender CLI.

The router saves the public senderConfigId. During dispatch, the aggregator reads the CLI rule and saves the route it used in dispatched_sender_config_id. This keeps both the requested route and the final route in the history. Generated fallback records do not appear in list APIs. If history refers to one, it is kept as inactive instead of being deleted. This is configured failover, not load balancing: activeRoute selects PRIMARY or SECONDARY.

Encoding, pricing, chunking, and priority

The direct API detects the language. Campaigns must provide lang because promotional and BTRC rules require it.

Encoding Language Single Concatenated Notes
GSM-7 en 160 septets 153 septets Extension characters consume two septets
UCS-2 bn 70 UTF-16 units 67 units Used for non-GSM-7 content or explicit bn

For campaigns, lang=en fails if the content is not GSM-7. The router builds personalized content before counting segments, so recipients may have different segment counts and costs. Each campaign message has a limit of ten segments.

recipientCost = (segmentCount × ratePerSegment) + diPrice

diPrice is charged once per MSISDN, not once per segment. A direct SMS charge happens before its records are saved. A campaign uses one idempotent charge key for the campaign and separate keys for reversals and refunds.

SMS type Recipients per outbound chunk Outbox priority
OTP 1 1 (highest)
TRANSACTIONAL 1 2
PROMOTIONAL Up to 999 3
Reporting events Event-defined 5

Every recipient in a chunk uses the same operator, MNO user, sender route, content, and segment count. The Kafka key is the chunk ID, which keeps related processing tied to the same ID.

Transactional outbox and events

The router saves business data and the related outbox record in one transaction. It publishes the outbox record to Kafka later.

sequenceDiagram
    participant API as Router API/worker
    participant DB as PostgreSQL
    participant OP as OutboxPublisher
    participant K as Kafka
    participant A as Aggregator integration

    API->>DB: Commit request, chunks, recipients, outbox
    API-->>API: Return accepted/created response
    loop fixed delay
        OP->>DB: Claim PENDING rows by priority and age
        OP->>K: Send batch concurrently
        K-->>OP: partition/offset or failure
        OP->>DB: Mark SENT or increment retry/fail
    end
    K->>A: Consume sms.outbound.{mnoUserId}
Hold "Ctrl" to enable pan & zoom
Topic Producer Consumer Contract
sms.outbound.{mnoUserId} Router Aggregator integration OutboxChunkPayload; one executable chunk
sms.reporting Router and aggregator Client service MESSAGE_SUBMITTED, MESSAGE_FINALIZED, and CAMPAIGN_UPSERT

The publisher tries five times in each round. After a wait period, OutboxRecoveryPoller can queue a failed event for another limited round. An event that keeps failing remains failed for investigation.

Message status aggregation

Status lookup uses (messageId, systemUserName). The router calculates the message status from its recipient statuses when the client requests it:

Condition Result
Any recipient PENDING or SUBMITTED PENDING
Every recipient DELIVERED DELIVERED
Every recipient FAILED, EXPIRED, or REJECTED FAILED
Any other terminal mix, including NOT_AVAILABLE PARTIAL

NOT_AVAILABLE is a final state, but it does not confirm delivery or a known failure. Recipient details are returned only when includeRecipients=true.

Architecture decisions

Decision Why it is used
Gateway-provided identity Authentication stays in one service. The router's HTTP port must remain private.
Reserve idempotency keys before charging Stops two matching requests from both being charged. A duplicate that is still in progress receives 409 and Retry-After: 2.
Transactional outbox A Kafka failure does not lose a saved request. Publishing can be retried and monitored.
Freeze campaign content, route, and price Later configuration changes do not change accepted charges or normal routes.
Store uploaded CSV data in PostgreSQL Any instance can continue the build after a restart without shared file storage.
Clear saga transaction boundaries Database updates and remote balance updates cannot share one transaction. Stable reversal and refund keys, plus recovery jobs, handle uncertain failures.
Database-time ShedLock Several instances can run pollers safely without differences between their system clocks. Lease columns remain timestamp without time zone.
Keep scheduler health out of liveness Restarting does not fix a bad lock stored in the database. Including this check in liveness could cause a restart loop.
Four scheduler threads One slow poller does not block every scheduled job. ShedLock still lets only one instance own each job.
Admin-only diagnostics Logger settings, environment data, and thread dumps may expose secrets or message content. Only basic health checks and info are open.

Known implementation constraints

  • Direct SMS charges the balance without a key before saving its database records. Unlike campaign charging, it has no automatic reversal if the later database transaction fails or the charge result is unclear. Clients should use request idempotency, but this does not reverse a charge left without a saved SMS request.
  • MNO-user create, list, and update responses currently include aggregatorPassword. The database value is encrypted, but the API response contains the decrypted value. Restrict this endpoint and disable response-body logging until the API uses a response object that hides the password.