TLDR:
encodeURIComponentleaves' ! ( ) *unencoded. When your URL goes into XML,'gets escaped to'— then decoded back to a literal'before the fetcher ever sees it. Encode those five characters yourself, or you'll spend an afternoon wondering why your call is silent.
What I Was Building
Apollo — my personal AI agent — can now place phone calls.
The stack: Telnyx (a telephony API), TeXML (Telnyx's XML-based call-control dialect, similar to Twilio's TwiML), and ElevenLabs (AI voice synthesis) to speak the actual audio.
The flow is straightforward: Apollo hits /call, my webhook generates a TeXML response with a <Play> tag pointing at /api/audio?text=..., Telnyx fetches the audio URL, ElevenLabs renders the speech, caller hears it.
Cheap telephony with a voice that doesn't sound like a robot. Basically GENIUS.
The Wall
Call connects. Rings. Picks up.
Silence.
Not an error. Not a hang-up. Just… nothing. The call stayed open. No audio ever played.
What I Chased First
I stared at the webhook response.
The URL in <Play> looked completely fine. text=don%27t for the apostrophe in "don't"... except it didn't look like that. It looked like text=don't. I stared at it for longer than I want to admit, convinced the problem was somewhere else — Telnyx config, ElevenLabs auth, outbound voice profile.
It wasn't any of those things.
Where the Bug Actually Lived
Here's the chain, step by step:
- The spoken text contains a contraction —
"it's","don't", whatever. - I call
encodeURIComponent("it's")— this is supposed to make it URL-safe. encodeURIComponentreturns"it's"— because it leaves'unencoded. Deliberately. RFC 3986 calls it "unreserved."- That raw
'goes into<Play>https://example.com/api/audio?text=it's</Play>. - The XML serializer does exactly what it should — it escapes
'to'. Correct behavior. - Telnyx parses the XML response. It decodes
'back into a literal'. Also correct. - Telnyx's URL fetcher now tries to fetch a URL with a literal apostrophe in the query string.
- It silently fails. Call connects. No audio.
The XML escaping was right. The bug was that I handed it a URL that was never fully encoded in the first place.
The Fix (Two Lines)
const encoded = encodeURIComponent(text)
.replace(/[!'()*]/g, c => '%' + c.charCodeAt(0).toString(16).toUpperCase());
That .replace post-processes the five characters encodeURIComponent intentionally skips: ! ' ( ) *.
Apostrophe becomes %27. Every time. Before the URL ever touches XML.
After that commit, the audio played on the first try.
Memorize These Five Characters
! ' ( ) *
MDN lists them explicitly. Two of them — ' and * — will burn you.
And this isn't just a Telnyx thing. The same trap is live anywhere a URL gets embedded in something XML-shaped: TwiML, RSS, OPML, sitemaps, Atom feeds, EPUB, SOAP. The escaping is always "correct" on both sides — the serializer and the parser do their jobs perfectly. The URL just needed to be clean before it went in.
The lesson I'm taking out of this: after any encodeURIComponent whose output is going into XML, run the post-process. Make it a habit. It costs nothing and saves the exact flavor of debugging that will make you question your entire setup before the answer turns out to be a single apostrophe.
P.S. Debug tip: if you suspect this is happening, round-trip your XML response through a parser and check the actual bytes the consumer receives after decode. Don't trust the pretty-printed string — trust what comes out the other side.