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=+15555550000Alternatively 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
Bodyis sent. - Only
Title→Titleis 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
- WhatsApp channel — same client, different prefix
- Channels & Providers
- Send a notification