GenerateSaaS

SMS

Send rate-limited broadcast and admin-test text messages through Twilio or Amazon SNS with @repo/sms, gated by config.sms.

@repo/sms sends text messages through a swappable provider, gated by config.sms in packages/config/src/index.ts. Generated projects ship { enabled: false }, so every call site short-circuits and phone-related UI is hidden. Enable it and pick a provider, and the backend exposes a queued sendSMS() helper and registers the delivery worker.

There is no generic per-user transactional SMS channel and no SMS 2FA - the second factor is TOTP and backup codes only. SMS powers broadcasts and the admin test send, nothing else.

Configuration

config.sms is a discriminated union: { enabled: false } or { enabled: true; provider; limitPerSecond? }.

KeyTypeDefaultDescription
enabledbooleanfalse (shipped)Master flag; false unregisters the worker and hides phone UI.
provider"twilio" | "sns"required when enabledDelivery backend - Twilio or Amazon SNS.
limitPerSecondnumber?10Caps outbound throughput (worker throttle).
packages/config/src/index.ts
sms: {
  enabled: true,
  provider: "twilio",
  limitPerSecond: 10
}

Credentials live in environment variables, not in config - see Environment Variables.

ProviderEnv vars
TwilioTWILIO_ACCOUNT_SID, TWILIO_AUTH_TOKEN, TWILIO_PHONE_NUMBER
Amazon SNSAMAZON_SNS_REGION, AMAZON_SNS_KEY, AMAZON_SNS_SECRET, optional AMAZON_SNS_SENDER_ID, AMAZON_SNS_ORIGINATION_NUMBER

Sending an SMS

sendSMS(to, body) (packages/sms/src/send.ts) is the call you make from app code. It enqueues an sms/send event, so delivery is retried and rate-limited in the worker rather than reaching the provider inline.

import { sendSMS } from "@repo/sms";

await sendSMS("+15551234567", "Your code is 123456");

getSMSProvider() lazily imports and caches the provider class (TwilioSMSProvider or SNSSMSProvider) and throws when config.sms.enabled is false. The worker uses it; feature code does not.

Delivery and rate limiting

The worker sendSMSFunction (packages/api/src/functions/notifications/sms.ts) is throttled to config.sms.limitPerSecond events per second (throttle: { limit, period: "1s" }, default 10) and retries failed sends up to 3 times, protecting your provider account from bursts.

  • It is registered in packages/api/src/routes/inngest.ts only when config.sms.enabled is true - see Background jobs.
  • For HTTP-layer limits unrelated to SMS, see Caching.

What SMS actually sends

createNotification only inserts an in-app row. SMS goes out on exactly two paths, both gated by config.sms?.enabled in packages/notifications/src/service.ts:

SurfaceTriggerTargets
Broadcastconfig.sms.enabled and config.notifications.enabled; createBroadcast queues announcement/broadcast.smsUsers who are not banned, have marketingOptIn, and have a phone number - numbers deduplicated, HTML stripped
Admin test sendsendTestAnnouncement with the SMS channel selected and the target user holding a phone numberOne [TEST]-prefixed message to a single user

Broadcast concurrency is config.notifications.broadcastConcurrency (default 5). If either flag is off, the worker is unregistered and the admin announcements composer hides its SMS channel - see Notifications.

Adding a provider

Both providers implement the SMSProvider interface from packages/sms/src/types.ts (send(payload) => Promise<{ id }>).

Create a class in packages/sms/src/providers/ implementing SMSProvider.
Add the provider literal to the SMSProvider union in packages/config/src/types/services.ts.
Add a case for it in the switch inside getSMSProvider() (packages/sms/src/send.ts) - the exhaustiveCheck enforces this at build time.
Register any new credential env vars in packages/runtime/src/env.ts - providers read from env, not config.

On this page