A Story About Getting Stuck on Signature Verification for Twilio ConversationRelay WebSocket Communication
This page has been translated by machine translation. View original
Introduction
While working on validating a voice AI bot using Twilio ConversationRelay with my own WebSocket server, I encountered an issue where only the signature validation for WebSocket Upgrade requests kept failing, causing calls to repeatedly disconnect with errors. The root cause was that I had reused the same URL construction method I had built for POST webhook signature validation, applying it directly to Upgrade request signature validation.
When I calculated the signature by converting the target URL to https://, it did not match the actual signature received from Twilio. It was necessary to calculate using the wss:// scheme exactly as specified in the TwiML <ConversationRelay url="wss://...">.
What is ConversationRelay WebSocket Signature Validation?
ConversationRelay is a mechanism that connects Twilio voice calls to your own server via WebSocket. On the server side, it accepts WebSocket Upgrade requests sent by Twilio in response to TwiML <Connect><ConversationRelay>. Twilio advises validating the X-Twilio-Signature on these Upgrade requests as well, in order to verify the source of the connection.
Validation Environment
- WebSocket server: Plain Node.js +
ws+twiliopackage - Node.js runtime: v24.18.0
- Deployment target: Vercel Functions
Target Audience
- Those trying to receive Twilio ConversationRelay on their own server
- Those trying to implement
X-Twilio-Signaturevalidation on WebSocket Upgrade requests - Those struggling with signature validation errors of unknown cause
References
- ConversationRelay Onboarding (Official Documentation)
- Webhooks Security (Official Documentation)
- ConversationRelay (TwiML Reference)
Issue: Calls Disconnecting Due to Signature Validation Errors
I implemented signature validation for WebSocket Upgrade requests. I reused the same URL construction method I had been using for POST webhooks: requestUrl = PUBLIC_BASE_URL + req.url (where PUBLIC_BASE_URL starts with https://).
When I actually made a call, it disconnected with error 64102 (Unable to connect to websocket URL). The ringtone would play, but the connection would not be established after the TwiML response.
Looking at the server-side logs, /voice (POST) was passing signature validation and proceeding to normal processing, but only the Upgrade request to /ws was returning a 403 with invalid_signature. The fact that the POST side was passing through while using the same TWILIO_AUTH_TOKEN became the clue for my investigation.
Investigation: Comparing the Actual Signature
I temporarily added debug logging and compared the actual signature value received from Twilio against the expected value calculated on the server side — they did not match.
So I wrote a Node.js script locally, calculated signatures using HMAC-SHA1 for multiple URL candidates, and compared them against the actually received signature.
const crypto = require('crypto');
function sign(url, params = {}) {
const data = Object.keys(params).sort().reduce((acc, key) => acc + key + params[key], url);
return crypto.createHmac('sha1', AUTH_TOKEN).update(Buffer.from(data, 'utf-8')).digest('base64');
}
const candidates = [
'https://example.vercel.app/ws',
'wss://example.vercel.app/ws',
'https://example.vercel.app/ws/',
'https://example.vercel.app:443/ws',
];
for (const url of candidates) {
console.log(url, sign(url) === RECEIVED_SIGNATURE ? 'MATCH' : 'no match');
}
Only the candidate calculated with the wss:// scheme matched the actually received signature.
Root Cause: The Scheme of the Signature Target URL
The URL specified in TwiML <ConversationRelay url="wss://..."> was being used as-is as the target for signature calculation. The conversion to https:// used in POST webhook validation did not apply to WebSocket Upgrade requests.
Fix: Updated Code
I changed only the scheme of the signature target URL from https:// to wss:// and redeployed.
function validateGetRequest(req) {
const signature = req.headers['x-twilio-signature'];
const requestUrl = PUBLIC_BASE_URL.replace(/^http/, 'ws') + req.url;
return twilio.validateRequest(AUTH_TOKEN, signature, requestUrl, {});
}
I confirmed that the setup event was successfully received in an actual call.
Summary
When implementing my own signature validation for Twilio ConversationRelay WebSocket connections, I encountered an issue where only the Upgrade request validation kept failing and calls repeatedly disconnected with errors, because I had reused the URL construction method intended for POST webhooks. The root cause was that the signature target URL needed to be calculated using the wss:// scheme exactly as specified in the TwiML.
When implementing X-Twilio-Signature validation yourself for ConversationRelay WebSocket connections, I recommend not reusing POST webhook code as-is, and instead verifying the scheme of the signature target URL.