Send transactional SMS from Vendure through TurboSMS, the Ukrainian bulk-SMS provider.
The plugin adds no GraphQL API extensions, no entities and no admin UI. It exports one
injectable service, TurboSmsService, with send(), sendBulk() and getBalance().
It is deliberately message-agnostic: it sends the text you hand it. Composing that text — templates, translations, which language a given customer reads — stays in your application, which knows its own copy. What the plugin does own is everything specific to sending SMS through this provider: the wire format, phone number formatting, segment accounting, per-recipient outcomes, and the account balance.
No runtime dependencies: the client is built on the global fetch.
Compatible with Vendure ^3.7.0.
@vendure/core and @nestjs/common are peer dependencies — the plugin uses the copies
already in your project.
Then inject the service anywhere in your own plugin:
Remember to add TurboSmsPlugin to your own plugin's imports if you inject its service,
since Vendure plugins are Nest modules.
TurboSmsPlugin.init(options):
| Option | Type | Default | Description |
|---|---|---|---|
apiKey | string | required | The TurboSMS API key (the bearer token from your TurboSMS account). |
sender | string | required | The registered alphanumeric sender name ("alpha name"). Overridable per call. |
dryRun | boolean | false | When true, nothing is sent: the message is written to the Vendure log instead. |
apiUrl | string | 'https://api.turbosms.ua/' | Base URL of the TurboSMS REST API. Point it at a mock server in tests. |
timeout | number | 10000 | How long a request may take before it is aborted, in milliseconds. |
lowBalanceAlert | { threshold?, schedule?, onLowBalance?, minIntervalBetweenAlerts?, onCheckFailed? } | — | Low-balance alerting: a callback, a scheduled check, or both. See Watching the balance. |
Both return a TurboSmsSendResult:
A request can be accepted as a whole while an individual number is refused, which is easy to miss when reading only the top-level response code. That is why the result splits recipients up front:
There is no message template or localization layer: the plugin sends the text you hand it. See Localizing messages for how to keep that copy in your application.
TurboSMS wants digits only, no leading + — 380501234567. Storefronts collect whatever
the customer typed, so every recipient is stripped down before it is sent:
+ and separators included.00 — the international access code, the written form of + — is dropped.Both steps only remove notation. The plugin never adds a country code, because which
country a national number like 0501234567 belongs to is not something it can know — that
is a fact about your customers. Such a number goes out as stored and TurboSMS refuses it,
which shows up in refused rather than being silently guessed at.
The same stripping is exported as normalizePhoneNumber, so an application that needs to
reason about a number before sending — picking a language from the country code, say — can
apply it rather than re-deriving it:
So store phone numbers in international form. If you have national ones, expand them where the country is known:
The stripping does not validate: a string that is not a phone number goes out as whatever digits it contained, and TurboSMS refuses it per recipient.
TurboSMS bills per segment, and a segment is much smaller in Cyrillic than the familiar
160 characters: one non-Latin character re-encodes the whole message to UCS-2, where a
segment holds 70 characters instead of 160. A 75-character Ukrainian message therefore
costs two segments, and a message that mixes in a single і costs the same as one written
entirely in Ukrainian.
Worth knowing when writing campaign copy, or when showing an author how much room is left.
The plugin does not count segments for you — that is the GSM 03.38 standard rather than
anything specific to TurboSMS, and it is a solved problem: use a dedicated package such as
split-sms or
sms-segments-calculator if you
need the exact count.
The plugin ships no templates on purpose: a published package cannot know your copy, and
Vendure's I18nService translates GraphQL error results for API responses, so it is not
available on the background paths that send SMS. Keep the strings in your application:
If the recipient's number is a better signal of what they read than the storefront locale,
branch on it yourself — recipient.startsWith('380') ? LanguageCode.uk : ctx.languageCode.
That is a decision about your customers, not about TurboSMS, so it stays on your side.
The plugin publishes on Vendure's event bus, so metering and audit logging do not have to wrap every call site:
| Event | When |
|---|---|
TurboSmsSentEvent | A send request was accepted. Carries the full result, dryRun sends too. |
TurboSmsFailedEvent | A send request failed as a whole, published just before the error is thrown. |
TurboSmsLowBalanceEvent | The scheduled check found the balance below the threshold. Not published for a refused send, and gated by minIntervalBetweenAlerts like the callback. |
Running out of credit stops SMS silently from the application's point of view: the API
simply starts refusing sends. There are two triggers, and both call the same
onLowBalance callback, so you wire up notification once.
| Trigger | When it fires | Needs |
|---|---|---|
| Refused send | TurboSMS rejects a send for insufficient funds (code 103) | nothing |
| Scheduled check | The polled balance is below threshold | threshold + a scheduler |
The refused send is exact and immediate, costs no extra API call and works without a scheduler — but by then messages are already failing. The scheduled check is what warns you before that happens. Configure both in production.
A third thing can happen: the scheduled check fails and the balance is simply unknown. That
is onCheckFailed — see When the check itself fails.
message is the same line the plugin logs, so the simple case needs no unpacking. Add a
threshold to also poll the balance ahead of time:
The callback is handed Vendure's Injector, so it can use anything in the application
without you building a plugin to hold a subscription:
Rules worth knowing:
Errors are caught and logged. A failing callback never breaks a send or fails a scheduled run.
It is awaited, so keep it quick — a slow callback delays the send's rejection and
counts against the scheduled task's timeout. That is DefaultSchedulerPlugin's
defaultTimeout (60 s unless you changed it), except when one request (timeout) plus
20 s of headroom would not fit in it — then the task sets that budget itself. Queue
anything slow.
That comparison needs a number. A defaultTimeout written as a duration string ('30s')
is only readable by the scheduler's own parser, so the task leaves it alone and logs a
warning. Express it in milliseconds if you want the check made — especially if it is
short, since the balance check would otherwise be cut off mid-request on every run.
It can fire once per refused send, so a burst of failures means a burst of calls. Debounce before paging anyone.
In a cluster the refused-send trigger fires on whichever instance sent the SMS, while the scheduled check runs once.
Dry-run fires neither trigger — nothing reaches the API and the scheduled task skips
itself — so a local test of onLowBalance will look like nothing happened.
While the balance stays low, every scheduled run alerts. That is the right default for a
single alert channel that collapses duplicates, and the wrong one for a chat room. Set
minIntervalBetweenAlerts to quieten it:
A recovery re-arms the alert immediately. Topped up at noon and drained again by evening, and you are told — a plain "once per day" timer would swallow that, and only the check itself can tell the difference, because your callback never sees the healthy runs.
The interval starts once the alert has gone out. A callback that throws is logged and retried on the next scheduled run, not silenced until the interval is up.
State lives in Vendure's CacheService, so it is as durable as your cache strategy: Redis
or DB survives restarts and is shared between instances, the default in-memory strategy is
per process — it resets on restart, and in a cluster each instance keeps its own, so use a
shared strategy there. Cache failures fail open — a duplicate alert beats a silently dropped
one. Changing threshold re-arms too, since it is part of the key. 0 (or a negative
value) means no interval, the same as leaving it out.
The interval gates the scheduled check only, TurboSmsLowBalanceEvent included. A refused
send always calls onLowBalance: its rate is bounded by your own send volume, and each one
is a customer message that actually failed.
TurboSMS being unreachable is not a low balance — it means you no longer know what the balance is. The task rethrows, so the run is recorded as failed, but nothing is pushed anywhere unless you ask for it:
onCheckFailed fires for a TurboSmsError — a refusal, or a transport failure, which
includes a 2xx body with no balance in it. Anything else is a bug rather than an outage and
is rethrown untouched. It shares
minIntervalBetweenAlerts under its own key, and re-arms as soon as a check succeeds.
Without it, a multi-day outage shows up only as failed runs in the scheduled-tasks screen — which is exactly where nobody is looking while SMS still appears to work.
schedule takes a cron expression or a cron-time-generator callback, and is only used
when a threshold is set:
The scheduled check needs a scheduler plugin (such as Vendure's DefaultSchedulerPlugin)
configured, since that is what runs scheduled tasks.
Omit lowBalanceAlert and nothing happens: no task is registered, no callback runs,
nothing extra is logged. There is deliberately no enabled flag — Vendure's own task
config has none either, and one more boolean would just be a second way to express "leave
it out".
To pause only the scheduled check at runtime without redeploying, disable it like any other Vendure task — in the admin UI's scheduled-tasks screen, or on the Admin API:
That does not affect the refused-send trigger, which is not a scheduled task.
If you already have event subscribers, the scheduled check also publishes
TurboSmsLowBalanceEvent. A refused send publishes no low-balance event — there is no
balance figure to report — but it does publish TurboSmsFailedEvent, so the same case is
one predicate away:
getBalance() is there for a one-off check. It is a live call even in dry-run mode, where
the configured API key is usually a placeholder — guard it with isDryRun.
None. The plugin contributes no schema extensions, resolvers, entities, permissions or custom fields — it is a service-only plugin.
Everything the plugin throws extends TurboSmsError, so one catch covers falling back
to another channel. Both kinds carry the endpoint that failed.
| Error | When | Extra fields |
|---|---|---|
TurboSmsRejectedError | TurboSMS answered, and the answer was a refusal — unknown alpha name, empty balance, … | responseCode, responseStatus, responseResult, recipientCodes, recipients, text |
TurboSmsTransportError | The request never produced a usable answer: network failure, timeout, non-2xx, non-JSON body | status (when there was an HTTP response), cause |
Response codes 0, 1 and 800–803 are treated as accepted; everything else raises a
TurboSmsRejectedError.
Code 103 (NOT_ENOUGH_MONEY) means the account is out of credit. It is exported as
INSUFFICIENT_FUNDS_RESPONSE_CODE, and it is what triggers lowBalanceAlert.onLowBalance
with reason: 'sendRejected' — see Watching the balance.
A refusal also carries a row per recipient. recipientCodes reads their codes in order, and
is empty when the rejection was request-level rather than per-number. It is a getter over
responseResult, so it is not in JSON.stringify(error) or { ...error } — the rows are:
A TurboSmsTransportError means the outcome is unknown — the message may or may not
have gone out, so an automatic retry can deliver it twice.
The message body is never put into an error's message, so codes do not leak into logs
through a stack trace. It is available on TurboSmsRejectedError.text if you need it.
text is an own enumerable property, though, so serializing the whole error puts the body
back in — JSON.stringify(error) in an alert or a log line will carry whatever the message
said, one-time codes included. Pick the fields you want, or redact text.
With dryRun: true, nothing touches the network: the recipients and the message body are
written to the Vendure log under the TurboSmsPlugin context, and the call resolves with
dryRun: true. TurboSmsService.isDryRun exposes the flag, so monitoring code can skip
balance checks when there is no real account behind the plugin.
Because the body is logged verbatim, anything sensitive in it — a one-time code, an order total — ends up in your logs. Dry run is a development mode; do not enable it in production.
See CHANGELOG.md.
MIT © Uplab
#16 136d49e Thanks @brmk! - Export the protocol knowledge consumers were re-deriving, and make the scheduled balance
check survive contact with a real ops channel.
New exports:
normalizePhoneNumber — the same stripping the plugin applies to every recipient. An
application that reasons about a number before sending (picking a language from the
country code, say) no longer has to reimplement it.RECIPIENT_COUNTRY_NOT_ALLOWED_CODE (406) and RECIPIENT_INSUFFICIENT_FUNDS_CODE (203)
— the per-recipient codes a caller has to classify. Distinct from the request-level
INSUFFICIENT_FUNDS_RESPONSE_CODE (103), which is a different row of the response.TurboSmsRejectedError.recipientCodes — the codes in order, empty for a request-level
rejection.New lowBalanceAlert options:
minIntervalBetweenAlerts — stay quiet after alerting, but re-arm as soon as the balance
recovers, so a top-up followed by another drop is still reported. The interval only
starts once the alert went out, so a callback that throws is retried next run. Backed by
Vendure's CacheService and failing open. Omitted (or 0), the behaviour is unchanged:
an alert on every scheduled run while the balance is low.onCheckFailed — called when a scheduled check cannot read the balance at all. The task
still rethrows, so the failed run is recorded either way; the callback is what turns "we
no longer know the balance" into something that reaches a person.The scheduled task also sets its own timeout when the configured request timeout plus 20 s
of callback headroom would not fit in DefaultSchedulerPlugin's defaultTimeout, so a slow
API response is not reported as a task failure. When it fits, the scheduler's setting is left
as it was — as it is for a defaultTimeout given as a duration string, which only the
scheduler's own parser can read, with a warning so a short one is not left unnoticed.
TurboSmsService.getBalance() now throws a TurboSmsTransportError for a 2xx body without a
balance in it, instead of a bare TypeError, so the scheduled check reports it as an outage.
dd0f7b1 Thanks @brmk! - Initial release: send transactional SMS through TurboSMS.
TurboSmsPlugin.init({ apiKey, sender, dryRun?, apiUrl?, timeout?, lowBalanceAlert? })TurboSmsService — send(), sendBulk() and getBalance(), plus an isDryRun flag+, a leading 00); no country code is ever addedaccepted and refused, so a number refused inside an accepted request is not missedlowBalanceAlert warns before the account runs dry: an onLowBalance callback fires the moment TurboSMS refuses a send for insufficient funds, and — when a threshold is set — from a scheduled balance check as wellTurboSmsSentEvent / TurboSmsFailedEvent, and TurboSmsLowBalanceEvent from the scheduled balance checkINSUFFICIENT_FUNDS_RESPONSE_CODE, so a TurboSmsFailedEvent subscriber can recognise a refusal for insufficient funds without a magic numberTurboSmsError: TurboSmsRejectedError when TurboSMS refuses a request (carrying the per-recipient codes), TurboSmsTransportError when it fails or times outdryRun mode logs the message instead of calling the APIfetch