How to Monitor Ex_Twilio with Vigilmon
Ex_Twilio is the Elixir client library for the Twilio API. It lets your Phoenix application send SMS messages, make voice calls, manage phone numbers, and interact with Twilio's full communications platform. Authentication codes, appointment reminders, delivery notifications, and two-factor authentication flows all pass through this library.
When Ex_Twilio calls fail, your users don't receive their verification codes. Appointment reminders go unsent. Delivery notifications disappear. These failures often go unnoticed until a user complains — by which time multiple messages may have been lost.
Vigilmon lets you detect Twilio delivery failures, API errors, and quota limits before your users do.
Why Monitor Ex_Twilio?
Twilio failures have direct business impact:
- SMS delivery failure — message status
failedorundeliveredsilently drops the message; the API call succeeds (HTTP 201) but the user never receives it - Rate limiting — Twilio enforces per-second and per-number send limits; burst traffic causes
429errors that must be retried with backoff - Account balance depletion — Twilio charges per message; an empty account balance causes all sends to fail with error code
20003 - Phone number suspension — A2P 10DLC registration issues or spam complaints can cause a Twilio number to be suspended mid-campaign
- Webhook delivery failure — Twilio sends status callbacks to your app; if your webhook endpoint returns non-2xx, Twilio retries and eventually stops delivering status updates
- API key rotation — expired or rotated auth tokens cause all API calls to fail with
401
Key Metrics to Track
| Metric | What it tells you |
|--------|-------------------|
| SMS send success rate | Ratio of queued/sent vs failed/undelivered |
| API error rate by code | Frequency of specific Twilio error codes |
| Message delivery latency | Time from send to delivered status callback |
| Account balance | Remaining balance; alert before it hits zero |
| Rate limit hits (429) | Burst traffic exceeding per-number limits |
| Webhook callback failures | Whether your status endpoint is returning 2xx |
Step 1: Add Ex_Twilio to Your Project
# mix.exs
defp deps do
[
{:ex_twilio, "~> 0.10"},
{:httpoison, "~> 2.0"}
]
end
# config/config.exs
config :ex_twilio,
account_sid: System.get_env("TWILIO_ACCOUNT_SID"),
auth_token: System.get_env("TWILIO_AUTH_TOKEN")
Basic SMS send:
# lib/my_app/sms.ex
defmodule MyApp.SMS do
require Logger
def send(to, body, opts \\ []) do
from = Keyword.get(opts, :from, System.get_env("TWILIO_PHONE_NUMBER"))
params = [to: to, from: from, body: body]
start = System.monotonic_time()
result = ExTwilio.Message.create(params)
duration = System.monotonic_time() - start
case result do
{:ok, message} ->
:telemetry.execute(
[:my_app, :twilio, :sms, :send],
%{duration: duration},
%{status: message.status, direction: :outbound}
)
Logger.info("SMS queued: #{message.sid} to #{to} status=#{message.status}")
{:ok, message}
{:error, message, http_status} ->
:telemetry.execute(
[:my_app, :twilio, :sms, :error],
%{count: 1},
%{http_status: http_status, code: extract_error_code(message)}
)
Logger.error("SMS send failed: #{inspect(message)} http_status=#{http_status}")
{:error, message}
end
end
defp extract_error_code(%{"code" => code}), do: code
defp extract_error_code(_), do: "unknown"
end
Step 2: Handle Twilio Status Callbacks
Twilio delivers message status updates (sent, delivered, failed, undelivered) to a webhook URL you configure. Track these to catch delivery failures:
# lib/my_app_web/controllers/twilio_webhook_controller.ex
defmodule MyAppWeb.TwilioWebhookController do
use MyAppWeb, :controller
require Logger
# Twilio sends form-encoded POST callbacks
def message_status(conn, params) do
message_sid = params["MessageSid"]
status = params["MessageStatus"]
error_code = params["ErrorCode"]
to = params["To"]
:telemetry.execute(
[:my_app, :twilio, :delivery, :status],
%{count: 1},
%{status: status, error_code: error_code || "none"}
)
case status do
s when s in ["failed", "undelivered"] ->
Logger.error("SMS delivery failed: sid=#{message_sid} to=#{to} status=#{s} code=#{error_code}")
"delivered" ->
Logger.info("SMS delivered: sid=#{message_sid} to=#{to}")
_ ->
:ok
end
send_resp(conn, 200, "")
end
end
# lib/my_app_web/router.ex
scope "/webhooks/twilio", MyAppWeb do
post "/message-status", TwilioWebhookController, :message_status
end
Configure the status callback URL when creating messages:
params = [
to: to,
from: from,
body: body,
status_callback: "https://your-app.example.com/webhooks/twilio/message-status"
]
Step 3: Instrument Telemetry Metrics
# lib/my_app/telemetry.ex
def metrics do
[
counter("my_app.twilio.sms.send.count", tags: [:status, :direction]),
counter("my_app.twilio.sms.error.count", tags: [:http_status, :code]),
counter("my_app.twilio.delivery.status.count", tags: [:status, :error_code]),
distribution("my_app.twilio.sms.send.duration",
unit: {:native, :millisecond},
reporter_options: [buckets: [100, 250, 500, 1000, 2000, 5000]]
)
]
end
Step 4: Check Account Balance
Build a periodic balance check to alert before the account runs dry:
# lib/my_app/twilio_account_monitor.ex
defmodule MyApp.TwilioAccountMonitor do
use GenServer
require Logger
@poll_interval :timer.minutes(15)
@balance_alert_threshold_usd 10.00
def start_link(_), do: GenServer.start_link(__MODULE__, %{}, name: __MODULE__)
def init(state) do
schedule()
{:ok, state}
end
def handle_info(:check_balance, state) do
check_balance()
schedule()
{:noreply, state}
end
def check_balance do
account_sid = System.get_env("TWILIO_ACCOUNT_SID")
case ExTwilio.Account.find(account_sid) do
{:ok, account} ->
balance = account.balance |> Decimal.new() |> Decimal.to_float()
:telemetry.execute(
[:my_app, :twilio, :account, :balance],
%{value: balance},
%{currency: account.currency}
)
if balance < @balance_alert_threshold_usd do
Logger.warning("Twilio balance low: #{account.currency} #{balance}")
end
{:ok, balance}
{:error, message, status} ->
Logger.error("Twilio balance check failed: #{inspect(message)} http=#{status}")
{:error, message}
end
end
defp schedule, do: Process.send_after(self(), :check_balance, @poll_interval)
end
Step 5: Build a Twilio Health Endpoint
# lib/my_app_web/controllers/health_controller.ex
defmodule MyAppWeb.HealthController do
use MyAppWeb, :controller
def index(conn, _params) do
balance_check = MyApp.TwilioAccountMonitor.check_balance()
checks = %{
twilio: format_twilio_check(balance_check),
database: check_db()
}
status = if twilio_healthy?(balance_check), do: 200, else: 503
conn
|> put_status(status)
|> json(%{
status: if(status == 200, do: "ok", else: "degraded"),
checks: checks
})
end
defp format_twilio_check({:ok, balance}) do
%{status: "ok", balance_usd: balance}
end
defp format_twilio_check({:error, reason}) do
%{status: "error", reason: inspect(reason)}
end
defp twilio_healthy?({:ok, balance}), do: balance > 0
defp twilio_healthy?(_), do: false
defp check_db do
case Ecto.Adapters.SQL.query(MyApp.Repo, "SELECT 1", []) do
{:ok, _} -> "ok"
_ -> "error"
end
rescue
_ -> "error"
end
end
Step 6: Create Monitors in Vigilmon
HTTP monitor for the health endpoint:
- Sign in at vigilmon.online
- Click New Monitor → HTTP
- Set URL to
https://your-app.example.com/health - Set interval to 5 minutes (Twilio balance changes slowly)
- Alert condition: status
200and body contains"twilio":{"status":"ok"}
Heartbeat monitor for SMS pipeline liveness:
Send a test SMS to a dedicated Twilio test number and ping Vigilmon only when Twilio acknowledges it:
# lib/my_app/twilio_heartbeat.ex
defmodule MyApp.TwilioHeartbeat do
use GenServer
require Logger
@heartbeat_url System.get_env("VIGILMON_TWILIO_HEARTBEAT_URL")
# Twilio magic test numbers — never charge and always succeed
@test_to "+15005550006"
@interval :timer.minutes(5)
def start_link(_), do: GenServer.start_link(__MODULE__, %{}, name: __MODULE__)
def init(state) do
schedule()
{:ok, state}
end
def handle_info(:check, state) do
params = [
to: @test_to,
from: System.get_env("TWILIO_PHONE_NUMBER"),
body: "Vigilmon heartbeat #{System.system_time(:second)}"
]
case ExTwilio.Message.create(params) do
{:ok, message} when message.status in ["queued", "sent"] ->
ping_vigilmon()
Logger.debug("Twilio heartbeat ok: #{message.sid}")
{:ok, message} ->
Logger.warning("Twilio heartbeat unexpected status: #{message.status}")
{:error, err, status} ->
Logger.error("Twilio heartbeat failed: #{inspect(err)} http=#{status}")
end
schedule()
{:noreply, state}
end
defp ping_vigilmon do
if @heartbeat_url do
:httpc.request(:get, {String.to_charlist(@heartbeat_url), []}, [{:timeout, 5000}], [])
end
end
defp schedule, do: Process.send_after(self(), :check, @interval)
end
Create a Heartbeat monitor in Vigilmon with an 8-minute expected interval. A missed heartbeat means Twilio API access is broken.
Step 7: Handle Twilio Error Codes
Specific Twilio error codes require specific responses. Map them to alerts:
# lib/my_app/twilio_error_handler.ex
defmodule MyApp.TwilioErrorHandler do
require Logger
# Twilio error codes that require immediate operator action
@critical_codes ["20003", "21610", "30008"]
def handle({:error, %{"code" => code, "message" => message}, _http_status}) do
case code do
"20003" ->
Logger.error("CRITICAL: Twilio auth failed — check TWILIO_AUTH_TOKEN rotation")
"21610" ->
Logger.error("CRITICAL: Twilio phone number suspended — check A2P 10DLC registration")
"30008" ->
Logger.warning("Twilio: Message undeliverable to #{message} — carrier block")
"429" ->
Logger.warning("Twilio rate limit hit — reduce burst send rate")
_ ->
Logger.error("Twilio error #{code}: #{message}")
end
if code in @critical_codes do
:telemetry.execute([:my_app, :twilio, :critical_error], %{count: 1}, %{code: code})
end
end
def handle(_), do: :ok
end
Step 8: Alerting
In Vigilmon, configure Notifications → New Channel:
Slack for Twilio API failure:
🔴 DOWN: Twilio SMS — MyApp
Monitor: /health returning twilio.status = "error"
Check: TWILIO_AUTH_TOKEN, account balance, and phone number status
Runbook: https://internal.example.com/runbooks/twilio-failure
PagerDuty for heartbeat miss:
A missed Twilio heartbeat means no SMS messages can be sent. If your application relies on Twilio for authentication codes or critical notifications, treat this as a P1 incident.
Balance alert:
Add a separate HTTP monitor that asserts balance_usd is above your threshold:
Monitor URL: https://your-app.example.com/health
Alert condition: body JSON path $.checks.twilio.balance_usd > 10
Common Ex_Twilio Issues and Fixes
Authentication failure (20003):
# Verify your credentials
curl -X GET "https://api.twilio.com/2010-04-01/Accounts/$TWILIO_ACCOUNT_SID.json" \
-u "$TWILIO_ACCOUNT_SID:$TWILIO_AUTH_TOKEN"
Rate limiting (429) — too many messages per second:
# Wrap sends in a rate limiter; Ex_Twilio does not retry automatically
defmodule MyApp.SMS do
def send_with_retry(to, body, opts \\ []) do
case MyApp.SMS.send(to, body, opts) do
{:error, %{"code" => "429"}, _} ->
Process.sleep(1000)
MyApp.SMS.send(to, body, opts)
result ->
result
end
end
end
Webhook not receiving status callbacks:
# Test your webhook endpoint is reachable from the internet
curl -X POST https://your-app.example.com/webhooks/twilio/message-status \
-d "MessageSid=SM123&MessageStatus=delivered&To=%2B15555555555"
Ensure the endpoint returns HTTP 200 — Twilio stops sending callbacks after repeated non-2xx responses.
Undelivered messages — carrier filtering:
Messages with URLs, marketing language, or high opt-out rates trigger carrier filtering. Register your campaign via Twilio's A2P 10DLC portal and use a messaging service SID instead of a raw phone number.
What You've Built
| What | How |
|------|-----|
| SMS send instrumentation | Telemetry on every Message.create/1 call |
| Delivery status tracking | Webhook receiving Twilio status callbacks |
| Account balance monitor | Periodic balance check with low-balance warning |
| Structured health endpoint | JSON response with Twilio status and balance |
| SMS pipeline heartbeat | Test send to Twilio magic number every 5 minutes |
| Error code handler | Specific handling for auth, rate limit, and carrier errors |
| Real-time alerting | Vigilmon HTTP monitor + PagerDuty on heartbeat miss |
Ex_Twilio makes building communications features in Elixir easy. Vigilmon makes sure those features keep delivering when your users need them most.
Next Steps
- Set up a Twilio messaging service (instead of a raw number) for better deliverability and throughput
- Add a Grafana panel for SMS delivery rate by country to identify regional carrier issues
- Use Vigilmon's status history to track SMS delivery SLA over time
- Configure separate heartbeat monitors for SMS, voice, and verification use cases
Get started free at vigilmon.online.