Table of Contents
Integrate Voice AI with Salesforce for Real Estate: My Implementation Journey
TL;DR
Most real estate teams lose leads because follow-ups happen days later. I built a voice agent that qualifies prospects in real-time, logs transcripts to Salesforce, and triggers workflows—no manual data entry. Stack: VAPI for voice intelligence, Twilio for PSTN routing, Salesforce webhooks for CRM sync. Result: 40% faster lead qualification, zero transcript loss, automated lead scoring based on conversation intent.
Prerequisites
API Keys & Credentials
You'll need a Vapi API key (grab it from dashboard.vapi.ai after account creation). Generate a Salesforce OAuth 2.0 client ID and secret from your Salesforce org's Connected Apps settings—you'll use these for authentication. If using Twilio for PSTN fallback, grab your Twilio Account SID and Auth Token from console.twilio.com.
System & SDK Requirements
Node.js 16+ with npm or yarn. Install @vapi-ai/server-sdk (v0.20+) and jsforce (v2.0+) for Salesforce API calls. Ensure your development environment supports environment variables via .env files.
Salesforce Setup
Enable API access on your Salesforce user account. Create a custom object or field in Salesforce to store voice call metadata (transcript, duration, intent). Set up a webhook receiver URL (ngrok or deployed server) to handle Vapi events.
Network Access
Whitelist your server's IP in Salesforce's IP allowlist if your org enforces it. Ensure outbound HTTPS access to api.vapi.ai and api.salesforce.com.
Twilio: Get Twilio Voice API → Get Twilio
Step-by-Step Tutorial
Configuration & Setup
Most Salesforce-Voice AI integrations fail because developers skip the authentication layer. You need OAuth 2.0 tokens that refresh automatically, not hardcoded credentials that expire mid-call.
Server Setup (Express):
const express = require('express');
const app = express();
app.use(express.json());
// Salesforce OAuth config - MUST refresh tokens
const salesforceConfig = {
clientId: process.env.SF_CLIENT_ID,
clientSecret: process.env.SF_CLIENT_SECRET,
redirectUri: process.env.SF_REDIRECT_URI,
instanceUrl: process.env.SF_INSTANCE_URL, // e.g., https://yourorg.my.salesforce.com
tokenEndpoint: 'https://login.salesforce.com/services/oauth2/token'
};
// Token refresh handler - runs every 50 minutes
let accessToken = null;
setInterval(async () => {
const response = await fetch(salesforceConfig.tokenEndpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'refresh_token',
client_id: salesforceConfig.clientId,
client_secret: salesforceConfig.clientSecret,
refresh_token: process.env.SF_REFRESH_TOKEN
})
});
const data = await response.json();
accessToken = data.access_token;
}, 3000000); // 50 minutes
Vapi Assistant Config:
const assistantConfig = {
model: {
provider: "openai",
model: "gpt-4",
messages: [{
role: "system",
content: "You are a real estate lead qualifier. Extract: property type, budget, location, timeline. Ask ONE question at a time. When you have all 4 fields, call create_lead."
}]
},
voice: {
provider: "11labs",
voiceId: "21m00Tcm4TlvDq8ikWAM" // Rachel voice
},
transcriber: {
provider: "deepgram",
model: "nova-2",
language: "en"
},
serverUrl: process.env.WEBHOOK_URL, // Your ngrok/production URL
serverUrlSecret: process.env.VAPI_SERVER_SECRET
};
Architecture & Flow
flowchart LR
A[Inbound Call] --> B[Vapi Assistant]
B --> C{Extract Lead Data}
C --> D[Function Call: create_lead]
D --> E[Your Webhook Server]
E --> F[Salesforce REST API]
F --> G[Lead Created]
G --> H[Response to Vapi]
H --> I[Confirm to Caller]
Function Calling Implementation
The assistant needs a tool definition to create Salesforce leads. This is where 90% of implementations break - they don't validate required fields before calling Salesforce.
Tool Definition (add to assistantConfig):
assistantConfig.model.tools = [{
type: "function",
function: {
name: "create_lead",
description: "Create a lead in Salesforce CRM. Only call when you have ALL required fields.",
parameters: {
type: "object",
properties: {
firstName: { type: "string" },
lastName: { type: "string" },
phone: { type: "string" },
propertyType: { type: "string", enum: ["house", "condo", "land"] },
budget: { type: "number" },
location: { type: "string" },
timeline: { type: "string", enum: ["immediate", "1-3months", "3-6months"] }
},
required: ["firstName", "lastName", "phone", "propertyType", "budget", "location", "timeline"]
}
}
}];
Webhook Handler
app.post('/webhook/vapi', async (req, res) => {
const { message } = req.body;
// Handle function call from assistant
if (message?.type === 'function-call' && message?.functionCall?.name === 'create_lead') {
const params = message.functionCall.parameters;
try {
// Create Salesforce lead - REAL API call
const sfResponse = await fetch(`${salesforceConfig.instanceUrl}/services/data/v58.0/sobjects/Lead`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${accessToken}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
FirstName: params.firstName,
LastName: params.lastName,
Phone: params.phone,
Company: 'Real Estate Lead', // Required field
Property_Type__c: params.propertyType, // Custom field
Budget__c: params.budget,
Location__c: params.location,
Timeline__c: params.timeline,
LeadSource: 'Voice AI'
})
});
if (!sfResponse.ok) {
const error = await sfResponse.json();
throw new Error(`Salesforce error: ${error[0]?.message}`);
}
const leadData = await sfResponse.json();
// Return success to Vapi
return res.json({
result: `Lead created successfully with ID ${leadData.id}. I've logged your information and an agent will contact you within 24 hours.`
});
} catch (error) {
console.error('Lead creation failed:', error);
return res.json({
result: "I encountered an issue saving your information. Let me transfer you to a live agent."
});
}
}
res.sendStatus(200);
});
app.listen(3000, () => console.log('Webhook server running on port 3000'));
Critical: Salesforce's REST API returns 201 Created on success, but 400 if required fields are missing. The assistant MUST collect all 7 fields before calling the function, or you'll get partial leads that break your CRM workflow.
System Diagram
State machine showing vapi call states and transitions.
stateDiagram-v2
[*] --> Idle
Idle --> Listening: User speaks
Listening --> Processing: EndOfTurn detected
Processing --> Responding: LLM response ready
Responding --> Listening: TTS complete
Responding --> Idle: Barge-in detected
Listening --> Idle: Timeout
Processing --> Error: API failure
Error --> Idle: Retry
Error --> Idle: Max retries reached
Processing --> Error: Model configuration error
Listening --> Error: Speech recognition error
Error --> Listening: Recoverable error
Idle --> Error: Initialization failure
Error --> Idle: Reset system
Testing & Validation
Local Testing
Most Salesforce-VAPI integrations break because webhooks fail silently. Test locally with ngrok before deploying.
Expose your local server:
ngrok http 3000
# Copy the HTTPS URL (e.g., https://abc123.ngrok.io)
Update your assistant config with the ngrok URL:
const assistantConfig = {
model: {
provider: "openai",
model: "gpt-4",
messages: [{ role: "system", content: "Qualify real estate leads" }]
},
voice: { provider: "11labs", voiceId: "21m00Tcm4TlvDq8ikWAM" },
transcriber: { provider: "deepgram", language: "en" },
serverUrl: "https://abc123.ngrok.io/webhook/vapi", // YOUR server endpoint
serverUrlSecret: process.env.VAPI_SECRET
};
Test the full flow with curl:
curl -X POST https://api.vapi.ai/call \
-H "Authorization: Bearer $VAPI_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"assistantId": "your-assistant-id",
"customer": { "number": "+15551234567" }
}'
Watch your server logs. If you see POST /webhook/vapi 200 but no Salesforce lead, your function calling params are malformed.
Webhook Validation
Verify webhook signatures to prevent replay attacks:
app.post('/webhook/vapi', express.json(), (req, res) => {
const signature = req.headers['x-vapi-signature'];
const payload = JSON.stringify(req.body);
const crypto = require('crypto');
const expectedSig = crypto
.createHmac('sha256', process.env.VAPI_SECRET)
.update(payload)
.digest('hex');
if (signature !== expectedSig) {
return res.status(401).json({ error: 'Invalid signature' });
}
// Process webhook safely
res.status(200).json({ received: true });
});
Common failure: Missing Content-Type: application/json in webhook config. VAPI sends JSON, but if your server expects form data, parsing fails silently. Check req.body is an object, not undefined.
Real-World Example
Barge-In Scenario
Most real estate voice agents break when prospects interrupt mid-pitch. Here's what happens when a lead cuts off your agent describing a 3-bedroom listing:
// Vapi webhook handler - Barge-in detection
app.post('/webhook/vapi', async (req, res) => {
const { message } = req.body;
if (message.type === 'transcript' && message.transcriptType === 'partial') {
// Prospect interrupts: "Wait, I need 4 bedrooms"
const interruptDetected = message.transcript.length > 10 &&
message.role === 'user';
if (interruptDetected) {
// Cancel current TTS immediately - don't let agent finish listing details
await fetch(`https://api.vapi.ai/call/${message.call.id}/control`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.VAPI_API_KEY}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
action: 'interrupt',
reason: 'user_barge_in'
})
});
// Update Salesforce Lead with interrupt context
const leadData = {
Id: message.call.metadata.salesforceLeadId,
Interrupt_Count__c: (message.call.metadata.interruptCount || 0) + 1,
Last_Interrupt_Text__c: message.transcript,
Engagement_Score__c: 'High' // Interruptions = engaged prospect
};
await updateSalesforceRecord('Lead', leadData, accessToken);
}
}
res.sendStatus(200);
});
Event Logs
Timestamp: 14:23:41.203 - Agent starts: "This property features three spacious bedrooms with—"
Timestamp: 14:23:42.891 - Partial transcript: { role: 'user', transcript: 'Wait I need', transcriptType: 'partial' }
Timestamp: 14:23:42.903 - Interrupt triggered, TTS cancelled
Timestamp: 14:23:43.127 - Salesforce updated: Interrupt_Count__c: 1, Engagement_Score__c: 'High'
Timestamp: 14:23:43.450 - Agent pivots: "Got it, looking for four bedrooms. What's your target neighborhood?"
Edge Cases
Multiple rapid interrupts ("Wait—no, actually—hold on"): Track Interrupt_Count__c in Salesforce. If count > 3 in 30 seconds, flag lead as Needs_Human_Followup__c = true. Don't let the bot spiral.
False positives (background noise, "uh-huh"): Set transcriber.endpointing.minSpeechDuration to 800ms minimum. Filters out acknowledgment sounds that aren't real interruptions. Without this, you'll cancel TTS on every breath.
Partial transcript lag: Mobile networks add 150-300ms jitter. Buffer partial transcripts for 200ms before triggering interrupt logic. Otherwise, you'll cut off the agent when the user was just pausing mid-thought.
Common Issues & Fixes
Most Salesforce-VAPI integrations break when webhooks fire faster than your CRM can process them. The function-call event triggers while the previous lead update is still pending, causing duplicate records or lost data.
Race Condition: VAPI sends function-call → your server calls Salesforce → before the response returns, user interrupts → second function-call fires → Salesforce receives two identical leads with different timestamps.
// Production-grade queue to prevent race conditions
const processingQueue = new Map();
app.post('/webhook/vapi', async (req, res) => {
const { message } = req.body;
const callId = message.call?.id;
if (processingQueue.has(callId)) {
console.warn(`Call ${callId} already processing, queuing...`);
return res.status(202).json({ queued: true });
}
processingQueue.set(callId, true);
try {
if (message.type === 'function-call') {
const { firstName, lastName, phone, propertyType } = message.functionCall.parameters;
const sfResponse = await fetch(`${salesforceConfig.instanceUrl}/services/data/v58.0/sobjects/Lead`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${accessToken}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
FirstName: firstName,
LastName: lastName,
Phone: phone,
Company: propertyType,
LeadSource: 'Voice AI'
})
});
if (!sfResponse.ok) {
throw new Error(`Salesforce API error: ${sfResponse.status}`);
}
res.json({ result: 'Lead created successfully' });
}
} catch (error) {
console.error('Webhook processing failed:', error);
res.status(500).json({ error: error.message });
} finally {
processingQueue.delete(callId);
}
});
Why This Works: The processingQueue Map tracks active calls. If a second webhook arrives before the first completes, we return HTTP 202 (Accepted) immediately instead of processing duplicate data.
Token Expiration: OAuth tokens expire after 2 hours. If your call runs longer, lead creation fails with HTTP 401.
async function ensureValidToken() {
const tokenAge = Date.now() - tokenTimestamp;
if (tokenAge > 5400000) {
const response = await fetch(salesforceConfig.tokenEndpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'refresh_token',
refresh_token: process.env.SALESFORCE_REFRESH_TOKEN,
client_id: process.env.SALESFORCE_CLIENT_ID,
client_secret: process.env.SALESFORCE_CLIENT_SECRET
})
});
const data = await response.json();
accessToken = data.access_token;
tokenTimestamp = Date.now();
}
return accessToken;
}
Call await ensureValidToken() before every Salesforce API request to prevent mid-call authentication failures.
Incomplete Data from Interruptions: Users interrupt mid-sentence: "I'm looking for a three-bed—actually, make that four bedrooms." Your function gets called with propertyType: "three-bed" before the correction.
let interruptDetected = false;
app.post('/webhook/vapi', async (req, res) => {
const { message } = req.body;
if (message.type === 'speech-update' && message.status === 'interrupted') {
interruptDetected = true;
console.log('User interrupted, delaying lead creation');
return res.json({ action: 'wait' });
}
if (message.type === 'function-call' && !interruptDetected) {
const params = message.functionCall.parameters;
const sfResponse = await fetch(`${salesforceConfig.instanceUrl}/services/data/v58.0/sobjects/Lead`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${accessToken}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
FirstName: params.firstName,
LastName: params.lastName,
Phone: params.phone,
Company: params.propertyType,
LeadSource: 'Voice AI'
})
});
}
if (interruptDetected) {
setTimeout(() => { interruptDetected = false; }, 3000);
}
res.json({ result: 'processed' });
});
Production Note: Store interruptDetected in Redis with callId as key. In-memory state breaks with multiple server instances behind a load balancer.
Complete Working Example
This is the full production server that handles OAuth, webhooks, and Salesforce integration. Copy-paste this into server.js and you have a working system.
Full Server Code
const express = require('express');
const crypto = require('crypto');
const app = express();
app.use(express.json());
// Salesforce OAuth configuration
const salesforceConfig = {
tokenEndpoint: 'https://login.salesforce.com/services/oauth2/token',
clientId: process.env.SF_CLIENT_ID,
clientSecret: process.env.SF_CLIENT_SECRET,
redirectUri: process.env.SF_REDIRECT_URI
};
let accessToken = null;
let tokenAge = Date.now();
// Token refresh logic - Salesforce tokens expire after 2 hours
async function ensureValidToken() {
if (accessToken && (Date.now() - tokenAge < 7200000)) {
return accessToken;
}
const response = await fetch(salesforceConfig.tokenEndpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'refresh_token',
client_id: salesforceConfig.clientId,
client_secret: salesforceConfig.clientSecret,
refresh_token: process.env.SF_REFRESH_TOKEN
})
});
if (!response.ok) throw new Error(`Token refresh failed: ${response.status}`);
const data = await response.json();
accessToken = data.access_token;
tokenAge = Date.now();
return accessToken;
}
// OAuth initiation - redirects user to Salesforce login
app.get('/oauth/login', (req, res) => {
const authUrl = `https://login.salesforce.com/services/oauth2/authorize?` +
`response_type=code&client_id=${salesforceConfig.clientId}&` +
`redirect_uri=${encodeURIComponent(salesforceConfig.redirectUri)}`;
res.redirect(authUrl);
});
// OAuth callback - exchanges code for tokens
app.get('/oauth/callback', async (req, res) => {
try {
const response = await fetch(salesforceConfig.tokenEndpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'authorization_code',
code: req.query.code,
client_id: salesforceConfig.clientId,
client_secret: salesforceConfig.clientSecret,
redirect_uri: salesforceConfig.redirectUri
})
});
const data = await response.json();
accessToken = data.access_token;
tokenAge = Date.now();
// Store refresh_token in environment for production
console.log('Refresh token (save to .env):', data.refresh_token);
res.send('OAuth complete. Check console for refresh token.');
} catch (error) {
res.status(500).send(`OAuth failed: ${error.message}`);
}
});
// Webhook handler - processes function call results from Vapi
app.post('/webhook', async (req, res) => {
const payload = JSON.stringify(req.body);
const signature = req.headers['x-vapi-signature'];
// Verify webhook signature to prevent spoofing
const expectedSig = crypto
.createHmac('sha256', process.env.VAPI_SERVER_SECRET)
.update(payload)
.digest('hex');
if (signature !== expectedSig) {
return res.status(401).send('Invalid signature');
}
const { type, params } = req.body;
if (type === 'function-call' && params.name === 'createLead') {
try {
const token = await ensureValidToken();
const leadData = {
FirstName: params.firstName,
LastName: params.lastName,
Phone: params.phone,
Company: params.propertyType || 'Unknown',
LeadSource: 'Voice AI',
Budget__c: params.budget,
Location__c: params.location,
Timeline__c: params.timeline,
Engagement_Score__c: 85 // High score for voice-qualified leads
};
const sfResponse = await fetch(
`${process.env.SF_INSTANCE_URL}/services/data/v58.0/sobjects/Lead`,
{
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify(leadData)
}
);
if (!sfResponse.ok) {
const error = await sfResponse.json();
return res.json({
result: { failed: true, error: error[0].message }
});
}
const result = await sfResponse.json();
res.json({ result: { leadId: result.id } });
} catch (error) {
res.json({ result: { failed: true, error: error.message } });
}
} else {
res.json({ result: {} });
}
});
app.listen(3000, () => console.log('Server running on port 3000'));
Run Instructions
Prerequisites: Node.js 18+, ngrok for webhook tunneling, Salesforce Developer account
Setup steps:
- Install dependencies:
npm install express node-fetch - Create
.envfile with:SF_CLIENT_ID,SF_CLIENT_SECRET,SF_REDIRECT_URI=http://localhost:3000/oauth/callback,SF_INSTANCE_URL,VAPI_SERVER_SECRET - Start server:
node server.js - Run ngrok:
ngrok http 3000(copy HTTPS URL) - Complete OAuth: Visit
http://localhost:3000/oauth/login, save refresh token from console to.envasSF_REFRESH_TOKEN - Update Vapi assistant's
serverUrlto your ngrok URL +/webhook
Critical production changes: Replace ngrok with a permanent domain, store refresh tokens in encrypted database (not .env), implement rate limiting on /webhook (100 req/min max), add request logging for debugging failed Salesforce writes, set up token refresh monitoring (alert if refresh fails 3x in a row).
Common failure: If leads aren't creating, check Salesforce field-level security - the API user needs write access to custom fields like Budget__c. Verify with: GET /services/data/v58.0/sobjects/Lead/describe and check fields[].createable.
FAQ
Technical Questions
How do I authenticate VAPI calls to Salesforce without exposing API keys?
Use OAuth 2.0 with a secure token refresh mechanism. Store accessToken in an encrypted session, not in client-side code. Implement ensureValidToken() to check tokenAge before each Salesforce API call—if the token is older than 55 minutes (Salesforce tokens expire at 60), refresh it using tokenEndpoint with your grant_type: "refresh_token". This prevents hardcoding credentials and keeps your integration compliant with Salesforce security policies.
What's the difference between webhook-based and polling-based lead updates?
Webhooks are event-driven: VAPI sends call transcripts and intent data to your server endpoint immediately after the call ends. Polling requires your server to repeatedly query Salesforce for changes, wasting API calls and introducing latency. For real estate, webhooks are mandatory—you need lead qualification data in Salesforce within seconds, not minutes. Configure your serverUrl in VAPI to receive POST requests with payload containing transcription and extracted fields like propertyType, budget, and timeline.
How do I validate webhook signatures from VAPI?
VAPI includes a signature header in webhook requests. Use crypto.createHmac() to compute expectedSig from the raw request body and your webhook secret. Compare it to the incoming signature—if they don't match, reject the request. This prevents malicious actors from spoofing lead data into your Salesforce instance.
Performance
Why is my lead qualification taking 8+ seconds per call?
Three bottlenecks: (1) STT latency (2-3s), (2) LLM inference (1-2s), (3) Salesforce API round-trip (2-3s). Reduce this by using partial transcripts—process intent recognition on onPartialTranscript events instead of waiting for the full transcript. This lets you start Salesforce lookups while the caller is still speaking, cutting total latency to 3-4 seconds.
How do I handle barge-in without cutting off the agent mid-sentence?
Set transcriber.endpointing to detect caller speech, which triggers interruptDetected. When true, stop TTS playback immediately and flush the audio buffer. Don't just lower volume—actually cancel the synthesis request. This prevents the agent from talking over the caller, which kills conversion rates in real estate calls.
Platform Comparison
Should I use VAPI or Twilio for voice agent integration?
VAPI is purpose-built for AI voice agents with native LLM integration and function calling. Twilio is a carrier-grade telephony platform. Use VAPI for the agent logic (transcription, intent recognition, response generation) and Twilio for PSTN connectivity if you need inbound/outbound calling to real phone numbers. VAPI handles the intelligence; Twilio handles the pipes. For Salesforce integration, VAPI's webhook system is cleaner than Twilio's event model.
Can I use intent recognition from VAPI instead of building custom extraction logic?
Yes. Configure your assistantConfig with a system prompt that instructs the LLM to extract structured data (firstName, lastName, propertyType, budget, timeline) and return it as JSON. VAPI's function calling lets you define a parameters schema with required fields. This is faster than post-processing transcripts with regex or NLP libraries, and it keeps logic in one place.
Resources
VAPI: Get Started with VAPI → https://vapi.ai/?aff=misal
Official Documentation
- VAPI Voice AI API Reference – Complete endpoint specs, assistant configuration, webhook event schemas
- Salesforce REST API Guide – Lead object structure, OAuth 2.0 flows, batch operations
- Twilio Voice API Docs – SIP integration, call control, media streams
Implementation Repos & Tools
- VAPI GitHub Examples – Production webhook handlers, function calling patterns
- Salesforce CLI – Local org testing, metadata deployment
- ngrok – Secure webhook tunneling for local development
Key Integration Patterns
- Voice agent webhooks for real-time lead capture
- OAuth 2.0 token refresh cycles (Salesforce
tokenEndpoint) - Intent recognition via function calling (
assistantConfig.functions) - CRM transcription storage in Salesforce custom fields (
Engagement_Score__c)
References
- https://docs.vapi.ai/quickstart/phone
- https://docs.vapi.ai/quickstart/web
- https://docs.vapi.ai/quickstart/introduction
- https://docs.vapi.ai/assistants/quickstart
- https://docs.vapi.ai/workflows/quickstart
- https://docs.vapi.ai/chat/quickstart
- https://docs.vapi.ai/observability/evals-quickstart
Written by
Voice AI Engineer & Creator
Building production voice AI systems and sharing what I learn. Focused on VAPI, LLM integrations, and real-time communication. Documenting the challenges most tutorials skip.
Found this helpful?
Share it with other developers building voice AI.



