Troubleshooting Twilio Webhooks: Why Your SMS Status Callbacks Are Failing

Last updated March 1, 202611 min read
TL;DR

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 3000 to 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.

twilio-send-sms.js
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/status

Now 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:

  1. Your Auth Token
  2. The full URL Twilio is posting to (including protocol, domain, path, and query string)
  3. 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:

twilio-signature.js
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-vs-right.js
// ❌ 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:

StatusMeaning
queuedMessage accepted by Twilio, waiting to be sent
sentHanded off to the carrier network
deliveredCarrier confirmed delivery to the handset
undeliveredCarrier could not deliver (invalid number, blocked, etc.)
failedTwilio 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:

CodeMeaning
30001Queue overflow — too many messages queued
30003Unreachable destination handset
30005Unknown destination handset
30006Landline or unreachable carrier
30007Message 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:

complete-handler.js
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:

  1. Create a Hooklistener endpoint to see the raw request without any server-side processing
  2. Inspect the Content-Type — confirm it's application/x-www-form-urlencoded
  3. Check the X-Twilio-Signature header — if it's present, Twilio successfully reached your URL
  4. Read the form body — see exactly which parameters Twilio sent, including MessageStatus, ErrorCode, and MessageSid
  5. 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.