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")
endJSON 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")
endHow Verification Works
Twilio's signature algorithm:
- Take the full webhook URL (including scheme, host, port, and path)
- For form-encoded bodies: sort the POST parameters alphabetically by key, then append each key-value pair to the URL
- For JSON bodies: compute the SHA256 hash of the raw body, append it to the
URL as
?bodySHA256=<hash> - HMAC-SHA1 the resulting string using your Auth Token as the key
- 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
endRouter
# lib/my_app_web/router.ex
scope "/twilio" do
post "/voice", MyAppWeb.TwilioController, :voice
post "/message", MyAppWeb.TwilioController, :message
endURL Considerations
The URL used for verification must exactly match the URL Twilio sends the request to, including:
- Scheme (
https://nothttp://) - 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
| Parameter | Description |
|---|---|
CallSid | Unique identifier for the call |
From | Caller's phone number |
To | Called phone number |
CallStatus | ringing, in-progress, completed, busy, no-answer, failed |
Direction | inbound or outbound-api |
Messaging Webhooks
| Parameter | Description |
|---|---|
MessageSid | Unique identifier for the message |
From | Sender's phone number |
To | Recipient's phone number |
Body | Message text |
NumMedia | Number of media attachments |
MediaUrl0 | URL 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
CallSidorMessageSidas an idempotency key. - Use HTTPS. Twilio will only send webhooks to HTTPS URLs in production. For local development, use ngrok or similar.