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
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
| 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
| 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:
- Validate and normalize accepted forms such as
017…,88017…, and+88017…to8801…. - Resolve the Bangladesh operator from the normalized prefix.
- Group recipients by operator and keep their original order within each group.
- Resolve an active public sender whose tenant, operator, CLI, and channel SMS type match.
- Resolve the billing rate for the same tenant, sender channel, and operator.
- 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.
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}
| 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.