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? }.
| Key | Type | Default | Description |
|---|---|---|---|
enabled | boolean | false (shipped) | Master flag; false unregisters the worker and hides phone UI. |
provider | "twilio" | "sns" | required when enabled | Delivery backend - Twilio or Amazon SNS. |
limitPerSecond | number? | 10 | Caps outbound throughput (worker throttle). |
sms: {
enabled: true,
provider: "twilio",
limitPerSecond: 10
}Credentials live in environment variables, not in config - see Environment Variables.
| Provider | Env vars |
|---|---|
| Twilio | TWILIO_ACCOUNT_SID, TWILIO_AUTH_TOKEN, TWILIO_PHONE_NUMBER |
| Amazon SNS | AMAZON_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.tsonly whenconfig.sms.enabledistrue- 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:
| Surface | Trigger | Targets |
|---|---|---|
| Broadcast | config.sms.enabled and config.notifications.enabled; createBroadcast queues announcement/broadcast.sms | Users who are not banned, have marketingOptIn, and have a phone number - numbers deduplicated, HTML stripped |
| Admin test send | sendTestAnnouncement with the SMS channel selected and the target user holding a phone number | One [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 }>).
packages/sms/src/providers/ implementing SMSProvider.SMSProvider union in packages/config/src/types/services.ts.case for it in the switch inside getSMSProvider() (packages/sms/src/send.ts) - the exhaustiveCheck enforces this at build time.packages/runtime/src/env.ts - providers read from env, not config.Admin Notifications
Push operator alerts for signups, purchases, refunds, and cancellations to Discord, Slack, or Telegram with @repo/admin-notifications.
File Storage
Upload avatars and files to S3 or local disk with per-user quotas, per-IP rate limiting, and magic-byte validation, gated by config.storage.