Skip to main content

Overview

This comprehensive guide walks you through integrating with Hamsa Voice Agent webhooks, from initial setup to production deployment. Whether you’re just getting started or implementing advanced patterns, this guide has you covered.
What You’ll Learn:
  • How to set up and configure webhook endpoints
  • Understanding webhook data structures and events
  • Implementing secure and reliable webhook handlers
  • Best practices for production deployments
  • Troubleshooting common issues

Stage 1: Getting Started

What Are Webhooks?

Webhooks allow your applications to receive real-time notifications about call events, transcriptions, and conversation outcomes. When a voice call completes or specific events occur, Hamsa sends HTTP POST requests to your configured endpoint with relevant data.

Real-time Events

Instant notifications as calls progress, from start to completion

Call Data

Full transcripts, recordings, and extracted information

Custom Parameters

Pass ANY custom data that gets echoed back for identification

Secure Delivery

HTTPS-only with Bearer token authentication support

How It Works - Complete Flow

The flow works in three stages: Initiation: Your application sends configuration with agentId and custom params (identifiers, context data). Call: User interacts with AI agent, which collects information through conversation. Webhook: Hamsa POSTs results to your endpoint with transcript, recording, outcome data, and your original params echoed back.

Key Concepts

What are they?Custom parameters are ANY data you send during agent initiation. They serve two crucial purposes:
  1. Provide context to the agent during the conversation
  2. Get echoed back in webhook responses for matching records
Example:
{
  "application_id": "12345",
  "user_id": "user-789",
  "session_id": "sess-abc-def"
}
All these fields will be available in the webhook’s outcomeResult object.
Types of Events:
  • call.started - Call begins
  • call.answered - Call connected
  • transcription.update - Real-time speech updates
  • tool.executed - Agent uses a tool
  • call.ended - Call completes (includes full data)
Each event contains relevant data for that stage of the call lifecycle.
How it works:
  1. You send: { params: { application_id: "12345" } }
  2. Agent conducts call
  3. You receive: { outcomeResult: { application_id: "12345", ... } }
This pattern allows you to match webhook responses to your database records without maintaining state.
What is it?The outcomeResult object in webhook responses contains:
  • Echoed data - All your custom params (unchanged)
  • Outcome data - Information collected by the agent
This combination lets you identify the record AND get the collected information in one payload.

Stage 2: Setup & Configuration

Prerequisites

Before setting up webhooks, ensure you have:
1

A publicly accessible server

Your webhook endpoint must be reachable from the internet. Use ngrok for local development.
2

HTTPS enabled

All webhook URLs must use HTTPS. HTTP endpoints will be rejected.
3

A web framework

Express.js, Flask, FastAPI, or any framework that can handle POST requests.
4

Basic authentication setup

Prepare to implement Bearer token authentication (recommended for production).

Setting Up Your Webhook URL

Webhook URL Requirements

HTTPS Required:
  • All webhook URLs must use HTTPS protocol
  • HTTP endpoints will be rejected
  • Self-signed certificates are not supported
  • Certificate must be valid and not expired
Valid Examples:
✅ https://api.yourcompany.com/webhook/hamsa
✅ https://webhook.example.com/hamsa/events
✅ https://your-app.herokuapp.com/webhooks/calls

❌ http://api.yourcompany.com/webhook (HTTP not allowed)
❌ https://192.168.1.100/webhook (local IPs not accessible)
❌ https://localhost:3000/webhook (localhost not accessible)

Local Development Setup

For local development, use ngrok to expose your localhost:
# Install ngrok
npm install -g ngrok
# or brew install ngrok

# Start your local server
node server.js  # Runs on port 3000

# In another terminal, start ngrok
ngrok http 3000

# Use the ngrok HTTPS URL in your webhook configuration
# Example: https://abc123.ngrok.io/webhook

Configuring Authentication

Option 1: No Authentication (Development Only)

Use for development or testing:
Configuration
{
  "webhookUrl": "https://abc123.ngrok.io/webhook",
  "webhookAuth": {
    "authKey": "noAuth"
  }
}
Not recommended for production. Anyone who discovers your webhook URL can send requests to it.
Use for production environments:
Configuration
{
  "webhookUrl": "https://api.yourcompany.com/webhook",
  "webhookAuth": {
    "authKey": "bearer",
    "authSecret": "Bearer your_secret_token_here"
  }
}
Generating Secure Tokens:
# Generate a random token (Linux/Mac)
openssl rand -base64 32

# Or use Node.js
node -e "console.log(require('crypto').randomBytes(32).toString('base64'))"

# Or use Python
python -c "import secrets; print(secrets.token_urlsafe(32))"
Token Format Requirements:
  • Must include the word “Bearer” followed by your token
  • Example: Bearer sk_live_abc123xyz789
  • Token should be long and randomly generated
  • Never commit tokens to source control

Adding Webhook to Your Agent

1

Navigate to Agent Configuration

Go to your agent’s configuration page in the Hamsa dashboard
2

