Call Control
When your voice app receives a call.received webhook, your server takes ownership of the call and controls it via the Update Call API.
┌─────────┐ ┌───────────┐ ┌─────────────┐
│ Caller │ │ DialStack │ │ Your Server │
└────┬────┘ └─────┬─────┘ └──────┬──────┘
│ │ │
│ 1. Call arrives │ │
│────────────────────────▶│ │
│ │ │
│ │ 2. Webhook POST │
│ │─────────────────────────▶│
│ │ │
│ │ 3. POST /v1/calls/{id} │
│ │◀─────────────────────────│
│ │ (attach audio) │
│ │ │
│ 4. Bidirectional audio │ │
│◀───────────────────────▶│◀────────────────────────▶│
│ (WebSocket) │ (WebSocket) │
│ │ │
Actions
Use the Update Call API to send actions. Actions are processed sequentially.
Attach Audio Stream
Connect bidirectional audio to your WebSocket server (see WebSocket API for the message protocol):
- SDK
- cURL
await dialstack.calls.update(
callId,
{ actions: [{ type: 'attach', url: 'wss://your-server.example.com/voice/stream' }] },
{ dialstackAccount: accountId }
);
curl -X POST https://api.dialstack.ai/v1/calls/call_01h2xcejqtf2nbrexx3vqjhp45 \
-H "Authorization: Bearer sk_live_YOUR_API_KEY" \
-H "DialStack-Account: acct_01h2xcejqtf2nbrexx3vqjhp41" \
-H "Content-Type: application/json" \
-d '{
"actions": [
{"type": "attach", "url": "wss://your-server.example.com/voice/stream"}
]
}'
The url must use wss://, so the call's audio is encrypted in transit.
The attach action blocks until the WebSocket disconnects, then processing continues with the next action.
Transfer
Transfer the caller to an extension, an E.164 phone number, or a SIP address:
- SDK
- cURL
await dialstack.calls.update(
callId,
{ actions: [{ type: 'transfer', target: '100' }] },
{ dialstackAccount: accountId }
);
curl -X POST https://api.dialstack.ai/v1/calls/call_01h2xcejqtf2nbrexx3vqjhp45 \
-H "Authorization: Bearer sk_live_YOUR_API_KEY" \
-H "DialStack-Account: acct_01h2xcejqtf2nbrexx3vqjhp41" \
-H "Content-Type: application/json" \
-d '{
"actions": [
{"type": "transfer", "target": "100"}
]
}'
If the transfer target answers or the caller hangs up, processing stops. If the transfer fails (no answer, busy), processing continues with the next action.
Transfer to a SIP address (e.g. an AI voice agent)
The target can also be a sip: URI, which routes the live call to an external SIP address such as an AI voice agent. Your server provides the full address — for example, after registering the call with your agent provider and receiving an identifier to dial:
await dialstack.calls.update(
callId,
{ actions: [{ type: 'transfer', target: 'sip:agent-7f3c@sip.example-ai.com' }] },
{ dialstackAccount: accountId }
);
This leg is not encrypted: signaling is plain SIP and the audio is connected over plain RTP. sips: URIs and ;transport=tls aren't supported. You can append ;transport=tcp or ;transport=udp to the URI to select the signaling transport. If the call's audio must be encrypted in transit, for example because it carries protected health information, stream it with attach instead.
Combining Actions
Chain actions for fallback behavior:
{
"actions": [
{ "type": "attach", "url": "wss://ai.example.com/voice" },
{ "type": "transfer", "target": "100" }
]
}
This connects to your AI voice assistant first. When the WebSocket disconnects (e.g., AI hands off), the call transfers to extension 100.
Replacing Actions
Sending a new update replaces all pending actions immediately. The current action is interrupted, and processing starts from the first action in the new list.
- SDK
- fetch
// AI decides to transfer the call
await dialstack.calls.update(
callId,
{ actions: [{ type: 'transfer', target: '100' }] },
{ dialstackAccount: accountId }
);
// AI decides to transfer the call
await fetch(`https://api.dialstack.ai/v1/calls/${callId}`, {
method: 'POST',
headers: {
Authorization: `Bearer ${apiKey}`,
'DialStack-Account': accountId,
'Content-Type': 'application/json',
},
body: JSON.stringify({
actions: [{ type: 'transfer', target: '100' }],
}),
});
WebSocket Audio Streaming
When DialStack executes an attach action, it connects to your WebSocket URL and streams audio bidirectionally: μ-law at 8 kHz, in base64-encoded JSON messages. The WebSocket API reference has the audio format and every message, including the begin message that opens the session and the end message sent before DialStack closes it.
Ending the Session
Either side can close the WebSocket to end the audio session. When it closes, DialStack continues processing with the next action (if any).
Using MediaStream (SDK)
The SDK provides a MediaStream class that parses the WebSocket messages and emits begin, audio, close (with the close code and reason) and error events. After begin, stream.callId, stream.accountId and stream.audioFormat are set.
import { MediaStream } from '@dialstack/sdk-server';
import { WebSocketServer } from 'ws';
const wss = new WebSocketServer({ port: 8080 });
wss.on('connection', (ws) => {
const stream = new MediaStream(ws);
stream.on('begin', (event) => {
console.log('Call started:', event.call_id);
console.log('Audio format:', event.audio_format);
// Send greeting audio
stream.sendAudio(greetingAudioBase64);
});
stream.on('audio', (event) => {
// event.payload contains base64-encoded μ-law audio
// event.timestamp contains the audio timestamp
// Process with your AI pipeline and respond
const responseAudio = processAudio(event.payload);
stream.sendAudio(responseAudio);
// Or send raw Buffer (auto base64-encoded)
stream.sendAudioBuffer(audioBuffer);
});
stream.on('close', (code, reason) => {
console.log('Stream closed:', code, reason);
});
stream.on('error', (error) => {
console.error('Stream error:', error);
});
});
Complete Example: AI Voice Assistant
- SDK
- Raw
import express from 'express';
import { WebSocketServer } from 'ws';
import { DialStack, MediaStream } from '@dialstack/sdk-server';
const app = express();
const dialstack = new DialStack(process.env.DIALSTACK_API_KEY);
const VOICE_APP_SECRET = process.env.VOICE_APP_SECRET;
// Webhook endpoint
app.post('/voice/webhook', express.raw({ type: 'application/json' }), async (req, res) => {
let event;
try {
event = DialStack.webhooks.constructEvent(
req.body,
req.headers['x-dialstack-signature'],
VOICE_APP_SECRET
);
} catch (err) {
return res.sendStatus(401);
}
const { call_id, account_id, from_number, from_name } = event;
console.log(`Incoming call from ${from_name || from_number}`);
res.sendStatus(200);
// Attach audio stream with fallback transfer
await dialstack.calls.update(
call_id,
{
actions: [
{ type: 'attach', url: 'wss://your-server.example.com/voice/stream' },
{ type: 'transfer', target: '100' },
],
},
{ dialstackAccount: account_id }
);
});
// WebSocket server for audio streaming
const wss = new WebSocketServer({ noServer: true });
wss.on('connection', (ws) => {
const stream = new MediaStream(ws);
stream.on('begin', (event) => {
console.log(`Audio stream started for call ${stream.callId}`);
stream.sendAudio(generateGreetingAudio());
});
stream.on('audio', (event) => {
const audioBuffer = Buffer.from(event.payload, 'base64');
processAudioWithAI(audioBuffer, (responseAudio) => {
stream.sendAudio(responseAudio);
});
});
stream.on('close', () => {
console.log(`Audio stream ended for call ${stream.callId}`);
});
});
const server = app.listen(3000);
server.on('upgrade', (request, socket, head) => {
if (request.url === '/voice/stream') {
wss.handleUpgrade(request, socket, head, (ws) => {
wss.emit('connection', ws, request);
});
} else {
socket.destroy();
}
});
const express = require('express');
const WebSocket = require('ws');
const app = express();
app.use(
express.json({
verify: (req, res, buf) => {
req.rawBody = buf;
},
})
);
const API_KEY = process.env.DIALSTACK_API_KEY;
const VOICE_APP_SECRET = process.env.VOICE_APP_SECRET;
const ACCOUNT_ID = process.env.DIALSTACK_ACCOUNT_ID;
// Webhook endpoint
app.post('/voice/webhook', async (req, res) => {
const signature = req.headers['x-dialstack-signature'];
try {
verifySignature(req.rawBody.toString(), signature, VOICE_APP_SECRET);
} catch (err) {
return res.sendStatus(401);
}
const { call_id, from_number, from_name } = req.body;
console.log(`Incoming call from ${from_name || from_number}`);
res.sendStatus(200);
await fetch(`https://api.dialstack.ai/v1/calls/${call_id}`, {
method: 'POST',
headers: {
Authorization: `Bearer ${API_KEY}`,
'DialStack-Account': ACCOUNT_ID,
'Content-Type': 'application/json',
},
body: JSON.stringify({
actions: [
{ type: 'attach', url: 'wss://your-server.example.com/voice/stream' },
{ type: 'transfer', target: '100' },
],
}),
});
});
// WebSocket server for audio streaming
const wss = new WebSocket.Server({ noServer: true });
wss.on('connection', (ws) => {
let callId;
ws.on('message', (data) => {
const message = JSON.parse(data);
switch (message.event) {
case 'begin':
callId = message.call_id;
console.log(`Audio stream started for call ${callId}`);
ws.send(JSON.stringify({ event: 'audio', payload: generateGreetingAudio() }));
break;
case 'audio':
const audioBuffer = Buffer.from(message.payload, 'base64');
processAudioWithAI(audioBuffer, (responseAudio) => {
ws.send(JSON.stringify({ event: 'audio', payload: responseAudio }));
});
break;
}
});
ws.on('close', () => {
console.log(`Audio stream ended for call ${callId}`);
});
});
const server = app.listen(3000);
server.on('upgrade', (request, socket, head) => {
if (request.url === '/voice/stream') {
wss.handleUpgrade(request, socket, head, (ws) => {
wss.emit('connection', ws, request);
});
} else {
socket.destroy();
}
});
// verifySignature(payload, signatureHeader, secret): see
// https://docs.dialstack.ai/guides/call-events/receiving-webhooks#signature-verification
See also
- Voice Apps — creating a voice app, its webhooks, and dial plan nodes.
- Listeners — stream one-way audio without taking over the call.
- WebSocket API — the audio streaming protocol.
- Update Call — the full list of actions.