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_payloadTLV. - 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:
| Provider | Sender ID handling | Auth | Notes |
|---|---|---|---|
| Africa's Talking | from is the registered short code or alphanumeric. | apiKey header apiKey: xxx. | Bulk + premium routes via different URLs. |
| Twilio | From (capital F) is the registered phone or alphanumeric sender. | Basic auth Authorization: Basic <b64(sid:token)>. | Override payload() to rename to→To, from→From, message→Body. |
| MessageBird | originator is the alphanumeric sender. | Authorization: AccessKey xxx. | Override payload() to rename from→originator. |
| Infobip | messages[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/dlrcallback 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
HttpSmsSenderdoes 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.