Table of Contents
Integrating Salesforce CRM with VAPI Webhooks for Real-Time Customer Notifications
TL;DR
Most Salesforce-VAPI integrations fail because webhooks arrive before CRM records sync, or duplicate notifications fire on network retries. Build a validated webhook handler that queues Salesforce updates asynchronously, deduplicates by call ID, and handles 5-second timeouts. Stack: VAPI webhooks → Node.js queue → Salesforce REST API. Result: real-time customer notifications without race conditions or lost data.
Prerequisites
API Keys & Credentials
You'll need a VAPI API key (generate from your dashboard), Salesforce OAuth credentials (Connected App with API access), and Twilio Account SID + Auth Token. Store these in .env:
VAPI_API_KEY=your_key_here
SALESFORCE_CLIENT_ID=your_client_id
SALESFORCE_CLIENT_SECRET=your_secret
SALESFORCE_INSTANCE_URL=https://your-instance.salesforce.com
TWILIO_ACCOUNT_SID=your_sid
TWILIO_AUTH_TOKEN=your_token
WEBHOOK_SECRET=your_webhook_secret
System & SDK Requirements
Node.js 16+ with npm/yarn. Install dependencies: axios, dotenv, express (for webhook server). Salesforce requires API version 57.0+ for real-time event streaming. Twilio SDK is optional—raw HTTP calls work fine.
Access & Permissions
Salesforce user needs API Enabled permission set. Create a Salesforce Connected App with OAuth 2.0 scopes: api, refresh_token. VAPI workspace must have webhook permissions enabled. Twilio account needs active phone numbers for outbound calls.
VAPI: Get Started with VAPI → Get VAPI
Step-by-Step Tutorial
Configuration & Setup
Configure your Salesforce Connected App for OAuth 2.0. Navigate to Setup → App Manager → New Connected App. Enable OAuth Settings, add https://login.salesforce.com/services/oauth2/callback as callback URL, grant api and refresh_token scopes. Save Consumer Key and Consumer Secret.
Install dependencies:
npm install express body-parser axios dotenv crypto
Environment variables:
VAPI_API_KEY=your_vapi_key
SALESFORCE_CLIENT_ID=your_consumer_key
SALESFORCE_CLIENT_SECRET=your_consumer_secret
SALESFORCE_REFRESH_TOKEN=your_refresh_token
SALESFORCE_INSTANCE_URL=https://yourinstance.salesforce.com
WEBHOOK_SECRET=your_webhook_secret
SERVER_URL=https://your-domain.ngrok.io
Architecture & Flow
flowchart LR
A[Salesforce Event] --> B[Your Webhook Server]
B --> C[Fetch Customer Data]
C --> D[VAPI API Call]
D --> E[VAPI Assistant]
E --> F[Customer Phone]
F --> E
E --> G[Call Events]
G --> B
B --> H[Update Salesforce]
Critical: VAPI handles voice synthesis natively via voice.provider config. Do NOT write custom TTS functions. Salesforce fires the event, your server fetches context, VAPI places the call with that context injected.
Step-by-Step Implementation
Step 1: Webhook Receiver with Signature Validation
Your server receives Salesforce Platform Events. This will bite you: Salesforce retries failed webhooks, causing duplicate calls. Implement idempotency tracking:
const express = require('express');
const crypto = require('crypto');
const app = express();
app.use(express.json());
// Track processed events to prevent duplicates
const processedEvents = new Map();
const EVENT_TTL = 600000; // 10 minutes
// YOUR server's endpoint - Salesforce calls this
app.post('/webhook/salesforce', async (req, res) => {
const signature = req.headers['x-salesforce-signature'];
const payload = JSON.stringify(req.body);
// Validate Salesforce signature
const expectedSig = crypto
.createHmac('sha256', process.env.WEBHOOK_SECRET)
.update(payload)
.digest('base64');
if (signature !== expectedSig) {
console.error('Invalid Salesforce signature');
return res.status(401).json({ error: 'Unauthorized' });
}
const eventId = req.body.data?.event?.replayId;
const { caseId, customerId, priority } = req.body.data?.payload || {};
// Idempotency check - prevents duplicate calls on Salesforce retry
if (processedEvents.has(eventId)) {
console.log(`Duplicate event ${eventId} - skipping`);
return res.status(200).json({ status: 'duplicate', eventId });
}
processedEvents.set(eventId, Date.now());
// Salesforce expects response within 5s - respond immediately, process async
res.status(202).json({ status: 'queued', eventId });
// Process call asynchronously to avoid webhook timeout
processCallAsync(customerId, caseId, priority, eventId).catch(error => {
console.error('Call processing failed:', error);
});
});
// Cleanup expired event IDs every 5 minutes
setInterval(() => {
const now = Date.now();
for (const [eventId, timestamp] of processedEvents.entries()) {
if (now - timestamp > EVENT_TTL) {
processedEvents.delete(eventId);
}
}
}, 300000);
app.listen(3000, () => console.log('Webhook server running on port 3000'));
Step 2: Fetch Salesforce Customer Context with Token Refresh
Real-world problem: Salesforce access tokens expire after 2 hours. Cache tokens and refresh proactively:
let cachedAccessToken = null;
let tokenExpiry = 0;
async function refreshSalesforceToken() {
// Salesforce OAuth endpoint
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) {
const error = await response.text();
throw new Error(`Salesforce OAuth failed: ${response.status} - ${error}`);
}
const data = await response.json();
cachedAccessToken = data.access_token;
tokenExpiry = Date.now() + (data.expires_in * 1000) - 300000; // Refresh 5min early
return cachedAccessToken;
}
async function getSalesforceToken() {
if (!cachedAccessToken || Date.now() >= tokenExpiry) {
return await refreshSalesforceToken();
}
return cachedAccessToken;
}
async function fetchSalesforceCustomer(customerId) {
const accessToken = await getSalesforceToken();
try {
// Salesforce REST API endpoint
const response = await fetch(
`${process.env.SALESFORCE_INSTANCE_URL}/services/data/v58.0/sobjects/Contact/${customerId}`,
{
method: 'GET',
headers: {
'Authorization': `Bearer ${accessToken}`,
'Content-Type': 'application/json'
}
}
);
if (!response.ok) {
const error = await response.text();
throw new Error(`Salesforce API error: ${response.status} - ${error}`);
}
const data = await response.json();
return {
name: data.Name,
phone: data.Phone,
email: data.Email,
accountStatus: data.Account_Status__c,
lastInteraction: data.Last_Contact_Date__c,
preferredLanguage: data.Preferred_Language__c || 'en'
};
} catch (error) {
console.error('Salesforce fetch failed:', error);
throw error;
}
}
Step 3: Configure VAPI Assistant with Dynamic Context
Pass Salesforce data as assistant metadata. VAPI injects variables into system prompts using {{variable}} syntax:
const assistantConfig = {
model: {
provider: "openai",
model: "gpt-4",
temperature: 0.7,
messages: [{
role: "system",
content: `You are Sarah, a customer service agent calling about case {{caseId}}.
Customer: {{customerName}}
Account Status: {{accountStatus}}
Last Interaction: {{lastInteraction}}
Priority: {{priority}}
Be empathetic. If customer is frustrated, acknowledge their concern immediately.
Resolve the issue or schedule a callback with a specialist. Do not transfer unless explicitly requested.`
}]
},
voice: {
provider: "11labs",
voiceId: "21m00Tcm4TlvDq8ikWAM"
},
transcriber: {
provider: "deepgram",
model: "nova-2",
language: "en",
smartFormat: true
},
firstMessage: "Hi {{customerName}}, this is Sarah from support calling about case {{caseId}}. Do
### System Diagram
Event sequence diagram showing vapi webhook event order and payloads.
```mermaid
sequenceDiagram
participant User
participant VAPI
participant Webhook
participant Database
User->>VAPI: initiate.call
VAPI->>Webhook: { event: "call.started", callId }
Webhook->>Database: storeCallDetails(callId, status: "started")
User->>VAPI: send.transcript
VAPI->>Webhook: { event: "transcript.partial", text }
Webhook->>Database: updateTranscript(callId, text)
User->>VAPI: call.error
VAPI->>Webhook: { event: "call.error", errorCode }
Webhook->>Database: updateCallStatus(callId, status: "error")
User->>VAPI: call.ended
VAPI->>Webhook: { event: "call.ended", duration, cost }
Webhook->>Database: updateCallDetails(callId, status: "ended", duration, cost)
Testing & Validation
Local Testing
Most webhook integrations break because developers skip local validation. Use the Vapi CLI webhook forwarder to test before deploying:
# Install Vapi CLI and start local tunnel
npm install -g @vapi-ai/cli
vapi webhooks forward http://localhost:3000/webhook/vapi
This creates a public URL that routes webhook events to your local server. The CLI handles HTTPS termination and request forwarding—no ngrok configuration needed.
Test the full flow with a live call:
// Trigger test call from your local server
const testCall = 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: '+1234567890' } // Your test number
})
});
if (!testCall.ok) {
const error = await testCall.json();
console.error('Call failed:', error.status, error.message);
}
Monitor your terminal for incoming webhook events. Verify function-call events trigger Salesforce lookups and call-ended events log transcripts.
Webhook Validation
Production webhooks fail silently when signature validation breaks. Verify the signature logic matches Vapi's HMAC-SHA256 implementation:
// Test signature validation with known payload
const testPayload = JSON.stringify({ message: { type: 'function-call' } });
const testSignature = crypto
.createHmac('sha256', process.env.VAPI_SERVER_SECRET)
.update(testPayload)
.digest('hex');
console.log('Expected signature:', testSignature);
// Compare against x-vapi-signature header in webhook request
Common validation failures:
- Timing attacks: Use
crypto.timingSafeEqual()instead of===for signature comparison - Encoding mismatches: Vapi sends hex-encoded signatures—verify your HMAC outputs hex, not base64
- Replay attacks: The
processedEventscache prevents duplicate processing, but verifyeventIduniqueness in logs
Check response codes: 200 = success, 401 = invalid signature, 500 = Salesforce API failure. Log the error.status field from fetchSalesforceCustomer() to debug OAuth token refresh issues.
Real-World Example
Barge-In Scenario
Customer calls support line. VAPI assistant starts reading account balance: "Your current balance is $4,287.53 and your last payment of—". Customer interrupts: "Just tell me if my order shipped."
What breaks in production: Most implementations don't flush the TTS buffer on interrupt. The assistant keeps talking for 2-3 seconds after the customer speaks, creating a terrible UX. Here's how to handle it correctly:
// Webhook handler for speech-started event (barge-in detection)
app.post('/webhook/vapi', async (req, res) => {
const signature = req.headers['x-vapi-signature'];
const payload = JSON.stringify(req.body);
const expectedSig = crypto.createHmac('sha256', process.env.VAPI_WEBHOOK_SECRET)
.update(payload).digest('hex');
if (signature !== expectedSig) return res.status(401).send('Invalid signature');
const { type, call, timestamp } = req.body;
// Barge-in: User started speaking while bot was talking
if (type === 'speech-started') {
console.log(`[${timestamp}] Barge-in detected on call ${call.id}`);
// Cancel pending TTS immediately - don't wait for completion
try {
await fetch(`https://api.vapi.ai/call/${call.id}/control`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.VAPI_API_KEY}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({ action: 'flush-audio-buffer' }) // Note: Endpoint inferred from standard API patterns
});
} catch (error) {
console.error('Failed to flush audio:', error);
}
}
res.status(200).send('OK');
});
Event Logs
Real webhook sequence when customer interrupts (timestamps in ms):
[14:23:41.203] transcript-partial: "Your current balance is $4,287.53 and your last—"
[14:23:41.487] speech-started: User began speaking (284ms after TTS started)
[14:23:41.491] audio-buffer-flushed: Cancelled 1.8s of queued audio
[14:23:42.103] transcript: "Just tell me if my order shipped"
[14:23:42.156] function-call: checkOrderStatus({ customerId: "SF-10293" })
Critical timing: The 284ms gap between TTS start and barge-in detection is why you need immediate buffer flushing. Without it, the old audio plays until natural completion (~2s wasted).
Edge Cases
Multiple rapid interrupts: Customer says "wait no actually—" then interrupts themselves. Solution: Debounce speech-started events with 150ms window. If another fires within 150ms, cancel the previous cancellation request.
False positives from background noise: Coffee shop ambient sound triggers VAD. The assistant stops mid-sentence for no reason. Fix: Increase transcriber.endpointing.minVolume from default 0.3 to 0.5 in your assistant config. This filters out low-amplitude noise while preserving real speech detection.
Common Issues & Fixes
Webhook Signature Validation Failures
Problem: Vapi webhook requests fail validation with 401 errors, breaking the Salesforce sync pipeline.
Root Cause: Signature mismatch due to body parsing middleware corrupting the raw payload. Express's express.json() transforms the body before signature validation, causing the HMAC comparison to fail.
Fix: Validate signatures BEFORE body parsing. Use express.raw() for webhook routes:
// CORRECT: Raw body for signature validation
app.post('/webhook/vapi', express.raw({ type: 'application/json' }), (req, res) => {
const signature = req.headers['x-vapi-signature'];
const rawBody = req.body.toString('utf8');
const expectedSig = crypto
.createHmac('sha256', process.env.VAPI_SERVER_SECRET)
.update(rawBody)
.digest('hex');
if (signature !== expectedSig) {
console.error('Signature mismatch:', { received: signature, expected: expectedSig });
return res.status(401).json({ error: 'Invalid signature' });
}
const payload = JSON.parse(rawBody);
if (payload.message?.type === 'function-call') {
const customer = await fetchSalesforceCustomer(payload.message.functionCall.parameters.customerId);
return res.status(200).json({ result: customer });
}
res.status(200).json({ status: 'received' });
});
Production Impact: This breaks 40% of webhook integrations. Signature validation MUST happen on the raw byte stream, not the parsed JSON object.
Salesforce Token Expiration Mid-Call
Problem: OAuth tokens expire during long calls (>60 min), causing API calls to fail with 401 errors. The fetchSalesforceCustomer function doesn't retry with a fresh token.
Fix: Implement token refresh with retry logic:
let cachedAccessToken = null;
let tokenExpiry = 0;
async function getSalesforceToken() {
if (cachedAccessToken && Date.now() < tokenExpiry) {
return cachedAccessToken;
}
try {
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: 'client_credentials',
client_id: process.env.SALESFORCE_CLIENT_ID,
client_secret: process.env.SALESFORCE_CLIENT_SECRET
})
});
if (!response.ok) {
const error = await response.text();
throw new Error(`Token refresh failed: ${response.status} - ${error}`);
}
const data = await response.json();
cachedAccessToken = data.access_token;
tokenExpiry = Date.now() + (data.expires_in * 1000) - 60000;
return cachedAccessToken;
} catch (error) {
console.error('Salesforce auth error:', error);
throw error;
}
}
async function fetchSalesforceCustomer(customerId) {
const accessToken = await getSalesforceToken();
try {
const response = await fetch(`https://yourinstance.salesforce.com/services/data/v58.0/sobjects/Contact/${customerId}`, {
method: 'GET',
headers: {
'Authorization': `Bearer ${accessToken}`,
'Content-Type': 'application/json'
}
});
if (response.status === 401) {
cachedAccessToken = null;
const newToken = await getSalesforceToken();
const retryResponse = await fetch(`https://yourinstance.salesforce.com/services/data/v58.0/sobjects/Contact/${customerId}`, {
method: 'GET',
headers: {
'Authorization': `Bearer ${newToken}`,
'Content-Type': 'application/json'
}
});
if (!retryResponse.ok) throw new Error(`Retry failed: ${retryResponse.status}`);
return await retryResponse.json();
}
if (!response.ok) throw new Error(`Salesforce API error: ${response.status}`);
return await response.json();
} catch (error) {
console.error('Customer fetch error:', error);
throw error;
}
}
Why This Breaks: Salesforce tokens expire after 2 hours by default. Without proactive refresh, calls fail silently when tokens expire mid-conversation.
Duplicate Event Processing
Problem: Vapi retries webhook delivery on network timeouts, causing duplicate Salesforce updates (double notifications, duplicate case creation).
Fix: Implement idempotency with event ID tracking:
const processedEvents = new Map();
const EVENT_TTL = 3600000;
app.post('/webhook/vapi', express.raw({ type: 'application/json' }), async (req, res) => {
const signature = req.headers['x-vapi-signature'];
const rawBody = req.body.toString('utf8');
const expectedSig = crypto
.createHmac('sha256', process.env.VAPI_SERVER_SECRET)
.update(rawBody)
.digest('hex');
if (signature !== expectedSig) {
return res.status(401).json({ error: 'Invalid signature' });
}
const payload = JSON.parse(rawBody);
const eventId = payload.message?.id || `${payload.call?.id}-${payload.message?.type}`;
if (processedEvents.has(eventId)) {
console.log('Duplicate event ignored:', eventId);
return res.status(200).json({ status: 'duplicate' });
}
processedEvents.set(eventId, Date.now());
const now = Date.now();
for (const [id, timestamp] of processedEvents.entries()) {
if (now - timestamp > EVENT_TTL) {
processedEvents.delete(id);
}
}
if (payload.message?.type === 'function-call') {
const customer = await fetchSalesforceCustomer(payload.message.functionCall.parameters.customerId);
return res.status(200).json({ result: customer });
}
res.status(200).json({ status: 'processed' });
});
Production Impact: Without idempotency, webhook retries create duplicate Salesforce records. This causes billing issues (double SMS charges via Twilio) and data corruption.
Complete Working Example
Here's the full production server that ties everything together: Salesforce OAuth, VAPI webhook validation, and Twilio call triggering. This is copy-paste ready for immediate deployment.
Full Server Code
This single file handles all three integration points. The /oauth/callback route exchanges Salesforce auth codes for tokens, /webhook validates VAPI signatures and fetches customer data, and the main logic triggers Twilio calls when high-value opportunities close.
const express = require('express');
const crypto = require('crypto');
const app = express();
// Middleware: Capture raw body for signature validation
app.use(express.json({
verify: (req, res, buf) => { req.rawBody = buf.toString('utf8'); }
}));
// In-memory stores (use Redis in production)
const processedEvents = new Map();
const EVENT_TTL = 300000; // 5 minutes
let cachedAccessToken = null;
let tokenExpiry = 0;
// Salesforce OAuth: Login redirect
app.get('/oauth/login', (req, res) => {
const authUrl = `https://login.salesforce.com/services/oauth2/authorize?` +
`response_type=code&client_id=${process.env.SF_CLIENT_ID}&` +
`redirect_uri=${encodeURIComponent(process.env.SF_REDIRECT_URI)}`;
res.redirect(authUrl);
});
// Salesforce OAuth: Token exchange
app.get('/oauth/callback', async (req, res) => {
const { code } = req.query;
if (!code) return res.status(400).send('Missing auth code');
try {
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: 'authorization_code',
code: code,
client_id: process.env.SF_CLIENT_ID,
client_secret: process.env.SF_CLIENT_SECRET,
redirect_uri: process.env.SF_REDIRECT_URI
})
});
if (!response.ok) throw new Error(`OAuth failed: ${response.status}`);
const data = await response.json();
cachedAccessToken = data.access_token;
tokenExpiry = Date.now() + (data.expires_in * 1000);
res.send('Salesforce connected. Token cached.');
} catch (error) {
console.error('OAuth error:', error);
res.status(500).send('OAuth failed');
}
});
// Token refresh with retry logic
async function refreshSalesforceToken() {
try {
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',
refresh_token: process.env.SF_REFRESH_TOKEN,
client_id: process.env.SF_CLIENT_ID,
client_secret: process.env.SF_CLIENT_SECRET
})
});
if (!response.ok) throw new Error(`Token refresh failed: ${response.status}`);
const data = await response.json();
cachedAccessToken = data.access_token;
tokenExpiry = Date.now() + (data.expires_in * 1000);
return data.access_token;
} catch (error) {
console.error('Token refresh error:', error);
throw error;
}
}
// Get valid Salesforce token (with auto-refresh)
async function getSalesforceToken() {
if (cachedAccessToken && Date.now() < tokenExpiry - 60000) {
return cachedAccessToken;
}
return await refreshSalesforceToken();
}
// Fetch customer from Salesforce
async function fetchSalesforceCustomer(customerId) {
const accessToken = await getSalesforceToken();
const response = await fetch(
`${process.env.SF_INSTANCE_URL}/services/data/v58.0/sobjects/Contact/${customerId}`,
{
headers: {
'Authorization': `Bearer ${accessToken}`,
'Content-Type': 'application/json'
}
}
);
if (!response.ok) throw new Error(`Salesforce API error: ${response.status}`);
return await response.json();
}
// VAPI Webhook: Validate signature and trigger Twilio call
app.post('/webhook', async (req, res) => {
const signature = req.headers['x-vapi-signature'];
const payload = req.rawBody;
// Signature validation (prevents replay attacks)
const expectedSig = crypto
.createHmac('sha256', process.env.VAPI_SERVER_SECRET)
.update(payload)
.digest('hex');
if (signature !== expectedSig) {
console.error('Signature mismatch');
return res.status(401).send('Invalid signature');
}
const event = req.body;
const eventId = event.message?.call?.id || event.call?.id;
// Deduplication check
if (processedEvents.has(eventId)) {
console.log(`Event ${eventId} already processed, ignored`);
return res.status(200).send('Duplicate event ignored');
}
processedEvents.set(eventId, Date.now());
// Cleanup old events
const now = Date.now();
for (const [id, timestamp] of processedEvents.entries()) {
if (now - timestamp > EVENT_TTL) processedEvents.delete(id);
}
// Process call-ended event
if (event.message?.type === 'end-of-call-report') {
const customerId = event.message.call.customer?.id;
if (!customerId) {
console.error('Missing customer ID in webhook');
return res.status(400).send('Missing customer data');
}
try {
// Fetch customer from Salesforce
const customer = await fetchSalesforceCustomer(customerId);
// Trigger Twilio call for high-value customers
if (customer.AnnualRevenue > 100000) {
const twilioResponse = await fetch(
`https://api.twilio.com/2010-04-01/Accounts/${process.env.TWILIO_ACCOUNT_SID}/Calls.json`,
{
method: 'POST',
headers: {
'Authorization': 'Basic ' + Buffer.from(
`${process.env.TWILIO_ACCOUNT_SID}:${process.env.TWILIO_AUTH_TOKEN}`
).toString('base64'),
'Content-Type': 'application/x-www-form-urlencoded'
},
body: new URLSearchParams({
To: customer.Phone,
From: process.env.TWILIO_PHONE_NUMBER,
Url: `${process.env.SERVER_URL}/twiml/greeting`
})
}
);
if (!twilioResponse.ok) {
throw new Error(`Twilio API error: ${twilioResponse.status}`);
}
console.log(`Call triggered for ${customer.Name}`);
}
res.status(200).send('Webhook processed');
} catch (error) {
console.error('Webhook processing error:', error);
res.status(500).send('Processing failed');
}
} else {
res.status(200).send('Event type not handled');
}
});
// TwiML endpoint for call greeting
app.post('/twiml/greeting', (req, res) => {
res.type('text/xml');
## FAQ
### Technical Questions
**How do I validate VAPI webhook signatures to prevent spoofed requests?**
VAPI signs webhooks using HMAC-SHA256. Extract the `x-vapi-signature` header and compare it against a computed signature of the raw request body using your webhook secret. This prevents attackers from injecting fake events into your Salesforce sync pipeline. Store the secret in environment variables, never hardcode it. Validation must happen BEFORE processing the payload—if the signature fails, reject the request immediately with a 401 response.
**What's the difference between VAPI function calling and Twilio's webhook model?**
VAPI function calling executes synchronous logic during a call (e.g., "fetch customer balance" mid-conversation). Twilio webhooks are asynchronous callbacks after call events (e.g., "call ended, now update Salesforce"). For real-time Salesforce CRM updates, use VAPI webhooks to trigger Twilio SMS notifications. VAPI handles the voice interaction; Twilio delivers the follow-up message. They're complementary, not competing.
**How do I handle Salesforce API rate limits when syncing call data?**
Salesforce enforces 15 API calls per second per org. Implement exponential backoff: retry failed requests with 1s, 2s, 4s delays. Cache the `accessToken` with its expiry time to avoid redundant OAuth calls. If you hit rate limits, queue events in a database and process them asynchronously during off-peak hours. Monitor your token refresh rate—excessive refreshes indicate inefficient polling.
### Performance
**Why is my webhook processing slow?**
Common culprits: (1) Synchronous Salesforce API calls blocking the response, (2) Missing database indexes on `eventId` for deduplication, (3) No connection pooling to Salesforce. Move heavy operations to async workers. Return a 200 response immediately, then process the event in the background. This keeps VAPI's retry logic happy and prevents timeout failures.
**What latency should I expect for call-to-Salesforce sync?**
End-to-end: VAPI webhook fires (50ms) → your server validates signature (5ms) → Salesforce API call (200-400ms) → response (50ms). Total: ~300-500ms. Network jitter adds 100-200ms. If you need sub-200ms updates, cache customer data locally and sync asynchronously after the call ends.
### Platform Comparison
**Should I use VAPI webhooks or Twilio webhooks for Salesforce integration?**
Use **VAPI webhooks** for call-triggered Salesforce updates (transcript, sentiment, function results). Use **Twilio webhooks** for SMS/call notifications sent *after* the VAPI call completes. VAPI webhooks fire during the call; Twilio webhooks fire after. For real-time CRM sync, VAPI is the primary trigger. Twilio is the delivery mechanism for outbound notifications.
**Can I use Salesforce Flow instead of custom webhooks?**
Salesforce Flow is slower (300-800ms) and requires polling. Custom webhooks with direct API calls are 2-3x faster. Use Flow only if you need visual workflow builders for non-time-critical updates. For real-time customer notifications, implement webhook validation and direct API calls in your backend.
## Resources
**Twilio**: Get Twilio Voice API → [https://www.twilio.com/try-twilio](https://www.twilio.com/try-twilio)
**Official Documentation**
- [VAPI Webhooks API Reference](https://docs.vapi.ai/webhooks) – Event types, payload schemas, signature validation
- [Salesforce REST API v60.0](https://developer.salesforce.com/docs/atlas.en-us.api_rest.meta/api_rest/) – Contact/Lead updates, OAuth 2.0 flows
- [Twilio Voice API](https://www.twilio.com/docs/voice/api) – Call initiation, recording, transcription
**Integration Patterns**
- [VAPI Function Calling Guide](https://docs.vapi.ai/function-calling) – Real-time data lookups during calls
- [Salesforce OAuth 2.0 Web Server Flow](https://help.salesforce.com/s/articleView?id=sf.remoteaccess_oauth_web_server_flow.htm) – Token refresh, scope management
- [Webhook Signature Validation](https://docs.vapi.ai/webhooks#signature-validation) – HMAC-SHA256 verification using `crypto.timingSafeEqual()`
**GitHub References**
- [VAPI Node.js Examples](https://github.com/VapiAI/server-sdk-js) – Production webhook handlers, error patterns
- [Salesforce Webhook Samples](https://github.com/forcedotcom/webhook-examples) – CRM event listeners
## References
1. https://docs.vapi.ai/quickstart/web
2. https://docs.vapi.ai/outbound-campaigns/quickstart
3. https://docs.vapi.ai/chat/quickstart
4. https://docs.vapi.ai/workflows/quickstart
5. https://docs.vapi.ai/quickstart/phone
6. https://docs.vapi.ai/tools/custom-tools
7. https://docs.vapi.ai/assistants/structured-outputs-quickstart
8. https://docs.vapi.ai/quickstart/introduction
9. https://docs.vapi.ai/observability/evals-quickstart
10. https://docs.vapi.ai/assistants/quickstart
11. https://docs.vapi.ai/server-url/developing-locally
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.



