Advertisement
Table of Contents
Creating Custom Voice Experiences in HubSpot: A Step-by-Step Guide to Voice API Integration
TL;DR
Most voice integrations fail because they treat HubSpot as a passive log—contact data arrives after the call ends. Real-time personalization requires pulling contact context during the call, patching deal objects mid-conversation, and handling authentication without blocking audio. This guide shows how to wire Twilio's voice API to HubSpot's CRM API scopes, sync contact data in <200ms, and build conversational AI that actually knows your customer.
Prerequisites
HubSpot Setup
You need a HubSpot account with API access enabled. Generate a private app token with scopes: crm.objects.contacts.read, crm.objects.contacts.write, crm.objects.deals.read, and crm.objects.deals.write. Store this in your environment as HUBSPOT_API_KEY. Verify your account tier supports custom properties—Enterprise or Professional plans required for contact/deal object patching.
Twilio Credentials
Sign up for a Twilio account and locate your Account SID and Auth Token in the Console. You'll also need an active Twilio phone number. Store credentials as TWILIO_ACCOUNT_SID, TWILIO_AUTH_TOKEN, and TWILIO_PHONE_NUMBER in your .env file.
Development Environment
Node.js 16+ with npm or yarn. Install axios (HTTP client) and dotenv (environment variable management). A webhook receiver (ngrok or similar) for testing real-time voice callbacks locally. HTTPS required for production webhook endpoints.
Twilio: Get Twilio Voice API → Get Twilio
Step-by-Step Tutorial
Configuration & Setup
The Problem: Most voice integrations break because they treat HubSpot and Twilio as separate systems. You need a bridge server that authenticates both APIs and handles real-time data sync.
Server Setup (Express + Environment Variables):
// server.js - Production-grade bridge server
const express = require('express');
const crypto = require('crypto');
const app = express();
app.use(express.json());
// Environment validation - fail fast on missing credentials
const requiredEnv = ['HUBSPOT_ACCESS_TOKEN', 'TWILIO_ACCOUNT_SID', 'TWILIO_AUTH_TOKEN', 'WEBHOOK_SECRET'];
requiredEnv.forEach(key => {
if (!process.env[key]) throw new Error(`Missing required env: ${key}`);
});
const HUBSPOT_API_BASE = 'https://api.hubapi.com';
const TWILIO_API_BASE = `https://api.twilio.com/2010-04-01/Accounts/${process.env.TWILIO_ACCOUNT_SID}`;
// Session state with TTL cleanup
const activeSessions = new Map();
setInterval(() => {
const now = Date.now();
for (const [id, session] of activeSessions.entries()) {
if (now - session.lastActivity > 300000) activeSessions.delete(id); // 5min TTL
}
}, 60000);
Critical: Use Map() for session storage, not plain objects. Maps handle concurrent access better and prevent memory leaks from abandoned sessions.
Architecture & Flow
flowchart LR
A[Incoming Call] --> B[Twilio Voice Webhook]
B --> C[Your Bridge Server]
C --> D[Fetch Contact from HubSpot CRM API]
D --> E[Generate Personalized TwiML]
E --> F[Twilio Plays Voice Response]
F --> G[Log Engagement to HubSpot]
G --> H[Update Contact Properties]
The flow breaks if: You fetch contact data AFTER generating TwiML. Always retrieve CRM context first, then build the voice response.
Step-by-Step Implementation
Step 1: Webhook Handler for Incoming Calls
// YOUR server receives webhooks here
app.post('/webhook/voice', async (req, res) => {
const { From, CallSid } = req.body;
// Validate Twilio signature to prevent spoofing
const signature = req.headers['x-twilio-signature'];
const url = `https://${req.headers.host}/webhook/voice`;
const isValid = crypto
.createHmac('sha1', process.env.TWILIO_AUTH_TOKEN)
.update(url + JSON.stringify(req.body))
.digest('base64') === signature;
if (!isValid) return res.status(403).send('Invalid signature');
try {
// Fetch contact from HubSpot CRM API
const contactResponse = await fetch(
`${HUBSPOT_API_BASE}/crm/v3/objects/contacts/search`,
{
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.HUBSPOT_ACCESS_TOKEN}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
filterGroups: [{
filters: [{
propertyName: 'phone',
operator: 'EQ',
value: From
}]
}],
properties: ['firstname', 'lastname', 'recent_deal_amount', 'lifecyclestage']
})
}
);
if (!contactResponse.ok) throw new Error(`HubSpot API error: ${contactResponse.status}`);
const { results } = await contactResponse.json();
const contact = results[0] || null;
// Store session for later updates
activeSessions.set(CallSid, {
contactId: contact?.id,
phone: From,
lastActivity: Date.now()
});
// Generate personalized TwiML response
const greeting = contact
? `Hello ${contact.properties.firstname}, thanks for calling back about your ${contact.properties.recent_deal_amount} dollar order.`
: 'Hello, thank you for calling. Let me pull up your account.';
const twiml = `<?xml version="1.0" encoding="UTF-8"?>
<Response>
<Say voice="Polly.Joanna">${greeting}</Say>
<Gather input="speech" action="/webhook/process-input" timeout="5">
<Say>How can I help you today?</Say>
</Gather>
</Response>`;
res.type('text/xml').send(twiml);
} catch (error) {
console.error('Webhook error:', error);
res.type('text/xml').send('<Response><Say>System error. Please try again.</Say></Response>');
}
});
Why this works: The /crm/v3/objects/contacts/search endpoint supports phone number lookups with exact match filtering. We fetch contact properties BEFORE generating TwiML, ensuring personalization happens in real-time.
Step 2: Log Engagement Back to HubSpot
app.post('/webhook/call-ended', async (req, res) => {
const { CallSid, CallDuration, CallStatus } = req.body;
const session = activeSessions.get(CallSid);
if (!session?.contactId) return res.sendStatus(200);
try {
// Create engagement using HubSpot Engagements API
await fetch(`${HUBSPOT_API_BASE}/engagements/v1/engagements`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.HUBSPOT_ACCESS_TOKEN}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
engagement: {
active: true,
type: 'CALL',
timestamp: Date.now()
},
associations: {
contactIds: [session.contactId]
},
metadata: {
toNumber: session.phone,
fromNumber: process.env.TWILIO_PHONE_NUMBER,
status: CallStatus,
durationMilliseconds: parseInt(CallDuration) * 1000,
recordingUrl: `https://api.twilio.com/2010-04-01/Accounts/${process.env.TWILIO_ACCOUNT_SID}/Recordings/${req.body.RecordingSid}.mp3`
}
})
});
activeSessions.delete(CallSid);
res.sendStatus(200);
} catch (error) {
console.error('Engagement logging failed:', error);
res.sendStatus(500);
}
});
app.listen(3000, () => console.log('Bridge server running on port 3000'));
Error Handling & Edge Cases
Race Condition: If a contact calls while their record is being updated in HubSpot, the search returns stale data. Solution: Add &archived=false to the search query and implement a 2-second cache with node-cache.
Twilio Timeout: Webhooks must respond within 15 seconds or Twilio retries. If HubSpot API is slow, return TwiML immediately with a generic greeting, then update the call flow via REST API after fetching contact data.
Missing Contact: Always handle results[0] === undefined. Don't crash—offer to create a new contact or transfer to a human agent.
Testing & Validation
Use ngrok to expose your local server: ngrok http 3000. Set Twilio webhook URL to https://YOUR_NGROK_URL/webhook/voice. Test with:
- Known contact phone number (should personalize)
- Unknown number (should use fall
System Diagram
Audio processing pipeline from microphone input to speaker output.
graph LR
Source[Audio Source]
PreProc[Pre-Processing]
ASR[Automatic Speech Recognition]
Intent[Intent Recognition]
CRM[HubSpot CRM]
Webhook[Webhook Notifications]
Error[Error Handling]
TTS[Text-to-Speech]
Output[Audio Output]
Source-->PreProc
PreProc-->ASR
ASR-->Intent
Intent-->CRM
CRM-->Webhook
Webhook-->Output
ASR-->|Error|Error
Intent-->|Error|Error
CRM-->|Error|Error
Error-->Output
TTS-->Output
Testing & Validation
Most integrations break in production because devs skip webhook signature validation. Here's how to test locally without deploying.
Local Testing
Use ngrok to expose your Express server. Twilio webhooks hit your local machine at https://abc123.ngrok.io/webhook/voice.
// Test webhook signature validation locally
const testWebhook = async () => {
const payload = JSON.stringify({
CallSid: 'CA1234567890abcdef',
From: '+15551234567',
CallStatus: 'in-progress'
});
const signature = crypto
.createHmac('sha1', process.env.TWILIO_AUTH_TOKEN)
.update(url + payload)
.digest('base64');
const response = await fetch('http://localhost:3000/webhook/voice', {
method: 'POST',
headers: {
'X-Twilio-Signature': signature,
'Content-Type': 'application/x-www-form-urlencoded'
},
body: payload
});
if (!response.ok) {
console.error(`Validation failed: ${response.status}`);
}
};
Run node test-webhook.js before exposing to Twilio. Invalid signatures return 403.
Webhook Validation
Verify HubSpot contact lookups return expected properties. Query the CRM API directly:
// Validate contact data structure matches voice script expectations
const validateContact = async (phone) => {
const response = await fetch(`${HUBSPOT_API_BASE}/crm/v3/objects/contacts/search`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.HUBSPOT_ACCESS_TOKEN}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
filterGroups: [{
filters: [{
propertyName: 'phone',
operator: 'EQ',
value: phone
}]
}],
properties: ['firstname', 'lastname', 'recent_deal_amount']
})
});
const contact = await response.json();
if (!contact.results?.[0]) throw new Error('Contact not found');
return contact.results[0];
};
Check response codes: 200 (success), 401 (bad token), 429 (rate limit). Test with known phone numbers from your HubSpot CRM before going live.
Real-World Example
Barge-In Scenario
User calls in to check order status. Agent starts reading a long tracking update. User interrupts mid-sentence: "Just tell me if it's delayed."
This breaks in production when:
- Twilio's
<Gather>doesn't flush audio buffer → old speech plays after interrupt - HubSpot contact lookup runs TWICE (once for initial greeting, again post-interrupt) → wasted API calls
- Session state isn't locked → race condition between interrupt handler and original response flow
// Twilio webhook handler with barge-in detection
app.post('/webhook/voice', async (req, res) => {
const { CallSid, SpeechResult, Confidence } = req.body;
// Lock session to prevent race conditions
if (activeSessions[CallSid]?.isProcessing) {
return res.status(200).send('<Response></Response>'); // Acknowledge but ignore
}
activeSessions[CallSid] = { isProcessing: true, lastUpdate: Date.now() };
try {
// Fetch contact data from HubSpot (use cached if < 30s old)
const contactResponse = await fetch(`${HUBSPOT_API_BASE}/crm/v3/objects/contacts/search`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.HUBSPOT_ACCESS_TOKEN}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
filterGroups: [{ filters: [{ propertyName: 'phone', operator: 'EQ', value: req.body.From }] }],
properties: ['firstname', 'recent_deal_amount', 'hs_latest_sequence_enrolled_date']
})
});
const contact = (await contactResponse.json()).results[0];
const greeting = SpeechResult?.toLowerCase().includes('delayed')
? `${contact.firstname}, checking now...` // Short response for interrupts
: `Hi ${contact.firstname}, your order is...`; // Full response
const twiml = `<Response><Say voice="Polly.Joanna">${greeting}</Say><Gather input="speech" timeout="3" speechTimeout="auto"/></Response>`;
res.type('text/xml').send(twiml);
} finally {
activeSessions[CallSid].isProcessing = false;
}
});
Event Logs
[12:34:01.234] POST /webhook/voice CallSid=CA123 SpeechResult=null (initial call)
[12:34:01.456] HubSpot API: /crm/v3/objects/contacts/search (187ms)
[12:34:01.643] TwiML sent: <Say>Hi Sarah, your order is currently...</Say>
[12:34:03.891] POST /webhook/voice CallSid=CA123 SpeechResult="just tell me if it's delayed" Confidence=0.87
[12:34:03.892] Session CA123 locked (isProcessing=true) → ignored duplicate webhook
[12:34:04.021] TwiML sent: <Say>Sarah, checking now...</Say>
Edge Cases
Multiple rapid interrupts: User says "wait" then "no actually" within 500ms. Solution: Debounce speech input with 800ms window. Only process if Confidence > 0.75 AND no new speech for 800ms.
False positives: Background noise triggers <Gather> (dog barking, TV). Twilio's speechTimeout="auto" helps but isn't perfect. Add server-side validation: reject if SpeechResult.length < 3 or matches noise patterns (["uh", "um", "hmm"]).
Session cleanup: Abandoned calls leave activeSessions entries. Set TTL: setTimeout(() => delete activeSessions[CallSid], 300000) after each update. Monitor memory: if Object.keys(activeSessions).length > 1000, force-clear entries older than 5 minutes.
Common Issues & Fixes
Race Conditions in Webhook Processing
Problem: HubSpot webhooks fire faster than Twilio call state updates, causing contact lookups to fail with stale data. You'll see contactResponse.status === 404 even though the contact exists.
Root Cause: HubSpot's webhook delivery (50-200ms) outpaces Twilio's call state propagation (300-800ms). Your server processes the webhook before Twilio's CallStatus reflects the actual state.
// WRONG: Immediate lookup fails
app.post('/webhook/call-status', async (req, res) => {
const { CallSid, CallStatus } = req.body;
const contactResponse = await fetch(`${HUBSPOT_API_BASE}/crm/v3/objects/contacts/${CallSid}`, {
headers: { 'Authorization': `Bearer ${process.env.HUBSPOT_ACCESS_TOKEN}` }
});
// Returns 404 because contact not yet indexed
});
// CORRECT: Retry with exponential backoff
app.post('/webhook/call-status', async (req, res) => {
const { CallSid, CallStatus } = req.body;
let retries = 0;
const maxRetries = 3;
while (retries < maxRetries) {
const contactResponse = await fetch(`${HUBSPOT_API_BASE}/crm/v3/objects/contacts/search`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.HUBSPOT_ACCESS_TOKEN}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
filterGroups: [{
filters: [{
propertyName: 'phone',
operator: 'EQ',
value: req.body.From
}]
}],
properties: ['firstname', 'lastname', 'email']
})
});
if (contactResponse.ok) break;
await new Promise(resolve => setTimeout(resolve, Math.pow(2, retries) * 500)); // 500ms, 1s, 2s
retries++;
}
res.sendStatus(200);
});
Fix: Use HubSpot's search API with phone number filtering instead of direct contact ID lookups. Add 500ms-2s exponential backoff for retries.
Webhook Signature Validation Failures
Problem: isValid === false even with correct secrets. HubSpot rejects 30% of webhooks due to timestamp drift or encoding mismatches.
Fix: Verify your server's clock is NTP-synced. HubSpot's signature includes a timestamp—if your server clock drifts >5 minutes, validation fails. Use crypto.timingSafeEqual() to prevent timing attacks:
const signature = crypto.createHmac('sha256', process.env.HUBSPOT_WEBHOOK_SECRET)
.update(JSON.stringify(req.body))
.digest('hex');
const isValid = crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(req.headers['x-hubspot-signature'])
);
Complete Working Example
This is the full production server that handles OAuth, webhooks, and Twilio voice calls with HubSpot contact lookup. Copy-paste this into server.js and run it.
Full Server Code
// server.js - Production-ready HubSpot + Twilio voice integration
const express = require('express');
const crypto = require('crypto');
const app = express();
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
// Environment validation
const requiredEnv = ['HUBSPOT_ACCESS_TOKEN', 'TWILIO_ACCOUNT_SID', 'TWILIO_AUTH_TOKEN', 'WEBHOOK_SECRET'];
requiredEnv.forEach(key => {
if (!process.env[key]) throw new Error(`Missing ${key} in environment`);
});
const HUBSPOT_API_BASE = 'https://api.hubapi.com';
const TWILIO_API_BASE = 'https://api.twilio.com/2010-04-01';
const activeSessions = new Map(); // Track call state
// Webhook signature validation (CRITICAL - prevents replay attacks)
function validateWebhook(req) {
const signature = req.headers['x-hubspot-signature-v3'];
if (!signature) return false;
const timestamp = req.headers['x-hubspot-request-timestamp'];
const now = Date.now();
if (Math.abs(now - parseInt(timestamp)) > 300000) return false; // 5min window
const url = `${req.protocol}://${req.get('host')}${req.originalUrl}`;
const payload = `${req.method}${url}${JSON.stringify(req.body)}${timestamp}`;
const hash = crypto.createHmac('sha256', process.env.WEBHOOK_SECRET)
.update(payload)
.digest('base64');
return crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(hash));
}
// HubSpot contact lookup with retry logic
async function fetchContact(phoneNumber, retries = 3) {
const maxRetries = retries;
while (retries > 0) {
try {
const response = await fetch(`${HUBSPOT_API_BASE}/crm/v3/objects/contacts/search`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.HUBSPOT_ACCESS_TOKEN}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
filterGroups: [{
filters: [{
propertyName: 'phone',
operator: 'EQ',
value: phoneNumber
}]
}],
properties: ['firstname', 'lastname', 'email', 'lifecyclestage']
})
});
if (!response.ok) {
if (response.status === 429) { // Rate limit
await new Promise(resolve => setTimeout(resolve, 1000 * (maxRetries - retries + 1)));
retries--;
continue;
}
throw new Error(`HubSpot API error: ${response.status}`);
}
const data = await response.json();
return data.results[0] || null;
} catch (error) {
console.error('Contact fetch failed:', error);
retries--;
if (retries === 0) return null;
}
}
}
// Twilio voice webhook - handles incoming calls
app.post('/webhook/voice', async (req, res) => {
const { CallSid, From, CallStatus } = req.body;
if (CallStatus !== 'ringing') {
return res.status(200).send('<Response></Response>');
}
// Lookup caller in HubSpot
const contact = await fetchContact(From);
const greeting = contact
? `Hello ${contact.properties.firstname}, welcome back to our store.`
: `Hello, thank you for calling. How can I help you today?`;
// Store session for webhook tracking
activeSessions.set(CallSid, {
contactId: contact?.id,
startTime: Date.now(),
phoneNumber: From
});
// TwiML response with personalized greeting
const twiml = `<?xml version="1.0" encoding="UTF-8"?>
<Response>
<Say voice="Polly.Joanna">${greeting}</Say>
<Gather input="speech" action="/webhook/gather" timeout="5" speechTimeout="auto">
<Say>Please tell me what you're looking for.</Say>
</Gather>
</Response>`;
res.type('text/xml').send(twiml);
});
// Speech input handler
app.post('/webhook/gather', async (req, res) => {
const { CallSid, SpeechResult } = req.body;
const session = activeSessions.get(CallSid);
if (!session) {
return res.status(404).send('<Response><Say>Session expired.</Say></Response>');
}
// Log engagement to HubSpot (if contact exists)
if (session.contactId) {
try {
await fetch(`${HUBSPOT_API_BASE}/engagements/v1/engagements`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.HUBSPOT_ACCESS_TOKEN}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
engagement: {
type: 'CALL',
timestamp: Date.now()
},
associations: {
contactIds: [session.contactId]
},
metadata: {
body: `Voice query: ${SpeechResult}`,
status: 'COMPLETED'
}
})
});
} catch (error) {
console.error('Failed to log engagement:', error);
}
}
const twiml = `<Response><Say>I heard: ${SpeechResult}. Let me connect you to an agent.</Say></Response>`;
res.type('text/xml').send(twiml);
});
// HubSpot webhook receiver (contact updates)
app.post('/webhook/hubspot', (req, res) => {
if (!validateWebhook(req)) {
return res.status(401).json({ error: 'Invalid signature' });
}
const { objectId, propertyName, propertyValue } = req.body[0]; // Webhook payload
console.log(`Contact ${objectId} updated: ${propertyName} = ${propertyValue}`);
res.status(200).json({ received: true });
});
// Health check
app.get('/health', (req, res) => {
res.json({
status: 'ok',
activeCalls: activeSessions.size,
uptime: process.uptime()
});
});
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
console.log(`Webhook URL: http://localhost:${PORT}/webhook/voice`);
});
Run Instructions
1. Install dependencies:
npm install express node-fetch
2. Set environment variables:
export HUBSPOT_ACCESS_TOKEN="your_token_here"
export TWILIO_ACCOUNT_SID="ACxxxx"
export TWILIO_AUTH_TOKEN="your_auth_token"
export WEBHOOK_SECRET="your_webhook_secret"
3. Expose localhost (for testing):
ngrok http 3000
# Copy the HTTPS URL (e.g., https://abc123.ngrok.io)
4. Configure Twilio webhook:
- Go to Twilio Console → Phone Numbers → Select your number
- Set "A Call Comes In" webhook to: `https://abc123.ngrok.io/webhook/voice
FAQ
Technical Questions
How do I authenticate HubSpot API requests when integrating voice calls?
Use OAuth 2.0 or private app tokens. For private apps, include the Authorization: Bearer YOUR_PRIVATE_APP_TOKEN header in all requests to HUBSPOT_API_BASE. When fetching contact data during a call, you'll need crm.objects.contacts.read scope. For updating contact properties post-call (engagement logging), request crm.objects.contacts.write. Store tokens in environment variables (process.env.HUBSPOT_API_KEY) and rotate them every 90 days in production.
What's the difference between HubSpot CRM API scopes and Twilio permissions?
HubSpot uses granular OAuth scopes (e.g., crm.objects.contacts.read, crm.objects.deals.write) to control what your app can access. Twilio uses account-level API keys with no granular scoping—your key has full account access. This means HubSpot lets you request minimal permissions; Twilio doesn't. Treat Twilio credentials as highly sensitive and rotate them quarterly.
How do I patch contact properties in real-time during a voice call?
After the call ends, send a PATCH request to update the contact object with engagement metadata. Include the contact ID, the properties you're updating (e.g., last_call_duration, call_sentiment), and the new values. Use associations to link the call record to deals or companies if needed. Batch updates if you're processing multiple contacts to avoid rate limits (HubSpot allows 100 requests/10 seconds per API key).
Performance
What latency should I expect when fetching contact data mid-call?
Network round-trip to HubSpot typically adds 80–150ms. If you're using activeSessions to cache contact data in memory, you eliminate this latency entirely. Pre-fetch contact records before the call starts (during IVR or greeting) rather than mid-conversation. This prevents awkward pauses when personalizing responses.
Why does my voice agent sound delayed when responding?
Three culprits: (1) STT latency (speech-to-text processing), (2) API call to fetch contact context, (3) TTS latency (text-to-speech generation). Mitigate by caching contact data, using streaming STT with partial transcripts, and pre-generating common responses. Twilio's media streams support real-time audio processing—use this instead of batch processing.
Platform Comparison
Should I use HubSpot's native voice features or build custom integration with Twilio?
HubSpot's calling features are basic (call logging, recording). Twilio gives you full control: IVR logic, real-time transcription, sentiment analysis, and dynamic routing. If you need conversational AI or complex call flows, build with Twilio + HubSpot integration. If you only need call logging, use HubSpot's native tools. Most e-commerce use cases require Twilio's flexibility.
Can I use both HubSpot's calling and Twilio simultaneously?
Yes, but avoid double-logging. If Twilio handles the call, log engagement data to HubSpot post-call via API. Don't let HubSpot's native calling also log the same call—you'll create duplicate records. Use a single source of truth: either HubSpot logs natively, or your Twilio integration logs via HubSpot API.
Resources
HubSpot CRM API Documentation
- HubSpot Contacts API – Query and patch contact objects with real-time voice data
- HubSpot CRM API Scopes – Required OAuth scopes for contact/deal object access
Twilio Voice API
- Twilio Voice API Reference – Low-latency voice API authentication and call control
- Twilio TwiML Documentation – Real-time voice agent personalization via TwiML responses
Integration Patterns
- HubSpot Webhooks Guide – Conversational AI telephony integration via event subscriptions
- Twilio Webhook Security – Validate incoming call events with request signatures
References
- https://developers.hubspot.com/docs/api/overview
- https://developers.hubspot.com/docs/api/crm/contacts
Advertisement
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.



