Reverse-engineering Five9's XML — and what we kept verbatim
· Updated · ivrloom team
When we set out to build ivrloom, the second-hardest thing to get right (after the offline-first architecture) was the Universal IR — the canonical, vendor-neutral data shape every part of the product reads and writes. The hardest part wasn’t TypeScript types. It was reverse-engineering Five9’s XML thoroughly enough to round-trip a large production IVR with high fidelity — preserving even the elements we don’t yet fully model.
What we knew before opening a real .five9ivr
Almost nothing. Five9’s public documentation describes the IVR Script Designer’s features but never publishes the XML schema. Our initial node-type list was guesses based on docs page titles — MENU, PLAY_PROMPT, SKILL_TRANSFER, RUN_SUB_SCRIPT. We even codified this list in our spec docs.
It was all wrong.
What real .five9ivr files look like
The actual element names are lowercase camelCase matching Java conventions:
<modules>
<incomingCall>...</incomingCall>
<getDigits>...</getDigits>
<case>...</case>
<foreignScript>...</foreignScript>
<ifElse>...</ifElse>
<hangup>...</hangup>
</modules>
Five9 does have a <menu> module, and ivrloom reads it — including the keypad mapping that ties each digit to an option. Digit collection for free-form input (an account number, a PIN) is a separate <getDigits> module.
Corrected 2026-09-25: an earlier version of this post said there was no menu element and that a menu was a <getDigits> with several prompts. That was wrong; real exports contain <menu> modules.
Same for sub-scripts: the element is <foreignScript>, not RUN_SUB_SCRIPT. Same for conditions: <ifElse> and <case>, not IF or SWITCH.
The four findings that changed our design
1. The <functions> block contains compressed JavaScript
This was the surprise. Some Five9 IVRs include inline JavaScript at script scope:
<functions>
<entry>
<value>
<name>NormalizePhone</name>
<returnType>STRING</returnType>
<functionBody>H4sIAAAAAAAAAE2MuwqDMBSG9zzFT6YIJbSuwYIv0...</functionBody>
</value>
</entry>
</functions>
That functionBody decodes from base64 to gzip-compressed JavaScript source. Five9 runs it at IVR runtime. Any importer that decodes but doesn’t re-compress correctly will silently break the IVR.
Our IR stores the decoded plaintext JS for human readability AND the original encoded blob byte-identical. On export, if the source is unchanged, we re-emit the original blob exactly. If the user edited the JS, we recompress deterministically (gzip level 6, mtime=0) to match Python’s gzip.compress(..., mtime=0).
2. <events> are per-module exception handlers, not separate nodes
Each input-collecting node carries inline event handlers:
<getDigits>
...
<events>
<event>NO_MATCH</event>
<count>1</count>
<action>CONTINUE</action>
</events>
</getDigits>
We initially thought these were edges. They’re not — they’re behavior attached to the parent node. Our IR models them as a property on the node, not as separate graph nodes.
Corrected 2026-09-25: the example shows <events> on a <getDigits>, as in the early sample we worked from. None of the real getDigits modules we have examined since carry any events: when the caller runs out of time, Five9 moves on to the next module, and the check on what was entered lives there. Our later post on getDigits timeouts covers this.
3. <case> is a multi-arm guarded conditional, not a switch
Each <entry> in a <case> has its own complete <conditions> block:
<case>
<data>
<branches>
<entry>
<key>billing</key>
<value>
<conditions>
<comparisonType>EQUALS</comparisonType>
<leftOperand><variableName>__BUFFER__</variableName></leftOperand>
<rightOperand><stringValue><value>1</value></stringValue></rightOperand>
</conditions>
</value>
</entry>
</branches>
</data>
</case>
This means each case arm can have its own multi-clause condition with MORE_THAN, CONTAINS, etc. — not just a simple value match. Our IR mirrors this.
4. The queue-callback feature explodes into 10+ sibling elements
A single “offer queue callback” operation exports as <callbackAllowInternational>, <callbackPhoneNumberPrompt>, <callbackConfirmationPrompt>, <callbackConfirmingPhoneNumberPrompt>, <callbackEnteringPhoneNumberPrompt>, <callbackRecordingCallerNamePrompt>, <callbackDigit>, <callbackEnterDigitsMaxTimeSec>, <callbackQueueTimeoutSec>, <callbackNumberFromCav> — all siblings, all required, all part of one logical feature.
Our IR models them as a single callbackOffer node so the flow stays navigable on the canvas. Structured field-level editors for the callback settings are on the v1.5 roadmap, but round-tripping is already lossless: the node’s original <data> block is preserved verbatim and re-emitted byte-identical on export. (Updated July 2026 — earlier versions of the serializer re-emitted an empty data block here; that gap closed with the June serializer rework.)
The lossless-fallback rule
For every Five9 element we don’t model — and there are several we haven’t gotten to yet, like <crmUpdate>’s field structure — we store the verbatim XML in vendorExtensions._rawXml on the IR node, and re-emit it byte-identical on export. Edges still parse so the graph is navigable, but the node is rendered as an opaque “vendor-specific” card in the editor.
This is what keeps the fallback honest for elements we don’t recognize: any unknown element is preserved byte-for-byte via its raw XML, so it comes out exactly as it went in. The same rule now covers the elements we do model but don’t yet offer structured editors for — queue callback and the CRM steps keep their original <data> verbatim and re-emit it byte-identical. Structured field editors for them are the top of the v1.5 list.
What we learned
If you’re ever building a tool that reads someone else’s binary or XML format: don’t design the IR until you have at least a dozen real files in hand. Our six-node guess from documentation got two-thirds of the names wrong. The actual schema was both simpler (fewer node types than expected) and richer (the <functions> JS, the inline events, the case-arm conditions).
The IR is the contract between every part of the product. Get it wrong, and you’ll either keep round-trip-corrupting customer data, or you’ll be rewriting the editor every time a real export reveals another quirk. Get it right, and everything else — the visual editor, the AI proposals, the diff view, the simulator — composes naturally on top.
If you run a Five9 IVR, this is what you get to work with: ivrloom is a browser-based Five9 IVR Script Designer alternative built on exactly this IR — see plans and pricing.