# Calling

> WhatsApp voice calling with IVR flow builder, call transfers, and recording

Source: https://docs.soosh.io/features/calling/

## Overview

Soosh supports WhatsApp voice calling with WebRTC-based audio bridging. Incoming calls are handled by an Interactive Voice Response (IVR) system built using a visual drag-and-drop flow editor. The IVR plays greetings, collects DTMF input, makes HTTP callbacks, and routes callers to agent teams. Agents answer calls from the browser — no phone hardware required.

  **Visual Flow Editor**

    Drag-and-drop node-based IVR builder with 8 node types
  
  **Call Transfers**

    Route callers to agent teams with hold music
  
  **Call Hold**

    Put calls on hold with instant hold music and resume anytime
  
  **Call Recording**

    Record agent-caller audio as OGG/Opus, stored in S3
  
  **Outgoing Calls**

    Agents place outbound calls to contacts from the chat view, once the contact grants call permission
  

## How It Works

1. A WhatsApp user calls your business number
2. WhatsApp sends a webhook with the SDP offer
3. Soosh establishes a WebRTC peer connection and runs the IVR flow
4. The caller hears greetings, presses digits (DTMF) to navigate menus, and provides input
5. Based on the flow logic, the call is transferred to an agent team, routed to another flow, or hung up
6. An available agent accepts the transfer in the browser and speaks with the caller

## IVR Flow Builder

IVR flows are configured from **Settings > IVR Flows** in the admin UI. The editor uses a visual canvas where you drag and drop nodes, then connect them with edges to define the call flow.

### Creating a Flow

Each flow has:
- **Name** and optional description
- **WhatsApp Account** — which phone number this flow handles
- **Active** toggle — disabled flows are skipped
- **Call Start** toggle — marks this as the entry flow for incoming calls

> **Careful:** Each WhatsApp account must have exactly **one** flow with "Call Start" enabled. This is the flow that runs when an incoming call arrives. If no flow is marked as call start, incoming calls will not have IVR and will be rejected. Enabling "Call Start" on a flow automatically disables it on any other flow for the same account.

### Node Types

The flow editor provides 8 node types. Drag them from the palette onto the canvas, configure their properties in the side panel, and connect them with edges.

#### Greeting

Plays an audio message to the caller. The audio can be uploaded as a file or generated from text using TTS (text-to-speech).

| Property | Description |
|----------|-------------|
| **Audio** | Upload an audio file (OGG, MP3, WAV, etc.) — automatically transcoded to Opus |
| **TTS Text** | Type text to generate audio using Piper TTS |
| **Interruptible** | Config key `interruptible` — if enabled, the caller can press a digit to skip the greeting |

The greeting node has one output (`default`) that connects to the next node.

> **Note:** At playback time the player reads **Ogg/Opus** packets only, so every file under `audio_dir`
> (including `hold_music_file` and `ringback_file`) must already be Opus. Uploads through the UI
> are transcoded for you; files you drop in by hand are not.

#### Menu

Plays an audio prompt and waits for the caller to press a DTMF digit. Routes the call based on the digit pressed.

| Property | Description |
|----------|-------------|
| **Audio / TTS** | The prompt audio (e.g., "Press 1 for sales, press 2 for support") — config key `audio_file` |
| **Timeout** | Seconds to wait for input, config key `timeout_seconds` (default: 10) |
| **Max Retries** | Attempts on invalid/no input before following the `max_retries` edge (default: 3) |
| **Options** | Digit-to-label mappings (e.g., `1` → "Sales", `2` → "Support") |

**Output handles:**
- `digit:1`, `digit:2`, etc. — one per configured option
- `max_retries` — followed once every attempt has been used up
- `default` — the fallback edge, used whenever no exact-match edge exists for the outcome

Connect each output handle to the appropriate next node.

> **Note:** A timeout or an invalid digit does **not** produce its own edge — it simply consumes one attempt
> and replays the prompt. Only after `max_retries` attempts does the flow follow `max_retries`. If
> no options are configured, **any** digit is accepted.

The chosen digit is stored in the call context as `last_menu_digit` and as `menu_<node id>`, so
later HTTP callback nodes can interpolate it.

