Documentation

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:

emailprovider
direct_smtpAny SMTP server — host, port, username, app password.
googleGoogle Workspace.
send_gridSendGrid, by API key.
mailtrapMailtrap — the obvious choice for a development environment.
smsprovider
twilio
vonage

Because 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 default

Two 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_founddelivery

The slug does not exist for that channel and language.

missing_template_parametersdelivery

The template expected params you did not supply.

disabled_featuredelivery

The environment does not allow the template that was named.

cannot_senddelivery

The 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 for1

Used when a version exists for it.

English2

The fallback, and where the platform defaults live.

the oldest version of that template3

A 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.

Next

On this page