Tutorials

SMS API Abstraction Layer: How to Support Multiple SMS Providers With One Interface

Learn to build a robust sms api abstraction layer india in TypeScript/Node.js. Implement the adapter pattern to switch SMS gateways and prevent downtime.

StartMessaging Team Updated

Hardcoding a single API provider directly into your core application is a technical risk. If that provider experiences an outage, changes their authentication headers, or increases their message delivery fees, your engineering team must scramble to update and redeploy production code.

For Indian applications where deliverability directly impacts user retention, building an sms api abstraction layer india is a best practice. By decoupling your notification engine from specific gateway vendors, you can switch providers instantly, run parallel routes, and build carrier-level failover systems. This tutorial shows you how to design and implement a provider-agnostic SMS interface in TypeScript/Node.js using the Adapter Pattern.

Why Hardcoding a Single Provider is a Production Risk

Many startup backends start with a single file that calls a specific provider’s API directly. For example, a signup route might import a helper function that makes a POST request to Twilio or MSG91. This tight coupling introduces immediate reliability issues.

If a telecom route becomes congested, your code cannot react dynamically. You cannot route OTPs through StartMessaging while sending marketing updates through a cheaper carrier. If you decide to migrate to a new provider to take advantage of flat rates like ₹0.25/OTP, your developers must search the codebase for every API reference, update the variable payloads, and verify each endpoint.

An abstraction layer addresses this coupling. It defines a uniform contract that your business logic interacts with. The actual communication with the SMS gateway is isolated inside provider-specific adapter classes.

Designing the SMS Adapter Interface

We will start by defining the type definitions and interfaces for our abstraction layer. We need a unified input payload and a uniform response schema, regardless of which gateway executes the request.

Create a file named sms-provider.interface.ts in your services folder.

// sms-provider.interface.ts

export interface SmsPayload {
  phoneNumber: string;
  variables: {
    otp: string;
    appName?: string;
    [key: string]: any;
  };
  templateId?: string;
}

export interface SmsResponse {
  success: boolean;
  messageId: string;
  providerName: string;
  error?: string;
}

export interface SmsProvider {
  sendSms(payload: SmsPayload): Promise<SmsResponse>;
}

The SmsProvider interface forces every adapter class to implement the sendSms method. This method accepts our uniform SmsPayload and returns a standardized SmsResponse.

Implementing Concrete Provider Adapters

Now, we will implement concrete adapters for StartMessaging, MSG91, and Twilio. Each class will translate our unified payload into the payload format expected by that specific provider’s API.

1. The StartMessaging Adapter

// startmessaging.adapter.ts
import { SmsProvider, SmsPayload, SmsResponse } from './sms-provider.interface';

export class StartMessagingAdapter implements SmsProvider {
  private apiKey: string;
  private baseUrl = 'https://api.startmessaging.com/otp/send';

  constructor(apiKey: string) {
    this.apiKey = apiKey;
  }

  async sendSms(payload: SmsPayload): Promise<SmsResponse> {
    try {
      const response = await fetch(this.baseUrl, {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
          'X-API-Key': this.apiKey
        },
        body: JSON.stringify({
          phoneNumber: payload.phoneNumber,
          templateId: payload.templateId,
          variables: payload.variables
        })
      });

      const result = await response.json();

      if (!response.ok) {
        return {
          success: false,
          messageId: '',
          providerName: 'StartMessaging',
          error: result.message || 'API request failed'
        };
      }

      return {
        success: true,
        messageId: result.data.messageId,
        providerName: 'StartMessaging'
      };
    } catch (err: any) {
      return {
        success: false,
        messageId: '',
        providerName: 'StartMessaging',
        error: err.message
      };
    }
  }
}

2. The MSG91 Adapter

// msg91.adapter.ts
import { SmsProvider, SmsPayload, SmsResponse } from './sms-provider.interface';

export class Msg91Adapter implements SmsProvider {
  private authKey: string;
  private templateId: string;
  private baseUrl = 'https://control.msg91.com/api/v5/otp';

  constructor(authKey: string, defaultTemplateId: string) {
    this.authKey = authKey;
    this.templateId = defaultTemplateId;
  }

  async sendSms(payload: SmsPayload): Promise<SmsResponse> {
    const url = `${this.baseUrl}?template_id=${payload.templateId || this.templateId}&mobile=${payload.phoneNumber}&otp=${payload.variables.otp}`;
    
    try {
      const response = await fetch(url, {
        method: 'POST',
        headers: {
          'authkey': this.authKey,
          'accept': 'application/json'
        }
      });

      const result = await response.json();

      if (result.type !== 'success') {
        return {
          success: false,
          messageId: '',
          providerName: 'MSG91',
          error: result.message || 'Failed to dispatch'
        };
      }

      return {
        success: true,
        messageId: result.request_id || 'msg91_sent',
        providerName: 'MSG91'
      };
    } catch (err: any) {
      return {
        success: false,
        messageId: '',
        providerName: 'MSG91',
        error: err.message
      };
    }
  }
}

3. The Twilio Adapter

// twilio.adapter.ts
import { SmsProvider, SmsPayload, SmsResponse } from './sms-provider.interface';

export class TwilioAdapter implements SmsProvider {
  private accountSid: string;
  private authToken: string;
  private fromNumber: string;

  constructor(accountSid: string, authToken: string, fromNumber: string) {
    this.accountSid = accountSid;
    this.authToken = authToken;
    this.fromNumber = fromNumber;
  }

