Step IVR, End to End: When Menus Aren't Enough
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.

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
| Event | Meaning |
|---|---|
session_start | First entry into the flow — exactly once |
dtmf | Key pressed (with digit) |
dtmf_timeout | Input timeout |
audio_complete | Playback finished (interrupted?) |
api_response | Your own outbound API call result |
input_voice | ASR result with confidence |
recording_complete | Recording URL + duration |
phone_collected | Digit collection complete |
error | Something 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_startfires once, at true first entryresumemeans “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
| Need | Layer |
|---|---|
| Fixed menus, simple schedules | Built-in IVR / IVR Editor |
| Per-call data lookups, dynamic routing | Step IVR |
| Full conversational AI | Realtime bridge (see the bot post) |
| Assist and QA | Transcription plans |
Guides: Step IVR protocol and IVR Editor.