IVR Routing Architecture¶
Canonical reference for how inbound IVR calls flow through Asterisk on Astradial, what the dialplan generator emits per IVR, and the non-obvious behaviours that have bitten us. Read this before touching dialplanGenerator.generateIvrExtension, the IVR endpoints in server.js, or the IVR builder UI.
See also: Queue Routing Architecture for the queue side of the flow.
Call path for an IVR call¶
PSTN → Tata NNI → NUC Asterisk → (WireGuard) → Cloud Asterisk:
1. tata-did-route (match DID → org context)
2. org_<ctx>__incoming (Answer, MOH, CDR setup, MixMonitor)
3. Goto(org_<ctx>__ivr, <num>, 1) (DID config routes to IVR)
4. org_<ctx>__ivr <ivr.extension> extension:
- Set __ORG_ID, __IVR_ID
- Set CHANNEL(hangup_handler_push)
- Set IVR_RETRIES=0
- n(start), Background(greetings/<prompt_name>)
- WaitExten(<timeout>)
5a. Digit pressed → matches `exten => N,1,Goto(...)` for that digit
— routes to queue / extension / ai_agent / hangup / etc.
5b. No keypress → falls into `exten => t,...` (timeout handler)
5c. Invalid digit → falls into `exten => i,...` (invalid handler)
The IVR is a per-org dialplan context (org_<ctx>__ivr) with one extension per active IVR row. Menu options come from the ivr_menus table — one row per digit binding.
IVR row shape¶
| Column | Purpose |
|---|---|
extension | Dialplan extension number (e.g. 7001) — what the dialplan emits |
greeting_prompt | Filename (sans extension) of the TTS-generated .ulaw greeting under /var/lib/asterisk/sounds/greetings/ |
timeout | WaitExten seconds — how long to wait for DTMF after greeting |
max_retries | Number of times the greeting plays before timeout_action fires (see semantics below) |
timeout_action | One of retry, queue, extension, hangup — what to do once max_retries is exhausted |
timeout_destination | Queue number or internal extension for queue / extension actions |
timeout_prompt | Optional Playback file for hangup action (defaults to pm-invalid-option) |
invalid_prompt | Optional Playback file for invalid-digit handler |
greeting_voice, greeting_language, tts_model, style_instructions | TTS generation parameters |
Timeout (no-keypress) semantics¶
max_retries applies to every timeout_action, not just retry. The unified emit is:
exten => t,1,NoOp(IVR Timeout — action=<action>)
exten => t,n,Set(IVR_RETRIES=$[${IVR_RETRIES} + 1])
exten => t,n,GotoIf($[${IVR_RETRIES} < <maxRetries>]?<ivr.extension>,start)
exten => t,n,<terminal action emitted by timeout_action>
timeout_action | Terminal action (runs only after max_retries exhausted) |
|---|---|
queue | Goto(org_<ctx>__queue, <destination>, 1) |
extension | Goto(org_<ctx>__internal, <destination>, 1) |
hangup | Playback(<timeout_prompt or pm-invalid-option>) + Hangup() |
retry (default / legacy) | Same as hangup — after exhausting retries, play prompt + hangup |
Operator-facing model: Max retries = "how many times the greeting plays before giving up". A call with max_retries=2 + action=queue plays the greeting twice, then on the second timeout goes to the queue. max_retries=1 preserves the legacy "fire on first timeout" behaviour.
Historical note¶
Earlier versions short-circuited queue/extension/hangup to fire on the FIRST timeout regardless of max_retries. That made the Max retries field a silent no-op for those actions and confused operators who set max_retries=2 + Go to queue expecting two greeting plays — instead got immediate queue routing. Fixed; the unified emit above is the current behaviour.
Invalid input (wrong digit) semantics¶
exten => i,1,NoOp(Invalid Input)
exten => i,n,Set(IVR_RETRIES=$[${IVR_RETRIES} + 1])
exten => i,n,GotoIf($[${IVR_RETRIES} < <maxRetries>]?retry:maxretries)
exten => i,n(retry),Playback(<invalid_prompt or 'invalid'>)
exten => i,n,Goto(<ivr.extension>,start)
exten => i,n(maxretries),Playback(goodbye)
exten => i,n,Hangup()
IVR_RETRIES is shared with the t handler — pressing an invalid digit and then timing out both increment the same counter, so the call won't spend twice the budget.
Save-and-deploy lifecycle (critical)¶
Every IVR-modifying endpoint must redeploy + reload Asterisk after writing the DB. The bug we hit when this was missing: editor "Save" updated the row, but Asterisk kept serving the old dialplan until the operator clicked "Publish" explicitly.
| Endpoint | Trigger | Auto-deploys? |
|---|---|---|
PUT /api/v1/ivrs/:id | Editor saves general settings | ✅ yes |
PUT /api/v1/ivrs/:id/menu | Editor saves entry blocks | ✅ yes |
POST /api/v1/ivrs/:id/generate-greeting | TTS regenerate | ✅ yes |
POST /api/v1/ivrs/:id/publish | Explicit "Force redeploy" | ✅ yes (now redundant for normal save flow but kept as fallback) |
The auto-deploy chain is configDeploymentService.deployOrganizationConfiguration (rewrites the org's ext_<ctx>.conf) followed by reloadAsteriskConfiguration (AMI dialplan reload). Failure is logged but does not fail the save — the row commits regardless so the operator's edit isn't lost; the next save (or a manual publish) catches it up.
Greeting file format¶
TTS service writes to /var/lib/asterisk/sounds/greetings/greeting_<id>.{ulaw,alaw} (raw 8 kHz, no header). The IVR row stores greeting_prompt = greeting_<id> and the dialplan emits Background(greetings/<prompt_name>). Asterisk's format_g711 reads the .ulaw or .alaw directly without resampling.
See MOH Architecture for the same format convention applied to hold music.
Gotchas¶
- Background needs the
greetings/prefix. Bare filename leaves Asterisk searching/var/lib/asterisk/sounds/<lang>/and silently failing (call gets immediate WaitExten with no audio). Fixed in the generator; never emitBackground(<prompt_name>)without the subdir. - WaitExten(0) = wait forever. The generator uses
??not||sotimeout=0survives — if an operator explicitly sets it to 0, the IVR waits indefinitely. This is intentional for IVRs that shouldn't time out. max_retries=1skips retry.GotoIf($[1 < 1])is false on first hit; control falls through to the terminal action. This is the explicit escape hatch for orgs that want immediate routing.timeout_actionvalidation lives invalidateIvrNumericinserver.js. Add new values there before adding to the dialplan generator switch.__IVR_IDis set on the channel so the hangup handler can log which IVR was active. Don't remove this without auditing the hangup handler + CDR enrichment.
Where to read more¶
api/src/services/asterisk/dialplanGenerator.js→generateIvrExtension,generateIvrMenuOptionsapi/src/server.js→ IVR routes (search for/api/v1/ivrs)api/tests/dialplan-generator.test.js→ D27-D32 cover themax_retries-for-all-actions invariant