Every other webhook sender gives you a number and expects you to live with it. Shopify's five seconds, GitHub's ten, Slack's three. You can complain about them but you cannot change them.
Twilio is the exception. It exposes the timeouts as parameters on the webhook itself, along with a retry count and a policy for which failures deserve a retry. Five dials, all shipped with defaults, and in most integrations nobody has ever touched them.
That is a missed opportunity in both directions, because the defaults are wrong for the two most common cases.
TL;DR
ctconnect timeout: default 5000ms, range 100 to 10000rtread timeout: default 15000ms, range 100 to 15000tttotal time including retries: default 15000ms, range 100 to 15000rcretry count: 0 to 5, default 1rpretry policy: which failure types are worth retrying- The ceilings are hard. You cannot buy more than 15 seconds of read timeout at any price
Three timeouts, not one
Most people carry one mental model of a webhook timeout: how long the sender waits for a response. Twilio splits it into three stages, and knowing which one you are hitting changes the diagnosis completely.
ct is "the timeout in milliseconds Twilio will wait to establish its TCP connection to your web server." Default 5000ms, maximum 10000ms. Hitting this means Twilio could not open a socket at all. That is DNS, TLS, a firewall, or an origin that is fully down. It is never your handler being slow, because your handler has not run.
rt is "the amount of time in milliseconds that Twilio will wait for the initial HTTP response packet after the webhook request is sent." Default 15000ms, maximum 15000ms. This is the one people mean when they say timeout, and it is your handler's actual budget.
tt is "the total time allowed for all timeouts including retries." Default 15000ms, maximum 15000ms. This is the ceiling on the entire attempt sequence, and it is the parameter that catches people out, because it constrains retries too.
Notice that tt and rt share both the same default and the same maximum. With the defaults as shipped, one read timeout consumes the entire total-time budget, and the retry you thought you had configured never gets a chance to run. If you want retries to be meaningful, you have to lower rt so that two or more attempts fit inside tt.
That interaction is the single most useful thing to understand here, and it is not obvious from reading the parameters one at a time.
Setting them so retries actually happen
Suppose your handler normally answers in 400ms and you want two attempts before giving up.
rt = 3000 // 3s per attempt, generous against a 400ms handler
rc = 2 // two retries
tt = 12000 // room for three attempts plus connect overhead
ct = 3000 // fail fast if the socket will not open
rp = ct,rt,5xx
Now a hung handler is abandoned at three seconds instead of fifteen, and the retry lands while the incident is still the same incident. Compare that with the defaults, where a single hung request burns all fifteen seconds and the configured retry silently never fires.
The retry policy is worth being deliberate about. Retrying 4xx is usually wrong, because a 400 or a 404 means the request itself is unacceptable and sending it again will produce the same answer. Retry connect timeouts, read timeouts, and 5xx, which are the failures that are plausibly transient. This is the same reasoning as Exponential Backoff and Jitter for Webhook Retries, applied to a sender that lets you express it in configuration.
The fallback URL is the other half
Twilio's documentation notes that shorter timeouts help "speed up failover (to fallback URL) in the case of an outage on your server/application."
The fallback URL is a second endpoint Twilio calls when the primary fails, and it is the closest thing any major sender offers to a built-in circuit breaker. It only helps if the timeouts are short enough to reach it while the caller is still on the line, which is exactly why rt and ct matter for voice and messaging in a way they do not for a payment webhook.
Point it at something that cannot fail for the same reason the primary did. A fallback on the same host, behind the same load balancer, sharing the same database, is not a fallback. Static TwiML served from an object store is.
The case the dials do not solve
There is a category Twilio's configuration cannot rescue, and it is increasingly the common one.
An inbound SMS arrives and you want an LLM to write the reply. That call takes twenty to sixty seconds. The read timeout ceiling is fifteen, and you cannot raise it. There is no combination of rt, tt, and rc that makes a sixty-second handler fit inside a fifteen-second maximum.
The answer is the same one as for Slack: acknowledge and respond separately. Return empty TwiML immediately so Twilio's clock stops, then send the actual reply through the Messages API when the model finishes.
export async function POST(req: Request) {
const raw = await req.text(); // raw body, for the signature
if (!verifyTwilioSignature(raw, req.headers.get("x-twilio-signature"))) {
return new Response("unauthorized", { status: 403 });
}
await queue.publish(raw); // durable, ~10ms
// Empty TwiML: acknowledged, nothing to say yet
return new Response('<?xml version="1.0" encoding="UTF-8"?><Response></Response>', {
headers: { "content-type": "text/xml" },
});
}
The worker then calls messages.create when it has an answer. The user sees a reply arrive a few seconds later, which is normal for a text conversation. Twilio saw a response in 40 milliseconds. The wider version of this argument is in Webhooks for AI Agents.
For voice, the equivalent is returning TwiML that plays a message or enqueues the caller while the real work happens, because a caller listening to silence is a worse failure than a slow SMS.
Signature verification is different here
X-Twilio-Signature is not a plain HMAC over the body. Twilio builds the signed string from the full request URL plus, for form-encoded requests, the POST parameters sorted by key and concatenated. Then HMAC-SHA1 with your auth token, base64 encoded.
Two practical consequences.
The URL is part of the signature, so anything that rewrites it breaks verification. A proxy that strips a port, a load balancer that changes the scheme from https to http, or a framework that reconstructs the URL from headers will all produce a valid-looking request that fails the check. If verification fails in production and passes locally, compare the exact URL your code is signing against the one Twilio called.
It is SHA1, not SHA256, and the input is not the raw body for form-encoded requests. Use Twilio's own validateRequest helper rather than adapting a Stripe-style verifier, because the construction genuinely is different. The comparison across providers is in Verifying Stripe, GitHub, and Shopify Webhook Signatures.
Where AnyHook fits
The dials are useful and they have a hard ceiling. Fifteen seconds is the most Twilio will ever wait, and plenty of legitimate handlers need more than that.
Point the webhook at in.anyhook.net/you/twilio and the ceiling stops being your ceiling. AnyHook answers in under 50ms from the edge, comfortably inside even an aggressively lowered rt, then delivers to your handler with a budget of 60 to 300 seconds depending on plan.
- Retries against your endpoint run on exponential backoff and for far longer than
ttpermits - Every event is persisted before delivery, so a failed handler is a replay rather than a lost message
- Failure alerts at 1, 5, and 20 consecutive failures, with auto-pause at 20
If you need TwiML in the response, keep that in your own handler. A relay is the right shape when the webhook is a notification rather than a request for instructions.
Takeaway
Twilio gives you more control over webhook timing than any other major sender, and the defaults are set for a handler that answers instantly. Lower rt so retries fit inside tt, set rp to skip 4xx, and point the fallback URL at something with a different failure mode.
Then accept the ceiling for what it is. Fifteen seconds is the maximum, and if your handler needs a minute, no configuration will get you there. Acknowledge first and reply out of band.