TLDR: encodeURIComponent leaves ' ! ( ) * 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:

  1. The spoken text contains a contraction — "it's", "don't", whatever.
  2. I call encodeURIComponent("it's") — this is supposed to make it URL-safe.
  3. encodeURIComponent returns "it's" — because it leaves ' unencoded. Deliberately. RFC 3986 calls it "unreserved."
  4. That raw ' goes into <Play>https://example.com/api/audio?text=it's</Play>.
  5. The XML serializer does exactly what it should — it escapes ' to &apos;. Correct behavior.
  6. Telnyx parses the XML response. It decodes &apos; back into a literal '. Also correct.
  7. Telnyx's URL fetcher now tries to fetch a URL with a literal apostrophe in the query string.
  8. 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.