YeboLink Providers Deep Dive
YeboLink abstracts external messaging providers behind service classes. This enables easy switching between providers and consistent error handling.
Provider Architecture
┌─────────────────────────────────────────────────────────────────────┐
│ MessageProcessor │
│ (orchestrates delivery) │
└────────────────────────────┬────────────────────────────────────────┘
│
▼
┌──────────────┴──────────────┐
│ Channel Routing │
│ switch(message.channel) │
└──────────────┬──────────────┘
│
┌───────────────────┼───────────────────┐
▼ ▼ ▼
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ TwilioService │ │ EmailService │ │ Future... │
│ (SMS/Voice) │ │ (Resend) │ │ (WhatsApp/etc) │
└─────────────────┘ └─────────────────┘ └─────────────────┘
│ │
▼ ▼
┌─────────────────┐ ┌─────────────────┐
│ Twilio API │ │ Resend API │
└─────────────────┘ └─────────────────┘SMS Provider: Twilio (twilio.service.ts)
Overview
- Provider: Twilio
- Capabilities: SMS, Voice (planned)
- Authentication: API Key or Auth Token
- Features: Alphanumeric sender IDs, delivery callbacks
Configuration
bash
# Environment Variables
TWILIO_ACCOUNT_SID=ACxxxx
TWILIO_AUTH_TOKEN=xxxxx # Fallback auth
TWILIO_API_KEY_SID=SKxxxx # Preferred auth (revocable)
TWILIO_API_KEY_SECRET=xxxxx
TWILIO_PHONE_NUMBER=+1234567890 # Default senderClient Initialization
typescript
function createTwilioClient() {
const accountSid = env.TWILIO_ACCOUNT_SID;
if (!accountSid) return null;
// Prefer API Key auth (more secure, revocable)
if (env.TWILIO_API_KEY_SID && env.TWILIO_API_KEY_SECRET) {
logger.info('Twilio: using API Key authentication');
return twilio(env.TWILIO_API_KEY_SID, env.TWILIO_API_KEY_SECRET, { accountSid });
}
// Fallback to Auth Token
if (env.TWILIO_AUTH_TOKEN) {
logger.info('Twilio: using Auth Token authentication');
return twilio(accountSid, env.TWILIO_AUTH_TOKEN);
}
return null;
}Alphanumeric Sender IDs
Many African countries support brand names as SMS senders (instead of phone numbers).
typescript
// Countries supporting alphanumeric sender IDs
const ALPHA_SENDER_SUPPORTED = new Set([
'SZ', // Eswatini ✅ - MTN supports it
'ZA', // South Africa
'KE', // Kenya
'GH', // Ghana
'TZ', // Tanzania
'UG', // Uganda
'RW', // Rwanda
'ZM', // Zambia
'ZW', // Zimbabwe
'MZ', // Mozambique
'BW', // Botswana
]);
// Country detection from phone number
const PREFIX_TO_COUNTRY: Record<string, string> = {
'+268': 'SZ', // Eswatini
'+27': 'ZA', // South Africa
'+254': 'KE', // Kenya
'+233': 'GH', // Ghana
'+255': 'TZ', // Tanzania
'+256': 'UG', // Uganda
'+250': 'RW', // Rwanda
'+260': 'ZM', // Zambia
'+263': 'ZW', // Zimbabwe
'+258': 'MZ', // Mozambique
'+267': 'BW', // Botswana
'+234': 'NG', // Nigeria (doesn't support alpha)
};Sender Selection Logic:
typescript
let from: string;
if (senderName && supportsAlphaSender(to)) {
// Use brand name (max 11 alphanumeric chars)
from = sanitizeSenderName(senderName) || env.TWILIO_PHONE_NUMBER || '';
} else {
// Use phone number
from = env.TWILIO_PHONE_NUMBER || '';
}Name Sanitization:
typescript
function sanitizeSenderName(name: string): string {
// Alphanumeric only, max 11 chars
return name.replace(/[^a-zA-Z0-9]/g, '').slice(0, 11);
}Sending SMS
typescript
static async sendSMS(
to: string,
message: string,
senderName?: string,
statusCallback?: string
): Promise<{ sid: string; status: string }> {
if (!this.client) throw new Error('Twilio is not configured');
// Decide sender
let from: string;
if (senderName && supportsAlphaSender(to)) {
from = sanitizeSenderName(senderName) || env.TWILIO_PHONE_NUMBER || '';
} else {
from = env.TWILIO_PHONE_NUMBER || '';
}
if (!from) throw new Error('No Twilio sender configured');
try {
const createOpts: any = { body: message, to, from };
// Add status callback URL for delivery tracking
if (statusCallback) {
createOpts.statusCallback = statusCallback;
}
const result = await this.client.messages.create(createOpts);
logger.info('SMS sent', {
sid: result.sid,
to,
from,
status: result.status
});
return { sid: result.sid, status: result.status };
} catch (error: any) {
logger.error('SMS send failed', {
to,
from,
error: error.message,
code: error.code
});
throw new Error(`Failed to send SMS: ${error.message}`);
}
}Webhook Signature Verification
typescript
static validateWebhookSignature(
signature: string,
url: string,
params: Record<string, any>
): boolean {
if (!env.TWILIO_AUTH_TOKEN) {
logger.warn('Twilio webhook validation skipped — AUTH_TOKEN not set');
return true;
}
try {
return twilio.validateRequest(env.TWILIO_AUTH_TOKEN, signature, url, params);
} catch {
return false;
}
}Status Callback Parsing
typescript
static parseStatusCallback(body: any): {
messageSid: string;
status: string;
errorCode?: string;
errorMessage?: string;
} {
return {
messageSid: body.MessageSid || body.SmsSid,
status: this.mapTwilioStatus(body.MessageStatus || body.SmsStatus),
errorCode: body.ErrorCode,
errorMessage: body.ErrorMessage,
};
}
private static mapTwilioStatus(s: string): string {
const statusMap: Record<string, string> = {
queued: 'queued',
sending: 'queued',
sent: 'sent',
delivered: 'delivered',
undelivered: 'failed',
failed: 'failed',
received: 'delivered',
};
return statusMap[s?.toLowerCase()] || 'queued';
}Email Provider: Resend (email.service.ts)
Overview
- Provider: Resend
- Capabilities: Transactional email
- Features: HTML templates, text fallback
Configuration
bash
RESEND_API_KEY=re_xxxxx
FROM_EMAIL=noreply@yebolink.com
FROM_NAME=YeboLinkSending Email
typescript
import { Resend } from 'resend';
const resend = new Resend(process.env.RESEND_API_KEY);
export class EmailService {
static async sendEmail(
to: string,
subject: string,
html: string,
text?: string,
fromName?: string
): Promise<void> {
try {
const result = await resend.emails.send({
from: `${fromName || env.FROM_NAME} <${env.FROM_EMAIL}>`,
to,
subject,
html,
text: text || html.replace(/<[^>]*>/g, ''), // Strip HTML for text fallback
});
if (result.error) {
throw new Error(result.error.message);
}
logger.info(`Email sent to ${to}: ${subject}`, { id: result.data?.id });
} catch (error) {
logger.error('Failed to send email', { to, subject, error });
throw error;
}
}
}Template Emails
Pre-built templates for common scenarios:
Verification Email
typescript
static async sendVerificationEmail(
email: string,
name: string,
token: string
): Promise<void> {
const verificationUrl = `${env.FRONTEND_URL}/verify-email?token=${token}`;
const html = `
<div class="container">
<h2>Welcome to YeboLink, ${name}!</h2>
<p>Please verify your email address:</p>
<a href="${verificationUrl}" class="button">Verify Email Address</a>
<p>Or copy this link: ${verificationUrl}</p>
<p>This link expires in 24 hours.</p>
</div>
`;
await this.sendEmail(email, 'Verify your YeboLink account', html);
}Password Reset Email
typescript
static async sendPasswordResetEmail(
email: string,
name: string,
token: string
): Promise<void> {
const resetUrl = `${env.FRONTEND_URL}/reset-password?token=${token}`;
const html = `
<div class="container">
<h2>Password Reset Request</h2>
<p>Hi ${name},</p>
<p>Click below to reset your password:</p>
<a href="${resetUrl}" class="button">Reset Password</a>
<p>This link expires in 2 hours.</p>
<p><strong>Didn't request this? Ignore this email.</strong></p>
</div>
`;
await this.sendEmail(email, 'Reset your YeboLink password', html);
}Low Balance Alert
typescript
static async sendLowBalanceAlert(
email: string,
name: string,
balance: number
): Promise<void> {
const html = `
<div class="warning">
<p><strong>Hi ${name},</strong></p>
<p>Your YeboLink balance is running low:
<strong>${balance} credits remaining</strong></p>
</div>
<a href="${env.FRONTEND_URL}/billing" class="button">Add Credits</a>
`;
await this.sendEmail(email, 'YeboLink: Low Balance Alert', html);
}Provider Abstraction Layer
The MessageProcessor routes messages to appropriate providers:
typescript
// message-processor.service.ts
static async process(messageId: string, workspaceId: string): Promise<boolean> {
const message = await MessageModel.findById(messageId, workspaceId);
try {
let providerMessageId: string | undefined;
switch (message.channel) {
case 'sms':
const senderName = message.sender || workspace.sms_sender_name || 'YeboLink';
const statusCallback = `${env.APP_URL}/api/v1/webhooks/twilio/status`;
const result = await TwilioService.sendSMS(
message.recipient,
message.content.text || '',
senderName,
statusCallback
);
providerMessageId = result.sid;
break;
case 'email':
const fromName = message.content.from_name || message.sender || 'YeboLink';
const subject = message.content.subject || 'Message from YeboLink';
const htmlContent = message.content.html || message.content.text || '';
await EmailService.sendEmail(
message.recipient,
subject,
htmlContent,
message.content.text,
fromName
);
providerMessageId = `email_${Date.now()}_${Math.random().toString(36).slice(2, 11)}`;
break;
case 'whatsapp':
throw new Error('WhatsApp not yet implemented');
case 'voice':
throw new Error('Voice not yet implemented');
default:
throw new Error(`Unsupported channel: ${message.channel}`);
}
// Update message status
await MessageModel.updateStatus(messageId, 'sent', {
provider_message_id: providerMessageId,
});
return true;
} catch (error: any) {
await MessageModel.updateStatus(messageId, 'failed', {
error_message: error.message,
});
return false;
}
}Adding New Providers
To add a new provider (e.g., Africa's Talking):
1. Create Service Class
typescript
// services/africastalking.service.ts
import AfricasTalking from 'africastalking';
export class AfricasTalkingService {
private static client = AfricasTalking({
apiKey: process.env.AT_API_KEY,
username: process.env.AT_USERNAME,
});
static async sendSMS(
to: string,
message: string,
from?: string
): Promise<{ id: string; status: string }> {
const sms = this.client.SMS;
const result = await sms.send({
to: [to],
message,
from: from || process.env.AT_SENDER_ID,
});
return {
id: result.SMSMessageData.Recipients[0].messageId,
status: result.SMSMessageData.Recipients[0].status,
};
}
}2. Add Channel Config
typescript
// In database or config
{
channel: 'sms',
provider: 'africastalking',
config: {
senderId: 'YEBOLINK',
countries: ['KE', 'UG', 'TZ'], // Where to use this provider
},
credits_per_unit: 0.8, // Cheaper in some regions
}3. Update Router in MessageProcessor
typescript
case 'sms':
// Check provider config for destination country
const country = getCountryFromPhone(message.recipient);
const providerConfig = await ChannelConfig.findForChannel('sms', country);
if (providerConfig.provider === 'africastalking') {
const result = await AfricasTalkingService.sendSMS(
message.recipient,
message.content.text,
providerConfig.config.senderId
);
providerMessageId = result.id;
} else {
// Default to Twilio
const result = await TwilioService.sendSMS(...);
providerMessageId = result.sid;
}
break;Provider Fallback Strategy
For production reliability, implement fallback:
typescript
async function sendSMSWithFallback(to: string, message: string, from?: string) {
const providers = ['twilio', 'africastalking', 'infobip'];
for (const provider of providers) {
try {
switch (provider) {
case 'twilio':
return await TwilioService.sendSMS(to, message, from);
case 'africastalking':
return await AfricasTalkingService.sendSMS(to, message, from);
case 'infobip':
return await InfobipService.sendSMS(to, message, from);
}
} catch (error) {
logger.warn(`Provider ${provider} failed, trying next`, { error });
continue;
}
}
throw new Error('All SMS providers failed');
}Provider Status Tracking
Track provider health for intelligent routing:
typescript
interface ProviderHealth {
provider: string;
successRate: number; // Last 1000 messages
avgLatencyMs: number;
lastFailure?: Date;
isHealthy: boolean;
}
async function selectProvider(channel: string, country: string): Promise<string> {
const providers = await ProviderHealth.getHealthyProviders(channel, country);
// Sort by success rate, then latency
providers.sort((a, b) => {
if (b.successRate !== a.successRate) return b.successRate - a.successRate;
return a.avgLatencyMs - b.avgLatencyMs;
});
return providers[0]?.provider || 'twilio'; // Default fallback
}