Webhook Subscriptions
Webhook Subscriptions provide an enterprise-grade system for receiving real-time notifications when events occur in your voice agent system. Unlike basic webhooks, this system offers:
- Multiple Endpoints: Create unlimited webhook subscriptions per agent or workspace
- Event Filtering: Subscribe only to the events you care about
- HMAC Signing: Cryptographically signed payloads for security
- Automatic Retries: Failed deliveries are retried with exponential backoff
- Delivery Logs: Full audit trail of all webhook deliveries
- Secret Rotation: Rotate signing secrets without downtime
Quick Start
💡 Tip: Don't have a webhook endpoint yet? Use our free Webhook Tester to generate a test URL and inspect incoming webhooks in real-time.
1. Create a Subscription
curl -X POST https://app.edesy.in/api/webhooks/subscriptions \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_API_KEY" \
-d '{
"workspaceId": "ws_abc123",
"name": "CRM Integration",
"url": "https://your-crm.com/webhooks/voice-agent",
"enabledEvents": ["call.ended", "call.analyzed"]
}'
Response:
{
"id": "wh_sub_xyz789",
"name": "CRM Integration",
"url": "https://your-crm.com/webhooks/voice-agent",
"secret": "whsec_a1b2c3d4e5f6g7h8i9j0...",
"enabledEvents": ["call.ended", "call.analyzed"],
"enabled": true,
"status": "ACTIVE",
"createdAt": "2026-03-20T10:00:00Z"
}
Important: Save the
secretvalue immediately. This is the only time it will be shown. You'll need it to verify webhook signatures.
2. Receive Webhooks
Your endpoint will receive POST requests with signed payloads:
POST /webhooks/voice-agent HTTP/1.1
Host: your-crm.com
Content-Type: application/json
X-Webhook-Signature: t=1710936000,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
User-Agent: VoiceAgent-Webhook/1.0
{
"id": "evt_abc123",
"type": "call.ended",
"api_version": "2026-03",
"created": 1710936000,
"data": {
"event_type": "call.ended",
"call_sid": "CA123456",
"agent_id": 42,
"call_duration": 120,
...
}
}
3. Verify the Signature
See Signature Verification below for implementation examples.
Setting Up via Dashboard
You can also create and manage webhook subscriptions directly from the Edesy dashboard without writing any code.
Step 1: Navigate to Webhook Settings
- Log in to app.edesy.in
- Select your workspace from the sidebar
- Go to Settings → Webhooks (under the Integrations section)
Step 2: Create a New Subscription
- Click the "Add Webhook" button in the top right corner
- Fill in the subscription details:
| Field | Description | Example |
|---|---|---|
| Name | A descriptive name for this webhook | "CRM Integration" |
| Endpoint URL | Your HTTPS webhook endpoint | https://your-server.com/webhooks/edesy |
| Filter by Agent | (Optional) Receive events only from a specific agent | Select an agent or leave as "All agents" |
| Event Types | Select which events to receive | Check the events you need |
- Click "Create Webhook"
Step 3: Save Your Secret Key
After creating the subscription, a secret key will be displayed. This is the only time you'll see it.
⚠️ Important: Copy and save this secret key immediately. You'll need it to verify webhook signatures. If you lose it, you'll need to rotate the secret.
Store the secret securely in your application's environment variables:
EDESY_WEBHOOK_SECRET=whsec_a1b2c3d4e5f6g7h8i9j0...
Step 4: Test Your Webhook
- Click the Send icon (paper plane) next to your subscription
- This sends a test
call.endedevent to your endpoint - Check your server logs to confirm receipt
- The test payload includes
"_test": trueso you can identify test events
Step 5: Monitor Deliveries
- Click the Eye icon to view delivery logs
- You'll see:
- ✅ Successful deliveries (green checkmark)
- ❌ Failed deliveries (red X) with error messages
- Retry attempts and status codes
- Response times
Managing Subscriptions
| Action | How to |
|---|---|
| Enable/Disable | Toggle the switch next to the subscription |
| View Logs | Click the Eye icon |
| Send Test | Click the Send icon |
| Rotate Secret | Click the Rotate icon (generates new secret) |
| Delete | Click the Trash icon |
Building Your Webhook Endpoint
Before creating a subscription, you need a server endpoint to receive webhooks. Here are complete, production-ready examples.
Node.js (Express)
const express = require('express');
const crypto = require('crypto');
const app = express();
// IMPORTANT: Use raw body for signature verification
app.use('/webhooks', express.raw({ type: 'application/json' }));
const WEBHOOK_SECRET = process.env.EDESY_WEBHOOK_SECRET;
// Verify webhook signature
function verifySignature(payload, signatureHeader, secret) {
const parts = signatureHeader.split(',');
const timestamp = parts[0].replace('t=', '');
const signature = parts[1].replace('v1=', '');
// Check timestamp (prevent replay attacks)
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - parseInt(timestamp)) > 300) {
throw new Error('Timestamp too old');
}
// Compute expected signature
const signedPayload = `${timestamp}.${payload}`;
const expectedSignature = crypto
.createHmac('sha256', secret)
.update(signedPayload)
.digest('hex');
// Constant-time comparison
if (!crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(expectedSignature)
)) {
throw new Error('Invalid signature');
}
return true;
}
// Webhook endpoint
app.post('/webhooks/edesy', (req, res) => {
const signature = req.headers['x-webhook-signature'];
const payload = req.body.toString();
// Step 1: Verify signature
try {
verifySignature(payload, signature, WEBHOOK_SECRET);
} catch (err) {
console.error('Signature verification failed:', err.message);
return res.status(401).send('Invalid signature');
}
// Step 2: Parse the event
const event = JSON.parse(payload);
console.log('Received webhook:', event.type, event.id);
// Step 3: Respond immediately (process async)
res.status(200).send('OK');
// Step 4: Process the event asynchronously
processWebhookAsync(event);
});
async function processWebhookAsync(event) {
const { type, data } = event;
switch (type) {
case 'call.started':
console.log(`Call started: ${data.call_sid} from ${data.from}`);
// Log to your system, update CRM, etc.
break;
case 'call.ended':
console.log(`Call ended: ${data.call_sid}, duration: ${data.call_duration}s`);
// Save transcript, update records, trigger workflows
break;
case 'call.failed':
console.log(`Call failed: ${data.call_sid}, reason: ${data.failure_reason}`);
// Handle retry logic, update campaign status
break;
case 'call.analyzed':
console.log(`Call analyzed: ${data.conversation_id}`);
console.log(`Summary: ${data.call_summary}`);
// Store analysis results
break;
case 'call.recording_ready':
console.log(`Recording ready: ${data.recording_key}`);
// Download or process recording
break;
case 'call.transferred':
console.log(`Call transferred to: ${data.transfer_to}`);
break;
case 'call.voicemail_detected':
console.log(`Voicemail detected, action: ${data.action_taken}`);
break;
default:
console.log('Unknown event type:', type);
}
}
app.listen(3000, () => {
console.log('Webhook server running on port 3000');
});
Python (FastAPI)
from fastapi import FastAPI, Request, HTTPException, Header
import hmac
import hashlib
import time
import json
import os
from typing import Optional
import asyncio
app = FastAPI()
WEBHOOK_SECRET = os.environ.get("EDESY_WEBHOOK_SECRET")
def verify_signature(payload: str, signature_header: str, secret: str) -> bool:
"""Verify the webhook signature."""
parts = signature_header.split(',')
timestamp = parts[0].replace('t=', '')
signature = parts[1].replace('v1=', '')
# Check timestamp (prevent replay attacks)
now = int(time.time())
if abs(now - int(timestamp)) > 300:
raise ValueError('Timestamp too old')
# Compute expected signature
signed_payload = f"{timestamp}.{payload}"
expected_signature = hmac.new(
secret.encode(),
signed_payload.encode(),
hashlib.sha256
).hexdigest()
# Constant-time comparison
if not hmac.compare_digest(signature, expected_signature):
raise ValueError('Invalid signature')
return True
@app.post("/webhooks/edesy")
async def webhook_handler(
request: Request,
x_webhook_signature: Optional[str] = Header(None)
):
# Step 1: Get raw body
payload = await request.body()
payload_str = payload.decode()
# Step 2: Verify signature
if not x_webhook_signature:
raise HTTPException(status_code=401, detail="Missing signature")
try:
verify_signature(payload_str, x_webhook_signature, WEBHOOK_SECRET)
except ValueError as e:
raise HTTPException(status_code=401, detail=str(e))
# Step 3: Parse event
event = json.loads(payload_str)
print(f"Received webhook: {event['type']} - {event['id']}")
# Step 4: Process asynchronously
asyncio.create_task(process_webhook(event))
# Step 5: Return immediately
return {"status": "ok"}
async def process_webhook(event: dict):
"""Process webhook event asynchronously."""
event_type = event["type"]
data = event["data"]
if event_type == "call.started":
print(f"Call started: {data['call_sid']}")
# Your logic here
elif event_type == "call.ended":
print(f"Call ended: {data['call_sid']}, duration: {data.get('call_duration')}s")
# Save transcript, update CRM
elif event_type == "call.failed":
print(f"Call failed: {data['call_sid']}, reason: {data['failure_reason']}")
# Handle retry logic
elif event_type == "call.analyzed":
print(f"Call analyzed: {data['conversation_id']}")
print(f"Summary: {data.get('call_summary', 'N/A')}")
elif event_type == "call.recording_ready":
print(f"Recording ready: {data['recording_key']}")
elif event_type == "call.transferred":
print(f"Call transferred to: {data['transfer_to']}")
elif event_type == "call.voicemail_detected":
print(f"Voicemail detected: {data['action_taken']}")
# Run with: uvicorn main:app --host 0.0.0.0 --port 3000
Go (net/http)
package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"os"
"strconv"
"strings"
"time"
)
var webhookSecret = os.Getenv("EDESY_WEBHOOK_SECRET")
type WebhookEvent struct {
ID string `json:"id"`
Type string `json:"type"`
Created int64 `json:"created"`
Data map[string]interface{} `json:"data"`
}
func verifySignature(payload, signatureHeader, secret string) error {
parts := strings.Split(signatureHeader, ",")
if len(parts) != 2 {
return fmt.Errorf("invalid signature format")
}
timestamp := strings.TrimPrefix(parts[0], "t=")
signature := strings.TrimPrefix(parts[1], "v1=")
// Check timestamp
ts, _ := strconv.ParseInt(timestamp, 10, 64)
now := time.Now().Unix()
if now-ts > 300 || ts-now > 300 {
return fmt.Errorf("timestamp too old")
}
// Compute expected signature
signedPayload := fmt.Sprintf("%s.%s", timestamp, payload)
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(signedPayload))
expectedSignature := hex.EncodeToString(mac.Sum(nil))
// Constant-time comparison
if !hmac.Equal([]byte(signature), []byte(expectedSignature)) {
return fmt.Errorf("invalid signature")
}
return nil
}
func webhookHandler(w http.ResponseWriter, r *http.Request) {
// Step 1: Read body
payload, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "Failed to read body", http.StatusBadRequest)
return
}
// Step 2: Verify signature
signature := r.Header.Get("X-Webhook-Signature")
if err := verifySignature(string(payload), signature, webhookSecret); err != nil {
log.Printf("Signature verification failed: %v", err)
http.Error(w, "Invalid signature", http.StatusUnauthorized)
return
}
// Step 3: Parse event
var event WebhookEvent
if err := json.Unmarshal(payload, &event); err != nil {
http.Error(w, "Invalid JSON", http.StatusBadRequest)
return
}
log.Printf("Received webhook: %s - %s", event.Type, event.ID)
// Step 4: Respond immediately
w.WriteHeader(http.StatusOK)
w.Write([]byte("OK"))
// Step 5: Process asynchronously
go processWebhook(event)
}
func processWebhook(event WebhookEvent) {
data := event.Data
switch event.Type {
case "call.started":
log.Printf("Call started: %v", data["call_sid"])
case "call.ended":
log.Printf("Call ended: %v, duration: %v", data["call_sid"], data["call_duration"])
case "call.failed":
log.Printf("Call failed: %v, reason: %v", data["call_sid"], data["failure_reason"])
case "call.analyzed":
log.Printf("Call analyzed: %v", data["conversation_id"])
case "call.recording_ready":
log.Printf("Recording ready: %v", data["recording_key"])
case "call.transferred":
log.Printf("Call transferred to: %v", data["transfer_to"])
case "call.voicemail_detected":
log.Printf("Voicemail detected: %v", data["action_taken"])
default:
log.Printf("Unknown event: %s", event.Type)
}
}
func main() {
http.HandleFunc("/webhooks/edesy", webhookHandler)
log.Println("Webhook server running on :3000")
log.Fatal(http.ListenAndServe(":3000", nil))
}
Key Implementation Points
- Use raw body for signature verification - Don't parse JSON before verifying
- Respond immediately with 200 - Process webhooks asynchronously
- Verify signatures in production - Never skip signature verification
- Handle all event types - Even if you only need some, log unknown types
- Use idempotency - Events may be delivered multiple times; use
event.idto deduplicate
API Endpoints
List Subscriptions
GET /api/webhooks/subscriptions?workspaceId=ws_abc123
Query Parameters:
| Parameter | Type | Required | Description |
|---|---|---|---|
| workspaceId | string | Yes | Workspace ID |
| agentId | number | No | Filter by agent |
Create Subscription
POST /api/webhooks/subscriptions
Request Body:
{
"workspaceId": "ws_abc123",
"name": "My Webhook",
"url": "https://example.com/webhook",
"agentId": 42,
"enabledEvents": ["call.ended", "call.analyzed"],
"headers": {
"X-Custom-Header": "value"
}
}
| Field | Type | Required | Description |
|---|---|---|---|
| workspaceId | string | Yes | Workspace ID |
| name | string | Yes | Display name (1-100 chars) |
| url | string | Yes | Webhook endpoint URL (max 2048 chars) |
| agentId | number | No | Filter to specific agent |
| enabledEvents | string[] | Yes | Event types to receive |
| headers | object | No | Custom headers to include |
Update Subscription
PATCH /api/webhooks/subscriptions/{id}
Request Body: (all fields optional)
{
"name": "Updated Name",
"url": "https://new-url.com/webhook",
"enabledEvents": ["call.ended", "call.started"],
"enabled": true
}
Delete Subscription
DELETE /api/webhooks/subscriptions/{id}
Rotate Secret
POST /api/webhooks/subscriptions/{id}/rotate-secret
Response:
{
"id": "wh_sub_xyz789",
"secret": "whsec_newSecretHere...",
"message": "Secret rotated successfully"
}
Send Test Webhook
POST /api/webhooks/subscriptions/{id}/test
Request Body:
{
"eventType": "call.ended"
}
List Delivery Logs
GET /api/webhooks/subscriptions/{id}/deliveries?limit=50
Event Types
call.started
Fired when a call begins.
{
"event_type": "call.started",
"call_sid": "CA123456789",
"conversation_id": "conv_abc123",
"agent_id": 42,
"agent_name": "Sales Agent",
"direction": "inbound",
"from": "+1234567890",
"to": "+0987654321",
"call_provider": "twilio"
}
call.ended
Fired when a call ends. Contains the full conversation transcript.
{
"event_type": "call.ended",
"call_sid": "CA123456789",
"conversation_id": "conv_abc123",
"agent_id": 42,
"messages": [
{ "role": "assistant", "content": "Hello! How can I help you?" },
{ "role": "user", "content": "I'd like to check my order status" }
],
"call_duration": 120,
"call_state": {
"disposition": "SUCCESS",
"end_reason": "CONVERSATION_COMPLETE"
}
}
call.failed
Fired when an outbound call fails to connect. This is useful for handling failed call attempts in campaigns and implementing retry logic.
{
"event_type": "call.failed",
"call_sid": "CA123456789",
"conversation_id": "conv_abc123",
"agent_id": 42,
"agent_name": "Sales Agent",
"call_provider": "twilio",
"recipient_phone": "+1234567890",
"call_source": "campaign",
"failure_reason": "USER_BUSY",
"failure_code": "3010",
"failure_description": "Busy Line",
"hangup_source": "Carrier"
}
| Field | Description |
|---|---|
failure_reason |
Machine-readable reason code |
failure_code |
Provider-specific error code |
failure_description |
Human-readable description |
hangup_source |
Source of the hangup (Carrier, Callee, etc.) |
Common Failure Reasons:
| Reason | Description |
|---|---|
USER_BUSY |
Recipient's line is busy |
NO_ANSWER |
Call was not answered |
CALL_REJECTED |
Call was declined by recipient |
UNALLOCATED_NUMBER |
Phone number does not exist |
NORMAL_CLEARING |
Normal call termination |
call.recording_ready
Fired when the call recording is uploaded and ready for download.
{
"event_type": "call.recording_ready",
"conversation_id": "conv_abc123",
"call_sid": "CA123456789",
"agent_id": 42,
"recording_key": "recordings/ws_abc123/2026-03/conv_abc123.mp3",
"recording_duration": 120,
"recording_format": "mp3",
"recording_provider": "twilio"
}
| Field | Description |
|---|---|
recording_key |
Storage key for generating signed download URLs |
recording_duration |
Recording duration in seconds |
recording_format |
Audio format (mp3 or wav) |
recording_provider |
Source provider (twilio, exotel, plivo, etc.) |
call.analyzed
Fired after post-call analysis completes. Includes call summary and extracted data.
Auto-summarize payload:
{
"event_type": "call.analyzed",
"conversation_id": "conv_abc123",
"agent_id": 42,
"analysis_type": "auto_summarize",
"call_summary": "Customer called to check order status for order #12345.",
"completion_status": "completed",
"auto_summarized": true,
"end_reason": "CONVERSATION_COMPLETE",
"call_duration": 120,
"message_count": 8
}
Post-call extraction payload:
{
"event_type": "call.analyzed",
"conversation_id": "conv_abc123",
"agent_id": 42,
"analysis_type": "post_call_extraction",
"customer_name": "John Doe",
"order_id": "12345",
"issue_category": "shipping_delay",
"sentiment": "neutral",
"action_required": true
}
| Analysis Type | Trigger | Description |
|---|---|---|
auto_summarize |
Call disconnects before completion | Generates a basic summary |
post_call_extraction |
Explicit extraction via pipeline | Extracts structured data using templates |
fallback_extraction |
Automatic after transcript save | Runs if extraction template is configured |
call.transferred
Fired when a call is transferred to a human agent.
{
"event_type": "call.transferred",
"call_sid": "CA123456789",
"conversation_id": "conv_abc123",
"agent_id": 42,
"transfer_to": "+1987654321",
"transfer_reason": "Customer requested human agent"
}
call.voicemail_detected
Fired when voicemail is detected on an outbound call.
{
"event_type": "call.voicemail_detected",
"call_sid": "CA123456789",
"agent_id": 42,
"detection": {
"confidence": 0.95,
"strategy": "pattern_match"
},
"action_taken": "hangup"
}
Signature Verification
Every webhook payload is signed using HMAC-SHA256. The signature format is:
X-Webhook-Signature: t={timestamp},v1={signature}
Verification Steps
- Extract timestamp and signature from the header
- Create the signed payload:
{timestamp}.{json_body} - Compute HMAC-SHA256 using your secret
- Compare signatures using constant-time comparison
- Optionally validate timestamp is within 5 minutes
Node.js Example
const crypto = require('crypto');
function verifyWebhookSignature(payload, signatureHeader, secret) {
const parts = signatureHeader.split(',');
const timestamp = parts[0].replace('t=', '');
const signature = parts[1].replace('v1=', '');
// Check timestamp (prevent replay attacks)
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - parseInt(timestamp)) > 300) {
throw new Error('Timestamp too old');
}
// Compute expected signature
const signedPayload = `${timestamp}.${payload}`;
const expectedSignature = crypto
.createHmac('sha256', secret)
.update(signedPayload)
.digest('hex');
// Constant-time comparison
if (!crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(expectedSignature)
)) {
throw new Error('Invalid signature');
}
return true;
}
Python Example
import hmac
import hashlib
import time
def verify_webhook_signature(payload: str, signature_header: str, secret: str) -> bool:
parts = signature_header.split(',')
timestamp = parts[0].replace('t=', '')
signature = parts[1].replace('v1=', '')
# Check timestamp
now = int(time.time())
if abs(now - int(timestamp)) > 300:
raise ValueError('Timestamp too old')
# Compute expected signature
signed_payload = f"{timestamp}.{payload}"
expected_signature = hmac.new(
secret.encode(),
signed_payload.encode(),
hashlib.sha256
).hexdigest()
# Constant-time comparison
if not hmac.compare_digest(signature, expected_signature):
raise ValueError('Invalid signature')
return True
Go Example
func verifyWebhookSignature(payload, signatureHeader, secret string) error {
parts := strings.Split(signatureHeader, ",")
timestamp := strings.TrimPrefix(parts[0], "t=")
signature := strings.TrimPrefix(parts[1], "v1=")
// Check timestamp
ts, _ := strconv.ParseInt(timestamp, 10, 64)
if abs(time.Now().Unix()-ts) > 300 {
return fmt.Errorf("timestamp too old")
}
// Compute expected signature
signedPayload := fmt.Sprintf("%s.%s", timestamp, payload)
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(signedPayload))
expectedSignature := hex.EncodeToString(mac.Sum(nil))
// Constant-time comparison
if !hmac.Equal([]byte(signature), []byte(expectedSignature)) {
return fmt.Errorf("invalid signature")
}
return nil
}
Retry Logic
Failed webhook deliveries are automatically retried with exponential backoff:
| Attempt | Delay | Cumulative Time |
|---|---|---|
| 1 | 0s | 0s |
| 2 | 1s | 1s |
| 3 | 2s | 3s |
| 4 | 4s | 7s |
| 5 | 8s | 15s |
After 5 failed attempts, the delivery is marked as failed.
Success Criteria
- Success: HTTP status code 2xx (200-299)
- Failure: Non-2xx status, network error, or timeout (30s)
Health Tracking
The system tracks subscription health automatically:
| Field | Description |
|---|---|
consecutiveFailures |
Count of consecutive failed deliveries |
status |
ACTIVE, FAILING, or DISABLED |
lastDeliveryAt |
Timestamp of last delivery attempt |
lastStatusCode |
HTTP status code of last attempt |
Status transitions:
ACTIVE→FAILING: After 10 consecutive failuresFAILING→ACTIVE: After a successful delivery
Best Practices
1. Respond Quickly
Return a 2xx response immediately and process webhooks asynchronously:
app.post('/webhook', (req, res) => {
res.status(200).send('OK');
queue.add(() => processWebhook(req.body));
});
2. Handle Duplicates
Use the event id field for idempotency:
const processedEvents = new Set();
app.post('/webhook', (req, res) => {
const eventId = req.body.id;
if (processedEvents.has(eventId)) {
return res.status(200).send('Already processed');
}
processedEvents.add(eventId);
processWebhook(req.body);
res.status(200).send('OK');
});
3. Always Verify Signatures
Never trust webhook payloads without signature verification in production.
4. Use HTTPS
Always use HTTPS endpoints. HTTP URLs may be rejected.
5. Monitor Delivery Logs
Regularly check delivery logs to identify failing integrations:
curl "https://app.edesy.in/api/webhooks/subscriptions/{id}/deliveries?limit=10"
6. Rotate Secrets Periodically
Rotate webhook secrets every 90 days for security.
Error Responses
| Status | Error | Description |
|---|---|---|
| 400 | Invalid input |
Request validation failed |
| 401 | Unauthorized |
Not authenticated |
| 403 | Access denied |
Not a workspace member |
| 403 | Insufficient permissions |
Missing required permission |
| 404 | Subscription not found |
Invalid subscription ID |
Required Permissions
| Action | Required Permission |
|---|---|
| List, Get | SETTINGS_VIEW |
| Create, Update, Delete | SETTINGS_UPDATE |
| Rotate Secret | SETTINGS_UPDATE |
| Send Test | SETTINGS_UPDATE |
| List Deliveries | SETTINGS_VIEW |
Troubleshooting
Webhooks Not Being Delivered
- Check subscription is enabled: Go to Settings → Webhooks and ensure the toggle is ON
- Verify event types: Make sure you've subscribed to the events you expect (e.g.,
call.failedfor failed calls) - Check subscription status: If status is
FAILING, there may be issues with your endpoint - View delivery logs: Click the Eye icon to see specific error messages
Signature Verification Failing
Common causes:
- Using parsed JSON instead of raw body: You must use the raw request body string, not the parsed JSON object
- Wrong secret: Ensure you're using the correct secret for this subscription
- Timestamp expired: Events older than 5 minutes may be rejected. Check your server clock is synchronized
// ❌ Wrong - using parsed body
const payload = JSON.stringify(req.body);
// ✅ Correct - using raw body
const payload = req.rawBody; // Express with raw body parser
Endpoint Returning Errors
| Status Code | Cause | Solution |
|---|---|---|
| 401/403 | Authentication required | Your endpoint should accept unauthenticated requests or use signature verification instead |
| 500 | Server error | Check your server logs for exceptions |
| Timeout | Processing too slow | Respond with 200 immediately, process async |
Testing Locally
Option 1: Use the Edesy Webhook Tester (Recommended)
We provide a free Webhook Tester tool that generates unique URLs to receive and inspect webhooks in real-time. No setup required!
How to use:
- Go to edesy.in/tools/webhook-tester
- Click "Generate URL" to create a unique webhook endpoint
- Copy the generated URL (e.g.,
https://edesy.in/wh/abc12345) - Use this URL when creating your webhook subscription
- Watch incoming webhooks appear in real-time
- Inspect headers, body, and verify HMAC signatures
Features:
- ✅ Real-time webhook display
- ✅ JSON syntax highlighting
- ✅ HMAC signature verification
- ✅ Copy headers/body with one click
- ✅ No signup required
- ✅ URLs expire after 48 hours
Option 2: Use ngrok for Local Development
Use a tunneling service like ngrok to expose your local server:
# Install ngrok
brew install ngrok # macOS
# Start tunnel
ngrok http 3000
# Use the HTTPS URL (e.g., https://abc123.ngrok-free.app/webhooks)
# Update your webhook subscription with this URL
Events Not Firing
If events aren't being published at all:
- For
call.failed: Only fires for outbound calls that fail to connect (busy, no-answer, rejected) - For
call.ended: Only fires when the WebSocket connection closes after a connected call - For
call.started: Fires when the WebSocket connection is established
Getting Help
If you're still having issues:
- Check the Status Page for any service disruptions
- Contact support at [email protected] with your webhook subscription ID and delivery logs
Available Event Types Summary
| Event | Description | When It Fires |
|---|---|---|
call.started |
Call begins | WebSocket connection established |
call.ended |
Call completes | Call ends normally with transcript |
call.failed |
Call fails to connect | Outbound call busy/no-answer/rejected |
call.recording_ready |
Recording uploaded | Recording processed and available |
call.analyzed |
Analysis complete | Post-call summarization or extraction done |
call.transferred |
Call transferred | Agent initiates transfer |
call.voicemail_detected |
Voicemail detected | Answering machine detected on outbound call |
Next Steps
- Webhooks Guide - Basic webhook configuration
- REST API - Full API reference
- Call Recording - Recording configuration