Skip to main content

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
What You'll Build

A voice application that:

  1. Receives incoming calls on a VoIP number
  2. Streams audio bidirectionally over WebSocket
  3. Echoes back the caller's voice with a 500ms delay
  4. 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.

Keep Your API Key Secret

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
Environment Variables

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:

  1. Webhook endpoints (/webhook/answer and /webhook/hangup) - Superfone calls these when call events occur
  2. WebSocket server (/ws) - Handles real-time audio streaming
  3. 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
How It Works

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.

HTTPS Required

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.

What Just Happened?

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.

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!
Number Linking

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:

  1. Queued → API returns immediately with queued status
  2. Ringing → Phone starts ringing
  3. Answered → Callee picks up, your /webhook/answer is called
  4. Streaming → WebSocket connection established, audio flows
  5. Echo → Your server echoes back audio with 500ms delay
  6. Hangup → Call ends, your /webhook/hangup is called
Testing the Echo

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:

  1. Receive audio - onMedia() is called with a media event containing base64-encoded audio
  2. Schedule echo - Set a timer to send the audio back after delayMs
  3. Send audio - Call sendFn() with a playAudio command
  4. 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 Format

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:

  1. Call your linked number from any phone
  2. Watch your server logs - you'll see the answer webhook called
  3. Say something - you'll hear your voice echoed back
  4. 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'
}
Inbound vs Outbound

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

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
Need Help?

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_URL in .env matches your ngrok URL
  • Restart your server after updating .env

"Number not linked" error

Problem: API returns 400 when making a call.

Solution:

  • Verify the from number is linked to your app
  • Run node get-numbers.js to 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:// or wss://)
  • 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_MS is set (default 500ms)
  • Check server logs for [WebSocket] Stream started message
  • Make sure playAudio commands 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.