Find Call Webhook Section

Scroll to the Call Webhook section and click to expand
3

Enter Webhook URL

Input your publicly accessible HTTPS webhook URL
4

Configure Authentication

Choose authentication method and enter your Bearer token if applicable
5

Save Configuration

Save your agent configuration to activate the webhook
Webhooks are configured per agent. Each agent can have its own webhook URL and authentication settings.

Stage 3: Understanding Data Structures

Agent Initiation - What You Send

When initiating the agent, you send a configuration object with the following structure:
{
  agentId: string,           // Required: Your Hamsa agent ID
  params?: object,           // Optional: Custom parameters (ANY data you want)
  userId?: string,           // Optional: User identifier for tracking
  voiceEnablement?: boolean, // Optional: Enable voice (default: true)
  preferHeadphonesForIosDevices?: boolean, // Optional: iOS optimization
  connectionDelay?: {        // Optional: Platform-specific delays
    android?: number,
    ios?: number,
    default?: number
  }
}

Configuration Fields Reference

FieldTypeRequiredDescription
agentIdstring✅ YesYour Hamsa agent configuration ID
paramsobject⚠️ OptionalCustom parameters object - can contain ANY fields
userIdstring⚠️ OptionalUser identifier for tracking purposes
voiceEnablementboolean⚠️ OptionalEnable voice interaction (default: true)
preferHeadphonesForIosDevicesboolean⚠️ OptionaliOS audio optimization
connectionDelayobject⚠️ OptionalPlatform-specific connection delays

The params Object - Your Custom Data

Important: The params object can contain ANY fields you want. All fields you pass here will be:
  1. Available to the agent during the conversation
  2. Echoed back to you in the webhook response inside outcomeResult
params: {
  // IDENTIFIERS (highly recommended - used to match webhook to your records)
  user_id: string,              // Your user/customer ID
  session_id: string,           // Unique session identifier
  application_id: string,       // Application/form ID

  // CONTEXT DATA (optional - helps agent personalize conversation)
  user_name: string,            // User's name
  user_email: string,           // User's email
  user_phone: string,           // User's phone

  // BUSINESS DATA (optional - agent can reference during conversation)
  job_title: string,            // Job position
  company_name: string,         // Company name
  custom_field_1: string,       // Any other data
  custom_field_2: number,       // Can be strings, numbers, booleans
  custom_field_3: boolean,

  // INSTRUCTIONS (optional - guide agent behavior)
  interview_questions: string,  // JSON stringified array of questions
  context_data: string,         // Any additional context (CV, resume, etc.)
}

Data Types Supported in params

TypeSupportedNotes
string✅ YesMost common, use for text data
number✅ YesIntegers and decimals
boolean✅ Yestrue/false values
null✅ YesExplicit null values
Array⚠️ Use JSON stringConvert arrays to JSON string: JSON.stringify([1,2,3])
Object⚠️ Use JSON stringConvert objects to JSON string: JSON.stringify({key: 'value'})
Best Practice: For complex data (arrays, objects), convert to JSON strings before passing.

Complete Initiation Example

{
  "agentId": "agent-123-abc",
  "params": {
    "application_id": "12345",
    "user_id": "user-789",
    "session_id": "sess-abc-def",
    "candidate_name": "John Doe",
    "candidate_email": "[email protected]",
    "candidate_phone": "+1234567890",
    "job_title": "Software Engineer",
    "company_name": "Acme Corp",
    "interview_questions": "[\"Tell me about your experience\",\"What are your salary expectations?\"]",
    "custom_note": "VIP candidate",
    "priority_level": 1,
    "require_background_check": true
  },
  "userId": "user-789",
  "voiceEnablement": true
}

Webhook Events - What You Receive

Event Types Reference

Your webhook receives various events throughout a call’s lifecycle:
EventWhen It FiresContains
call.startedCall beginsCaller info, timestamp, custom params
call.answeredCall connectedConnection details, ring duration
transcription.updateUser/agent speaksReal-time text, speaker identification
tool.executedAgent uses a toolTool name, input, output, duration
call.endedCall completesFull transcript, recording, outcome data

Standard Event Structure

All webhook events follow this structure:
{
  "eventType": "call.ended",
  "callId": "call_uuid_12345",
  "timestamp": "2024-01-15T14:30:00.000Z",
  "projectId": "proj_abc123",
  "agentId": "agent_xyz789",
  "agentName": "Customer Support Agent",
  "data": {
    // Event-specific data
  }
}

Call Ended Event - The Most Important

