home / skills / amnadtaowsoam / cerebraskills / thai-sms-providers

thai-sms-providers skill

/90-thai-integrations/thai-sms-providers

This skill helps you integrate Thai SMS providers for OTP, notifications, and marketing by simplifying provider usage and retry handling.

npx playbooks add skill amnadtaowsoam/cerebraskills --skill thai-sms-providers

Review the files below or copy the command above to add this skill to your agents.

Files (1)
SKILL.md
16.7 KB
---
name: Thai SMS Providers
description: Connecting to SMS Gateway providers in Thailand for OTP, notifications, and marketing messages via ThaiBulkSMS, SMSMKT, and other providers
---

# Thai SMS Providers

## Overview

SMS remains an important channel for OTP verification and notifications in Thailand because it can reach all phone numbers without requiring internet, supporting users across all demographics.

## Why This Matters

- **Universal Reach**: All numbers can receive without internet
- **High Open Rate**: Higher than email
- **OTP Standard**: The standard for 2FA in Thailand
- **Legal Compliance**: Supports PDPA consent

---

## Core Concepts

### 1. ThaiBulkSMS Integration

```typescript
// lib/thaibulksms.ts
interface ThaiBulkSMSConfig {
  apiKey: string;
  apiSecret: string;
  sender?: string;
}

class ThaiBulkSMSClient {
  private config: ThaiBulkSMSConfig;
  private baseUrl = 'https://api-v2.thaibulksms.com';

  constructor(config: ThaiBulkSMSConfig) {
    this.config = config;
  }

  // Send single SMS
  async sendSMS(params: {
    to: string;
    message: string;
    sender?: string;
  }): Promise<SMSResult> {
    const response = await fetch(`${this.baseUrl}/sms`, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'Authorization': `Basic ${Buffer.from(`${this.config.apiKey}:${this.config.apiSecret}`).toString('base64')}`,
      },
      body: JSON.stringify({
        msisdn: this.formatPhoneNumber(params.to),
        message: params.message,
        sender: params.sender || this.config.sender || 'OTP',
      }),
    });

    const data = await response.json();

    if (data.status !== 'success') {
      throw new SMSError(data.error_code, data.message);
    }

    return {
      messageId: data.data.message_id,
      status: data.status,
      creditUsed: data.data.credit_used,
    };
  }

  // Send OTP
  async sendOTP(params: {
    to: string;
    ref?: string;
  }): Promise<OTPResult> {
    const response = await fetch(`${this.baseUrl}/otp/request`, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'Authorization': `Basic ${Buffer.from(`${this.config.apiKey}:${this.config.apiSecret}`).toString('base64')}`,
      },
      body: JSON.stringify({
        msisdn: this.formatPhoneNumber(params.to),
        ref_code: params.ref || this.generateRefCode(),
      }),
    });

    const data = await response.json();

    return {
      token: data.data.token,
      refCode: data.data.ref_code,
      expiresIn: data.data.expires_in,
    };
  }

  // Verify OTP
  async verifyOTP(params: {
    token: string;
    pin: string;
  }): Promise<boolean> {
    const response = await fetch(`${this.baseUrl}/otp/verify`, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'Authorization': `Basic ${Buffer.from(`${this.config.apiKey}:${this.config.apiSecret}`).toString('base64')}`,
      },
      body: JSON.stringify({
        token: params.token,
        pin: params.pin,
      }),
    });

    const data = await response.json();
    return data.data.verify;
  }

  // Check credit balance
  async getBalance(): Promise<number> {
    const response = await fetch(`${this.baseUrl}/credits`, {
      headers: {
        'Authorization': `Basic ${Buffer.from(`${this.config.apiKey}:${this.config.apiSecret}`).toString('base64')}`,
      },
    });

    const data = await response.json();
    return data.data.remaining_credits;
  }

  private formatPhoneNumber(phone: string): string {
    // Remove all non-digits
    let cleaned = phone.replace(/\D/g, '');

    // Convert 0x to 66x
    if (cleaned.startsWith('0')) {
      cleaned = '66' + cleaned.slice(1);
    }

    // Remove leading 66 if present, then add it back
    if (!cleaned.startsWith('66')) {
      cleaned = '66' + cleaned;
    }

    return cleaned;
  }

  private generateRefCode(): string {
    return Math.random().toString(36).substring(2, 8).toUpperCase();
  }
}
```