  async sendSms(payload: SmsPayload): Promise<SmsResponse> {
    const url = `https://api.twilio.com/2010-04-01/Accounts/${this.accountSid}/Messages.json`;
    const authHeader = 'Basic ' + Buffer.from(`${this.accountSid}:${this.authToken}`).toString('base64');
    
    // Twilio expects urlencoded body structures
    const bodyParams = new URLSearchParams();
    bodyParams.append('To', payload.phoneNumber);
    bodyParams.append('From', this.fromNumber);
    bodyParams.append('Body', `Your verification code is ${payload.variables.otp}`);

    try {
      const response = await fetch(url, {
        method: 'POST',
        headers: {
          'Authorization': authHeader,
          'Content-Type': 'application/x-www-form-urlencoded'
        },
        body: bodyParams
      });

      const result = await response.json();

      if (!response.ok) {
        return {
          success: false,
          messageId: '',
          providerName: 'Twilio',
          error: result.message || 'Twilio send failure'
        };
      }

      return {
        success: true,
        messageId: result.sid,
        providerName: 'Twilio'
      };
    } catch (err: any) {
      return {
        success: false,
        messageId: '',
        providerName: 'Twilio',
        error: err.message
      };
    }
  }
}

Implementing the Multi-Provider Coordinator

Now that we have adapters, we can build a coordinator class. This class acts as the main gateway interface for your application. It manages provider initialization, handles active-passive failover logic, and logs sending status.

// sms-coordinator.service.ts
import { SmsProvider, SmsPayload, SmsResponse } from './sms-provider.interface';

export class SmsCoordinatorService {
  private providers: SmsProvider[] = [];

  constructor(providers: SmsProvider[]) {
    if (providers.length === 0) {
      throw new Error('SmsCoordinator requires at least one registered provider.');
    }
    this.providers = providers;
  }

  async dispatch(payload: SmsPayload): Promise<SmsResponse> {
    let lastError = '';

    // Loop through registered providers sequentially in case of failures
    for (const provider of this.providers) {
      const result = await provider.sendSms(payload);
      
      if (result.success) {
        console.log(`SMS delivered using provider: ${result.providerName}`);
        return result;
      }

      console.warn(`Provider ${result.providerName} failed to deliver. Error: ${result.error}`);
      lastError = result.error || 'Unknown error';
    }

    return {
      success: false,
      messageId: '',
      providerName: 'Coordinator',
      error: `All SMS providers failed. Last error: ${lastError}`
    };
  }
}

With this coordination service, if your primary provider (e.g., StartMessaging) returns a failure status due to an authentication error or wallet depletion, the coordinator shifts execution to the secondary provider (e.g., MSG91 or Twilio) automatically.

Mocking and Testing the Abstraction Layer

Writing unit tests for authentication or onboarding flows should not trigger real SMS deliveries. Because our coordinator runs on an interface dependency, we can inject a mock adapter class in our test environment.

Here is an example of testing a registration workflow using Jest:

// registration.service.test.ts
import { SmsProvider, SmsPayload, SmsResponse } from './sms-provider.interface';
import { SmsCoordinatorService } from './sms-coordinator.service';

// Implement a mock provider for local testing
class MockSmsProvider implements SmsProvider {
  public sentMessages: SmsPayload[] = [];

  async sendSms(payload: SmsPayload): Promise<SmsResponse> {
    this.sentMessages.push(payload);
    return {
      success: true,
      messageId: 'mock_message_123',
      providerName: 'MockProvider'
    };
  }
}

describe('Registration Service Tests', () => {
  it('should trigger verification code send during signup', async () => {
    const mockProvider = new MockSmsProvider();
    const smsCoordinator = new SmsCoordinatorService([mockProvider]);

    const userPhone = '+919876543210';
    const samplePayload: SmsPayload = {
      phoneNumber: userPhone,
      variables: { otp: '883011' }
    };

    const result = await smsCoordinator.dispatch(samplePayload);

    expect(result.success).toBe(true);
    expect(mockProvider.sentMessages.length).toBe(1);
    expect(mockProvider.sentMessages[0].phoneNumber).toBe(userPhone);
    expect(mockProvider.sentMessages[0].variables.otp).toBe('883011');
  });
});

Mocking the provider ensures your test suite executes quickly, requires no external credentials, and prevents billing charges for fake test numbers.

Frequently Asked Questions

Q: Should the SMS abstraction layer live inside the application or as a separate microservice?

A: For small to medium systems, hosting the abstraction layer as a shared service class inside your monolith codebase is sufficient. For distributed microservice architectures, deploying a dedicated messaging service that exposes an internal HTTP or gRPC API is a better design.

Q: How do you handle provider-specific error codes uniformly?

A: Each provider has distinct error responses. Your adapter classes should parse these errors and translate them into a standardized enum (e.g., INVALID_NUMBER, INSUFFICIENT_FUNDS, RATE_LIMIT_EXCEEDED) before returning the response to the coordinator.

Q: Does using multiple providers mean I have to register on multiple DLT portals?

A: Yes, if you use standard gateways. You must register your business headers and templates across each gateway’s operator portal. However, by routing through StartMessaging as your primary provider, you can bypass local DLT registration requirements and start sending immediately.

Q: Can this pattern work with message queues?

A: Yes. In production systems, you should avoid calling your SMS abstraction service synchronously within HTTP request cycles. Instead, queue the send job (using BullMQ or RabbitMQ) and allow background worker processes to execute the coordinator’s dispatch method.

Ready to build a resilient messaging architecture? Sign up at StartMessaging to get your API keys and test your first provider adapter.

S

StartMessaging Team

StartMessaging Team

Related posts