The call.ended event contains the complete conversation data:
{
  "eventType": "call.ended",
  "callId": "call_uuid_12345",
  "timestamp": "2024-01-15T14:35:00.000Z",
  "projectId": "proj_abc123",
  "agentId": "agent_xyz789",
  "agentName": "Customer Support Agent",
  "data": {
    "timestamp": "2025-11-27T09:10:35.000Z",
    "data": {
      "conversationId": "conv-123-abc-456-def",
      "conversationRecording": "https://hamsa-recordings.s3.amazonaws.com/conv-123.mp3",
      "transcription": [
        { "Agent": "Hello! Thank you for joining. What is your name?" },
        { "User": "Hi, my name is John Doe." },
        { "Agent": "Nice to meet you, John. Can you tell me about your experience?" },
        { "User": "I have 5 years of experience in software development." },
        { "Agent": "Excellent. What are your salary expectations?" },
        { "User": "I'm looking for around 80,000 dollars per year." },
        { "Agent": "Thank you. What is your notice period?" },
        { "User": "I have a 2-week notice period." },
        { "Agent": "Great! That concludes our interview. Thank you!" },
        { "User": "Thank you!" }
      ],
      "outcomeResult": {
        "application_id": "12345",
        "user_id": "user-789",
        "session_id": "sess-abc-def",
        "candidate_name": "John Doe",
        "candidate_email": "[email protected]",
        "candidate_phone": "+1234567890",
        "job_title": "Software Engineer",
        "company_name": "Acme Corp",
        "interview_questions": "[\"Tell me about your experience\",\"What are your salary expectations?\"]",
        "custom_note": "VIP candidate",
        "priority_level": 1,
        "require_background_check": true,
        "expectedSalary": "80000 dollars per year",
        "noticePeriod": "2 weeks",
        "number_of_speakers": "1",
        "sentiment_score": 0.8
      }
    }
  }
}

Field Descriptions

Top-Level Event Fields:
FieldTypeDescription
eventTypestringType of event (e.g., “call.ended”)
callIdstringUnique identifier for this call
timestampstringISO 8601 timestamp when event occurred
projectIdstringYour Hamsa project ID
agentIdstringID of the agent that handled the call
agentNamestringName of the agent configuration
Conversation Data Fields (data.data):
FieldTypeDescription
conversationIdstringHamsa’s unique identifier for this conversation
conversationRecordingstringFull URL to audio recording file
Format: .mp3 or .wav
Accessible for download
transcriptionArray<object>Ordered array of conversation turns
outcomeResultobjectContains echoed params + outcome data

Transcription Array Format

Each element represents one turn in the conversation:
{
  Agent?: string,  // Present when agent spoke
  User?: string    // Present when user spoke
}

Understanding outcomeResult

The outcomeResult object is where the magic happens - it contains both your echoed parameters AND newly collected outcome data.

Echoed Parameters (What You Sent)

These are the exact same parameters you included in the params object when initiating the agent. They come from your application and are returned unchanged in the webhook payload, allowing you to match the webhook to your database records.
{
  "application_id": "12345",
  "user_id": "user-789",
  "session_id": "sess-abc-def",
  "candidate_name": "John Doe",
  "candidate_email": "[email protected]",
  "candidate_phone": "+1234567890",
  "job_title": "Software Engineer",
  "company_name": "Acme Corp",
  "interview_questions": "[...]",
  "custom_note": "VIP candidate",
  "priority_level": 1,
  "require_background_check": true
}

Outcome Data (New from Call)

These fields are generated during the voice call based on the conversation between the agent and the user. They come from the agent’s analysis of the call and are defined in your agent’s outcome configuration.
{
  "expectedSalary": "80000 dollars per year",
  "noticePeriod": "2 weeks",
  "number_of_speakers": "1",
  "sentiment_score": 0.8
}

Common Extracted Fields

These are fields commonly extracted by Hamsa agents (depending on your agent’s configuration):
FieldTypeDescriptionWhen Present
expectedSalarystring | nullSalary mentioned by userIf agent asked about salary
noticePeriodstring | nullNotice period mentionedIf agent asked about availability
number_of_speakersstring | numberCount of different speakers detectedIf audio analysis enabled
sentiment_scorenumber | nullConversation sentiment (-1 to 1)If sentiment analysis enabled
intentstring | nullIdentified user intentIf intent detection enabled
resolvedboolean | nullWhether issue was resolvedIf resolution tracking enabled
Note: The exact fields depend on your agent’s configuration and outcome schema. Check with your Hamsa agent setup to see what fields are extracted.

Stage 4: Implementation

Creating Your Webhook Endpoint

Node.js/Express Implementation

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; // Bearer your_secret_token

  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('Received 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;
      default:
        console.log('Unhandled event type:', event.eventType);
    }

    // Respond quickly to acknowledge receipt
    res.status(200).json({ received: true });

  } catch (error) {
    console.error('Webhook error:', error);
    res.status(500).json({ error: 'Internal server error' });
  }
});

app.listen(3000, () => {
  console.log('Webhook server listening on port 3000');
});

