Quickstart Tutorial
Build your first SFVoPI voice application in 10 minutes. This tutorial walks you through creating a simple echo server that receives calls, streams audio in real-time, and echoes back what the caller says with a delay.
By the end of this tutorial, you'll understand:
- How to set up a server with webhook endpoints
- How to handle real-time audio streaming over WebSocket
- How to make and receive phone calls programmatically
- How to process audio in real-time
A voice application that:
- Receives incoming calls on a VoIP number
- Streams audio bidirectionally over WebSocket
- Echoes back the caller's voice with a 500ms delay
- Logs call events (answered, ended)
Step 1: Prerequisites
Before you start, make sure you have:
API Key
You'll need a Superfone API key to use SFVoPI. Contact the Superfone team to get your API key.
Never commit your API key to version control or share it publicly. Store it in environment variables.
Node.js 18+
Check your Node.js version:
node --version
# Should output v18.0.0 or higher
If you don't have Node.js installed, download it from nodejs.org.
Public URL (ngrok)
Your server needs to be publicly accessible so Superfone can reach your webhooks and WebSocket endpoint. For local development, we'll use ngrok.
Install ngrok:
# macOS (Homebrew)
brew install ngrok
# Windows (Chocolatey)
choco install ngrok
# Linux (Snap)
snap install ngrok
# Or download from https://ngrok.com/download
Step 2: Create Your Project
Create a new directory and initialize a Node.js project:
mkdir my-sfvopi-app
cd my-sfvopi-app
npm init -y
Install dependencies:
npm install express ws dotenv
Create a .env file to store your configuration:
touch .env
Add your API key to .env:
SF_API_KEY=your_api_key_here
SF_API_BASE_URL=https://prod-api.superfone.co.in/superfone
PORT=3000
ECHO_DELAY_MS=500
The .env file stores sensitive configuration. Add it to .gitignore to prevent committing secrets.
Step 3: Create Your Server
Create a file named server.js:
// server.js
require('dotenv').config();
const express = require('express');
const { createServer } = require('http');
const { WebSocketServer } = require('ws');
const PORT = parseInt(process.env.PORT || '3000', 10);
const ECHO_DELAY_MS = parseInt(process.env.ECHO_DELAY_MS || '500', 10);
const PUBLIC_URL = process.env.PUBLIC_URL || `http://localhost:${PORT}`;
const app = express();
app.use(express.json());
// Health check endpoint
app.get('/', (req, res) => {
res.json({ status: 'ok', service: 'my-sfvopi-app' });
});
// Answer webhook - called when a call is answered
app.post('/webhook/answer', (req, res) => {
const payload = req.body;
console.log('[Answer Webhook] Call answered:', {
call_uuid: payload.call_uuid,
from: payload.from,
to: payload.to,
direction: payload.direction,
});
// Tell Superfone to stream audio to our WebSocket
const wsUrl = PUBLIC_URL.replace(/^http/, 'ws') + '/ws';
res.json({
stream: {
url: wsUrl,
codec: 'PCMU',
sample_rate: 8000,
direction: 'BOTH',
stream_timeout: 86400,
extra_headers: {
'X-Call-UUID': payload.call_uuid || 'unknown',
},
},
});
});
// Hangup webhook - called when a call ends
app.post('/webhook/hangup', (req, res) => {
const payload = req.body;
console.log('[Hangup Webhook] Call ended:', {
call_uuid: payload.call_uuid,
duration: payload.duration,
call_status: payload.call_status,
hangup_cause: payload.hangup_cause,
});
res.json({ received: true });
});
// Create HTTP server
const server = createServer(app);
// Create WebSocket server
const wss = new WebSocketServer({ server, path: '/ws' });
// Echo processor - stores timers for delayed echo
class EchoProcessor {
constructor(delayMs) {
this.delayMs = delayMs;
this.timers = [];
}
onMedia(mediaEvent, sendFn) {
const timer = setTimeout(() => {
// Echo back the audio after delay
sendFn({
event: 'playAudio',
media: {
payload: mediaEvent.media.payload,
contentType: mediaEvent.media.contentType,
sampleRate: mediaEvent.media.sampleRate,
},
});
}, this.delayMs);
this.timers.push(timer);
}
destroy() {
// Clear all pending timers
this.timers.forEach(timer => clearTimeout(timer));
this.timers = [];
}
}
// WebSocket connection handler
wss.on('connection', (ws) => {
console.log('[WebSocket] New connection established');
const echo = new EchoProcessor(ECHO_DELAY_MS);
let streamId = null;
ws.on('message', (data) => {
try {
const msg = JSON.parse(data.toString());
switch (msg.event) {
case 'start':
console.log(`[WebSocket] Stream started - streamId: ${msg.streamId}`);
streamId = msg.streamId;
break;
case 'media':
// Process incoming audio and echo it back
echo.onMedia(msg, (command) => {
if (ws.readyState === ws.OPEN) {
ws.send(JSON.stringify(command));
}
});
break;
case 'dtmf':
console.log(`[WebSocket] DTMF pressed: ${msg.digit}`);
break;
case 'clearedAudio':
console.log(`[WebSocket] Audio buffer cleared - seq: ${msg.sequenceNumber}`);
break;
case 'playedStream':
console.log(`[WebSocket] Checkpoint reached - name: ${msg.name}`);
break;
default:
console.warn(`[WebSocket] Unknown event:`, msg);
}
} catch (err) {
console.error('[WebSocket] Failed to parse message:', err);
}
});
ws.on('close', () => {
console.log('[WebSocket] Connection closed');
echo.destroy();
});
ws.on('error', (err) => {
console.error('[WebSocket] Error:', err);
echo.destroy();
});
});
// Start server
server.listen(PORT, () => {
console.log(`✅ Server running on port ${PORT}`);
console.log(`📡 WebSocket endpoint: ws://localhost:${PORT}/ws`);
console.log(`🔊 Echo delay: ${ECHO_DELAY_MS}ms`);
console.log(`🌐 Public URL: ${PUBLIC_URL}`);
console.log('');
console.log('Next steps:');
console.log('1. Run ngrok to expose your server');
console.log('2. Update PUBLIC_URL in .env with ngrok URL');
console.log('3. Restart server and create your SFVoPI app');
});
This server does three things:
- Webhook endpoints (
/webhook/answerand/webhook/hangup) - Superfone calls these when call events occur - WebSocket server (
/ws) - Handles real-time audio streaming - Echo processor - Receives audio, delays it, and sends it back
Start your server:
node server.js
You should see:
✅ Server running on port 3000
📡 WebSocket endpoint: ws://localhost:3000/ws
🔊 Echo delay: 500ms
🌐 Public URL: http://localhost:3000
When a call is answered, Superfone calls your /webhook/answer endpoint. Your server responds with a WebSocket URL. Superfone then connects to your WebSocket and streams audio bidirectionally. Your echo processor receives audio chunks, delays them, and sends them back.
Step 4: Make It Reachable
Your server is running locally, but Superfone can't reach it yet. Use ngrok to expose it publicly.
In a new terminal window, run:
ngrok http 3000
You'll see output like this:
Session Status online
Account your-account (Plan: Free)
Version 3.5.0
Region United States (us)
Forwarding https://abc123.ngrok.io -> http://localhost:3000
Copy the HTTPS URL (e.g., https://abc123.ngrok.io).
Update your .env file:
PUBLIC_URL=https://abc123.ngrok.io
Restart your server (Ctrl+C, then node server.js again) so it picks up the new PUBLIC_URL.
Superfone requires HTTPS for webhook URLs. ngrok provides HTTPS automatically. In production, use a proper SSL certificate.
Step 5: Create an App
Now let's create a SFVoPI app using the REST API. Your app defines the webhook URLs that Superfone will call.
Create a file named create-app.js:
// create-app.js
require('dotenv').config();
const https = require('https');
const API_KEY = process.env.SF_API_KEY;
const BASE_URL = process.env.SF_API_BASE_URL || 'https://prod-api.superfone.co.in/superfone';
const PUBLIC_URL = process.env.PUBLIC_URL;
if (!API_KEY) {
console.error('❌ Error: SF_API_KEY not set in .env');
process.exit(1);
}
if (!PUBLIC_URL) {
console.error('❌ Error: PUBLIC_URL not set in .env');
process.exit(1);
}
const data = JSON.stringify({
name: 'My First Voice App',
answer_url: `${PUBLIC_URL}/webhook/answer`,
answer_method: 'POST',
hangup_url: `${PUBLIC_URL}/webhook/hangup`,
hangup_method: 'POST',
});
const url = new URL('/sfvopi/apps', BASE_URL);
const options = {
hostname: url.hostname,
port: 443,
path: url.pathname,
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-API-Key': API_KEY,
'Content-Length': data.length,
},
};
const req = https.request(options, (res) => {
let body = '';
res.on('data', (chunk) => {
body += chunk;
});
res.on('end', () => {
if (res.statusCode === 201) {
const response = JSON.parse(body);
console.log('✅ App created successfully!');
console.log('');
console.log('App ID:', response.data.app_id);
console.log('Name:', response.data.name);
console.log('Answer URL:', response.data.answer_url);
console.log('Hangup URL:', response.data.hangup_url);
console.log('');
console.log('Save this App ID - you\'ll need it to link a number.');
} else {
console.error('❌ Error:', res.statusCode, body);
}
});
});
req.on('error', (err) => {
console.error('❌ Request failed:', err.message);
});
req.write(data);
req.end();
Run it:
node create-app.js
You should see:
✅ App created successfully!
App ID: sfv_app_abc123xyz456
Name: My First Voice App
Answer URL: https://abc123.ngrok.io/webhook/answer
Hangup URL: https://abc123.ngrok.io/webhook/hangup
Save this App ID - you'll need it to link a number.
Save the App ID - you'll need it in the next step.
You created a SFVoPI app that tells Superfone where to send webhook callbacks. When a call is answered, Superfone will POST to your /webhook/answer endpoint. When the call ends, it will POST to /webhook/hangup.
Step 6: Link a Number
Now let's link a phone number to your app so you can receive calls.
First, get available numbers. Create get-numbers.js:
// get-numbers.js
require('dotenv').config();
const https = require('https');
const API_KEY = process.env.SF_API_KEY;
const BASE_URL = process.env.SF_API_BASE_URL || 'https://prod-api.superfone.co.in/superfone';
const url = new URL('/sfvopi/numbers/available', BASE_URL);
const options = {
hostname: url.hostname,
port: 443,
path: url.pathname,
method: 'GET',
headers: {
'X-API-Key': API_KEY,
},
};
const req = https.request(options, (res) => {
let body = '';
res.on('data', (chunk) => {
body += chunk;
});
res.on('end', () => {
if (res.statusCode === 200) {
const response = JSON.parse(body);
console.log('📞 Available numbers:');
console.log('');
response.data.forEach((num, idx) => {
console.log(`${idx + 1}. ${num.voip_number} (${num.provider})`);
});
console.log('');
console.log('Use one of these numbers in the next step.');
} else {
console.error('❌ Error:', res.statusCode, body);
}
});
});
req.on('error', (err) => {
console.error('❌ Request failed:', err.message);
});
req.end();
Run it:
node get-numbers.js
You'll see a list of available numbers:
📞 Available numbers:
1. +918000000001 (superfone)
2. +918000000002 (superfone)
Use one of these numbers in the next step.
Now link a number to your app. Create link-number.js:
// link-number.js
require('dotenv').config();
const https = require('https');
const API_KEY = process.env.SF_API_KEY;
const BASE_URL = process.env.SF_API_BASE_URL || 'https://prod-api.superfone.co.in/superfone';
// Replace these with your values
const APP_ID = 'sfv_app_abc123xyz456'; // From Step 5
const VOIP_NUMBER = '+918000000001'; // From get-numbers.js
const data = JSON.stringify({
voip_number: VOIP_NUMBER,
});
const url = new URL(`/sfvopi/apps/${APP_ID}/numbers`, BASE_URL);
const options = {
hostname: url.hostname,
port: 443,
path: url.pathname,
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-API-Key': API_KEY,
'Content-Length': data.length,
},
};
const req = https.request(options, (res) => {
let body = '';
res.on('data', (chunk) => {
body += chunk;
});
res.on('end', () => {
if (res.statusCode === 201) {
const response = JSON.parse(body);
console.log('✅ Number linked successfully!');
console.log('');
console.log('Number:', response.data.voip_number);
console.log('App ID:', response.data.app_id);
console.log('');
console.log('You can now receive calls on this number!');
} else {
console.error('❌ Error:', res.statusCode, body);
}
});
});
req.on('error', (err) => {
console.error('❌ Request failed:', err.message);
});
req.write(data);
req.end();
Update the APP_ID and VOIP_NUMBER constants with your values, then run:
node link-number.js
You should see:
✅ Number linked successfully!
Number: +918000000001
App ID: sfv_app_abc123xyz456
You can now receive calls on this number!
A number can only be linked to one app at a time. When someone calls this number, Superfone will trigger your app's webhooks.
Step 7: Make Your First Call
Let's make an outbound call to test your app. Create make-call.js:
// make-call.js
require('dotenv').config();
const https = require('https');
const API_KEY = process.env.SF_API_KEY;
const BASE_URL = process.env.SF_API_BASE_URL || 'https://prod-api.superfone.co.in/superfone';
const PUBLIC_URL = process.env.PUBLIC_URL;
// Replace these with your values
const FROM_NUMBER = '+918000000001'; // Your linked number
const TO_NUMBER = '+918000000004'; // Number to call
const data = JSON.stringify({
from: FROM_NUMBER,
to: TO_NUMBER,
answer_url: `${PUBLIC_URL}/webhook/answer`,
answer_method: 'POST',
hangup_url: `${PUBLIC_URL}/webhook/hangup`,
hangup_method: 'POST',
call_time_limit: 300,
ring_timeout: 30,
});
const url = new URL('/sfvopi/calls', BASE_URL);
const options = {
hostname: url.hostname,
port: 443,
path: url.pathname,
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-API-Key': API_KEY,
'Content-Length': data.length,
},
};
const req = https.request(options, (res) => {
let body = '';
res.on('data', (chunk) => {
body += chunk;
});
res.on('end', () => {
if (res.statusCode === 201) {
const response = JSON.parse(body);
console.log('✅ Call initiated successfully!');
console.log('');
console.log('Request UUID:', response.data.request_uuid);
console.log('Status:', response.data.status);
console.log('');
console.log('📲 The call is being placed...');
console.log('When the callee answers, your webhook will be called.');
console.log('');
console.log('Watch your server logs to see the events!');
} else {
console.error('❌ Error:', res.statusCode, body);
}
});
});
req.on('error', (err) => {
console.error('❌ Request failed:', err.message);
});
req.write(data);
req.end();
Update FROM_NUMBER and TO_NUMBER, then run:
node make-call.js
You should see:
✅ Call initiated successfully!
Request UUID: sfv_ob_req_abc123xyz456
Status: queued
📲 The call is being placed...
When the callee answers, your webhook will be called.
Watch your server logs to see the events!
Now watch your server logs. When the call is answered, you'll see:
[Answer Webhook] Call answered: {
call_uuid: 'call_abc123',
from: '+918000000001',
to: '+918000000004',
direction: 'outbound'
}
[WebSocket] New connection established
[WebSocket] Stream started - streamId: stream_abc123
What happens next:
- Queued → API returns immediately with
queuedstatus - Ringing → Phone starts ringing
- Answered → Callee picks up, your
/webhook/answeris called - Streaming → WebSocket connection established, audio flows
- Echo → Your server echoes back audio with 500ms delay
- Hangup → Call ends, your
/webhook/hangupis called
When the call is answered, say something. You'll hear your voice echoed back after a 500ms delay. This proves your audio processing is working!
Step 8: Handle Audio
The echo processor is the simplest audio handler. Let's understand how it works:
class EchoProcessor {
constructor(delayMs) {
this.delayMs = delayMs;
this.timers = [];
}
onMedia(mediaEvent, sendFn) {
// Schedule audio to be sent back after delay
const timer = setTimeout(() => {
sendFn({
event: 'playAudio',
media: {
payload: mediaEvent.media.payload, // Base64-encoded audio
contentType: mediaEvent.media.contentType, // 'audio/PCMU'
sampleRate: mediaEvent.media.sampleRate, // 8000
},
});
}, this.delayMs);
this.timers.push(timer);
}
destroy() {
// Clean up all pending timers
this.timers.forEach(timer => clearTimeout(timer));
this.timers = [];
}
}
How it works:
- Receive audio -
onMedia()is called with amediaevent containing base64-encoded audio - Schedule echo - Set a timer to send the audio back after
delayMs - Send audio - Call
sendFn()with aplayAudiocommand - Clean up -
destroy()clears all pending timers when the call ends
Building on this pattern:
- AI Voice Bot - Replace echo with speech-to-text → LLM → text-to-speech
- Call Recording - Store audio chunks and upload to S3 when call ends
- Live Transcription - Stream audio to Deepgram or Whisper API
- IVR System - Detect DTMF tones and play menu options
Audio is base64-encoded PCMU (G.711 μ-law) at 8000 Hz. You don't need to decode it for echo - just pass through the payload. For AI processing, decode to PCM first.
Step 9: Receive Inbound Calls
Your app is already set up to receive inbound calls! Since you linked a number to your app in Step 6, any calls to that number will automatically trigger your webhooks.
Try it:
- Call your linked number from any phone
- Watch your server logs - you'll see the answer webhook called
- Say something - you'll hear your voice echoed back
- Hang up - you'll see the hangup webhook called
What happens:
[Answer Webhook] Call answered: {
call_uuid: 'call_xyz789',
from: '+918000000004',
to: '+918000000001',
direction: 'inbound'
}
[WebSocket] New connection established
[WebSocket] Stream started - streamId: stream_xyz789
[Hangup Webhook] Call ended: {
call_uuid: 'call_xyz789',
duration: 45,
call_status: 'COMPLETED',
hangup_cause: '16'
}
The only difference is the direction field in the webhook payload. Your code handles both the same way - stream audio and process it.
Step 10: Next Steps
Congratulations! You've built your first SFVoPI application. Here's what you learned:
- ✅ Set up webhook endpoints for call events
- ✅ Handle real-time audio streaming over WebSocket
- ✅ Process audio with a simple echo processor
- ✅ Make outbound calls via REST API
- ✅ Receive inbound calls on linked numbers
Where to go from here:
Learn More
- API Reference - Complete REST API documentation
- Webhooks - Deep dive into webhook payloads and responses
- Audio Streaming - WebSocket protocol and audio processing
- Authentication - API key management and security
Build Something Cool
- Voice AI Bot - Integrate with OpenAI Realtime API or Deepgram for conversational AI
- Call Center - Build IVR menus, call routing, and agent dashboards
- Voicemail - Record messages and transcribe them automatically
- Appointment Reminders - Make automated outbound calls with TTS
- Call Analytics - Track call duration, sentiment, and keywords
Production Checklist
Before deploying to production:
- Use a proper domain with SSL certificate (not ngrok)
- Store API keys in secure environment variables
- Add error handling and retry logic
- Implement webhook signature verification (coming soon)
- Set up monitoring and logging
- Test with multiple concurrent calls
- Add rate limiting to your endpoints
Join our community or contact support at hello@superfone.in for assistance.
Troubleshooting
"Connection refused" errors
Problem: Superfone can't reach your webhooks.
Solution:
- Make sure ngrok is running (
ngrok http 3000) - Verify
PUBLIC_URLin.envmatches your ngrok URL - Restart your server after updating
.env
"Number not linked" error
Problem: API returns 400 when making a call.
Solution:
- Verify the
fromnumber is linked to your app - Run
node get-numbers.jsto see available numbers - Check that you used the correct App ID in
link-number.js
No audio in WebSocket
Problem: WebSocket connects but no media events arrive.
Solution:
- Check that your answer webhook returns valid Stream JSON
- Verify the WebSocket URL is correct (should use
ws://orwss://) - Make sure your server is listening on the correct port
- Check ngrok logs for connection errors
Echo not working
Problem: Call connects but no echo is heard.
Solution:
- Verify
ECHO_DELAY_MSis set (default 500ms) - Check server logs for
[WebSocket] Stream startedmessage - Make sure
playAudiocommands are being sent (add console.log) - Test with a longer delay (2000ms) to make it more obvious
Call immediately hangs up
Problem: Call connects then immediately ends.
Solution:
- Check that your answer webhook responds within 10 seconds
- Verify the Stream JSON response is valid (correct codec, sample_rate)
- Look for errors in your server logs
- Make sure the WebSocket server is running on the correct path (
/ws)
Ready to build? Start with the API Reference or explore example projects.