Aviary
Channels

SMS

Text a notification through a pluggable SMS transport. Ships with a Twilio transport; return a plain string or a fluent SmsMessage, and route the recipient per notifiable.

The SMS channel sends a notification's text through a pluggable SmsTransport. A Twilio transport is bundled; swap in your own for any provider. Return a plain string for the message body, or a fluent SmsMessage when you want to override the sender number.

Install

pnpm add @dudousxd/nestjs-notifications-sms twilio
npm install @dudousxd/nestjs-notifications-sms twilio

The twilio SDK is only needed for the built-in TwilioTransport. Skip it if you supply a custom transport.

Register the channel

app.module.ts
import { SmsChannelModule } from '@dudousxd/nestjs-notifications-sms';

@Module({
  imports: [
    SmsChannelModule.forRoot({
      from: '+15555550100',
      twilio: {
        accountSid: process.env.TWILIO_ACCOUNT_SID!,
        authToken: process.env.TWILIO_AUTH_TOKEN!,
      },
    }),
  ],
})
export class AppModule {}

SmsChannelModule.forRoot() takes:

OptionTypeDefaultDescription
fromstringDefault sender number for messages that don't set their own.
transportType<SmsTransport>TwilioTransportTransport class used to deliver.
transportInstanceSmsTransportA pre-built transport instance (takes precedence over transport). Use for transports needing constructor args, e.g. a resilient failover transport.
twilio{ accountSid, authToken, from? }Credentials for the built-in TwilioTransport.
vonage{ apiKey, apiSecret, from? }Credentials for the built-in VonageTransport.
sns{ region, credentials?, senderId? }Config for the built-in AWS SnsTransport.
resolveTransport(tenant: string) => SmsTransportPer-tenant transport resolver. See Per-tenant config.
globalbooleantrueRegister globally so the channel is discoverable app-wide.

Three transports ship in the box — pick one with transport and supply its matching options:

Vonage
import { VonageTransport, SmsChannelModule } from '@dudousxd/nestjs-notifications-sms';

SmsChannelModule.forRoot({
  transport: VonageTransport,
  vonage: { apiKey: process.env.VONAGE_KEY!, apiSecret: process.env.VONAGE_SECRET!, from: 'Acme' },
});
AWS SNS
import { SnsTransport, SmsChannelModule } from '@dudousxd/nestjs-notifications-sms';

SmsChannelModule.forRoot({
  transport: SnsTransport,
  sns: { region: 'us-east-1', senderId: 'Acme' }, // credentials from the AWS default chain
});

Each transport pulls its own SDK as an optional peer (twilio, @vonage/server-sdk, @aws-sdk/client-sns) — install only the one you use.

The notification side

Annotate the payload method with the @Sms() handle and return a string:

login-code.notification.ts
import { type Notifiable, Notification } from '@dudousxd/nestjs-notifications-core';
import { Sms } from '@dudousxd/nestjs-notifications-sms';

@Notification()
export class LoginCode {
  constructor(private code: string) {}

  @Sms()
  toSms({ notifiable }: ChannelContext): string {
    return `Your code is ${this.code}`;
  }
}

The Sms handle also works as a via() token (via() { return [Sms]; }) for explicit routing; implement SmsNotification alongside the decorator for compile-time checks on toSms().

SmsMessage builder

Return an SmsMessage when you need to override the sender number for a single notification. Each method returns this:

login-code.notification.ts
import { Sms, SmsMessage } from '@dudousxd/nestjs-notifications-sms';

@Sms()
toSms({ notifiable }: ChannelContext): SmsMessage {
  return new SmsMessage()
    .content(`Your code is ${this.code}`)
    .from('+15555550199');
}
MethodEffect
.content(text)Set the SMS body text.
.from(number)Override the sender number for this message.

A message's from wins over the module's from, which in turn falls back to the transport's own default sender.

Routing the recipient

The recipient comes from routeNotificationFor('sms') — return the phone number to text:

user.ts
export class User implements Notifiable {
  constructor(public phone: string) {}

  routeNotificationFor(channel: string) {
    if (channel !== 'sms') return undefined;
    return this.phone;
  }
}

Custom transports

A transport is one method — implement SmsTransport and pass it as transport:

vonage.transport.ts
import { Injectable } from '@nestjs/common';
import type { SmsTransport, SmsTransportPayload } from '@dudousxd/nestjs-notifications-sms';

@Injectable()
export class VonageTransport implements SmsTransport {
  async send(payload: SmsTransportPayload): Promise<void> {
    // payload is { to, from?, text }
    await myVonageClient.send(payload.to, payload.from, payload.text);
  }
}
app.module.ts
SmsChannelModule.forRoot({ from: '+15555550100', transport: VonageTransport });

A custom transport is a normal Nest provider, so you can inject config or an HTTP client into it. The twilio option is ignored unless you use the default TwilioTransport.

Multi-provider failover

resilientTransport (from @dudousxd/nestjs-notifications-resilience) wraps an ordered list of transports and tries each until one succeeds — so when Twilio is down, the next provider (say Vonage) takes over. Beyond plain try-in-order, it adds a per-provider circuit breaker (a known-dead provider is skipped instantly instead of retried every send) and a per-attempt timeout (a slow provider is treated as a failure). Pass it as a pre-built instance via transportInstance (which takes precedence over the transport class):

pnpm add @dudousxd/nestjs-notifications-resilience @dudousxd/nestjs-resilience
app.module.ts
import { TwilioTransport, VonageTransport } from '@dudousxd/nestjs-notifications-sms';
import { resilientTransport } from '@dudousxd/nestjs-notifications-resilience';

SmsChannelModule.forRoot({
  from: '+15555550100',
  transportInstance: resilientTransport(
    [
      { id: 'twilio', transport: new TwilioTransport(twilioOpts) },
      { id: 'vonage', transport: new VonageTransport(vonageOpts) },
    ],
    {
      keyPrefix: 'sms',                              // circuit keys: 'sms:twilio', 'sms:vonage'
      timeoutMs: 8_000,                              // per-attempt timeout
      breaker: { threshold: 5, cooldownMs: 30_000 }, // open after 5 failures, probe after 30s
      onFailover: (id, error) => logger.warn(`sms provider ${id} failed over: ${error}`),
    },
  ),
});

The last error is rethrown only if every provider fails. By default breaker state is per-process; pass a distributed @dudousxd/nestjs-resilience-store-* adapter as store to share it fleet-wide. The same resilientTransport works for the mail channel — it's generic over any { send(payload) } transport.

Per-tenant config

In a multi-tenant app each tenant can text from its own provider/credentials. Pass resolveTransport — when a send is scoped to a tenant (via forTenant(id) or a @Tenant() property), the channel uses the SmsTransport you return instead of the default:

app.module.ts
SmsChannelModule.forRoot({
  from: '+15555550100',
  resolveTransport: (tenant) => tenantSmsTransports.get(tenant) ?? defaultTransport,
});

Sends with no tenant scope keep the default transport. See Multi-tenancy for the full picture.

On this page