Skip to content

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:

  1. Create the trigger — SmsAddTrigger with event: "customer.password_reset.delivery_requested" and the route condition arguments: { "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.
  2. 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] and account — login, currency, group, balance (also as amount), 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_positions and the rest;
  • <field>_utc for every Unix timestamp in the payload, so approved_time also arrives as approved_time_utc, formatted 2026-09-17 09:58:30. Templates have no date formatting, so the string is prepared here and merge gives 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 — STOP replies 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