#### Gather

Collects multi-digit input from the caller (e.g., account number, PIN). The collected digits are stored as a context variable for use in subsequent HTTP callbacks.

| Property | Description |
|----------|-------------|
| **Audio / TTS** | Prompt audio (e.g., "Please enter your account number") — config key `audio_file` |
| **Max Digits** | Maximum digits to collect, config key `max_digits` (default: 10) |
| **Terminator** | Character that ends input, config key `terminator` (default: `#`) |
| **Store As** | Variable name to store the input, config key `store_as` (e.g., `account_number`) |
| **Timeout** | Seconds to wait for input, config key `timeout_seconds` (default: 10) |
| **Max Retries** | Attempts before following the `max_retries` edge (default: 3) |

**Output handles:** `default` (digits collected) and `max_retries`. As with Menu, a timeout just
consumes an attempt — there is no `timeout` edge. The gather prompt is **not** interruptible.

Stored variables can be used in HTTP callback URL and body templates as `{{variable_name}}`.

#### HTTP Callback

Makes an HTTP request to an external API during the call flow. Useful for looking up caller information, validating input, or triggering actions in other systems.

| Property | Description |
|----------|-------------|
| **URL** | The endpoint URL, config key `url` (supports `{{variable}}` interpolation) |
| **Method** | `method` — `GET` (default) or `POST` |
| **Headers** | Custom HTTP headers, config key `headers`. Values are interpolated too. |
| **Body Template** | `body_template` — request body with variable interpolation (e.g., `{"phone": "{{caller_phone}}"}`) |
| **Timeout** | `timeout_seconds` — request timeout (default: 10) |
| **Store Response As** | `response_store_as` — variable that receives the raw response body |

**Built-in variables** available for interpolation:
- `{{caller_phone}}` — the caller's phone number
- `{{call_id}}` — the WhatsApp call ID
- `{{last_menu_digit}}` and `{{menu_<node id>}}` — the last Menu selection
- Any variable set by a previous Gather node

**Output handles:** `http:2xx` and `http:non2xx`. Network/transport errors also follow
`http:non2xx`. Wire up a `default` edge as a catch-all if you don't want to branch.

#### Transfer

Routes the caller to an agent team. Once the transfer starts, hold music plays while agents are
notified.

| Property | Description |
|----------|-------------|
| **Team** | The agent team to transfer to |

When a transfer executes:
1. Hold music plays for the caller
2. A transfer notification appears for all online agents in the target team
3. The first agent to accept connects to the caller via WebRTC audio bridge
4. If no agent accepts within the transfer timeout, the call is terminated

**Output handles:** if the node has **no** outgoing edges it is terminal — the call ends when the
transfer does. If you *do* connect outgoing edges, the node blocks until the transfer resolves and
then branches on `completed`, `no_answer`, or `abandoned`, letting the flow continue (e.g. offer a
voicemail prompt when nobody answers).

#### Goto Flow

Jumps to a different IVR flow. This is a **terminal node**. Use this to split complex IVR trees into reusable modules (e.g., a shared "Account Verification" flow).

| Property | Description |
|----------|-------------|
| **Target Flow** | The IVR flow to jump to |

The target flow starts from its entry node. The caller's context variables carry over, and the jump
is recorded on the call's IVR path for audit.

> **Note:** A missing or invalid `flow_id`, a target that cannot be loaded, or a target whose **Active**
> toggle is off all log an error and end the call. There is no return stack — when the target flow
> finishes, the call finishes.

#### Timing

Branches the call based on business hours. Configure a weekly schedule with per-day enable/disable and start/end times.

| Property | Description |
|----------|-------------|
| **Schedule** | `schedule` — an array of `{ "day": "monday", "enabled": true, "start_time": "09:00", "end_time": "18:00" }` entries |

**Output handles:**
- `in_hours` — current time is within the configured schedule
- `out_of_hours` — current time is outside the schedule

> **Careful:** The timing node has **no timezone property** — it evaluates against the **server's local time**.
> Set the process timezone (the `TZ` environment variable; `docker/.env.example` ships `TZ=UTC`) to
> match your business hours.
>
> A day missing from `schedule`, a disabled day, or an unparseable `start_time`/`end_time`
> (which must be `HH:MM`) all route to `out_of_hours`. The end time is exclusive.