Python/Flask Implementation

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:
        # Parse event data
        event = request.get_json()

        print(f"Event Type: {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)

        # Acknowledge receipt
        return jsonify({'received': True}), 200

    except Exception as e:
        print(f'Webhook error: {str(e)}')
        return jsonify({'error': 'Internal server error'}), 500

if __name__ == '__main__':
    app.run(port=3000)

Processing Events

Handling Different Event Types

async function handleCallStarted(event) {
  const { callId, data } = event;
  const { callDirection, fromNumber, toNumber, params } = data.data;

  console.log(`Call started: ${callId}`);
  console.log(`Direction: ${callDirection}`);
  console.log(`From: ${fromNumber} To: ${toNumber}`);

  // Store initial call data
  await database.calls.create({
    callId,
    direction: callDirection,
    fromNumber,
    toNumber,
    customParams: params,
    status: 'in_progress',
    startedAt: data.callStartedAt
  });
}

async function handleTranscription(event) {
  const { callId, data } = event;
  const { speaker, text, confidence } = data.data;

  // Store real-time transcription
  await database.transcriptions.create({
    callId,
    speaker,
    text,
    confidence,
    timestamp: event.timestamp
  });

  // Optionally trigger real-time actions
  if (speaker === 'user' && text.toLowerCase().includes('urgent')) {
    await notifySupport({
      callId,
      message: 'User mentioned urgent issue',
      transcript: text
    });
  }
}

async function handleToolExecution(event) {
  const { callId, data } = event;
  const { toolName, input, output, duration } = data.data;

  console.log(`Tool executed: ${toolName}`);
  console.log(`Duration: ${duration}ms`);

  // Log tool usage
  await database.toolExecutions.create({
    callId,
    toolName,
    input,
    output,
    duration,
    executedAt: event.timestamp
  });
}

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,
        headers: req.headers
      });
      return res.status(401).json({ error: 'Unauthorized' });
    }

    // 2. Validate payload structure
    const event = req.body;
    if (!event.callId || !event.eventType) {
      console.error('Invalid payload:', event);
      return res.status(400).json({ error: 'Invalid payload' });
    }

    // 3. Validate event type
    const validEventTypes = [
      'call.started',
      'call.answered',
      'transcription.update',
      'tool.executed',
      'call.ended'
    ];

    if (!validEventTypes.includes(event.eventType)) {
      console.warn('Unknown event type:', event.eventType);
      // Still return 200 to acknowledge receipt
      return res.status(200).json({
        received: true,
        warning: 'Unknown event type'
      });
    }

    // 4. Process event (with timeout)
    const timeoutPromise = new Promise((_, reject) => {
      setTimeout(() => reject(new Error('Processing timeout')), 4000);
    });

    const processingPromise = processEvent(event);

    await Promise.race([processingPromise, timeoutPromise]);

    // 5. Respond successfully
    res.status(200).json({ received: true });

  } catch (error) {
    console.error('Webhook error:', {
      error: error.message,
      stack: error.stack,
      body: req.body
    });

    // Still return 200 to prevent retries for processing errors
    // Log the error for investigation
    res.status(200).json({
      received: true,
      processing_error: true,
      error_id: generateErrorId()
    });
  }
});

