Edesy Conversation Relay connects an inbound call on your Indian number to your bot over a WebSocket using Twilio's ConversationRelay protocol. Your bot receives transcripts and sends text back; Edesy runs speech recognition, speech synthesis, barge-in and DTMF. A bot written for Twilio works unchanged.
Text only
No audio code in your bot
Compatible
Twilio ConversationRelay protocol
Barge-in
Tunable per number
Preflight
Test without a call
ConversationRelay-compatible
Speech handled by Edesy
Indian numbers
INR wallet + GST invoices
| Protocol | Twilio ConversationRelay wire protocol: JSON text frames over one WebSocket per call |
| Your bot handles | Text only: transcripts in, reply tokens out |
| Edesy handles | Speech recognition, speech synthesis, barge-in and DTMF detection |
| Calls | Inbound calls to your Edesy number |
| Setup | Dashboard (When a call comes in > AI voice bot, text in, text out) or PATCH /v1/phone-numbers/{id}/routing |
| Settings | Welcome greeting, voice, language, interruptible, min_bargein_word_count, DTMF detection, custom parameters |
| Mid-call | Switch language with a language message; play audio; send DTMF; end with handoffData |
| Preflight | POST /v1/relay-test: DNS, TCP, TLS, upgrade and protocol checks |
| Call events | call.relay.connected, call.relay.failed, call.relay.handoff, call.relay.usage |
| Billing | Prepaid INR wallet. Relay includes speech processing, so it is priced separately from plain call minutes: ask us for the rate |
No audio pipeline, SIP stack or telephony code on your side
Accept the connection, read setup, answer each prompt by streaming text tokens, and set last: true on the final one. The Node sample is about 90 lines.
POST /v1/relay-test with your relay_url. You get a dns, tcp, tls, upgrade and protocol checklist, and a hint if your bot never sends last: true.
In the dashboard choose AI voice bot (text in, text out) under When a call comes in, or PATCH the number's routing with inbound_target_type relay.
GET /v1/calls/{sid}/events shows call.relay.connected or call.relay.failed with the reason, the bot's handoff, and usage per call.
Every frame is JSON with a type.
| Type | When | Fields |
|---|---|---|
setup |
Once, before anything is spoken | sessionId, callSid, from, to, direction, callType, customParameters |
prompt |
The caller finished an utterance | voicePrompt, lang, last |
interrupt |
The caller talked over the bot | utteranceUntilInterrupt, durationUntilInterruptMs |
dtmf |
One keypress (when DTMF detection is on) | digit |
error |
A platform-side problem, informational | description |
| Type | Effect | Fields |
|---|---|---|
text |
Speak it. Stream tokens as your LLM produces them | token, last, interruptible, preemptible |
play |
Play an audio file, then resume the conversation | source, loop |
sendDigits |
Send DTMF out of the call (w waits half a second) |
digits |
language |
Switch speech and transcription language mid-call | ttsLanguage, transcriptionLanguage |
end |
End the call. handoffData is recorded as a call.relay.handoff event |
handoffData |
Set last: true on the final token of a turn. It flushes the speech synthesizer. Without it the end of every reply is never spoken. The relay test reports this for you.
Handle interrupt. When the caller barges in, unplayed speech is discarded, so your history holds words the caller never heard. Trim the last turn to utteranceUntilInterrupt.
Accept the WebSocket upgrade quickly. The connection attempt is bounded (3 seconds by default) and a fallback line is spoken if it expires. Authenticate at the upgrade, not after it.
Sent as inbound_relay_config. Sending it replaces the stored object; it is not merged.
| Field | Default | Notes |
|---|---|---|
welcome_greeting |
none | Spoken on connect, before the caller speaks. Omit it to let your bot speak first. |
tts_voice |
platform default | The voice your bot speaks with. |
tts_language / stt_language |
en-IN |
Speech and transcription language. |
dtmf_detection |
false |
Keypresses interrupt the bot and arrive as dtmf. |
interruptible |
true |
Whether the caller can talk over the bot. |
min_bargein_word_count |
1 |
Words of caller speech needed to interrupt. 0 interrupts on any voice activity. |
custom_parameters |
none | Passed through unchanged in setup. |
The routing update stores the relay URL and settings on the number and re-points its call handling at the relay. The relay test exchanges one turn with your bot before any caller does.
# Point a number at your relay bot
curl -X PATCH https://voice-api.edesy.in/v1/phone-numbers/$PHONE_NUMBER_ID/routing \
-H "Authorization: Bearer $EDESY_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"inbound_target_type": "relay",
"inbound_relay_url": "wss://bot.example.com/relay",
"inbound_relay_config": {
"welcome_greeting": "Hi, thanks for calling Acme. How can I help?",
"tts_language": "en-IN",
"stt_language": "en-IN",
"interruptible": true,
"min_bargein_word_count": 1,
"dtmf_detection": true,
"custom_parameters": {"store": "pune"}
}
}'
# Preflight: one turn of conversation from Edesy's network, no call
curl -X POST https://voice-api.edesy.in/v1/relay-test \
-H "Authorization: Bearer $EDESY_API_KEY" \
-H "Content-Type: application/json" \
-d '{"relay_url": "wss://bot.example.com/relay", "prompt": "what are your hours", "seconds": 5}'Adapted from the conversation-relay-edesy sample: it receives text and sends text. Replace replyTo() with your LLM and it is a working phone bot.
// Minimal relay bot (Node.js, ws). Swap replyTo() for your LLM call.
const { WebSocketServer } = require('ws');
const wss = new WebSocketServer({ port: 8787, path: '/relay' });
wss.on('connection', (ws) => {
const history = [];
ws.on('message', (raw) => {
const msg = JSON.parse(raw);
switch (msg.type) {
case 'setup': // once per call: callSid, from, to, customParameters
break;
case 'prompt': // the caller finished speaking
history.push({ role: 'user', text: msg.voicePrompt });
speak(ws, replyTo(msg.voicePrompt, history));
break;
case 'interrupt': // trim the bot's last turn to what was heard
if (history.at(-1)?.role === 'bot') history.at(-1).text = msg.utteranceUntilInterrupt;
break;
case 'dtmf':
speak(ws, `You pressed ${msg.digit}.`);
break;
}
});
});
function speak(ws, text) {
const words = text.split(' ');
words.forEach((w, i) => ws.send(JSON.stringify({
type: 'text', token: i ? ` ${w}` : w,
last: i === words.length - 1, // flushes speech synthesis
})));
}The audio side of a phone conversation
Each caller utterance arrives as a prompt with the transcript in voicePrompt and the language in lang.
Stream reply tokens as your LLM produces them; Edesy speaks them in the voice and language set on the number.
Callers can talk over the bot. Tune it with interruptible and min_bargein_word_count, and get an interrupt with what was heard.
With dtmf_detection on, keypresses arrive as dtmf messages. sendDigits presses keys out of the call, for example into another IVR.
Send a language message to change ttsLanguage and transcriptionLanguage without dropping the call.
end closes the call and records your handoffData as a call.relay.handoff event your systems can read.
Full comparison: choosing SIP, stream or relay
You have an LLM and want to exchange text. Edesy runs the speech pipeline.
You run your own speech recognition, synthesis and barge-in, for example in Pipecat, and want raw L16 audio.
Your voice AI platform already takes SIP calls: LiveKit, Vapi, Retell, Bolna and others.
Explore more comparisons, alternatives, and resources
Tell us about your bot and your expected monthly minutes. We'll share the relay rate and help with the first call.
Point an Indian number at your relay bot and test it from our network before the first caller rings.