#### Hangup

Plays an optional goodbye message and terminates the call. This is a **terminal node** — it cannot have outgoing edges.

| Property | Description |
|----------|-------------|
| **Audio / TTS** | Optional goodbye message |

### Building a Flow

1. Click **Add Flow** and give it a name, select the WhatsApp account, and enable "Active" and "Call Start"
2. Drag nodes from the palette at the top onto the canvas
3. Click a node to configure its properties in the right panel
4. Connect nodes by dragging from an output handle (bottom/right of a node) to the input (top) of another node
5. The first node added becomes the **entry node** (marked with a green indicator). You can change this by deleting and re-adding nodes
6. Click **Save** to persist the flow

### Example Flow

Below is a screenshot of an example IVR flow built in the visual editor:

<img src={ivrFlowExample.src} alt="Example IVR flow in the visual editor" />

## Call Transfers

When a caller reaches a Transfer node:

1. Hold music plays for the caller
2. A transfer notification appears for all agents in the target team
3. The first agent to accept connects to the caller
4. The call is bridged — both parties hear each other through the browser

> **Note:** Transfer timeout is configurable (default: 120 seconds). If no agent accepts, the call is terminated.

## Call Hold

During an active call, agents can put the caller on hold by clicking the **Hold** button (pause icon) in the call panel. Hold music plays immediately to the caller with seamless audio continuity — no gaps or clicks.

- **Hold**: Stops the audio bridge between agent and caller, starts playing the configured hold music file on loop
- **Resume**: Stops hold music and restores two-way audio instantly

The hold button toggles between pause (hold) and play (resume) icons, with an amber highlight when the call is on hold. The status text in the call panel shows "On Hold" while held.

Hold music is configurable per organization from **Settings > Organization > Calling**. The default file is set in `config.toml` via `hold_music_file`.

> **Note:** RTP sequence numbers are maintained across bridge and hold music transitions, so the caller's audio client never drops packets or experiences glitches during the switch.

## Call Recording

When enabled, calls are recorded during the agent-caller bridge phase. Recordings are saved as OGG/Opus files and uploaded to S3.

To enable, add to your `config.toml`:

```toml
[calling]
recording_enabled = true

[storage]
type = "s3"
s3_bucket = "your-bucket"
s3_region = "us-east-1"
s3_key = "AKIA..."
s3_secret = "..."
```

> **Careful:** Recording is **S3-only**. With `recording_enabled = true` but no S3 client configured, recording
> is silently skipped — nothing is written to local storage.

Recordings are accessible from the call log detail view, which generates time-limited presigned URLs for playback.

> **Note:** Only the agent-caller conversation is recorded. The IVR portion (automated prompts before an agent connects) is not recorded.

## Outgoing Calls

Agents can place outbound WhatsApp voice calls to a contact directly from the chat view. WhatsApp requires the consumer to **grant call permission** before an agent can dial them — so the flow is: request permission → wait for the consumer to accept → place the call.

### Call Permission Flow

1. The agent sends a call permission request to the contact
2. The contact receives an interactive WhatsApp prompt and taps **Accept** (or **Decline**)
3. Once accepted, the agent can initiate the WebRTC call

