Skip to main content

Migrating off SMPP (EthioTelecomSmsSender) onto a JSON HTTP API

EthioTelecomSmsSender opens a long-lived TCP socket to a wholesale SMSC over SMPP 3.4. It works, but operationally:

  • The TCP bind needs reconnect logic + heartbeat handling.
  • Long messages need explicit segmentation via message_payload TLV.
  • Non-Latin scripts (Amharic, Turkish, Arabic) require UCS-2 BE encoding handled at the sender layer.
  • Carrier sender-ID registration is per-account.

Modern aggregators publish a plain JSON HTTP API. This runbook documents the swap.

Eligible providers

The default HttpSmsSender payload shape { to, from, message } matches:

ProviderSender ID handlingAuthNotes
Africa's Talkingfrom is the registered short code or alphanumeric.apiKey header apiKey: xxx.Bulk + premium routes via different URLs.
TwilioFrom (capital F) is the registered phone or alphanumeric sender.Basic auth Authorization: Basic <b64(sid:token)>.Override payload() to rename toTo, fromFrom, messageBody.
MessageBirdoriginator is the alphanumeric sender.Authorization: AccessKey xxx.Override payload() to rename fromoriginator.
Infobipmessages[0].destinations[0].to + messages[0].from + messages[0].text.Authorization: App xxx.Override payload() to wrap in the messages[] shape.

Anything that doesn't fit the default shape: subclass HttpSmsSender and override the protected payload(to, message) method.

The migration steps

1. Pick a provider + register the sender ID

Most providers require sender-ID pre-registration for unsolicited A2P messaging. Without it, carriers reject or fine the messages.

2. Set env vars

SMS_PROVIDER=http
SMS_HTTP_URL=https://api.africastalking.com/version1/messaging
SMS_HTTP_AUTH="apiKey YOUR_KEY" # provider-specific format
SMS_SENDER_ID=DemozPay # the registered ID

The boot-time refine on AppConfig (config.ts:superRefine) fails fast if SMS_PROVIDER=http and either SMS_HTTP_URL or SMS_SENDER_ID is unset.

3. Restart the API

SmsModule is @Global() — the factory at sms.module.ts re-resolves SMS_SENDER to HttpSmsSender based on the env value. Consumers that inject SMS_SENDER (auth's phone-OTP plugin, notifications, support broadcasts) pick up the change without code edits.

4. Verify

# Trigger a phone-OTP flow via better-auth, watch the api logs:
curl -X POST http://localhost:8000/api/auth/sign-in/phone-number \
-H 'Content-Type: application/json' \
-d '{"phoneNumber":"+251911234567"}'

Logs from HttpSmsSender:

HTTP SMS sent to=********4567 from="DemozPay" → ATXrqXr7...

The trailing token is the provider's message_id — persisted for delivery-receipt correlation.

Per-provider subclass example — Twilio

@Injectable()
export class TwilioSmsSender extends HttpSmsSender {
protected override payload(to: string, message: string) {
return { To: `+${to}`, From: this.senderId, Body: message };
}
}

Then bind:

{ provide: SMS_SENDER, useExisting: TwilioSmsSender }

Rollback

Set SMS_PROVIDER=ethio-telecom (or back to logger for dev) and restart. The SMPP code is still in the codebase — nothing was deleted.

What this DOESN'T close

  • Delivery receipts. Most providers post DLRs back to a webhook URL you configure. Wiring a POST /api/sms/dlr callback is a separate piece of work — until then, the message_id is persisted but the delivery state is not.
  • Rate limits. Provider APIs throttle per second / per day. The default HttpSmsSender does not implement client-side rate limiting; let the provider's 429s flow up and let the caller-side retry handle it.
  • Carrier sender-ID compliance. Registration is a manual, per-country process.