Communications
Sending email and SMS through your own providers, and why your code never names one.
Your code names a template and a language. It never names a provider, a subject line, or a body. That indirection is the whole design, and everything below follows from it.
await nulvless.comms.email.send({
to: '[email protected]',
template: 'grants-invite',
language: 'pt-BR',
params: { organization: 'Acme Inc', role: 'admin' },
});Change the provider, rewrite the copy, add a language — none of it touches this call.
Bring your own provider
Messages go out through your accounts, not ours. Each environment configures its own, and each provider is independently enabled:
emailproviderdirect_smtpAny SMTP server — host, port, username, app password.googleGoogle Workspace.send_gridSendGrid, by API key.mailtrapMailtrap — the obvious choice for a development environment.smsprovidertwiliovonageBecause the provider is per environment, development can point at Mailtrap while production points at SendGrid — with the same template slugs and the same calling code. A test never reaches a real inbox because of configuration, not because someone remembered to guard the call.
Verify the wiring before a user depends on it:
await nulvless.comms.email.test('[email protected]');Worth running from your deploy pipeline against a fresh environment. A misconfigured provider is otherwise invisible until the first person needs a verification code.
The platform sends its own messages too
Confirmation codes, recovery links, invitations — the platform sends these through the same providers and the same templates. Every environment starts with the set it needs, and each is editable:
welcome-user · email-otp-verification · email-link-verification ·
email-otp-enable-mfa-request · change-email-identity ·
reset-password-email · grants-invite
And for SMS: phone-otp-verification · phone-link-verification ·
phone-otp-enable-mfa-request · change-phone-identity
This is why a sign-up email arrives in your brand voice without you writing any sending code — and why breaking one of these templates breaks sign-up.
A template is per channel. The email and SMS versions of the same idea are
separate slugs, because wording that works in an inbox does not survive 160
characters. Editing email-otp-verification does nothing to
phone-otp-verification.
Editing one writes your version, not over ours
The platform defaults are not rows you own, so editing does not modify them. Saving stores your version for that channel, slug and language, and delivery prefers it:
your version for the language asked for
↓ none
the platform defaultTwo things follow. Deleting your version is how you go back to the default —
there is nothing to restore, because the default was never overwritten. And
because the defaults ship in English only, a project with no Portuguese version
of welcome-user sends the English one rather than failing.
Adding a language is additive for the same reason: welcome-user in pt-BR is
a separate record, and writing it does not touch the English one.
Sending is queued
The call publishes a message; a worker delivers it. A call that resolves without an error means accepted for delivery, not delivered.
That has one consequence worth designing around: delivery failures do not come back to you. They are recorded on the message, with a code:
template_not_founddeliveryThe slug does not exist for that channel and language.
missing_template_parametersdeliveryThe template expected params you did not supply.
disabled_featuredeliveryThe environment does not allow the template that was named.
cannot_senddeliveryThe provider rejected it.
So a template referencing {{ organization }} when you sent none does not fail
at the call site — it fails minutes later, silently, unless you read the
message's history. Treat params as a contract.
Language
language picks the variant. Omit it and the environment's default applies.
Delivery resolves it in three steps, and the last one is worth knowing about:
the language you asked for1Used when a version exists for it.
English2The fallback, and where the platform defaults live.
the oldest version of that template3A last resort when neither exists — so a message can go out in a language nobody asked for.
That third step only applies to templates you created: if you write
order-shipped in pt-BR alone and then send it in es-ES, the Portuguese one
is delivered. Write the English version too if you would rather that never
happens.
Set the language once on the client rather than at each call:
createClient(url, key, { language: () => navigator.language });It is a function so it can be resolved per request rather than frozen at construction — which matters on a server handling users in different locales.
Templates as a triple
Everything but create and list addresses a template by channel + slug +
language:
await nulvless.comms.templates.get('email', 'welcome-user', 'pt-BR');
await nulvless.comms.templates.update('email', 'welcome-user', params, 'pt-BR');There is no id to hold. The same slug in two languages is two records, which is what makes adding a language additive rather than a migration.
The third channel
Besides email and SMS a template can be a webhook: instead of rendering a
message for a person, it posts to a url you own. The call site is identical, so
a notification can move from email to a webhook as configuration — useful for
routing something to Slack or to your own service without touching the code that
triggers it.
Rate limits
Sends are quota-limited per channel and per environment. Exhausting one answers
429 rate_limit_exceeded.
SMS also has a minimum interval per phone number: by default, a number receives
at most one SMS every 60 seconds. A second send inside that window answers the
same 429. Change it, or set it to 0 to turn it off, in the environment's
communications rate limits.
Unlike the auth service, this one sends no retryAfter — and the SDK would
not surface it anyway. Use your own backoff rather than reading a field that is
not there.