SMS Module¶
The SMS module sends text messages from the platform: confirmation codes, alerts, and marketing campaigns. It queues a message, picks a provider, sends it, then tracks the delivery report and what the message cost.
A message is never sent straight from the request. It goes into a queue first, and the queue is what makes retries, rate limits, send windows and scheduling possible without the caller waiting for the provider.
Customer password recovery integration¶
The server publishes customer.password_reset.delivery_requested once a customer-recovery
workflow rule has picked a delivery_route — see the
event contract. No rule,
no event: the server owns the token, the module owns the templates and the provider.
The module handles the event like any other trigger, so wiring it up is configuration, not code:
- Create the trigger — SmsAddTrigger with
event: "customer.password_reset.delivery_requested"and the route conditionarguments: { "match": { "delivery_route": "customer-recovery" } }. Every subscriber receives the event, so without the condition this module would also send on routes meant for another channel. - Create the template for that trigger with
kind: "TRANSACTIONAL"— a recovery message must ignore marketing opt-outs and send windows.
Available placeholders are the payload fields: request_id, customer_id, brand,
language, country, first_name, last_name, email, phone, delivery_route,
reset_url, requested_at, expires_at. Timestamps are Unix seconds, and the module adds
requested_at_utc and expires_at_utc with the same moments as 2026-09-17 09:58:30
strings — templates have no date formatting of their own (see
Platform event enrichment). Mind the length: a recovery URL is long, and the text is billed
in segments — the link alone usually pushes the message past one segment.
Repeated delivery of one event does not produce a second message: the deduplication key uses
request_id. A new recovery request by the same person is a new request_id and does send.
reset_url is replaced with *** in the stored placeholder data and in the journal, and the
rendered text is not written to the journal at all — see
Secrets in placeholder data.
The same event can drive several channels at once: the mailer subscribes to it with its own trigger. The platform cannot split channels itself — one recovery request emits exactly one event, and delivery can be claimed only once — so the fan-out happens here, in the modules.
There is no delivery acknowledgement back to the server: a queued message means the module accepted the request, nothing more.
Platform event enrichment¶
Some platform events carry identifiers instead of people. cashier.deposit.approved and
cashier.withdrawal.approved name a customer_id and a login;
account.margin_call.alert names only a login. None of them carries a phone number, and a
template that shows a balance needs more than the event has.
The module fills the gaps itself, through the commands the platform publishes on the Moleculer bus:
| Command | What it adds | Permission |
|---|---|---|
MngGetAccountInfo |
customer_id for a login, currency, group, balance and margin |
see_accounts_balance for the money fields |
MngGetCustomerContacts |
the real phone number, unmasked | see_customer_contacts |
MngGetCustomer |
first and last name, language, country, status, desk | see_customers |
GetTradesByLogin |
number of open positions | — |
GetGroups |
margin_call and margin_stopout, the margin call and stop out levels |
— |
Requests go out as the service manager from [platform] managerId, so that account needs
those permissions and needs to see every desk. A manager without see_accounts_balance
still gets an answer: the platform returns zeros instead of an error, and the message
would show 0.00 as if the account were empty.
Enrichment runs when the event carries no recipient of its own, or when it names a
login. An event that already has the number and no account — a recovery request, for
example — costs no platform calls at all.
What templates get¶
customer—first_name,last_name,full_name,email,phone,language,country,status,desk;accounts[0]andaccount—login,currency,group,balance(also asamount),equity,credit,bonus,margin_used,free_margin,margin_level,open_positions,margin_call_level,stop_out_level;- the same account values under flat names:
account_login,account_currency,free_margin,margin_level,open_positionsand the rest; <field>_utcfor every Unix timestamp in the payload, soapproved_timealso arrives asapproved_time_utc, formatted2026-09-17 09:58:30. Templates have no date formatting, so the string is prepared here andmergegives it the name a template expects.
Part of this needs no platform call at all. customer.created carries the customer flat —
first_name, email, phone, language, country — and those values are also exposed as
customer.*, so a template written as {{customer.first_name}} works whichever event
triggered it. status is the exception and is never taken from an event: in
customer.created it is the customer status, in kyc_step.update the status of a KYC
step, and the same name would otherwise carry two different meanings into one message.
accounts[0] is the account named by the event, not the first account of the customer.
A customer may hold many, and a deposit, a withdrawal or a margin call is always about one
of them.
Event fields win. Whatever the event carries stays untouched, including inside
accounts[0]: account.margin_call.alert describes the moment of the transition while
the platform answers about now, and the message has to show the numbers that caused it.
merge is applied twice — before enrichment, so identifiers are found even under
non-standard names, and after it, so added fields can be renamed
(approved_time_utc → value_datetime_utc).
Cost and failure¶
A platform that refuses one of these calls does not stop the message: the module logs a warning and sends what it has. The recipient is the exception — an event that still has no number is skipped and counted as an error, which is visible in the module log.
One event costs up to five commands, of which the group list is cached for
enrichCacheSec (300 seconds by default). Contacts and the customer card are deliberately
not cached: they are personal data, and the number may have changed seconds before the
event arrived. Enrichment happens after the arguments condition and after the brand
check, so an event meant for another route or another brand costs nothing.
[triggers] enrich switches the whole thing off; [triggers] enrichCacheSec sets the
group cache.
Events published without a rule¶
These arrive straight from the Router mapping, so a trigger is all that is needed:
| Event | Delivers | Recipient comes from |
|---|---|---|
customer.created |
registration, welcome message | the event itself |
customer.update |
status or lifecycle change, with previous values | customer_id |
kyc_step.update |
KYC step approved or rejected, with reject_reason |
customer_id |
account.update |
leverage, group or enable changed | login |
trade.add, trade.closed |
position opened or closed | login |
account.margin_state |
every margin transition, including stop out and recovery | login |
Two of these deserve a condition rather than a bare subscription. trade.add,
trade.closed and account.update fire continuously on a live platform, and each event
with a login costs four platform calls, so narrow them with arguments.
account.margin_state is the superset of account.margin_call.alert: subscribe to both
and the customer is told twice about one margin call.
Events this makes usable¶
| Event | Delivers |
|---|---|
cashier.deposit.approved |
deposit confirmation |
cashier.withdrawal.approved |
withdrawal confirmation |
account.margin_call.alert |
margin call warning |
Each needs an active workflow rule on the platform — cashier.publish_approved for the
two cashier events, account.margin_call.publish for the margin call. Without a rule the
platform publishes nothing, and the trigger in the SMS module never fires.
What it covers¶
- Providers — 11 SMS integrations, each as an adapter with its own credentials schema and capability set.
- Queue — FIFO with attempts, exponential backoff, stuck-message recovery and expiry.
- Routing — the provider is chosen by the destination, with failover to backup profiles.
- Encoding and segments — GSM-7 or UCS-2 is detected, and the text is measured in segments, because that is what the operator bills.
- Templates and triggers — a platform event fires a template, so codes and notifications do not need a caller.
- Delivery reports — final status from the provider, by webhook or by polling.
- Send windows — messages respect the local time of the recipient.
- DNC and opt-out —
STOPreplies and blacklisted numbers are never messaged again. - Spend and balance — per-segment price in reports, provider balance with low-balance warnings.
Methods¶
Names in the table are the TCP command names; the REST column is the HTTP entry point of the same method.
Messages¶
| Method | REST | Description |
|---|---|---|
| SmsSendMessage | POST sms/message |
Queue a message with an explicit text |
| SmsSendFromTemplate | POST sms/message/fromTemplate |
Queue a message rendered from a template |
| SmsSendBulk | POST sms/message/bulk |
Queue up to 10 000 recipients as one campaign |
| SmsResendMessage | POST sms/message/resend |
Send a copy of an existing message |
| SmsCancelMessage | POST sms/message/cancel |
Cancel a message that has not left the queue |
| SmsPreviewMessage | POST sms/message/preview |
Text, encoding, segments and unresolved placeholders without sending |
| SmsGetMessage | GET sms/message/:id |
One message with attempts, error and webhook events |
| SmsGetMessages | GET sms/messages/list |
Message history with filters and aggregates |
| SmsGetStats | GET sms/stats |
Volume, segments, spend and delivery rate |
| SmsGetLastMessagesByParents | — | Last message time for a batch of customers or leads |
Templates and triggers¶
| Method | REST | Description |
|---|---|---|
| SmsAddTemplate | POST sms/template |
Create a text template with placeholders |
| SmsUpdateTemplate | PUT sms/template |
Update a template |
| SmsCloneTemplate | POST sms/template/clone |
Copy a template, for another language |
| SmsDeleteTemplate | DELETE sms/template |
Delete a template |
| SmsGetTemplates | GET sms/templates/list |
Templates of the brand |
| SmsAddTrigger | POST sms/trigger |
Subscribe a platform event to templates |
| SmsUpdateTrigger | PUT sms/trigger |
Update a trigger |
| SmsDeleteTrigger | DELETE sms/trigger |
Delete a trigger |
| SmsFireTrigger | POST sms/trigger/fire |
Fire an event by hand, for testing |
| SmsGetTriggers | GET sms/triggers/list |
Triggers of the brand |
| SmsGetTriggerSubscriptions | GET sms/triggers/subscriptions |
What the module is listening to right now |
Providers, profiles and agents¶
| Method | REST | Description |
|---|---|---|
| SmsGetProviderAdapters | GET sms/providers/adapters |
Implemented adapters, capabilities and credential fields |
| SmsAddProvider | POST sms/provider |
Register an SMS provider |
| SmsUpdateProvider | PUT sms/provider |
Update a provider record |
| SmsDeleteProvider | DELETE sms/provider |
Soft-delete a provider |
| SmsGetProviders | GET sms/providers/list |
Provider catalogue |
| SmsAddProfile | POST sms/providerProfile |
Create a provider profile with credentials |
| SmsUpdateProfile | PUT sms/providerProfile |
Update a provider profile |
| SmsDeleteProfile | DELETE sms/providerProfile |
Soft-delete a provider profile |
| SmsGetProfiles | GET sms/providerProfiles/list |
Provider profiles of the brand |
| SmsGetMyProfiles | GET sms/providerProfile/available/me |
Profiles and templates available to the current manager |
| SmsGetProfileBalance | GET sms/providerProfiles/balance |
Provider balance with low-balance flags |
| SmsAddAgent | POST sms/agent |
Link a manager to a sender identity |
| SmsUpdateAgent | PUT sms/agent |
Update a manager-to-sender link |
| SmsDeleteAgent | DELETE sms/agent |
Remove a manager-to-sender link |
| SmsGetAgents | GET sms/agents/list |
Manager-to-sender links |
Blacklist and send windows¶
| Method | REST | Description |
|---|---|---|
| SmsAddBlacklistPhone | POST sms/blacklist |
Blacklist a number |
| SmsImportBlacklistPhones | POST sms/blacklist/import |
Bulk import up to 10 000 numbers |
| SmsDeleteBlacklistPhone | DELETE sms/blacklist |
Remove a number from the blacklist |
| SmsCheckBlacklistPhone | GET sms/blacklist/check |
Check a number before sending |
| SmsGetBlacklist | GET sms/blacklist/list |
Blacklist entries |
| SmsAddSendWindow | POST sms/sendWindow |
Allowed sending hours for a country |
| SmsUpdateSendWindow | PUT sms/sendWindow |
Update a send window |
| SmsDeleteSendWindow | DELETE sms/sendWindow |
Delete a send window |
| SmsCheckSendWindow | GET sms/sendWindow/check |
Whether a number can be messaged now, and when the window opens |
| SmsGetSendWindows | GET sms/sendWindows/list |
Send windows |
Webhooks and service¶
| Method | REST | Description |
|---|---|---|
| SmsAddWebhook | POST sms/webhook |
Create a public delivery-report URL for a profile |
| SmsUpdateWebhook | PUT sms/webhook |
Update a webhook |
| SmsDeleteWebhook | DELETE sms/webhook |
Delete a webhook |
| SmsGetWebhooks | GET sms/webhooks/list |
Webhooks of the brand |
| SmsGetWebhookEvents | GET sms/webhookEvents/list |
Raw incoming provider events |
| SmsHandleWebhook | POST sms/handleWebhooks/:uuid |
Public endpoint the provider calls |
| SmsPing | GET sms/ping |
Cheap liveness probe with a problem list |
| SmsHealth | GET sms/health |
Full module state: database, adapters, crons, queue, balances |
Entities¶
| Entity | Scope | Purpose |
|---|---|---|
providers |
global | SMS integration catalogue; name is the adapter name |
providerProfiles |
brand | Credentials, default senderId, deliveryUpdateBy, allowed destinations, routingPriority |
agents |
brand | Manager-to-sender link, used by INDIVIDUAL profiles |
triggers |
brand or global | Platform event to template subscription; arguments holds the firing condition, merge renames event fields |
templates |
brand | Text with placeholders, language, message class |
sms |
brand | Messages, queue and state: status, kind, attempts, encoding, parts, price, campaignId |
webhooks |
brand | Public delivery-report URLs and their secrets |
webhookEvents |
via webhooks |
Raw provider payloads and deduplication |
phoneBlacklist |
brand | DNC numbers and opt-outs |
sendWindows |
brand | Allowed sending hours per country |
Everything is scoped by brand. Desks are not part of the model: a phone number, a
template and a provider profile belong to a brand, not to a department.
The only desk the module ever sees is the one it reads from the platform for the analytics row, and nothing is stored per desk.
Secrets in placeholder data¶
A platform event can carry a one-time secret in its payload — a password recovery link,
a confirmation code, a temporary password. The module renders the message from the full
data, but stores a redacted copy: secret fields are replaced with *** in the data
column and in the journal context. The mask is kept instead of dropping the key, so an
investigation can tell "hidden on purpose" from "the platform never sent it".
Recognised out of the box: reset_url, recovery_url, token, access_token,
refresh_token, api_key, secret, password, otp, one_time_code,
verification_code, pin and their spellings — names are compared ignoring case,
dashes and underscores, so reset_url and resetUrl are the same field. The list is
extended in the module configuration and the built-in entries cannot be switched off.
The journal does not receive the text either: the snapshot written to a journal record
leaves out message, because a link or a code already rendered into the text cannot be
masked and a journal record lives for a long time. The text is read from the sms table
by the same id when an incident is investigated.
This does not cover the text stored in the sms table: it is kept until the message is
sent and stays available for resending, and a link inside it is part of the text.
Recovery tokens expire (30 minutes for password reset), which bounds how long a stored
link is worth anything.
Message statuses¶
| Status | Meaning |
|---|---|
PENDING |
In the queue, not sent yet |
IN_PROGRESS |
The queue is handing it to the provider right now |
SENT |
Provider accepted it; the operator has not reported yet |
DELIVERED |
Operator confirmed delivery to the handset |
UNDELIVERED |
Operator reported a failed delivery |
REJECTED |
Provider or operator refused the message |
EXPIRED |
No final report arrived within the allowed window |
FAILED |
Sending failed and no attempts are left |
SENT is not a final status: for an SMS "the provider accepted it" and "the handset got
it" are different events, sometimes hours apart. Statuses are normalised by the module —
every adapter maps its own vocabulary into this set.
Message class¶
Every message has a kind: TRANSACTIONAL or MARKETING.
TRANSACTIONAL |
MARKETING |
|
|---|---|---|
| Send windows | ignored | applied |
Opt-out (STOP) |
ignored | blocks |
| Blacklist, regulator entries | block | block |
A confirmation code must reach the customer at 3 a.m., and an opt-out from promotions is not a refusal to receive login codes. That is the whole reason the two classes exist.
Sending pipeline¶
sequenceDiagram
participant CRM
participant Module as SMS module
participant Queue
participant Provider
CRM->>Module: SmsSendMessage
Module->>Module: phone, blacklist, duplicate, encoding, segments
Module->>Module: send window and schedule
Module->>Queue: PENDING
Queue->>Provider: send (attempt N)
Provider-->>Queue: provider message id → SENT
Provider-->>Module: delivery report (webhook or polling)
Module-->>CRM: sms.message.final
Checks that can reject a message for free run before the provider is contacted: number validity, blacklist and opt-out, duplicate protection, text length in segments, send window and schedule. Then the message is queued, and everything after that — attempts, backoff, failover to a backup profile — happens without the caller waiting.
Events¶
| Event | When |
|---|---|
sms.message.queued |
Message accepted into the queue |
sms.message.sent |
Provider accepted the message |
sms.message.final |
Delivered, undelivered, rejected, expired or failed |
sms.statusChanged |
Notification for the manager UI |