DEV Community

Cover image for Integrating Salesforce CRM with VAPI Webhooks for Real-Time Customer Notifications
CallStack Tech
CallStack Tech

Posted on • Originally published at callstack.tech

Integrating Salesforce CRM with VAPI Webhooks for Real-Time Customer Notifications

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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]
Enter fullscreen mode Exit fullscreen mode

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'));
Enter fullscreen mode Exit fullscreen mode

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;
  }
}
Enter fullscreen mode Exit fullscreen mode

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.

Enter fullscreen mode Exit fullscreen mode


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)
Enter fullscreen mode Exit fullscreen mode


## Testing & Validation

## Local Testing

Most webhook integrations break because developers skip local validation. Use the Vapi CLI webhook forwarder to test before deploying:

Enter fullscreen mode Exit fullscreen mode


bash

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:**

Enter fullscreen mode Exit fullscreen mode


javascript
// 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:

Enter fullscreen mode Exit fullscreen mode


javascript
// 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 `processedEvents` cache prevents duplicate processing, but verify `eventId` uniqueness 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:

Enter fullscreen mode Exit fullscreen mode


javascript
// 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);
}
Enter fullscreen mode Exit fullscreen mode

}

res.status(200).send('OK');
});


## Event Logs

Real webhook sequence when customer interrupts (timestamps in ms):

Enter fullscreen mode Exit fullscreen mode

[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:

Enter fullscreen mode Exit fullscreen mode


javascript
// 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:

Enter fullscreen mode Exit fullscreen mode


javascript
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;
Enter fullscreen mode Exit fullscreen mode

} 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();
Enter fullscreen mode Exit fullscreen mode

} 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:

Enter fullscreen mode Exit fullscreen mode


javascript
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.

Enter fullscreen mode Exit fullscreen mode


javascript
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.');
Enter fullscreen mode Exit fullscreen mode

} 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;
Enter fullscreen mode Exit fullscreen mode

} 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');
}
Enter fullscreen mode Exit fullscreen mode

} 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

Official Documentation

Integration Patterns

GitHub References

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

Top comments (0)