function generateErrorId() {
  return `err_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
}

Validation Helper

const Joi = require('joi');

const eventSchema = Joi.object({
  eventType: Joi.string().required(),
  callId: Joi.string().uuid().required(),
  timestamp: Joi.string().isoDate().required(),
  projectId: Joi.string().required(),
  agentId: Joi.string().required(),
  agentName: Joi.string().required(),
  data: Joi.object().required(),
});

function validateEvent(event) {
  const { error, value } = eventSchema.validate(event);

  if (error) {
    console.error('Validation error:', error.details);
    return { valid: false, error: error.details };
  }

  return { valid: true, value };
}

// Usage
app.post('/webhook', async (req, res) => {
  const validation = validateEvent(req.body);

  if (!validation.valid) {
    return res.status(400).json({
      error: 'Invalid payload',
      details: validation.error
    });
  }

  // Process validated event
  await processEvent(validation.value);
  res.status(200).json({ received: true });
});

Stage 5: Advanced Topics

Async Processing

For production systems, always process webhooks asynchronously to avoid timeouts and ensure reliability.

Queue-Based Processing

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 for processing
    await eventQueue.add('process-event', event, {
      attempts: 3,
      backoff: {
        type: 'exponential',
        delay: 2000
      },
      removeOnComplete: true,
      removeOnFail: false
    });

    // 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;

  console.log(`Processing event: ${event.eventType} for call ${event.callId}`);

  try {
    if (event.eventType === 'call.ended') {
      await saveToDatabase(event);
      await sendNotifications(event);
      await updateAnalytics(event);
    } else if (event.eventType === 'transcription.update') {
      await processTranscription(event);
    }

    console.log(`Successfully processed: ${event.callId}`);

  } catch (error) {
    console.error(`Processing failed for ${event.callId}:`, error);
    throw error; // Will trigger retry
  }
});

// Monitor queue
eventQueue.on('completed', (job) => {
  console.log(`Job ${job.id} completed`);
});

eventQueue.on('failed', (job, err) => {
  console.error(`Job ${job.id} failed:`, err.message);
});

Idempotency

Prevent duplicate processing of webhook events:

Redis-Based Idempotency

const redis = require('redis');
const client = redis.createClient();

async function isEventProcessed(event) {
  const eventKey = `webhook:${event.callId}:${event.eventType}:${event.timestamp}`;

  // Check if already processed
  const exists = await client.exists(eventKey);
  if (exists) {
    return true;
  }

  // Mark as processed (expire after 24 hours)
  await client.setex(eventKey, 86400, 'processed');
  return false;
}

app.post('/webhook', async (req, res) => {
  const event = req.body;

  // Check for duplicates
  const alreadyProcessed = await isEventProcessed(event);
  if (alreadyProcessed) {
    console.log('Duplicate event, skipping:', event.callId);
    return res.status(200).json({
      received: true,
      duplicate: true
    });
  }

  // Process event
  await processEvent(event);

  res.status(200).json({ received: true });
});

Database-Based Idempotency

async function processEventIdempotent(event) {
  const eventId = `${event.callId}-${event.eventType}-${event.timestamp}`;

  try {
    // Attempt to create event record (unique constraint on eventId)
    await database.webhookEvents.create({
      eventId,
      callId: event.callId,
      eventType: event.eventType,
      eventData: event,
      processedAt: new Date(),
      status: 'processing'
    });

    // Process event
    await processEvent(event);

    // Update status
    await database.webhookEvents.update({
      where: { eventId },
      data: { status: 'completed' }
    });

  } catch (error) {
    if (error.code === 'P2002') { // Unique constraint violation
      console.log('Duplicate event detected:', eventId);
      return { duplicate: true };
    }
    throw error;
  }

  return { duplicate: false };
}

Security Best Practices

Security Checklist:
  • ✅ Always use HTTPS endpoints
  • ✅ Implement Bearer token authentication
  • ✅ Validate all incoming data
  • ✅ Use strong, randomly generated tokens
  • ✅ Rotate tokens periodically
  • ✅ Log authentication failures
  • ✅ Rate limit webhook endpoint
  • ✅ Monitor for suspicious patterns

Token Management

// ✅ GOOD - Store tokens securely
require('dotenv').config();
const WEBHOOK_SECRET = process.env.WEBHOOK_SECRET;

// ❌ BAD - Never hardcode tokens
const WEBHOOK_SECRET = 'Bearer abc123';  // Don't do this!

// ✅ GOOD - Validate token format
function isValidBearerToken(token) {
  return token && token.startsWith('Bearer ') && token.length > 20;
}

// ✅ GOOD - Rotate tokens periodically
async function rotateWebhookToken() {
  const newToken = `Bearer ${crypto.randomBytes(32).toString('base64')}`;

  // Update in secure storage
  await secretManager.updateSecret('WEBHOOK_SECRET', newToken);

  // Update in Hamsa dashboard
  await hamsaApi.updateAgentWebhook({
    agentId: 'agent-123',
    webhookAuth: {
      authKey: 'bearer',
      authSecret: newToken
    }
  });

  console.log('Webhook token rotated successfully');
}

Rate Limiting

const rateLimit = require('express-rate-limit');

const webhookLimiter = rateLimit({
  windowMs: 1 * 60 * 1000, // 1 minute
  max: 100, // Limit each IP to 100 requests per windowMs
  message: 'Too many webhook requests from this IP',
  standardHeaders: true,
  legacyHeaders: false,
  skip: (req) => {
    // Skip rate limiting for valid tokens
    return verifyToken(req);
  }
});

app.post('/webhook', webhookLimiter, verifyToken, async (req, res) => {
  // Process webhook
});

IP Whitelisting (Optional)

const ALLOWED_IPS = [
  '34.123.45.67',  // Hamsa webhook server IP
  '52.89.12.34'    // Backup server IP
];

function checkIpWhitelist(req, res, next) {
  const clientIp = req.ip || req.connection.remoteAddress;

  if (!ALLOWED_IPS.includes(clientIp)) {
    console.warn('Unauthorized IP:', clientIp);
    return res.status(403).json({ error: 'Forbidden' });
  }

  next();
}

app.post('/webhook', checkIpWhitelist, verifyToken, async (req, res) => {
  // Process webhook
});

Use Cases & Patterns

CRM Integration

async function syncToCRM(event) {
  if (event.eventType !== 'call.ended') return;

  const { outcomeResult, transcription } = event.data.data;
  const { fromNumber } = event.data.data;

  // Build transcript text
  const transcriptText = transcription
    .map(turn => Object.values(turn)[0])
    .join('\n');

  // Update customer record
  await crm.contacts.upsert({
    where: { phone: fromNumber },
    update: {
      lastCallDate: event.timestamp,
      lastCallDuration: event.data.data.callDuration,
      lastCallNotes: transcriptText,
      customFields: {
        intent: outcomeResult.intent,
        sentiment: outcomeResult.sentiment_score,
        resolved: outcomeResult.resolved
      }
    },
    create: {
      phone: fromNumber,
      firstCallDate: event.timestamp,
      source: 'ai_agent'
    }
  });

  // Create activity record
  await crm.activities.create({
    type: 'phone_call',
    contactPhone: fromNumber,
    subject: `AI Agent Call - ${outcomeResult.intent || 'General'}`,
    description: transcriptText,
    outcome: outcomeResult.resolved ? 'resolved' : 'needs_followup',
    recordingUrl: event.data.data.conversationRecording,
    date: event.timestamp
  });
}

Analytics Dashboard

async function updateAnalytics(event) {
  if (event.eventType !== 'call.ended') return;

  const { callDuration, outcomeResult } = event.data.data;

  await analytics.track({
    event: 'Call Completed',
    properties: {
      callId: event.callId,
      agentId: event.agentId,
      agentName: event.agentName,
      duration: callDuration,
      intent: outcomeResult.intent,
      sentiment: outcomeResult.sentiment_score,
      resolved: outcomeResult.resolved,
      speakers: outcomeResult.number_of_speakers,
      timestamp: event.timestamp
    }
  });

  // Update real-time dashboard metrics
  await dashboard.increment('total_calls');
  await dashboard.increment(`calls_by_intent.${outcomeResult.intent}`);
  await dashboard.addToAverage('average_duration', callDuration);

  if (outcomeResult.resolved) {
    await dashboard.increment('resolved_calls');
  }
}

Notification System

async function sendNotifications(event) {
  if (event.eventType !== 'call.ended') return;

  const { outcomeResult, callDuration } = event.data.data;

  // High-priority notifications
  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*Agent:* ${event.agentName}\n*Duration:* ${callDuration}s`
          }
        },
        {
          type: 'section',
          fields: [
            { type: 'mrkdwn', text: `*Intent:*\n${outcomeResult.intent || 'Unknown'}` },
            { type: 'mrkdwn', text: `*Resolved:*\n${outcomeResult.resolved ? '✅ Yes' : '❌ No'}` },
            { type: 'mrkdwn', text: `*Sentiment:*\n${outcomeResult.sentiment_score || 'N/A'}` }
          ]
        },
        {
          type: 'actions',
          elements: [
            {
              type: 'button',
              text: { type: 'plain_text', text: 'View Details' },
              url: `https://dashboard.example.com/calls/${event.callId}`
            }
          ]
        }
      ]
    });
  }

  // Email summary
  await emailService.send({
    to: '[email protected]',
    subject: `Call Summary - ${event.callId}`,
    template: 'call-summary',
    data: {
      callId: event.callId,
      agentName: event.agentName,
      duration: callDuration,
      transcript: event.data.data.transcription,
      outcome: outcomeResult,
      recordingUrl: event.data.data.conversationRecording
    }
  });
}

