Small Node.js proxy that converts Voximplant WebSocket media protocol to Vapi WebSocket Transport.
The service can use a default Vapi assistant from VAPI_ASSISTANT_ID, or a request can pass assistantId to route a call to a specific assistant.
PSTN call -> Voximplant -> this proxy -> Vapi WebSocket Transport -> Vapi Assistant
Voximplant side:
JSON start/media/stop with base64 audio payload
Vapi side:
raw binary mulaw/8000 audio frames
Downlink note: observed Vapi WebSocket binary audio is playable as mulaw/8000, while Voximplant starts WebSocket playback as PCM16. The proxy decodes Vapi mulaw/8000 and sends Voximplant playback as PCM16/16000.
npm install
cp .env.example .envEdit .env:
VAPI_API_KEY=...
VAPI_ASSISTANT_ID=... # optional default assistant
VAPI_API_URL=https://api.vapi.ai
PORT=8000
PUBLIC_BASE_URL=https://proxy.example.com
VAPI_OUTPUT_SAMPLE_RATE=8000Run:
npm startHealth check:
curl http://localhost:8000/healthPOST /start
Creates a Vapi WebSocket call and returns a Voximplant-facing WebSocket URL.
Diagnostic replay:
wss://proxy.example.com/test/replay-last
This replays the latest saved vapi-downlink.raw file to Voximplant as PCM16/16000 without creating a Vapi call.
Request body can be empty:
{}Optional metadata:
{
"assistantId": "your_vapi_assistant_id",
"metadata": {
"caller": "375...",
"destination": "375..."
},
"assistantOverrides": {
"variableValues": {
"callerPhone": "375...",
"destinationPhone": "375..."
}
}
}If assistantId is omitted, the proxy uses VAPI_ASSISTANT_ID from .env.
Response:
{
"callId": "...",
"sessionId": "...",
"status": "queued",
"voxWebSocketUrl": "wss://proxy.example.com/vox/...",
"audioFormat": {
"format": "mulaw",
"container": "raw",
"sampleRate": 8000
}
}require(Modules.WebSocket);
const PROXY_START_URL = 'https://proxy.example.com/start';
VoxEngine.addEventListener(AppEvents.CallAlerting, async ({ call }) => {
call.answer();
const response = await Net.httpRequestAsync(PROXY_START_URL, {
method: 'POST',
headers: ['Content-Type: application/json'],
postData: JSON.stringify({
assistantId: 'your_vapi_assistant_id',
metadata: {
caller: call.callerid(),
destination: call.number()
},
assistantOverrides: {
variableValues: {
callerPhone: call.callerid(),
destinationPhone: call.number()
}
}
})
});
const session = JSON.parse(response.text);
const ws = VoxEngine.createWebSocket(session.voxWebSocketUrl);
ws.addEventListener(WebSocketEvents.OPEN, () => {
call.sendMediaTo(ws, { encoding: WebSocketAudioEncoding.ULAW });
ws.sendMediaTo(call);
});
ws.addEventListener(WebSocketEvents.ERROR, (event) => {
Logger.write('Proxy WS error: ' + JSON.stringify(event));
});
ws.addEventListener(WebSocketEvents.CLOSE, (event) => {
Logger.write('Proxy WS closed: ' + JSON.stringify(event));
VoxEngine.terminate();
});
call.addEventListener(CallEvents.Disconnected, () => {
ws.close();
VoxEngine.terminate();
});
});server {
listen 80;
server_name proxy.example.com;
location / {
proxy_pass http://127.0.0.1:8000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
}
}Then issue TLS:
sudo certbot --nginx -d proxy.example.com