Skip to content

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 emit Background(<prompt_name>) without the subdir.
  • WaitExten(0) = wait forever. The generator uses ?? not || so timeout=0 survives — if an operator explicitly sets it to 0, the IVR waits indefinitely. This is intentional for IVRs that shouldn't time out.
  • max_retries=1 skips 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_action validation lives in validateIvrNumeric in server.js. Add new values there before adding to the dialplan generator switch.
  • __IVR_ID is 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.jsgenerateIvrExtension, generateIvrMenuOptions
  • api/src/server.js → IVR routes (search for /api/v1/ivrs)
  • api/tests/dialplan-generator.test.js → D27-D32 cover the max_retries-for-all-actions invariant