Stage 6: Troubleshooting & Support

Common Issues

Symptoms:
  • No webhook events arriving at your endpoint
  • Call completes but no POST request received
Possible Causes:
  1. Webhook URL is not publicly accessible
  2. Webhook URL not configured in Hamsa dashboard
  3. Firewall/security rules blocking POST requests
  4. Server not running or crashed
  5. HTTPS certificate invalid
Solutions:
# Test your webhook endpoint
curl -X POST https://your-endpoint.com/webhook \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer your_token" \
  -d '{"test": true, "eventType": "test", "callId": "test-123", "timestamp": "2024-01-01T00:00:00.000Z"}'

# Should return 200 OK
# If not, check:
# 1. Is your server running?
# 2. Is the URL correct?
# 3. Is HTTPS configured properly?
# 4. Are firewall rules allowing traffic?
For Local Development:
# Use ngrok to expose localhost
ngrok http 3000

# Use the ngrok HTTPS URL in your webhook configuration
# Example: https://abc123.ngrok.io/webhook
Symptoms:
  • Receiving webhook data but can’t identify which record it belongs to
  • Missing application_id or user_id in outcomeResult
Causes:
  • Not passing unique identifier in params during initiation
  • Identifier not being echoed correctly
  • Wrong field name being checked
Solutions:
// ✅ GOOD - Always include unique identifiers
const config = {
  agentId: 'agent-123',
  params: {
    application_id: applicationId.toString(),  // Unique identifier
    user_id: userId.toString(),                // Secondary identifier
    session_id: generateSessionId()            // Session tracking
  }
};

// In webhook handler - verify identifiers exist
app.post('/webhook', async (req, res) => {
  const { outcomeResult } = req.body.data.data;

  console.log('Received outcomeResult:', outcomeResult);

  const applicationId = outcomeResult.application_id;

  if (!applicationId) {
    console.error('Missing application_id in webhook');
    // Log full payload for debugging
    console.error('Full payload:', JSON.stringify(req.body, null, 2));
    return res.status(400).json({ error: 'Missing identifier' });
  }

  // Proceed with matching
  const record = await database.findByApplicationId(applicationId);
  // ...
});
Debugging Tips:
// Log what you're sending during initiation
console.log('Initiating agent with params:', config.params);