### 2. SMSMKT Integration

```typescript
// lib/smsmkt.ts
interface SMSMKTConfig {
  apiKey: string;
  secret: string;
  sender?: string;
}

class SMSMKTClient {
  private config: SMSMKTConfig;
  private baseUrl = 'https://api.smsmkt.com/v1';

  constructor(config: SMSMKTConfig) {
    this.config = config;
  }

  private getAuthToken(): string {
    const timestamp = Date.now().toString();
    const signature = crypto
      .createHmac('sha256', this.config.secret)
      .update(this.config.apiKey + timestamp)
      .digest('hex');

    return Buffer.from(`${this.config.apiKey}:${timestamp}:${signature}`).toString('base64');
  }

  async sendSMS(params: {
    to: string | string[];
    message: string;
    sender?: string;
    scheduleAt?: Date;
  }): Promise<SMSResult> {
    const recipients = Array.isArray(params.to) ? params.to : [params.to];

    const response = await fetch(`${this.baseUrl}/sms/send`, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'Authorization': `Bearer ${this.getAuthToken()}`,
      },
      body: JSON.stringify({
        sender: params.sender || this.config.sender,
        to: recipients.map(r => this.formatPhone(r)),
        message: params.message,
        ...(params.scheduleAt && { send_at: params.scheduleAt.toISOString() }),
      }),
    });

    const data = await response.json();

    if (!data.success) {
      throw new SMSError(data.error_code, data.error_message);
    }

    return {
      messageId: data.data.message_id,
      status: 'sent',
      recipients: data.data.recipients,
    };
  }

  async getDeliveryReport(messageId: string): Promise<DeliveryReport> {
    const response = await fetch(`${this.baseUrl}/sms/report/${messageId}`, {
      headers: {
        'Authorization': `Bearer ${this.getAuthToken()}`,
      },
    });

    const data = await response.json();

    return {
      messageId,
      status: data.data.status,
      deliveredAt: data.data.delivered_at,
      errorCode: data.data.error_code,
    };
  }

  private formatPhone(phone: string): string {
    let cleaned = phone.replace(/\D/g, '');
    if (cleaned.startsWith('0')) {
      cleaned = '66' + cleaned.slice(1);
    }
    return cleaned;
  }
}
```

### 3. Unified SMS Service

