Step IVR, End to End: When Menus Aren't Enough

M
Miuda Team
Building conversational AI tooling

Static IVR trees handle “press 1 for sales.” They don’t handle “look up this caller’s open tickets, read the balance, and route on both.” Step IVR hands each call step to your own service, which answers with the next action — dynamic logic with the PBX still owning media, recording, and CDRs.

IVR editor — the visual surface for menu-based flows

The Shape of a Step

The PBX POSTs a ProviderContext to your endpoint:

{
  "session_id": "call_abc123",
  "caller": "1001",
  "callee": "4000",
  "direction": "inbound",
  "ivr_id": "smart-ivr",
  "variables": { "account": "A-8192" },
  "event": { "type": "session_start" }
}

Your service replies with an ActionNode — play, collect digits, menu, route to extension/queue/trunk, record, hangup, and more. The PBX performs it and calls you back with the result as the next ProviderEvent. That’s the whole loop.

The Event Types You’ll Actually Handle

EventMeaning
session_startFirst entry into the flow — exactly once
dtmfKey pressed (with digit)
dtmf_timeoutInput timeout
audio_completePlayback finished (interrupted?)
api_responseYour own outbound API call result
input_voiceASR result with confidence
recording_completeRecording URL + duration
phone_collectedDigit collection complete
errorSomething failed (reason)

The Resume Event — the Detail That Separates Good IVRs

When a flow suspends on a voip_bridge (a consult transfer) and the call comes back with no buffered digits, the PBX sends resume with resume_from_step_id — not a second session_start.

That distinction is the whole ballgame:

  • session_start fires once, at true first entry
  • resume means “continue from this step” — restore variables, don’t replay the menu
  • Getting this wrong is the classic “caller hears the greeting twice and re-enters their account number” bug

Your provider must branch on event.type == "resume" and jump to the referenced step.

Falling Back Gracefully

Your service will be down sometime. [proxy.ivr_fallback] routes the call into a local IVR instead of failing:

[proxy.ivr_fallback]
default = "default"

[[proxy.ivr_fallback.rules]]
name = "vip"
priority = 100
match = { "from.user" = "^9" }
target = "builtin_vip_step"

Rules use dialplan match semantics, descending priority, first match wins. Callers hear a menu, not a failure tone.

Deployment-Local Endpoints & TTS Aliases

  • Provider URLs can reference the deployment itself (host placeholders resolve locally), so a tree authored once runs unchanged on any node.
  • Prompts can be referenced by alias and resolved through the deployment’s TTS config — no engine-specific strings baked into third-party flows.

A Minimal Provider, in Python

The docs ship a working example, but the skeleton is tiny:

@app.post("/step")
def step(ctx: dict):
    ev = ctx["event"]
    if ev["type"] == "session_start":
        return play("welcome.wav", then="collect_account")
    if ev["type"] == "resume":
        return jump(ev.get("resume_from_step_id", "root"))
    if ev["type"] == "dtmf":
        return route_queue("support-queue")
    return hangup()

Stateless is the goal: everything you need is in the context (or your own store keyed by session_id).

When to Use Step IVR vs Other Layers

NeedLayer
Fixed menus, simple schedulesBuilt-in IVR / IVR Editor
Per-call data lookups, dynamic routingStep IVR
Full conversational AIRealtime bridge (see the bot post)
Assist and QATranscription plans

Guides: Step IVR protocol and IVR Editor.

Get new posts by email

Deep dives on Rust telephony, contact centers and wholesale voice. No spam.

Thanks — you're on the list.

We use cookies for anonymous analytics to improve the site. Nothing is loaded until you accept. Privacy Policy