// Log what you receive in webhook
console.log('Received in webhook:', outcomeResult);

// Compare field names (case-sensitive!)
// application_id !== applicationId
Symptoms:
  • Receiving 401 Unauthorized responses
  • Webhook attempts shown in logs but rejected
Causes:
  • Bearer token mismatch
  • Token format incorrect
  • Missing “Bearer” prefix
  • Token changed in dashboard but not in code
Solutions:
// ✅ CORRECT format
{
  "webhookAuth": {
    "authKey": "bearer",
    "authSecret": "Bearer sk_live_abc123xyz789"
  }
}

// ❌ Common mistakes:
// "authSecret": "sk_live_abc123xyz789"  // Missing "Bearer"
// "authSecret": "bearer sk_live_abc123xyz789"  // Lowercase "bearer"
// "authKey": "noAuth" with authSecret set  // Conflicting settings

// Debug token mismatch:
app.post('/webhook', (req, res) => {
  const receivedAuth = req.headers.authorization;
  const expectedAuth = process.env.WEBHOOK_SECRET;

  console.log('Received auth:', receivedAuth);
  console.log('Expected auth:', expectedAuth);
  console.log('Match:', receivedAuth === expectedAuth);

  if (receivedAuth !== expectedAuth) {
    console.error('Auth mismatch!');
    console.error('Length - Received:', receivedAuth?.length, 'Expected:', expectedAuth?.length);
    return res.status(401).json({ error: 'Unauthorized' });
  }

  // Continue...
});
Symptoms:
  • Receiving some events but not all
  • call.started received but not call.ended
Causes:
  1. Webhook endpoint returning errors for some events
  2. Timeout (not responding within 5 seconds)
  3. Network connectivity issues during specific events
  4. Processing logic throwing errors for certain event types
Solutions:
// Always respond quickly (under 5 seconds)
app.post('/webhook', async (req, res) => {
  const event = req.body;

  // Acknowledge immediately
  res.status(200).json({ received: true });

  // Process asynchronously
  setImmediate(() => {
    processEvent(event).catch((err) => {
      console.error('Processing error:', err);
      // Event already acknowledged, error won't cause retry
    });
  });
});

// Add monitoring to track all event types
const eventCounts = {};

app.post('/webhook', (req, res) => {
  const eventType = req.body.eventType;
  eventCounts[eventType] = (eventCounts[eventType] || 0) + 1;

  console.log('Event counts:', eventCounts);
  // Should see: { 'call.started': 1, 'transcription.update': 15, 'call.ended': 1 }

  res.status(200).json({ received: true });
});
Symptoms:
  • Receiving the same event multiple times
  • Database records being created twice
Cause:
  • Webhook retries due to slow responses
  • Network issues causing retransmission
  • Not implementing idempotency
Solution:
// Implement idempotency using Redis
const redis = require('redis').createClient();

app.post('/webhook', async (req, res) => {
  const event = req.body;
  const eventKey = `webhook:${event.callId}:${event.eventType}:${event.timestamp}`;

  // Check if already processed
  const exists = await redis.exists(eventKey);
  if (exists) {
    console.log('Duplicate event detected:', eventKey);
    return res.status(200).json({
      received: true,
      duplicate: true
    });
  }

  // Mark as processed (expire after 24 hours)
  await redis.setex(eventKey, 86400, 'processed');

  // Process event
  await processEvent(event);

  res.status(200).json({ received: true });
});
Symptoms:
  • Expected fields are null or undefined
  • Outcome data not present
Causes:
  • Agent didn’t collect that information during call
  • Field name mismatch with agent configuration
  • User didn’t provide the information
  • Agent configuration doesn’t include that extraction
Solutions:
// Always handle optional fields gracefully
const expectedSalary = outcomeResult.expectedSalary ?? 'Not provided';
const noticePeriod = outcomeResult.noticePeriod || null;

// Distinguish between null (not collected) and undefined (field missing)
if (outcomeResult.expectedSalary === undefined) {
  console.log('Field not in agent configuration');
} else if (outcomeResult.expectedSalary === null) {
  console.log('User did not provide salary information');
} else {
  console.log('Salary collected:', outcomeResult.expectedSalary);
}

// Log all available fields for debugging
console.log('Available outcomeResult fields:',
  Object.keys(outcomeResult)
);
Symptoms:
  • Arrays or objects sent in params are not preserved correctly
  • Receiving stringified data or corrupted structures
Cause:
  • Not converting complex data to JSON strings before sending
Solution:
// ✅ GOOD - Convert to JSON string before sending
const config = {
  agentId: 'agent-123',
  params: {
    application_id: '12345',
    interview_questions: JSON.stringify([
      "Tell me about your experience",
      "What are your salary expectations?"
    ]),
    candidate_data: JSON.stringify({
      name: "John Doe",
      skills: ["JavaScript", "Python"]
    })
  }
};

