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

ParameterDescriptionDefault
urlProvider main endpoint (required)-
headersAdditional HTTP HeadersNone
max_retriesMax retry attempts3
retry_delay_msRetry interval (ms)1000
timeout_secsHTTP 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.typeWhen triggeredAdditional fields
session_startSession begins (first call)-
dtmfUser presses a keydigit: key value
dtmf_timeoutDigit input timeout-
audio_completeAudio playback finishedinterrupted: whether interrupted
api_responseAPI call returnedstatus: HTTP status code, body: response body
phone_collectedNumber collection completenumber: collected number
recording_completeRecording finishedurl: recording URL, duration_secs: duration
input_voiceVoice recognition resulttext: recognized text, confidence: confidence score
errorError occurredreason: error reason
dtmf_menu_invalidInvalid key pressdigit: key value
dtmf_menu_timeoutMenu timeout-

3.3 ActionNode (Provider → RustPBX)

Terminal Actions (IVR ends after execution)

typeDescriptionFields
transferTransfer to extension/queue/external numbertarget: destination
hangupHang up-
queueEnter queuequeue: queue name
voicemailEnter voicemailextension: extension number
play_and_hangupPlay prompt then hang upfile / tts_text
jump_ivrJump to another IVRivr_id: target IVR
route_to_agentRoute to agentagent_id
voip_bridgeBridge to external VoIPuri: SIP URI

Non-terminal Actions (wait for event after execution, then call Provider again)

typeDescriptionFields
promptPlay prompt/TTSfile / tts_text, interruptible, next
dtmf_menuDTMF menuprompt / tts_text, timeout_ms, max_retries, keys
collect_dtmfCollect multi-digit DTMFnum_digits, timeout_ms, end_key, variable
input_phoneCollect phone numbermax_digits, timeout_ms
input_voiceVoice inputtimeout_ms, language
apiCall external HTTP APIurl, method, headers, body
torecordRecord audiomax_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_phone set the variable
  • torecord sets the variable (recording URL)
  • api response is stored in the api_response variable

4. Session Lifecycle Endpoints

In addition to the main url, RustPBX calls two notification endpoints (fire-and-forget):

EndpointDescriptionURL Rule
/startSession start{url}/start (appended to url)
/endSession 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:

ParameterDescriptionDefault
max_retriesRetry count3
retry_delay_msRetry interval1000

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

FeatureTree ModeStep Mode
ConfigurationTOML file (static)HTTP API (dynamic)
Use caseFixed menu structuresDynamic logic, AI decisions
LatencyNone (local execution)One HTTP round-trip per step
Visual editingIVR Editor supportedNot supported
Variables/stateLimitedFully customizable
TTSSupportedSupported (via tts_text)