Twilio sends webhook requests to your application when events happen — calls connect, messages are received, status changes occur, etc. This guide covers verifying and handling those requests.

Signature Verification

Every webhook request includes an X-Twilio-Signature header. Always verify it before trusting the payload to prevent spoofed requests.

Form-Encoded Webhooks

Most Twilio webhooks send form-encoded POST bodies:

url = "https://myapp.com/twilio/voice"
params = conn.params  # %{"CallSid" => "CA123", "From" => "+14158675310", ...}
signature = get_req_header(conn, "x-twilio-signature") |> List.first()
auth_token = Application.fetch_env!(:twilio_elixir, :auth_token)

if Twilio.Webhook.valid?(url, params, signature, auth_token) do
  handle_call(params)
else
  send_resp(conn, 403, "Invalid signature")
end

JSON Body Webhooks

Some newer Twilio webhooks send JSON bodies. These use a different verification method — a SHA256 hash of the body is appended to the URL:

url = "https://myapp.com/twilio/status"
body = conn.assigns.raw_body  # the raw request body string
signature = get_req_header(conn, "x-twilio-signature") |> List.first()
auth_token = Application.fetch_env!(:twilio_elixir, :auth_token)

if Twilio.Webhook.valid_body?(url, body, signature, auth_token) do
  event = JSON.decode!(body)
  handle_event(event)
else
  send_resp(conn, 403, "Invalid signature")
end

How Verification Works

Twilio's signature algorithm:

  1. Take the full webhook URL (including scheme, host, port, and path)
  2. For form-encoded bodies: sort the POST parameters alphabetically by key, then append each key-value pair to the URL
  3. For JSON bodies: compute the SHA256 hash of the raw body, append it to the URL as ?bodySHA256=<hash>
  4. HMAC-SHA1 the resulting string using your Auth Token as the key
  5. Base64-encode the result

The X-Twilio-Signature header contains this Base64-encoded HMAC. The SDK uses constant-time comparison to prevent timing attacks.

Phoenix Integration

Basic Controller

defmodule MyAppWeb.TwilioController do
  use MyAppWeb, :controller

  @auth_token Application.compile_env!(:twilio_elixir, :auth_token)

  def voice(conn, params) do
    url = current_url(conn)
    signature = get_req_header(conn, "x-twilio-signature") |> List.first()

    if Twilio.Webhook.valid?(url, params, signature, @auth_token) do
      xml = Twilio.TwiML.VoiceResponse.new()
      |> Twilio.TwiML.VoiceResponse.say("Hello! Thanks for calling.")
      |> Twilio.TwiML.VoiceResponse.to_xml()

      conn
      |> put_resp_content_type("text/xml")
      |> send_resp(200, xml)
    else
      send_resp(conn, 403, "Forbidden")
    end
  end

  def message(conn, params) do
    url = current_url(conn)
    signature = get_req_header(conn, "x-twilio-signature") |> List.first()

    if Twilio.Webhook.valid?(url, params, signature, @auth_token) do
      from = params["From"]
      body = params["Body"]
      Logger.info("SMS from #{from}: #{body}")

      xml = Twilio.TwiML.MessagingResponse.new()
      |> Twilio.TwiML.MessagingResponse.message("Thanks for your message!")
      |> Twilio.TwiML.MessagingResponse.to_xml()

      conn
      |> put_resp_content_type("text/xml")
      |> send_resp(200, xml)
    else
      send_resp(conn, 403, "Forbidden")
    end
  end

  defp current_url(conn) do
    MyAppWeb.Endpoint.url() <> conn.request_path
  end
end

Router

# lib/my_app_web/router.ex
scope "/twilio" do
  post "/voice", MyAppWeb.TwilioController, :voice
  post "/message", MyAppWeb.TwilioController, :message
end

URL Considerations

The URL used for verification must exactly match the URL Twilio sends the request to, including:

  • Scheme (https:// not http://)
  • Host (the public-facing hostname, not localhost)
  • Port (include non-standard ports like :8443)
  • Path (exact match, including trailing slashes)

If you're behind a reverse proxy or load balancer, make sure you reconstruct the URL from the original request, not the proxied one. Using your endpoint's configured URL (as shown above) is usually the safest approach.

Status Callbacks

When you create a call or message, you can specify a StatusCallback URL. Twilio will send webhooks as the resource's status changes:

{:ok, message} = Twilio.Api.V2010.MessageService.create(client, %{
  "To" => "+15551234567",
  "From" => "+15559876543",
  "Body" => "Hello!",
  "StatusCallback" => "https://myapp.com/twilio/status"
})

Status callback webhooks are verified the same way as other webhooks — check the X-Twilio-Signature header.

Common Webhook Parameters

Voice Webhooks

ParameterDescription
CallSidUnique identifier for the call
FromCaller's phone number
ToCalled phone number
CallStatusringing, in-progress, completed, busy, no-answer, failed
Directioninbound or outbound-api

Messaging Webhooks

ParameterDescription
MessageSidUnique identifier for the message
FromSender's phone number
ToRecipient's phone number
BodyMessage text
NumMediaNumber of media attachments
MediaUrl0URL of the first media attachment

Tips

  • Always verify signatures. Never trust webhook data without checking X-Twilio-Signature.
  • Respond quickly. Twilio expects a response within 15 seconds for voice webhooks or the call will fail. Process events asynchronously if needed.
  • Return TwiML. Voice and messaging webhooks expect an XML response. See the TwiML guide for building responses.
  • Handle duplicates. Network retries can cause the same webhook to arrive more than once. Use CallSid or MessageSid as an idempotency key.
  • Use HTTPS. Twilio will only send webhooks to HTTPS URLs in production. For local development, use ngrok or similar.