```typescript
// services/sms.service.ts
type SMSProvider = 'THAIBULKSMS' | 'SMSMKT';

interface SMSServiceConfig {
  defaultProvider: SMSProvider;
  providers: {
    THAIBULKSMS?: ThaiBulkSMSConfig;
    SMSMKT?: SMSMKTConfig;
  };
}

class SMSService {
  private providers: Map<SMSProvider, ThaiBulkSMSClient | SMSMKTClient>;
  private defaultProvider: SMSProvider;

  constructor(config: SMSServiceConfig) {
    this.defaultProvider = config.defaultProvider;
    this.providers = new Map();

    if (config.providers.THAIBULKSMS) {
      this.providers.set('THAIBULKSMS', new ThaiBulkSMSClient(config.providers.THAIBULKSMS));
    }
    if (config.providers.SMSMKT) {
      this.providers.set('SMSMKT', new SMSMKTClient(config.providers.SMSMKT));
    }
  }

  async sendOTP(phone: string): Promise<OTPSession> {
    // Rate limiting
    const rateKey = `otp:rate:${phone}`;
    const attempts = await redis.incr(rateKey);
    await redis.expire(rateKey, 3600); // 1 hour window

    if (attempts > 5) {
      throw new RateLimitError('Too many OTP requests');
    }

    // Generate OTP
    const otp = this.generateOTP();
    const refCode = this.generateRefCode();
    const expiresAt = new Date(Date.now() + 5 * 60 * 1000); //5 minutes

    // Store OTP
    const session = await prisma.otpSession.create({
      data: {
        phone: this.normalizePhone(phone),
        otp: await bcrypt.hash(otp, 10),
        refCode,
        expiresAt,
        attempts: 0,
      },
    });

    // Send SMS
    const message = `Your OTP code is ${otp} (Ref: ${refCode}) Expires in 5 minutes`;

    try {
      await this.sendSMS(phone, message);
    } catch (error) {
      // Fallback to another provider
      const fallbackProvider = this.getFallbackProvider();
      if (fallbackProvider) {
        await this.sendSMS(phone, message, fallbackProvider);
      } else {
        throw error;
      }
    }

    return {
      sessionId: session.id,
      refCode,
      expiresAt,
    };
  }

  async verifyOTP(sessionId: string, otp: string): Promise<boolean> {
    const session = await prisma.otpSession.findUnique({
      where: { id: sessionId },
    });

    if (!session) {
      throw new Error('OTP session not found');
    }

    if (session.expiresAt < new Date()) {
      throw new Error('OTP expired');
    }

    if (session.attempts >= 3) {
      throw new Error('Too many failed attempts');
    }

    const isValid = await bcrypt.compare(otp, session.otp);

    if (!isValid) {
      await prisma.otpSession.update({
        where: { id: sessionId },
        data: { attempts: { increment: 1 } },
      });
      return false;
    }

    // Mark as verified
    await prisma.otpSession.update({
      where: { id: sessionId },
      data: { verifiedAt: new Date() },
    });

    return true;
  }

  async sendSMS(
    to: string,
    message: string,
    provider?: SMSProvider
  ): Promise<void> {
    const selectedProvider = provider || this.defaultProvider;
    const client = this.providers.get(selectedProvider);

    if (!client) {
      throw new Error(`SMS provider ${selectedProvider} not configured`);
    }

    // Log outgoing SMS
    const log = await prisma.smsLog.create({
      data: {
        to: this.normalizePhone(to),
        message,
        provider: selectedProvider,
        status: 'PENDING',
      },
    });

    try {
      const result = await client.sendSMS({ to, message });

      await prisma.smsLog.update({
        where: { id: log.id },
        data: {
          status: 'SENT',
          messageId: result.messageId,
        },
      });
    } catch (error) {
      await prisma.smsLog.update({
        where: { id: log.id },
        data: {
          status: 'FAILED',
          error: error.message,
        },
      });
      throw error;
    }
  }

  async sendBulkSMS(params: {
    recipients: string[];
    message: string;
    scheduleAt?: Date;
  }): Promise<BulkSMSResult> {
    const results: SMSResult[] = [];
    const chunks = this.chunkArray(params.recipients, 100);

    for (const chunk of chunks) {
      const client = this.providers.get(this.defaultProvider) as SMSMKTClient;
      const result = await client.sendSMS({
        to: chunk,
        message: params.message,
        scheduleAt: params.scheduleAt,
      });
      results.push(result);
    }

    return {
      totalSent: params.recipients.length,
      results,
    };
  }

  private generateOTP(): string {
    return Math.floor(100000 + Math.random() * 900000).toString();
  }

  private generateRefCode(): string {
    return Math.random().toString(36).substring(2, 6).toUpperCase();
  }

  private normalizePhone(phone: string): string {
    let cleaned = phone.replace(/\D/g, '');
    if (cleaned.startsWith('66')) {
      cleaned = '0' + cleaned.slice(2);
    }
    return cleaned;
  }

  private chunkArray<T>(array: T[], size: number): T[][] {
    const chunks: T[][] = [];
    for (let i = 0; i < array.length; i += size) {
      chunks.push(array.slice(i, i + size));
    }
    return chunks;
  }

  private getFallbackProvider(): SMSProvider | null {
    for (const [provider] of this.providers) {
      if (provider !== this.defaultProvider) {
        return provider;
      }
    }
    return null;
  }
}
```

