SMS Service
Reliable SMS messaging service using Twilio. Send OTP codes, transactional messages, and notifications via SMS.
Installation & Setup
1. Install Twilio SDK
pnpm add twilio2. Configure Environment Variables
# .env
TWILIO_ACCOUNT_SID=ACxxxxxxxxxxxx
TWILIO_AUTH_TOKEN=your_auth_token
TWILIO_PHONE_NUMBER=+12345678903. Usage
The SMS Service is available as a singleton via getSMSService.
import { getSMSService } from '@orbitusdev/core/services/sms';
const smsService = getSMSService();Supported Providers
- Twilio (Default)
- AWS SNS (Coming soon)
OTP (One-Time Password) Messages
Generate and Send OTP
// lib/otp.ts
import { getSMSService } from '@orbitusdev/core/services/sms';
import { randomInt } from 'crypto';
export function generateOTP(length = 6): string {
const min = Math.pow(10, length - 1);
const max = Math.pow(10, length) - 1;
return randomInt(min, max).toString();
}
export async function sendOTP(phoneNumber: string, locale: 'en' | 'tr' = 'en') {
const smsService = getSMSService();
const otp = generateOTP();
// Store OTP in database with expiration
await prisma.otp.create({
data: {
phoneNumber,
code: otp,
expiresAt: new Date(Date.now() + 5 * 60 * 1000) // 5 minutes
}
});
const messages = {
en: `Your verification code is: ${otp}. Valid for 5 minutes.`,
tr: `Doğrulama kodunuz: ${otp}. 5 dakika geçerlidir.`
};
await smsService.sendSMS({
to: phoneNumber,
message: messages[locale]
});
return { success: true };
}Verify OTP
export async function verifyOTP(phoneNumber: string, code: string) {
const otp = await prisma.otp.findFirst({
where: {
phoneNumber,
code,
expiresAt: { gt: new Date() },
verified: false
}
});
if (!otp) {
return { success: false, error: 'Invalid or expired OTP' };
}
// Mark as verified
await prisma.otp.update({
where: { id: otp.id },
data: { verified: true }
});
return { success: true };
}Transactional Messages
Order Confirmation
import { getSMSService } from '@orbitusdev/core/services/sms';
export async function sendOrderConfirmation(order: {
id: string;
phoneNumber: string;
total: number;
locale: 'en' | 'tr';
}) {
const smsService = getSMSService();
const messages = {
en: `Order #${order.id} confirmed! Total: $${order.total}. Track your order at orbitus.dev/orders/${order.id}`,
tr: `Sipariş #${order.id} onaylandı! Toplam: ₺${order.total}. Siparişinizi takip edin: orbitus.dev/orders/${order.id}`
};
await smsService.sendSMS({
to: order.phoneNumber,
message: messages[order.locale]
});
}Delivery Updates
import { getSMSService } from '@orbitusdev/core/services/sms';
export async function sendDeliveryUpdate(delivery: {
orderId: string;
phoneNumber: string;
status: 'shipped' | 'out_for_delivery' | 'delivered';
locale: 'en' | 'tr';
}) {
const smsService = getSMSService();
const statusMessages = {
en: {
shipped: `Your order #${delivery.orderId} has been shipped!`,
out_for_delivery: `Your order #${delivery.orderId} is out for delivery.`,
delivered: `Your order #${delivery.orderId} has been delivered. Enjoy!`
},
tr: {
shipped: `Siparişiniz #${delivery.orderId} kargoya verildi!`,
out_for_delivery: `Siparişiniz #${delivery.orderId} dağıtıma çıktı.`,
delivered: `Siparişiniz #${delivery.orderId} teslim edildi. Afiyet olsun!`
}
};
await smsService.sendSMS({
to: delivery.phoneNumber,
message: statusMessages[delivery.locale][delivery.status]
});
}Payment Alerts
import { getSMSService } from '@orbitusdev/core/services/sms';
export async function sendPaymentAlert(payment: {
phoneNumber: string;
amount: number;
status: 'success' | 'failed';
locale: 'en' | 'tr';
}) {
const smsService = getSMSService();
const messages = {
en: {
success: `Payment of $${payment.amount} successful. Thank you!`,
failed: `Payment of $${payment.amount} failed. Please try again.`
},
tr: {
success: `₺${payment.amount} tutarındaki ödeme başarılı. Teşekkürler!`,
failed: `₺${payment.amount} tutarındaki ödeme başarısız. Lütfen tekrar deneyin.`
}
};
await smsService.sendSMS({
to: payment.phoneNumber,
message: messages[payment.locale][payment.status]
});
}Server Actions
Send OTP Action
'use server';
import { sendOTP } from '@/lib/otp';
import { z } from 'zod';
const phoneSchema = z.string().regex(/^\+[1-9]\d{1,14}$/);
export async function sendOTPAction(phoneNumber: string, locale: 'en' | 'tr') {
// Validate phone number (E.164 format)
const result = phoneSchema.safeParse(phoneNumber);
if (!result.success) {
return { success: false, error: 'Invalid phone number format' };
}
try {
await sendOTP(phoneNumber, locale);
return { success: true };
} catch (error) {
return { success: false, error: 'Failed to send OTP' };
}
}Verify OTP Action
'use server';
import { verifyOTP } from '@/lib/otp';
export async function verifyOTPAction(phoneNumber: string, code: string) {
try {
const result = await verifyOTP(phoneNumber, code);
return result;
} catch (error) {
return { success: false, error: 'Verification failed' };
}
}Client Components
OTP Input Form
'use client';
import { useState, useTransition } from 'react';
import { sendOTPAction, verifyOTPAction } from './actions';
export function OTPForm() {
const [phoneNumber, setPhoneNumber] = useState('');
const [otp, setOtp] = useState('');
const [step, setStep] = useState<'phone' | 'verify'>('phone');
const [isPending, startTransition] = useTransition();
const handleSendOTP = () => {
startTransition(async () => {
const result = await sendOTPAction(phoneNumber, 'en');
if (result.success) {
setStep('verify');
}
});
};
const handleVerifyOTP = () => {
startTransition(async () => {
const result = await verifyOTPAction(phoneNumber, otp);
if (result.success) {
// Redirect or show success
}
});
};
if (step === 'phone') {
return (
<div>
<input
type="tel"
value={phoneNumber}
onChange={(e) => setPhoneNumber(e.target.value)}
placeholder="+1234567890"
/>
<button onClick={handleSendOTP} disabled={isPending}>
{isPending ? 'Sending...' : 'Send OTP'}
</button>
</div>
);
}
return (
<div>
<input
type="text"
value={otp}
onChange={(e) => setOtp(e.target.value)}
placeholder="Enter 6-digit code"
maxLength={6}
/>
<button onClick={handleVerifyOTP} disabled={isPending}>
{isPending ? 'Verifying...' : 'Verify'}
</button>
</div>
);
}Bulk SMS
Send to Multiple Recipients
import { getSMSService } from '@orbitusdev/core/services/sms';
export async function sendBulkSMS(recipients: string[], message: string) {
const smsService = getSMSService();
const results = await Promise.allSettled(
recipients.map((phoneNumber) =>
smsService.sendSMS({ to: phoneNumber, message })
)
);
const successful = results.filter((r) => r.status === 'fulfilled').length;
const failed = results.filter((r) => r.status === 'rejected').length;
return {
total: recipients.length,
successful,
failed
};
}Rate Limiting
Prevent SMS spam with rate limiting:
import { rateLimit } from '@orbitusdev/core/lib/security/rate-limit';
export async function sendOTPWithRateLimit(phoneNumber: string) {
const { success } = await rateLimit.check(`sms:${phoneNumber}`, {
limit: 3, // 3 SMS
window: 3600000 // per hour
});
if (!success) {
throw new Error('Too many SMS requests. Please try again later.');
}
return sendOTP(phoneNumber);
}Error Handling
import { getSMSService } from '@orbitusdev/core/services/sms';
import { logger } from '@orbitusdev/core/services/logger';
export async function sendSMSSafe(params: SendSMSParams) {
const smsService = getSMSService();
try {
const result = await smsService.sendSMS(params);
logger.info('SMS sent successfully', { to: params.to });
return result;
} catch (error) {
logger.error('Failed to send SMS', error, { to: params.to });
// Retry logic
if (error.code === 'NETWORK_ERROR') {
await delay(1000);
return smsService.sendSMS(params);
}
throw error;
}
}Best Practices
- Validate phone numbers - Use E.164 format (+1234567890)
- Rate limit - Prevent spam and abuse
- Short messages - SMS has 160 character limit
- Include brand name - Identify yourself in messages
- Provide opt-out - Include “Reply STOP to unsubscribe”
- Respect time zones - Don’t send at night
- Store OTPs securely - Hash in database
- Set expiration - OTPs should expire quickly (5-10 min)
- Log all SMS - For debugging and auditing
- Handle errors - Gracefully fail, provide alternatives
Security Considerations
- Never expose Twilio credentials - Use environment variables
- Validate all inputs - Prevent injection attacks
- Rate limit strictly - SMS is expensive and can be abused
- Use HTTPS only - For webhook callbacks
- Verify webhook signatures - Ensure requests are from Twilio
- Expire OTPs quickly - 5 minutes maximum
- Limit OTP attempts - 3-5 attempts before lockout
- Log security events - Track suspicious activity
Cost Optimization
// Use a pool of phone numbers to avoid rate limits
const phoneNumbers = [
process.env.TWILIO_PHONE_1,
process.env.TWILIO_PHONE_2,
process.env.TWILIO_PHONE_3
];
function getPhoneNumber(recipientNumber: string): string {
// Round-robin or hash-based selection
const index = Math.abs(hashCode(recipientNumber)) % phoneNumbers.length;
return phoneNumbers[index]!;
}
function hashCode(str: string): number {
let hash = 0;
for (let i = 0; i < str.length; i++) {
hash = (hash << 5) - hash + str.charCodeAt(i);
hash |= 0;
}
return hash;
}Testing
Mock SMS Service
// tests/mocks/sms.ts
export const mockSendSMS = jest.fn().mockResolvedValue({
success: true,
messageId: 'SM1234567890',
status: 'sent'
});
jest.mock('@orbitusdev/core/services/sms', () => ({
sendSMS: mockSendSMS
}));Test with Twilio Test Credentials
Twilio provides test credentials that don’t send real SMS:
TWILIO_ACCOUNT_SID=ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
TWILIO_AUTH_TOKEN=your_test_auth_tokenRelated
Last updated on