Step IVR Integration Guide
Step IVR is RustPBX’s externalized IVR mode: each call step invokes your Provider API via HTTP POST, letting an external system decide the next action. Ideal for scenarios requiring dynamic logic, database queries, AI decisions, and more.
1. How It Works
Incoming call enters IVR
│
▼
RustPBX calls POST /ivr/start (notify new session)
│
▼
RustPBX calls POST /ivr/step (carries event)
│
▼
Provider returns ActionNode (next action)
│
├── prompt (play prompt/TTS) → wait for event → POST /ivr/step again
├── dtmf_menu (collect digits) → wait for DTMF → POST /ivr/step
├── collect_dtmf (collect multi-digit) → POST /ivr/step
├── input_voice (voice input) → POST /ivr/step
├── api (call external API) → POST /ivr/step (carries api_response)
├── torecord (record) → POST /ivr/step (carries recording_complete)
├── transfer (transfer) → end
├── hangup (hang up) → end
└── queue/voicemail (enter queue/mailbox) → end
On termination, POST /ivr/end is called (fire-and-forget).
2. Configuration
2.1 Route Configuration
Point the call to Step IVR in a route rule:
[[route]]
name = "to-ivr"
action = "application"
application = "ivr:smart-ivr"
[route.match]
to_user = "^4000$"
2.2 IVR Definition
Create config/ivr/smart_ivr.toml:
[ivr]
name = "smart-ivr"
ivr_mode = "step"
[ivr.provider]
url = "http://10.0.0.50:8080/ivr/step"
max_retries = 3
retry_delay_ms = 1000
timeout_secs = 10
[ivr.provider.headers]
Authorization = "Bearer my-secret-key"
X-App-Id = "my-ivr-app"
# Optional: TTS configuration (for tts_text field)
[ivr.tts]
enabled = true
2.3 Provider Configuration Parameters
| Parameter | Description | Default |
|---|---|---|
url | Provider main endpoint (required) | - |
headers | Additional HTTP Headers | None |
max_retries | Max retry attempts | 3 |
retry_delay_ms | Retry interval (ms) | 1000 |
timeout_secs | HTTP timeout (s) | 10 |
3. Protocol Details
3.1 ProviderContext (RustPBX → Provider)
Each POST request body:
{
"session_id": "call_abc123",
"caller": "1001",
"callee": "4000",
"direction": "inbound",
"tenant_id": "default",
"ivr_id": "smart-ivr",
"variables": { "key": "value" },
"sip_headers": { "X-Custom": "value" },
"event": { "type": "session_start" }
}
3.2 ProviderEvent Types
| event.type | When triggered | Additional fields |
|---|---|---|
session_start | Session begins (first call) | - |
dtmf | User presses a key | digit: key value |
dtmf_timeout | Digit input timeout | - |
audio_complete | Audio playback finished | interrupted: whether interrupted |
api_response | API call returned | status: HTTP status code, body: response body |
phone_collected | Number collection complete | number: collected number |
recording_complete | Recording finished | url: recording URL, duration_secs: duration |
input_voice | Voice recognition result | text: recognized text, confidence: confidence score |
error | Error occurred | reason: error reason |
dtmf_menu_invalid | Invalid key press | digit: key value |
dtmf_menu_timeout | Menu timeout | - |
3.3 ActionNode (Provider → RustPBX)
Terminal Actions (IVR ends after execution)
| type | Description | Fields |
|---|---|---|
transfer | Transfer to extension/queue/external number | target: destination |
hangup | Hang up | - |
queue | Enter queue | queue: queue name |
voicemail | Enter voicemail | extension: extension number |
play_and_hangup | Play prompt then hang up | file / tts_text |
jump_ivr | Jump to another IVR | ivr_id: target IVR |
route_to_agent | Route to agent | agent_id |
voip_bridge | Bridge to external VoIP | uri: SIP URI |
Non-terminal Actions (wait for event after execution, then call Provider again)
| type | Description | Fields |
|---|---|---|
prompt | Play prompt/TTS | file / tts_text, interruptible, next |
dtmf_menu | DTMF menu | prompt / tts_text, timeout_ms, max_retries, keys |
collect_dtmf | Collect multi-digit DTMF | num_digits, timeout_ms, end_key, variable |
input_phone | Collect phone number | max_digits, timeout_ms |
input_voice | Voice input | timeout_ms, language |
api | Call external HTTP API | url, method, headers, body |
torecord | Record audio | max_duration, silence_timeout, variable |
3.4 Action Chaining (next field)
Non-terminal actions can be chained via the next field to reduce HTTP round-trips:
{
"type": "prompt",
"tts_text": "Please wait",
"next": {
"type": "api",
"url": "http://crm/query",
"next": {
"type": "dtmf_menu",
"tts_text": "Query complete, press 1 to continue",
"timeout_ms": 5000
}
}
}
3.5 Variable Substitution
Strings in ActionNode support $var_name$ variable substitution:
{
"type": "transfer",
"target": "$collected_number$"
}
Variable sources:
collect_dtmf/input_phoneset thevariabletorecordsets thevariable(recording URL)apiresponse is stored in theapi_responsevariable
4. Session Lifecycle Endpoints
In addition to the main url, RustPBX calls two notification endpoints (fire-and-forget):
| Endpoint | Description | URL Rule |
|---|---|---|
/start | Session start | {url}/start (appended to url) |
/end | Session end | {url}/end (appended to url) |
Both endpoints are POST with the same request body as the main endpoint. Providers can initialize/clean up session state here.
5. Error Handling
Behavior when the Provider is unreachable:
| Parameter | Description | Default |
|---|---|---|
max_retries | Retry count | 3 |
retry_delay_ms | Retry interval | 1000 |
After retries are exhausted, the fallback action is executed (hangs up if not configured).
6. Python Provider Example
from http.server import HTTPServer, BaseHTTPRequestHandler
import json, time
class IvrProvider(BaseHTTPRequestHandler):
sessions = {}
def do_POST(self):
body = json.loads(self.rfile.read(int(self.headers.get("Content-Length", 0))))
sid = body.get("session_id", "")
if self.path.endswith("/start"):
self.sessions[sid] = {"step": "welcome"}
self._json(200, {"status": "ok"})
return
if self.path.endswith("/end"):
self.sessions.pop(sid, None)
self._json(200, {"status": "ok"})
return
# Main step endpoint
event = body.get("event", {"type": "session_start"})
session = self.sessions.get(sid, {"step": "welcome"})
ev_type = event.get("type", "")
if session["step"] == "welcome":
session["step"] = "menu"
self._json(200, {
"type": "prompt",
"tts_text": "Welcome, press 1 to check balance, press 2 for agent",
"interruptible": True
})
elif ev_type == "dtmf" and event["digit"] == "1":
self._json(200, {"type": "play_and_hangup", "tts_text": "Your balance is 100"})
elif ev_type == "dtmf" and event["digit"] == "2":
self._json(200, {"type": "transfer", "target": "agent"})
else:
self._json(200, {"type": "hangup"})
def _json(self, status, data):
self.send_response(status)
self.send_header("Content-Type", "application/json")
self.end_headers()
self.wfile.write(json.dumps(data).encode())
HTTPServer(("0.0.0.0", 8080), IvrProvider).serve_forever()
6.1 Testing
# Simulate session_start
curl -X POST http://localhost:8080/ivr/step \
-H "Content-Type: application/json" \
-d '{"session_id":"test","event":{"type":"session_start"},"caller":"1001","callee":"4000"}'
# Simulate pressing key 1
curl -X POST http://localhost:8080/ivr/step \
-H "Content-Type: application/json" \
-d '{"session_id":"test","event":{"type":"dtmf","digit":"1"},"caller":"1001","callee":"4000"}'
7. Comparison with Tree Mode
| Feature | Tree Mode | Step Mode |
|---|---|---|
| Configuration | TOML file (static) | HTTP API (dynamic) |
| Use case | Fixed menu structures | Dynamic logic, AI decisions |
| Latency | None (local execution) | One HTTP round-trip per step |
| Visual editing | IVR Editor supported | Not supported |
| Variables/state | Limited | Fully customizable |
| TTS | Supported | Supported (via tts_text) |