### 4. API Routes

```typescript
// api/otp/send/route.ts
import { NextRequest, NextResponse } from 'next/server';

const smsService = new SMSService({
  defaultProvider: 'THAIBULKSMS',
  providers: {
    THAIBULKSMS: {
      apiKey: process.env.THAIBULKSMS_API_KEY!,
      apiSecret: process.env.THAIBULKSMS_API_SECRET!,
      sender: 'MyApp',
    },
  },
});

export async function POST(req: NextRequest) {
  try {
    const { phone } = await req.json();

    if (!phone) {
      return NextResponse.json({ error: 'Phone required' }, { status: 400 });
    }

    const session = await smsService.sendOTP(phone);

    return NextResponse.json({
      sessionId: session.sessionId,
      refCode: session.refCode,
      expiresAt: session.expiresAt,
    });
  } catch (error) {
    if (error instanceof RateLimitError) {
      return NextResponse.json({ error: error.message }, { status: 429 });
    }
    return NextResponse.json({ error: 'Failed to send OTP' }, { status: 500 });
  }
}

// api/otp/verify/route.ts
export async function POST(req: NextRequest) {
  try {
    const { sessionId, otp } = await req.json();

    const isValid = await smsService.verifyOTP(sessionId, otp);

    if (!isValid) {
      return NextResponse.json({ error: 'Invalid OTP' }, { status: 400 });
    }

    // Generate token or proceed with registration
    return NextResponse.json({ success: true });
  } catch (error) {
    return NextResponse.json({ error: error.message }, { status: 400 });
  }
}
```

### 5. React OTP Component

```tsx
// components/OTPVerification.tsx
'use client';

import { useState } from 'react';

interface OTPVerificationProps {
  phone: string;
  onVerified: () => void;
}

export function OTPVerification({ phone, onVerified }: OTPVerificationProps) {
  const [sessionId, setSessionId] = useState<string | null>(null);
  const [refCode, setRefCode] = useState<string | null>(null);
  const [otp, setOtp] = useState('');
  const [error, setError] = useState<string | null>(null);
  const [countdown, setCountdown] = useState(0);

  const requestOTP = async () => {
    try {
      const res = await fetch('/api/otp/send', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ phone }),
      });

      if (!res.ok) {
        const data = await res.json();
        throw new Error(data.error);
      }

      const data = await res.json();
      setSessionId(data.sessionId);
      setRefCode(data.refCode);
      setCountdown(60);

      // Start countdown
      const interval = setInterval(() => {
        setCountdown(c => {
          if (c <= 1) clearInterval(interval);
          return c - 1;
        });
      }, 1000);
    } catch (err) {
      setError(err.message);
    }
  };

  const verifyOTP = async () => {
    try {
      const res = await fetch('/api/otp/verify', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ sessionId, otp }),
      });

      if (!res.ok) {
        const data = await res.json();
        throw new Error(data.error);
      }

      onVerified();
    } catch (err) {
      setError(err.message);
    }
  };

  return (
    <div className="space-y-4">
      {!sessionId ? (
        <button
          onClick={requestOTP}
          className="w-full bg-blue-600 text-white py-2 rounded"
        >
          Send OTP Code
        </button>
      ) : (
        <>
          <p className="text-sm text-gray-600">
            Reference code: <span className="font-mono">{refCode}</span>
          </p>

          <input
            type="text"
            value={otp}
            onChange={(e) => setOtp(e.target.value.replace(/\D/g, '').slice(0, 6))}
            placeholder="Enter 6-digit OTP"
            className="w-full p-2 border rounded text-center text-2xl tracking-widest"
            maxLength={6}
          />

          <button
            onClick={verifyOTP}
            disabled={otp.length !== 6}
            className="w-full bg-green-600 text-white py-2 rounded disabled:opacity-50"
          >
            Verify OTP
          </button>

          {countdown > 0 ? (
            <p className="text-sm text-gray-500 text-center">
              Resend available in {countdown} seconds
            </p>
          ) : (
            <button
              onClick={requestOTP}
              className="w-full text-blue-600 text-sm"
            >
              Resend OTP
            </button>
          )}
        </>
      )}

      {error && (
        <p className="text-red-500 text-sm">{error}</p>
      )}
    </div>
  );
}
```

