Webhook Integration for Developers
Learn how to build production-ready webhook handlers to receive and process real-time call events from Hamsa voice agents.Prerequisites:
- Webhook URL configured in dashboard (Setup Guide)
- Basic understanding of HTTP POST requests and JSON
- Node.js, Python, or your preferred backend framework
Understanding Webhook Data
Custom Parameters (Echo Pattern)
The most powerful feature of Hamsa webhooks is the echo pattern. All custom parameters you send during agent initiation are echoed back in the webhook response. What You Send:{
"agentId": "agent-123-abc",
"params": {
"application_id": "12345",
"user_id": "user-789",
"session_id": "sess-abc-def",
"candidate_name": "John Doe",
"company_name": "Acme Corp"
}
}
{
"eventType": "call.ended",
"data": {
"data": {
"outcomeResult": {
"application_id": "12345", // ← Echoed back
"user_id": "user-789", // ← Echoed back
"session_id": "sess-abc-def", // ← Echoed back
"candidate_name": "John Doe", // ← Echoed back
"company_name": "Acme Corp", // ← Echoed back
"expectedSalary": "80000", // ← New data from call
"noticePeriod": "2 weeks" // ← New data from call
}
}
}
}
Event Structure
All webhook events follow this structure:{
eventType: "call.started" | "call.answered" | "transcription.update" | "tool.executed" | "call.ended",
callId: string, // Unique call identifier
timestamp: string, // ISO 8601 timestamp
projectId: string, // Your Hamsa project ID
agentId: string, // Agent configuration ID
agentName: string, // Agent name
data: object // Event-specific data
}
Call Ended Event (Most Important)
Thecall.ended event contains complete conversation data:
{
"eventType": "call.ended",
"callId": "call_uuid_12345",
"timestamp": "2024-01-15T14:35:00.000Z",
"data": {
"timestamp": "2024-01-15T14:35:00.000Z",
"data": {
"conversationId": "conv-123-abc",
"conversationRecording": "https://hamsa-recordings.s3.amazonaws.com/recording.mp3",
"transcription": [
{ "Agent": "Hello! How can I help you?" },
{ "User": "I need assistance." }
],
"outcomeResult": {
// Your echoed params + collected data
}
}
}
}
Implementing Webhook Handlers
Node.js/Express Implementation
- Basic Handler
- Call Ended Handler
import express from 'express';
const app = express();
app.use(express.json());
// Middleware to verify Bearer token
function verifyToken(req, res, next) {
const authHeader = req.headers.authorization;
const expectedToken = process.env.WEBHOOK_SECRET;
if (authHeader !== expectedToken) {
return res.status(401).json({ error: 'Unauthorized' });
}
next();
}
// Webhook endpoint
app.post('/webhook', verifyToken, async (req, res) => {
try {
const event = req.body;
console.log('Event:', event.eventType);
console.log('Call ID:', event.callId);
// Handle different event types
switch (event.eventType) {
case 'call.started':
await handleCallStarted(event);
break;
case 'call.ended':
await handleCallEnded(event);
break;
case 'transcription.update':
await handleTranscription(event);
break;
}
// Respond quickly
res.status(200).json({ received: true });
} catch (error) {
console.error('Webhook error:', error);
// Still return 200 to avoid retries
res.status(200).json({
received: true,
processing_error: true
});
}
});
app.listen(3000, () => {
console.log('Webhook server running on port 3000');
});
async function handleCallEnded(event) {
// Extract webhook data
const { timestamp } = event.data;
const {
conversationId,
conversationRecording,
transcription,
outcomeResult
} = event.data.data;
// Extract your custom identifiers (echoed back)
const applicationId = outcomeResult.application_id;
const userId = outcomeResult.user_id;
const sessionId = outcomeResult.session_id;
console.log('Identifiers:', {
applicationId,
userId,
sessionId
});
// Extract collected data (new from call)
const expectedSalary = outcomeResult.expectedSalary;
const noticePeriod = outcomeResult.noticePeriod;
const numberOfSpeakers = outcomeResult.number_of_speakers;
// Build transcript text
const transcriptText = transcription
.map(turn => {
if (turn.Agent) return `Agent: ${turn.Agent}`;
if (turn.User) return `User: ${turn.User}`;
return '';
})
.filter(Boolean)
.join('\n');
// Update your database
await database.calls.update({
where: { applicationId },
data: {
conversationId,
recordingUrl: conversationRecording,
transcript: transcriptText,
expectedSalary,
noticePeriod,
numberOfSpeakers,
completedAt: timestamp
}
});
}
Python/Flask Implementation
- Basic Handler
- Call Ended Handler
from flask import Flask, request, jsonify
import os
app = Flask(__name__)
def verify_token():
auth_header = request.headers.get('Authorization')
expected_token = os.environ.get('WEBHOOK_SECRET')
if auth_header != expected_token:
return False
return True
@app.route('/webhook', methods=['POST'])
def webhook():
# Verify authentication
if not verify_token():
return jsonify({'error': 'Unauthorized'}), 401
try:
event = request.get_json()
print(f"Event: {event.get('eventType')}")
print(f"Call ID: {event.get('callId')}")
# Handle different event types
event_type = event.get('eventType')
if event_type == 'call.started':
handle_call_started(event)
elif event_type == 'call.ended':
handle_call_ended(event)
elif event_type == 'transcription.update':
handle_transcription(event)
return jsonify({'received': True}), 200
except Exception as e:
print(f'Webhook error: {str(e)}')
# Still return 200
return jsonify({
'received': True,
'processing_error': True
}), 200
if __name__ == '__main__':
app.run(port=3000)
def handle_call_ended(event):
# Extract webhook data
data = event['data']
timestamp = data['timestamp']
conversation_data = data['data']
conversation_id = conversation_data['conversationId']
recording_url = conversation_data['conversationRecording']
transcription = conversation_data['transcription']
outcome_result = conversation_data['outcomeResult']
# Extract identifiers (echoed back)
application_id = outcome_result.get('application_id')
user_id = outcome_result.get('user_id')
session_id = outcome_result.get('session_id')
# Extract collected data
expected_salary = outcome_result.get('expectedSalary')
notice_period = outcome_result.get('noticePeriod')
num_speakers = outcome_result.get('number_of_speakers')
# Build transcript text
transcript_parts = []
for turn in transcription:
if 'Agent' in turn:
transcript_parts.append(f"Agent: {turn['Agent']}")
elif 'User' in turn:
transcript_parts.append(f"User: {turn['User']}")
transcript_text = '\n'.join(transcript_parts)
# Update database
update_database({
'application_id': application_id,
'conversation_id': conversation_id,
'recording_url': recording_url,
'transcript': transcript_text,
'expected_salary': expected_salary,
'notice_period': notice_period,
'completed_at': timestamp
})
Advanced Topics
Async Processing with Queues
Always process webhooks asynchronously to avoid timeouts:const Queue = require('bull');
const eventQueue = new Queue('call-events', {
redis: { host: '127.0.0.1', port: 6379 }
});
// Webhook endpoint - respond immediately
app.post('/webhook', verifyToken, async (req, res) => {
const event = req.body;
try {
// Add to queue
await eventQueue.add('process-event', event, {
attempts: 3,
backoff: {
type: 'exponential',
delay: 2000
}
});
// Respond immediately
res.status(200).json({ received: true });
} catch (error) {
console.error('Queue error:', error);
res.status(500).json({ error: 'Failed to queue event' });
}
});
// Process events in background
eventQueue.process('process-event', async (job) => {
const event = job.data;
if (event.eventType === 'call.ended') {
await saveToDatabase(event);
await sendNotifications(event);
await updateAnalytics(event);
}
});
Idempotency
Prevent duplicate processing:const redis = require('redis').createClient();
async function isEventProcessed(event) {
const eventKey = `webhook:${event.callId}:${event.eventType}:${event.timestamp}`;
const exists = await redis.exists(eventKey);
if (exists) {
return true; // Already processed
}
// Mark as processed (expire after 24 hours)
await redis.setex(eventKey, 86400, 'processed');
return false;
}
app.post('/webhook', async (req, res) => {
const event = req.body;
const alreadyProcessed = await isEventProcessed(event);
if (alreadyProcessed) {
console.log('Duplicate event, skipping');
return res.status(200).json({
received: true,
duplicate: true
});
}
await processEvent(event);
res.status(200).json({ received: true });
});
Error Handling
Comprehensive error handling:app.post('/webhook', async (req, res) => {
try {
// 1. Verify authentication
if (!verifyToken(req)) {
console.warn('Authentication failed:', {
ip: req.ip
});
return res.status(401).json({ error: 'Unauthorized' });
}
// 2. Validate payload
const event = req.body;
if (!event.callId || !event.eventType) {
console.error('Invalid payload');
return res.status(400).json({ error: 'Invalid payload' });
}
// 3. Process with timeout
const timeoutPromise = new Promise((_, reject) => {
setTimeout(() => reject(new Error('Processing timeout')), 4000);
});
await Promise.race([
processEvent(event),
timeoutPromise
]);
res.status(200).json({ received: true });
} catch (error) {
console.error('Webhook error:', {
error: error.message,
stack: error.stack
});
// Still return 200
res.status(200).json({
received: true,
processing_error: true
});
}
});
Common Use Cases
CRM Integration
async function syncToCRM(event) {
if (event.eventType !== 'call.ended') return;
const { outcomeResult, transcription } = event.data.data;
// Build transcript
const transcriptText = transcription
.map(turn => Object.values(turn)[0])
.join('\n');
// Update CRM
await crm.contacts.upsert({
where: { phone: outcomeResult.phone },
update: {
lastCallDate: event.timestamp,
lastCallNotes: transcriptText,
intent: outcomeResult.intent,
sentiment: outcomeResult.sentiment_score
}
});
// Create activity
await crm.activities.create({
type: 'phone_call',
contactPhone: outcomeResult.phone,
subject: `AI Agent Call - ${outcomeResult.intent}`,
description: transcriptText,
recordingUrl: event.data.data.conversationRecording
});
}
Notification System
async function sendNotifications(event) {
if (event.eventType !== 'call.ended') return;
const { outcomeResult, callDuration } = event.data.data;
// Urgent notification
if (outcomeResult.priority === 'urgent' || !outcomeResult.resolved) {
await slack.sendMessage({
channel: '#support-team',
text: '🚨 Urgent call requires attention',
blocks: [
{
type: 'section',
text: {
type: 'mrkdwn',
text: `*Call ID:* ${event.callId}\n*Duration:* ${callDuration}s`
}
},
{
type: 'actions',
elements: [
{
type: 'button',
text: { type: 'plain_text', text: 'View Details' },
url: `https://dashboard.example.com/calls/${event.callId}`
}
]
}
]
});
}
}
Troubleshooting
Cannot Match Webhook to Record
Problem: Missing application_id in outcomeResult Solution:// ✅ Always include unique identifiers
const config = {
agentId: 'agent-123',
params: {
application_id: applicationId.toString(),
user_id: userId.toString(),
session_id: generateSessionId()
}
};
// In webhook - verify identifiers exist
const applicationId = outcomeResult.application_id;
if (!applicationId) {
console.error('Missing identifier');
console.error('Full payload:', JSON.stringify(req.body, null, 2));
}
Missing or Null Fields
Problem: Expected fields are null or undefined Solution:// Handle optional fields gracefully
const expectedSalary = outcomeResult.expectedSalary ?? 'Not provided';
const noticePeriod = outcomeResult.noticePeriod || null;
// Distinguish between null and undefined
if (outcomeResult.expectedSalary === undefined) {
console.log('Field not in agent configuration');
} else if (outcomeResult.expectedSalary === null) {
console.log('User did not provide information');
} else {
console.log('Value:', outcomeResult.expectedSalary);
}
Duplicate Events
Problem: Receiving same event multiple times Solution: Implement idempotency (see above section)Testing
Local Testing with cURL
# Test with full payload
curl -X POST https://your-endpoint.com/webhook \
-H "Content-Type: application/json" \
-H "Authorization: Bearer your_secret_token" \
-d '{
"eventType": "call.ended",
"callId": "test-123",
"timestamp": "2024-01-15T14:35:00.000Z",
"data": {
"data": {
"conversationId": "conv-test",
"transcription": [],
"outcomeResult": {
"application_id": "12345"
}
}
}
}'
Monitoring
const winston = require('winston');
const logger = winston.createLogger({
level: 'info',
format: winston.format.json(),
transports: [
new winston.transports.File({
filename: 'webhook-errors.log',
level: 'error'
}),
new winston.transports.File({
filename: 'webhook-all.log'
})
]
});
app.post('/webhook', async (req, res) => {
const event = req.body;
logger.info('Webhook received', {
eventType: event.eventType,
callId: event.callId,
ip: req.ip
});
try {
await processEvent(event);
logger.info('Processed successfully', {
callId: event.callId
});
} catch (error) {
logger.error('Processing failed', {
callId: event.callId,
error: error.message,
stack: error.stack
});
}
res.status(200).json({ received: true });
});
Next Steps
Configure Webhooks
Set up webhook URLs in the dashboard
Webhooks Overview
Learn webhook concepts and patterns
Outcomes Configuration
Configure what data to extract from calls
API Reference
Complete API documentation