Permission records track one of four states: `pending`, `accepted`, `declined`, or `expired`. Calling must be enabled for the organization (see [Enabling Calling per Organization](#enabling-calling-per-organization)).

### Endpoints

Both endpoints require the `outgoing_calls` permission (`write` to request, `read` to check).

#### Request permission

`POST /api/calls/permission-request`

```json
{
  "contact_id": "uuid-of-contact",
  "whatsapp_account": "account-name"
}
```

Sends the permission prompt to the contact via the WhatsApp Messages API and stores a `pending` permission record. Response:

```json
{ "status": "success", "data": { "permission_id": "uuid" } }
```

#### Check permission

`GET /api/calls/permission/{contactId}?whatsapp_account=account-name`

Queries the current permission status directly from the WhatsApp API. Response:

```json
{ "status": "success", "data": { "status": "accepted" } }
```

> **Note:** Permission grants can also arrive **out of band** — for example when Meta shows the consumer a call prompt on its own, or when a request was sent outside your configured calling hours. When the consumer replies to accept or decline and no prior request record exists, Soosh creates the permission record from the reply so the grant is captured rather than dropped.

### Placing the Call

`POST /api/calls/outgoing`

```json
{
  "contact_id": "uuid-of-contact",
  "whatsapp_account": "account-name",
  "sdp_offer": "<browser WebRTC SDP offer>"
}
```

The browser generates a WebRTC offer; the server answers with an SDP answer and bridges the audio. Hang up with `POST /api/calls/outgoing/{id}/hangup`.

## Call Logs

All calls (incoming and outgoing) are logged with:
- Caller phone number and contact name
- Direction, status, and duration
- Who disconnected the call (client, agent, or system)
- IVR flow traversal path (shown as a step-by-step trace)
- Agent who handled the call
- Recording playback (if enabled)

Filter logs by status, direction, account, or IVR flow.

## Configuration

Add to your `config.toml`:

```toml
[calling]
audio_dir = "./audio"                  # Directory for IVR audio files
hold_music_file = "hold_music.opus"    # Hold music file (relative to audio_dir); this is the default
ringback_file = "ringback.opus"        # Ringback tone for outgoing calls (no default — falls back to hold music)
max_call_duration = 300                # Max call duration in seconds (default 300)
transfer_timeout_secs = 120            # Seconds to wait for a team to accept a transfer
per_agent_timeout_secs = 0             # Global default: seconds to ring each agent before rotating (0 → falls back to 15; a team's own setting overrides this)
recording_enabled = false              # Enable call recording to S3
udp_port_min = 10000                   # WebRTC UDP port range start
udp_port_max = 10100                   # WebRTC UDP port range end
udp_mux_port = 0                       # One UDP port for every call (0 = off: one port per connection from the range)
public_ip = ""                         # Public IP for NAT (required on cloud/AWS)
relay_only = false                     # Force all media through TURN relay

# ICE servers (STUN/TURN) for WebRTC connectivity
[[calling.ice_servers]]
urls = ["stun:stun.l.google.com:19302"]

[[calling.ice_servers]]
urls = ["turn:your-turn-server:3478"]
username = "user"
credential = "pass"
```

#### TURN with HMAC shared-secret credentials

Instead of a static `username`/`credential`, you can point Soosh at a coturn server running in `use-auth-secret` mode (the TURN REST API). Soosh then generates a fresh, short-lived credential for every client — no long-lived password is ever handed out.

```toml
[[calling.ice_servers]]
urls = ["turn:your-turn-server:3478"]
secret = "your-coturn-static-auth-secret"   # matches coturn's `static-auth-secret`
credential_ttl = 86400                        # credential lifetime in seconds (default: 86400 = 24h)
```

When `secret` is set, Soosh derives credentials on each request following the coturn REST convention: the username is the credential's Unix expiry timestamp (`<expiry>`, or `<expiry>:<username>` if you also set `username`), and the credential is the base64-encoded HMAC-SHA1 of that username keyed by `secret`. The generated pair is returned by `GET /api/calls/ice-servers` for browser peer connections and is also used server-side when the IVR bridge builds its own peer connection.

On the coturn side, enable:

```
use-auth-secret
static-auth-secret=your-coturn-static-auth-secret
```

> **Note:** `credential_ttl` only applies when `secret` is set. If omitted or `<= 0`, it defaults to 86400 seconds (24 hours). Static `username`/`credential` servers ignore it.

> **Careful:** On cloud providers (AWS, GCP, etc.), you must set `public_ip` to your server's public IP address. Without it, WebRTC candidates will advertise the private IP and calls will fail to connect. If running in Docker, use `--network=host` or map the UDP port range.

> **Careful:** When using `relay_only = true`, do **not** set `public_ip` — it can cause ICE negotiation errors. The TURN server handles all media relaying.

### Enabling Calling per Organization

Calling is enabled per-organization in the database. Set `calling_enabled = true` on the organization record to allow calls for that org.

## Text-to-Speech (IVR Greetings)

Soosh uses [Piper](https://github.com/OHF-Voice/piper1-gpl) for offline text-to-speech generation. When admins type greeting text in the IVR flow editor, the server generates OGG/Opus audio files using Piper + `opusenc`. This is optional — you can also upload pre-recorded audio files directly.

### Install Dependencies

Piper requires the `espeak-ng` shared library at runtime, and `opusenc` is needed to convert WAV output to OGG/Opus:

```bash
# Debian/Ubuntu
sudo apt install espeak-ng opus-tools

# Fedora
sudo dnf install espeak-ng opus-tools
```

### Install Piper

```bash
# Download Piper binary (Linux x86_64)
wget https://github.com/rhasspy/piper/releases/download/2023.11.14-2/piper_linux_x86_64.tar.gz
tar xf piper_linux_x86_64.tar.gz
sudo mv piper/piper /usr/local/bin/
```

### Download a Voice Model

Piper voices are available at [huggingface.co/rhasspy/piper-voices](https://huggingface.co/rhasspy/piper-voices) (mirrors at [OHF-Voice](https://huggingface.co/OHF-Voice)). Each voice has a `.onnx` model file and a `.onnx.json` config file — both are required.

**Choosing a voice:**
- Browse voices and listen to samples at [rhasspy.github.io/piper-samples](https://rhasspy.github.io/piper-samples/)
- Voices come in quality levels: `low`, `medium`, and `high` — `medium` is a good balance of quality and speed
- For US English, `en_US-lessac-medium` is recommended (~60MB)

```bash
mkdir -p /opt/piper/models

# Download model and config
wget https://huggingface.co/rhasspy/piper-voices/resolve/main/en/en_US/lessac/medium/en_US-lessac-medium.onnx \
  -O /opt/piper/models/en_US-lessac-medium.onnx
wget https://huggingface.co/rhasspy/piper-voices/resolve/main/en/en_US/lessac/medium/en_US-lessac-medium.onnx.json \
  -O /opt/piper/models/en_US-lessac-medium.onnx.json
```

### Configure TTS

Add to your `config.toml`:

```toml
[tts]
piper_binary = "/usr/local/bin/piper"
piper_model = "/opt/piper/models/en_US-lessac-medium.onnx"
# opusenc_binary = "opusenc"  # defaults to finding in PATH
```

### Test TTS

```bash
echo "Press 1 for sales, press 2 for support." | piper \
  --model /opt/piper/models/en_US-lessac-medium.onnx \
  --output_file test.wav
opusenc --bitrate 24 test.wav test.ogg
# Play: aplay test.wav  OR  ffplay test.ogg
```

> **Tip:** Generated audio files are cached in the `audio_dir` (default: `./audio`) using a SHA256 hash of the text. The same text always reuses the existing file, so regeneration is instant after the first run.

## Firewall & Network

For WebRTC to work, ensure the following ports are open:

| Port | Protocol | Purpose |
|------|----------|---------|
| 10000–10100 | UDP | WebRTC media (configurable via `udp_port_min`/`udp_port_max`) |
| `udp_mux_port` | UDP | WebRTC media for every call, when set (replaces the range above) |
| 3478 | TCP/UDP | TURN server (if using relay) |

### How many calls at once

Without `udp_mux_port`, each WebRTC connection takes one UDP port from `udp_port_min`–`udp_port_max`, and a call with an agent on the line uses two connections (WhatsApp and the agent's browser). The default range of 101 ports therefore carries about **50 simultaneous calls**; the next call gets no network route and fails.

Set `udp_mux_port` to carry every call on a single UDP port instead. Ports then don't limit concurrent calls (CPU and bandwidth do). Open that one port in your firewall, and set `public_ip`: with both set the server skips STUN, which would otherwise still bind a range port per call. With `relay_only = true`, media goes through TURN and neither setting applies.

The **Calling check** on each WhatsApp account shows which mode is active and, in range mode, how many ports are in use. The server also logs a warning when the range is 90% used.

> **Tip:** If direct UDP connectivity is unreliable (e.g., behind strict NATs), set `relay_only = true` and configure a TURN server. This routes all media through the TURN relay at the cost of slightly higher latency.