// In webhook handler - parse JSON strings
const questions = JSON.parse(outcomeResult.interview_questions);
const candidateData = JSON.parse(outcomeResult.candidate_data);

console.log('Questions:', questions);
console.log('Candidate:', candidateData);

Testing Your Webhook

Local Testing with cURL

# Test basic connectivity
curl -X POST https://your-endpoint.com/webhook \
  -H "Content-Type: application/json" \
  -d '{"test": true}'

# Test with authentication
curl -X POST https://your-endpoint.com/webhook \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer your_secret_token" \
  -d '{"test": true}'

# Test with full call.ended 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-call-123",
    "timestamp": "2024-01-15T14:35:00.000Z",
    "projectId": "proj_test",
    "agentId": "agent_test",
    "agentName": "Test Agent",
    "data": {
      "timestamp": "2024-01-15T14:35:00.000Z",
      "data": {
        "conversationId": "conv-test-123",
        "conversationRecording": "https://example.com/recording.mp3",
        "transcription": [
          {"Agent": "Hello, how can I help you?"},
          {"User": "I need assistance."}
        ],
        "outcomeResult": {
          "application_id": "12345",
          "user_id": "user-789",
          "expectedSalary": "80000",
          "noticePeriod": "2 weeks"
        }
      }
    }
  }'

Monitoring & Logging

// Add comprehensive logging
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,
    timestamp: event.timestamp,
    ip: req.ip
  });

  try {
    await processEvent(event);

    logger.info('Webhook processed successfully', {
      callId: event.callId,
      eventType: event.eventType
    });

    res.status(200).json({ received: true });

  } catch (error) {
    logger.error('Webhook processing failed', {
      callId: event.callId,
      eventType: event.eventType,
      error: error.message,
      stack: error.stack
    });

    // Still return 200
    res.status(200).json({
      received: true,
      processing_error: true
    });
  }
});

Integration Checklist

Before Going Live

1

Data Structure Planning

Define what custom params you’ll send in params (identifiers, context). Document your params structure for your team. Plan how you’ll use echoed data to match records. Decide what outcome data you expect from agent. Ensure identifiers are unique and persistent.
2

Webhook Setup

Create webhook endpoint (POST route). Make endpoint publicly accessible (use ngrok for testing). Configure webhook URL in Hamsa dashboard. Test webhook receives POST requests. Verify webhook returns 200 OK within 5 seconds. Implement Bearer token authentication.
3

Implementation

Implement event type handling (call.started, call.ended, etc.). Add data extraction from outcomeResult. Implement database updates. Add transcript parsing. Handle recording URL storage. Test with missing/null fields.
4

Error Handling

Validate incoming payload structure. Handle authentication failures. Implement proper error logging. Add timeout handling. Test error scenarios.
5

Advanced Features

Implement async processing (queue-based). Add idempotency checks. Set up rate limiting. Add monitoring and alerting. Implement retry logic.
6

Testing

Test with sample initiation data. Verify all custom params are echoed correctly. Test transcript parsing. Test recording URL access. Test with missing/null fields. Test error handling (invalid data, missing identifiers). Load test with multiple concurrent webhooks.
7

Production Deployment

Use HTTPS for webhook endpoint. Use strong Bearer token authentication. Add webhook authentication verification. Add comprehensive logging and monitoring. Set up error alerts. Document your integration. Train team on monitoring.

Summary

Key Takeaways

Initiation Data

Send configuration with agentId and params containing ANY custom data

Echo Pattern

All fields in params are echoed back in webhook’s outcomeResult

Identifiers

Always include unique identifiers to match webhooks to your records

Event Types

Handle different event types: started, answered, transcription, ended

Quick Response

Always respond with 200 OK within 5 seconds to avoid timeouts

Async Processing

Use queues for time-consuming operations after acknowledging receipt

Idempotency

Implement duplicate detection using callId + eventType + timestamp

Validation

Always validate echoed identifiers and handle missing fields

Security

Use HTTPS, Bearer tokens, and validate all incoming data

Quick Reference

Data Flow:
YOU SEND (Initiation):
params: { application_id: "12345", user_name: "John", ... }

         ↓ [ Call Happens ] ↓

YOU RECEIVE (Webhook):
outcomeResult: {
  application_id: "12345",     // ← Your data (echoed)
  user_name: "John",           // ← Your data (echoed)
  expectedSalary: "80k",       // ← New data (collected)
  noticePeriod: "2 weeks"      // ← New data (collected)
}
Integration Phases:
PhaseWhat You Send/ReceivePurpose
InitiationConfiguration with paramsSend identifiers + context to agent
CallN/A (agent conducts call)Agent collects information from user
WebhookPOST with echoed params + new dataReceive results and match to your records

Next Steps


Need Help?

Document Version: 2.0
Last Updated: 2025-12-08
Hamsa SDK Version: Compatible with @hamsa-ai/voice-agents-sdk v2.x+
For support, contact Hamsa Support or refer to official SDK documentation.