tutorial

How to Monitor Ex_Twilio with Vigilmon

Track Ex_Twilio SMS delivery rates, API error responses, and phone number health in your Elixir application, and use Vigilmon to alert when Twilio calls fail or message delivery degrades.

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 failed or undelivered silently 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 429 errors 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:

  1. Sign in at vigilmon.online
  2. Click New Monitor → HTTP
  3. Set URL to https://your-app.example.com/health
  4. Set interval to 5 minutes (Twilio balance changes slowly)
  5. Alert condition: status 200 and 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.

Monitor your app with Vigilmon

Free plan — 5 monitors, no credit card required. Up and running in 60 seconds.

Start free →