Last updated 2026-05-28

SMS channel

The SMS channel delivers a text message via Twilio's Messages API. The same Twilio Client backs both this provider and the WhatsApp provider — the channel-specific rules live on the provider, not the client.

When you'd use this

OTP codes, urgent alerts, anything that needs to reach a user when they don't have your app installed and aren't reading email.

Configuration

-e NOTIFY_SMS_PROVIDER=twilio \
-e NOTIFY_SMS_ACCOUNT_SID=ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx \
-e NOTIFY_SMS_AUTH_TOKEN=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx \
-e NOTIFY_SMS_FROM=+15555550000

Alternatively configure a Messaging Service for sender pools / pool-of-numbers:

-e NOTIFY_SMS_PROVIDER=twilio \
-e NOTIFY_SMS_ACCOUNT_SID=ACxxx \
-e NOTIFY_SMS_AUTH_TOKEN=xxx \
-e NOTIFY_SMS_MESSAGING_SERVICE_SID=MGxxx

When both From and MessagingServiceSID are set, the provider prefers the Messaging Service.

Library wiring

import (
"github.com/elloloop/notify"
"github.com/elloloop/notify/channels/twilio"
)
client := twilio.NewClient(twilio.ClientConfig{
AccountSID: "ACxxx",
AuthToken: "xxx",
From: "+15555550000",
})
registry := notify.NewProviderRegistry()
registry.Register(twilio.NewSMS(client))

Sending an SMS

curl -sX POST http://notify:8081/elloloop.notify.v1.NotificationInternalService/Notify \
-H 'Content-Type: application/json' \
-H "X-Notify-Internal-Token: $NOTIFY_INTERNAL_TOKEN" \
-d '{
"tenantId": "acme",
"notificationId": "otp-2026-05-27-001",
"userIds": ["user-alice"],
"channels": ["DELIVERY_CHANNEL_SMS"],
"title": "",
"body": "Your code: 472918",
"addresses": {
"user-alice": {
"byChannel": {
"sms": "+15555550199"
}
}
}
}'

Response:

{"delivered": 1, "pending": 0, "failed": 0}

Validation

The provider does a cheap structural E.164 check before calling Twilio — empty, missing +, non-digit body, or length outside 6..15 short-circuits to StatusFailed:

func validateE164(s string) error {
if s == "" {
return errors.New("To: empty recipient address")
}
if !strings.HasPrefix(s, "+") {
return fmt.Errorf("To: %q must be E.164 (must start with '+')", s)
}
digits := s[1:]
if len(digits) < 6 || len(digits) > 15 {
return fmt.Errorf("To: %q has invalid length", s)
}
for _, r := range digits {
if r < '0' || r > '9' {
return fmt.Errorf("To: %q contains non-digit characters", s)
}
}
return nil
}

This is intentionally structural, not a phone-number validity check — Twilio remains the authority on whether a number can actually receive SMS. We just avoid burning a request on obvious garbage.

Body composition

SMS does not carry a separate subject line. The provider collapses Title and Body with Body winning when non-empty:

  • Both supplied → only Body is sent.
  • Only TitleTitle is sent.
  • Neither → empty body; Twilio rejects with code 21602.

Testing with httptest

Because the provider uses Go's net/http via an injectable http.Client, you can swap the Twilio endpoint for an httptest.Server and drive any response shape from a unit test. The full test lives in channels/twilio/sms_test.go; abbreviated shape:

func TestSMSSend(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if got := r.PostFormValue("To"); got != "+15555550199" {
t.Fatalf("To = %q", got)
}
if got := r.PostFormValue("From"); got != "+15555550000" {
t.Fatalf("From = %q", got)
}
if got := r.PostFormValue("Body"); got != "Your code: 472918" {
t.Fatalf("Body = %q", got)
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"sid":"SM-test-123"}`))
}))
defer srv.Close()
client := twilio.NewClient(twilio.ClientConfig{
AccountSID: "ACxxx", AuthToken: "xxx", From: "+15555550000",
BaseURL: srv.URL, // overrides https://api.twilio.com
})
provider := twilio.NewSMS(client)
receipt, err := provider.Send(context.Background(), notify.Message{
To: "+15555550199",
Title: "",
Body: "Your code: 472918",
})
if err != nil { t.Fatal(err) }
if receipt.ProviderMessageID != "SM-test-123" {
t.Fatalf("SID = %q", receipt.ProviderMessageID)
}
}

Receipts and error handling

On success the receipt carries Twilio's SID as ProviderMessageID and StatusDelivered. On failure the wrapped error is propagated and the row is recorded as StatusFailed.

Related