Troubleshooting Twilio Webhooks: Why Your SMS Status Callbacks Are Failing
Most Twilio status callback failures come from one of these:
- Your callback URL isn't publicly reachable (localhost, firewall, no HTTPS)
- X-Twilio-Signature validation fails because the URL used for validation doesn't exactly match the configured URL (trailing slashes, http vs https)
- Your handler expects JSON but Twilio sends
application/x-www-form-urlencoded - Your handler doesn't return a 2xx status code, so Twilio retries forever
- Use
hooklistener tunnel --port 3000to receive Twilio callbacks locally
You send an SMS via Twilio, set a StatusCallback URL, and... nothing arrives. No errors in Twilio's console, no requests hitting your server, no logs anywhere. This is the most common Twilio webhook frustration, and it's almost always caused by one of a handful of issues. This guide covers every one of them.
How Twilio webhooks work
Twilio uses two distinct callback mechanisms that are often confused:
Request URL (on the phone number)
Called when an inbound SMS or call arrives. Configured on the phone number in the Twilio Console. Twilio expects TwiML in response.
StatusCallback (on the message/call)
Called when the status of an outbound message or call changes (queued → sent → delivered / failed). Set per-message when you create it via the API. Twilio does not expect TwiML — just a 2xx response.
Both are delivered as HTTP POST requests with Content-Type: application/x-www-form-urlencoded — not JSON. This catches many developers off guard.
const client = require("twilio")(accountSid, authToken);
const message = await client.messages.create({
to: "+15551234567",
from: "+15559876543",
body: "Your order has shipped!",
statusCallback: "https://k7x2m9.hook.events/twilio/status"
});
console.log("Message SID:", message.sid);Twilio will POST to your statusCallback URL each time the message status changes. The body includes fields like MessageSid, MessageStatus, To, From, and ErrorCode.
1. StatusCallback URL not reachable
The most common issue: your callback URL points to localhost, is behind a firewall, or doesn't have HTTPS. Twilio silently drops callbacks it can't deliver — there's no error message in the console.
Fix this by using a Hooklistener tunnel to make your local server reachable:
# Start your handler
node server.js # listening on port 3000
# Create a public HTTPS URL
hooklistener login
hooklistener tunnel --port 3000
# Use the tunnel URL as your StatusCallback
# https://k7x2m9.hook.events/twilio/statusNow Twilio can reach your handler, and you can see every request in the Hooklistener CLI's live log.
Info:Check Twilio's debugger at console.twilio.com/develop/debugger for failed callback attempts. Twilio logs connection timeouts and non-2xx responses there, but only after the fact.
2. X-Twilio-Signature validation failing
Twilio signs every webhook request with the X-Twilio-Signature header. The signature is computed from:
- Your Auth Token
- The full URL Twilio is posting to (including protocol, domain, path, and query string)
- All POST parameters, sorted alphabetically by key, with key and value concatenated
The validation URL must exactly match what Twilio used. Common mismatches:
Trailing slash mismatch
https://example.com/twilio/status and https://example.com/twilio/status/ produce different signatures. Use whichever one you registered with Twilio.
HTTP vs HTTPS
If a reverse proxy terminates TLS, your app might see http:// but Twilio signed with https://. Use the original public URL for validation.
Port numbers
If your callback URL includes a port (e.g. :8080), the port must be included in the validation URL too.
Here's correct signature validation using the official Twilio library:
const twilio = require("twilio");
app.post("/twilio/status", express.urlencoded({ extended: false }), (req, res) => {
const signature = req.headers["x-twilio-signature"];
// Use the FULL public URL — not localhost
const url = "https://k7x2m9.hook.events/twilio/status";
const isValid = twilio.validateRequest(
process.env.TWILIO_AUTH_TOKEN,
signature,
url,
req.body // POST params as object
);
if (!isValid) {
console.error("Twilio signature validation failed");
return res.status(403).send("Forbidden");
}
console.log("Status update:", req.body.MessageStatus, "for", req.body.MessageSid);
res.status(200).send();
});Important:When using a tunnel, validate against the tunnel URL (e.g. https://k7x2m9.hook.events/twilio/status), not http://localhost:3000/twilio/status. Twilio signed with the public URL it POSTed to.
3. Expecting JSON (Twilio sends form-encoded)
Twilio sends all webhooks as application/x-www-form-urlencoded, not JSON. If your handler uses express.json() or expects req.body to be a JSON object, the body will be empty or unparsed.
// ❌ Wrong — this won't parse Twilio's form-encoded body
app.use(express.json());
app.post("/twilio/status", (req, res) => {
console.log(req.body); // {} or undefined
});
// ✅ Correct — parse form-encoded data
app.post("/twilio/status", express.urlencoded({ extended: false }), (req, res) => {
console.log(req.body.MessageStatus); // "delivered"
console.log(req.body.MessageSid); // "SM..."
res.status(200).send();
});If you're using a Hooklistener endpoint to inspect the raw payload, you'll see the form-encoded body clearly in the dashboard — this is a fast way to confirm what Twilio is actually sending.
4. Not returning a 2xx status code
Twilio interprets any non-2xx response as a failure and will retry the callback. For status callbacks, Twilio retries up to 3 times with exponential backoff. For incoming message/call webhooks, Twilio falls back to the Fallback URL if configured.
Common causes of non-2xx responses:
- Unhandled exception in your handler (Express returns 500)
- Returning TwiML for a status callback (not needed — just return 200)
- Your handler returns a response body but forgets to set the status code
- Middleware rejecting the request before your handler runs (CORS, auth middleware, etc.)
Info:For incoming message webhooks (the Request URL on your phone number), Twilio does expect TwiML in the response body. But for status callbacks, just return an empty 200. No TwiML needed.
5. Understanding SMS status values
The MessageStatus field in status callbacks follows this progression:
| Status | Meaning |
|---|---|
queued | Message accepted by Twilio, waiting to be sent |
sent | Handed off to the carrier network |
delivered | Carrier confirmed delivery to the handset |
undelivered | Carrier could not deliver (invalid number, blocked, etc.) |
failed | Twilio could not send (e.g. invalid "To" number, account suspended) |
You'll typically receive 2-3 callbacks per message as it progresses through these states. Common confusion: sent does not mean delivered. A message can be sent but then become undelivered if the carrier rejects it.
When MessageStatus is undelivered or failed, check the ErrorCode field. Common error codes:
| Code | Meaning |
|---|---|
30001 | Queue overflow — too many messages queued |
30003 | Unreachable destination handset |
30005 | Unknown destination handset |
30006 | Landline or unreachable carrier |
30007 | Message filtered (spam/carrier filter) |
6. Twilio's retry behavior and fallback URLs
Understanding how Twilio retries failed webhooks prevents surprises:
- Status callbacks: Retried up to 3 times with exponential backoff if your server returns a non-2xx response or times out (15 second timeout).
- Incoming message/call webhooks: Not retried. Instead, Twilio immediately falls back to the Fallback URL if configured on the phone number. If no fallback is set, the inbound message/call gets an error response.
- Connection timeout: Twilio waits 15 seconds for your server to respond before considering it a failure.
A complete handler that covers all the gotchas:
const express = require("express");
const twilio = require("twilio");
const app = express();
// Status callback handler — no TwiML needed
app.post("/twilio/status",
express.urlencoded({ extended: false }),
(req, res) => {
try {
const { MessageSid, MessageStatus, ErrorCode, To } = req.body;
console.log(`[${MessageSid}] ${MessageStatus}${ErrorCode ? ` (error: ${ErrorCode})` : ""} → ${To}`);
// Handle terminal states
if (MessageStatus === "delivered") {
// Mark as delivered in your database
} else if (MessageStatus === "failed" || MessageStatus === "undelivered") {
// Alert, retry with a different provider, etc.
}
// Always return 200 — even if you don't care about this status
res.status(200).send();
} catch (err) {
console.error("Status callback error:", err);
res.status(200).send(); // Still return 200 to prevent retries
}
}
);
app.listen(3000);Important:Return 200 even when your handler encounters an error processing the callback. If you return 500, Twilio will retry — and your handler will fail again with the same payload, potentially creating duplicate side effects.
7. Debugging Twilio callbacks with Hooklistener
When Twilio callbacks aren't working and you're not sure why, Hooklistener gives you visibility into exactly what Twilio is sending:
- Create a Hooklistener endpoint to see the raw request without any server-side processing
- Inspect the Content-Type — confirm it's
application/x-www-form-urlencoded - Check the X-Twilio-Signature header — if it's present, Twilio successfully reached your URL
- Read the form body — see exactly which parameters Twilio sent, including
MessageStatus,ErrorCode, andMessageSid - Use the tunnel for local development — forward Twilio callbacks to your local handler and see every request in the CLI log
The Hooklistener CLI shows method, path, status, and response time for every request — making it immediately clear whether your handler is receiving callbacks and what it's returning.