Skip to the answer
Most Salesforce voice integrations lose partial data when leads hang up mid-call. Build a VAPI voice agent that writes lead scores to Salesforce in real-time during the conversation—not after. When a prospect mentions budget or timeline, your webhook fires immediately to update custom Lead fields. If the call drops at minute 3 of a 5-minute qualification, you still capture those signals. Stack: VAPI for conversational AI, Salesforce REST API for CRM writes, OAuth 2.0 for auth. Outcome: Zero data loss, instant routing of hot leads to sales reps, and complete transcripts even from incomplete calls.
What you need first
API credentials:
- VAPI API key from dashboard.vapi.ai
- Salesforce Connected App (Consumer Key + Secret)
- Salesforce OAuth 2.0 refresh token (generate via Postman or Workbench)
- Twilio Account SID + Auth Token (optional, for phone number provisioning)
Salesforce setup:
- Custom Lead fields:
AI_Qualification_Score__c(Number),Call_Transcript__c(Long Text),Last_AI_Contact__c(DateTime) - API-enabled user profile with Lead read/write permissions
- Process Builder or Flow to route leads when Score > 70
Infrastructure:
- Node.js 18+ (native fetch, async/await)
- Public HTTPS endpoint (ngrok for dev, AWS Lambda for prod)
- Webhook secret for signature validation
Advertisement
Step-by-step
1. Configure the VAPI assistant with Salesforce context
The assistant needs qualification criteria before it can extract signals from conversations. Define BANT (Budget, Authority, Need, Timeline) prompts in the system message.
const assistantConfig = {
model: {
provider: "openai",
model: "gpt-4",
messages: [{
role: "system",
content: "You qualify leads by asking: company size, budget range, decision-maker role, timeline. Extract signals from responses and trigger Salesforce updates."
}]
},
voice: {
provider: "11labs",
voiceId: "21m00Tcm4TlvDq8ikWAM"
},
transcriber: {
provider: "deepgram",
model: "nova-2",
language: "en"
},
serverUrl: process.env.WEBHOOK_URL,
serverUrlSecret: process.env.WEBHOOK_SECRET
};
2. Build the webhook handler for real-time updates
Your server receives transcript events during the call and pushes qualification signals to Salesforce immediately. Validate webhook signatures to prevent spoofing.
const express = require('express');
const crypto = require('crypto');
const app = express();
app.use(express.json());
app.post('/webhook/vapi', async (req, res) => {
// Validate signature
const signature = req.headers['x-vapi-signature'];
const expectedSig = crypto
.createHmac('sha256', process.env.WEBHOOK_SECRET)
.update(JSON.stringify(req.body))
.digest('hex');
if (signature !== expectedSig) {
return res.status(401).json({ error: 'Invalid signature' });
}
const { message } = req.body;
if (message.type === 'transcript') {
const transcript = message.transcript;
const callId = message.call.id;
const leadId = message.call.metadata?.leadId;
// Extract BANT signals
const signals = {
hasBudget: /\$[\d,]+|budget.*\d+/i.test(transcript),
hasAuthority: /decision|ceo|vp|director/i.test(transcript),
hasNeed: /problem|challenge|need/i.test(transcript),
hasTimeline: /next (week|month|quarter)|asap/i.test(transcript)
};
const score = Object.values(signals).filter(Boolean).length * 25;
// Update Salesforce asynchronously
updateSalesforceLeadScore(leadId, score, signals).catch(err => {
console.error('Salesforce update failed:', err);
});
}
res.status(200).json({ received: true });
});
async function updateSalesforceLeadScore(leadId, score, signals) {
const token = await getSalesforceToken();
const response = await fetch(
`https://${process.env.SALESFORCE_INSTANCE}.salesforce.com/services/data/v58.0/sobjects/Lead/${leadId}`,
{
method: 'PATCH',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
AI_Qualification_Score__c: score,
Qualification_Signals__c: JSON.stringify(signals),
Last_AI_Contact__c: new Date().toISOString()
})
}
);
if (!response.ok) {
throw new Error(`Salesforce API error: ${response.status}`);
}
}
3. Implement OAuth token refresh with expiry handling
Salesforce access tokens expire after 2 hours. Cache the token and refresh it 5 minutes before expiration to prevent mid-call auth failures.
const tokenCache = {
accessToken: null,
expiresAt: 0
};
async function getSalesforceToken() {
const now = Date.now();
if (tokenCache.accessToken && tokenCache.expiresAt - now > 300000) {
return tokenCache.accessToken;
}
const response = await fetch('https://login.salesforce.com/services/oauth2/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'refresh_token',
client_id: process.env.SALESFORCE_CLIENT_ID,
client_secret: process.env.SALESFORCE_CLIENT_SECRET,
refresh_token: process.env.SALESFORCE_REFRESH_TOKEN
})
});
if (!response.ok) throw new Error(`OAuth failed: ${response.status}`);
const data = await response.json();
tokenCache.accessToken = data.access_token;
tokenCache.expiresAt = now + (data.expires_in * 1000) - 300000;
return data.access_token;
}
4. Trigger outbound qualification calls from Salesforce
When a lead enters your queue, initiate a VAPI call with the lead's phone number and Salesforce ID in metadata.
app.post('/api/qualify-lead', async (req, res) => {
const { leadId, phoneNumber } = req.body;
const response = await fetch('https://api.vapi.ai/call', {
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.VAPI_API_KEY}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
assistantId: process.env.VAPI_ASSISTANT_ID,
customer: { number: phoneNumber },
metadata: { leadId }
})
});
if (!response.ok) {
throw new Error(`VAPI call failed: ${response.status}`);
}
const callData = await response.json();
res.json({ success: true, callId: callData.id });
});
5. Handle race conditions with retry logic
Salesforce has 200-800ms replication lag. If your webhook fires immediately after lead creation, the record might not exist yet. Retry with exponential backoff.
async function updateWithRetry(leadId, score, signals, maxRetries = 3) {
for (let i = 0; i < maxRetries; i++) {
try {
const token = await getSalesforceToken();
const response = await fetch(
`https://${process.env.SALESFORCE_INSTANCE}.salesforce.com/services/data/v58.0/sobjects/Lead/${leadId}`,
{
method: 'PATCH',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
AI_Qualification_Score__c: score,
Qualification_Signals__c: JSON.stringify(signals)
})
}
);
if (response.status === 404 && i < maxRetries - 1) {
await new Promise(resolve => setTimeout(resolve, Math.pow(2, i) * 500));
continue;
}
if (!response.ok) throw new Error(`Salesforce error: ${response.status}`);
return await response.json();
} catch (error) {
if (i === maxRetries - 1) throw error;
}
}
}
Smoke test
Start your server and expose it via ngrok. Configure the webhook URL in your VAPI dashboard.
# Terminal 1: Start server
node server.js
# Terminal 2: Expose via ngrok
ngrok http 3000
# Copy the HTTPS URL (e.g., https://abc123.ngrok.io)
Trigger a test call with a known Salesforce Lead ID:
curl -X POST http://localhost:3000/api/qualify-lead \
-H "Content-Type: application/json" \
-d '{
"leadId": "00Q5e00000ABC123",
"phoneNumber": "+15551234567"
}'
Expected response:
{
"success": true,
"callId": "call_abc123xyz"
}
Check your server logs for webhook events. You should see:
Webhook received: transcript event
Extracted signals: {"hasBudget":true,"hasAuthority":false,"hasNeed":true,"hasTimeline":false}
Salesforce update successful: Lead 00Q5e00000ABC123 scored 50/100
Query Salesforce to verify the Lead record updated:
curl "https://yourinstance.salesforce.com/services/data/v58.0/sobjects/Lead/00Q5e00000ABC123" \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN"
The AI_Qualification_Score__c field should reflect the calculated score.
From an actual deployment
A prospect calls in at 10:23:14 AM. The AI asks about company size. At 10:23:47, the prospect says: "We're a 200-person team looking to deploy by end of Q2." The webhook receives this partial transcript:
{
"message": {
"type": "transcript",
"transcript": "we're a 200-person team looking to deploy by end of Q2",
"timestamp": "2024-01-15T10:23:47.234Z"
},
"call": {
"id": "call_abc123",
"metadata": { "leadId": "00Q5e000001XYZ" }
}
}
The server extracts signals: hasTimeline: true (matches "end of Q2"). Score updates from 0 to 25. At 10:24:12, the prospect mentions "$50k budget." New transcript arrives:
{
"transcript": "our budget is around $50k for this project"
}
Score updates to 50 (timeline + budget). At 10:24:38, the call drops—network issue on the prospect's end. The final webhook payload shows:
{
"message": {
"type": "end-of-call-report",
"endedReason": "customer-ended-call"
}
}
Salesforce Lead record shows AI_Qualification_Score__c: 50, Last_AI_Contact__c: 2024-01-15T10:24:38Z. The sales rep sees this incomplete qualification in their dashboard and can follow up with context. Without real-time updates, this lead would have been lost entirely.
Full server
This is the complete production-ready implementation with OAuth refresh, webhook validation, retry logic, and error handling.
// server.js
const express = require('express');
const crypto = require('crypto');
const app = express();
app.use(express.json());
const tokenCache = {
accessToken: null,
expiresAt: 0
};
async function getSalesforceToken() {
const now = Date.now();
if (tokenCache.accessToken && tokenCache.expiresAt > now) {
return tokenCache.accessToken;
}
const response = await fetch('https://login.salesforce.com/services/oauth2/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'refresh_token',
client_id: process.env.SALESFORCE_CLIENT_ID,
client_secret: process.env.SALESFORCE_CLIENT_SECRET,
refresh_token: process.env.SALESFORCE_REFRESH_TOKEN
})
});
if (!response.ok) throw new Error(`OAuth failed: ${response.status}`);
const data = await response.json();
tokenCache.accessToken = data.access_token;
tokenCache.expiresAt = now + (data.expires_in * 1000) - 300000;
return data.access_token;
}
function extractQualificationSignals(transcript) {
const signals = {
budget: /\$[\d,]+|budget.*\d+/i.test(transcript),
timeline: /next (week|month|quarter)|asap|urgent|Q[1-4]/i.test(transcript),
authority: /decision maker|ceo|vp|director|approve/i.test(transcript),
need: /problem|challenge|need|looking for/i.test(transcript)
};
const score = Object.values(signals).filter(Boolean).length * 25;
return { signals, score };
}
async function updateWithRetry(leadId, qualificationData, retries = 3) {
for (let i = 0; i < retries; i++) {
try {
const accessToken = await getSalesforceToken();
const response = await fetch(
`https://${process.env.SALESFORCE_INSTANCE}.salesforce.com/services/data/v58.0/sobjects/Lead/${leadId}`,
{
method: 'PATCH',
headers: {
'Authorization': `Bearer ${accessToken}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
AI_Qualification_Score__c: qualificationData.score,
Qualification_Signals__c: JSON.stringify(qualificationData.signals),
Last_AI_Contact__c: new Date().toISOString()
})
}
);
if (response.status === 401 && i < retries - 1) {
tokenCache.accessToken = null;
continue;
}
if (response.status === 404 && i < retries - 1) {
await new Promise(resolve => setTimeout(resolve, Math.pow(2, i) * 500));
continue;
}
if (!response.ok) throw new Error(`Salesforce update failed: ${response.status}`);
return await response.json();
} catch (error) {
if (i === retries - 1) throw error;
await new Promise(resolve => setTimeout(resolve, 1000 * Math.pow(2, i)));
}
}
}
app.post('/webhook/vapi', async (req, res) => {
const signature = req.headers['x-vapi-signature'];
const expectedSig = crypto
.createHmac('sha256', process.env.WEBHOOK_SECRET)
.update(JSON.stringify(req.body))
.digest('hex');
if (signature !== expectedSig) {
console.error('Invalid webhook signature');
return res.status(401).json({ error: 'Unauthorized' });
}
const { message, call } = req.body;
if (message.type === 'transcript') {
const transcript = message.transcript;
const leadId = call?.metadata?.leadId;
if (!leadId) {
console.error('Missing leadId in call metadata');
return res.status(400).json({ error: 'Missing leadId' });
}
try {
const qualificationData = extractQualificationSignals(transcript);
await updateWithRetry(leadId, qualificationData);
console.log(`Lead ${leadId} scored: ${qualificationData.score}/100`);
return res.json({ success: true, score: qualificationData.score });
} catch (error) {
console.error('Webhook processing failed:', error);
return res.status(500).json({ error: 'Processing failed' });
}
}
if (message.type === 'end-of-call-report') {
const transcript = message.transcript || '';
const leadId = call?.metadata?.leadId;
if (leadId && transcript) {
try {
const qualificationData = extractQualificationSignals(transcript);
await updateWithRetry(leadId, qualificationData);
console.log(`Final score for lead ${leadId}: ${qualificationData.score}/100`);
} catch (error) {
console.error('End-of-call update failed:', error);
}
}
}
res.json({ received: true });
});
app.post('/api/qualify-lead', async (req, res) => {
const { leadId, phoneNumber } = req.body;
if (!leadId || !phoneNumber) {
return res.status(400).json({ error: 'Missing leadId or phoneNumber' });
}
try {
const response = await fetch('https://api.vapi.ai/call', {
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.VAPI_API_KEY}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
assistantId: process.env.VAPI_ASSISTANT_ID,
customer: { number: phoneNumber },
metadata: { leadId }
})
});
if (!response.ok) throw new Error(`VAPI call failed: ${response.status}`);
const callData = await response.json();
res.json({ success: true, callId: callData.id });
} catch (error) {
console.error('Call initiation failed:', error);
res.status(500).json({ error: 'Failed to initiate call' });
}
});
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => console.log(`Server running on port ${PORT}`));
Run it:
Create .env:
VAPI_API_KEY=your_vapi_key
VAPI_ASSISTANT_ID=your_assistant_id
WEBHOOK_SECRET=your_webhook_secret
SALESFORCE_CLIENT_ID=your_salesforce_client_id
SALESFORCE_CLIENT_SECRET=your_salesforce_client_secret
SALESFORCE_REFRESH_TOKEN=your_refresh_token
SALESFORCE_INSTANCE=yourinstance.my.salesforce.com
PORT=3000
Install and start:
npm install express
node server.js
Expose via ngrok and configure the webhook URL in VAPI dashboard: https://your-ngrok-url.ngrok.io/webhook/vapi
Where to go next
VAPI API Reference (https://docs.vapi.ai) — Function calling patterns for triggering Salesforce updates mid-conversation, webhook event schemas, assistant configuration options.
Salesforce REST API Guide (https://developer.salesforce.com/docs/atlas.en-us.api_rest.meta/api_rest/) — Lead object field reference, SOQL query syntax for fetching existing lead data, composite API for batch updates.
OAuth 2.0 RFC 6749 (https://datatracker.ietf.org/doc/html/rfc6749) — Authorization code grant flow details, refresh token mechanics, security considerations for storing credentials.
VAPI Server-Side Examples (https://github.com/VapiAI/server-side-example-node) — Production webhook handlers with signature validation, error handling patterns, session management for multi-turn conversations.
Salesforce Connected App Setup (https://help.salesforce.com/s/articleView?id=sf.connected_app_create.htm) — Step-by-step guide to generating OAuth credentials, configuring callback URLs, setting API scopes for Lead access.
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.
Tutorials in your inbox
Weekly voice AI tutorials and production tips. No spam.
Found this helpful?
Share it with other developers building voice AI.