## Quick Start

1. **Register account:**
   - [ThaiBulkSMS](https://www.thaibulksms.com/)
   - [SMSMKT](https://www.smsmkt.com/)

2. **Get API credentials**

3. **Register Sender ID** (if custom sender is needed)

4. **Implement SMS client**

5. **Test with real phone numbers**

## Production Checklist

- [ ] Rate limiting implemented
- [ ] OTP expiration handling
- [ ] Failed attempt tracking
- [ ] Fallback provider configured
- [ ] SMS logs stored
- [ ] Delivery reports monitored
- [ ] Credit balance alerting
- [ ] PDPA consent tracking

## Anti-patterns

1. **Not hashing OTP**: Must hash before storing
2. **OTP without expiration**: Must have expiration
3. **No attempt limit**: Risk of brute force
4. **Hardcoded credentials**: Use environment variables

## Further Reading

- [ThaiBulkSMS API Docs](https://developer.thaibulksms.com/)
- [SMSMKT API Docs](https://www.smsmkt.com/api-docs/)
- [PDPA Guidelines](https://www.pdpc.or.th/)

Overview

This skill connects applications to Thai SMS gateway providers for OTP, transactional notifications, and marketing messages. It supports ThaiBulkSMS and SMSMKT, unified sending, OTP lifecycle management, rate limiting, and provider fallback for reliability. The implementation focuses on phone normalization, delivery logging, and simple OTP session handling.

How this skill works

The skill wraps provider SDKs to format Thai phone numbers, authenticate requests, and call SMS and OTP endpoints. A unified service chooses a default provider, logs outbound messages, stores OTP sessions (with hashed codes and expiry), enforces rate limits, and falls back to alternate providers on failure. Bulk sending chunks recipients and uses provider-specific scheduling and delivery report APIs.

When to use it

  • Send single or bulk SMS to Thai phone numbers for OTP, alerts, or campaigns
  • Implement two-factor authentication (OTP) with server-side session management
  • Ensure high deliverability with automatic provider fallback
  • Schedule marketing or transactional campaigns with recipient chunking
  • Log and track delivery status and provider message IDs

Best practices

  • Normalize and validate phone numbers to Thailand format before sending
  • Hash OTPs in storage and enforce short expiry and attempt limits
  • Implement rate limiting per phone to prevent abuse (example: 5/hour)
  • Log provider responses and errors for reconciliation and retries
  • Use provider-specific features (scheduling, delivery reports, sender names) and respect PDPA consent for marketing messages
  • Monitor credit/balance and rotate providers to avoid single points of failure

Example use cases

  • User registration flow: send OTP via SMS, verify code, then mark user verified
  • Password reset: generate a short-lived OTP and deliver via default provider with fallback
  • Transactional alerts: send payment, delivery, or system alerts to users’ phones
  • Marketing campaign: schedule bulk SMS to segmented recipients using chunked sends
  • Delivery reconciliation: poll delivery reports and update message logs with statuses

FAQ

Which providers are included?

ThaiBulkSMS and SMSMKT are implemented; the architecture supports adding additional providers with the same client interface.

How are phone numbers formatted?

Numbers are cleaned of non-digits and normalized to Thailand format (leading 0 or country code handled consistently before sending).

What protection exists against OTP abuse?

The service enforces rate limits per phone, short OTP expiry (example 5 minutes), and limits on verification attempts (example 3 attempts).

How does fallback work when a provider fails?

If sending fails, the service attempts to send through a configured fallback provider and records failures in the SMS log for retries or alerting.