Skip to main content

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"
  }
}
What You Receive in Webhook:
{
  "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
      }
    }
  }
}
This pattern allows you to match webhook responses to your database records without maintaining state.

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)

The call.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

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');
});

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

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)

Best Practices

Performance
  • Always respond within 5 seconds
  • Use async processing for time-consuming operations
  • Implement queue-based processing for production
Security
  • Verify Bearer token on every request
  • Use HTTPS only
  • Store tokens securely in environment variables
  • Rotate tokens periodically
Reliability
  • Implement idempotency to handle duplicates
  • Add comprehensive error logging
  • Monitor webhook delivery rates
  • Set up alerting for failures
Data Handling
  • Always validate echoed identifiers exist
  • Handle null/undefined fields gracefully
  • Parse complex data (arrays, objects) from JSON strings
  • Store recordings and transcripts for audit trails

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