Webhook Subscriptions

Subscribe to voice agent call events (call.analyzed, call.ended, call.failed, call.extracted, call.recording_ready) with HMAC-signed payloads, 5 delivery attempts and delivery logs.

11 min read

Base URL
https://voice-agent.edesy.in/api/v1
Auth
Authorization: Bearer vp_live_...Get a key
Spec
OpenAPI JSON

Webhook subscriptions send signed HTTP POSTs to your endpoint when a call event happens. You can subscribe to five events: call.analyzed, call.ended, call.failed, call.extracted and call.recording_ready. Each delivery is signed with HMAC-SHA256 in the X-Webhook-Signature header and retried up to 5 times. Unlike the basic per-agent webhook, this system offers:

  • Multiple Endpoints: Create several webhook subscriptions per workspace, optionally filtered to one agent
  • 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://voice-agent.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 secret value 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#

  1. Log in to voice-agent.edesy.in
  2. Select your workspace from the sidebar
  3. Go to Settings โ†’ Webhooks (under the Integrations section)

Step 2: Create a New Subscription#

  1. Click the "Add Webhook" button in the top right corner
  2. 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
  1. 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#

  1. Click the Send icon (paper plane) next to your subscription
  2. This sends a test call.ended event to your endpoint
  3. Check your server logs to confirm receipt
  4. The test payload includes "_test": true so you can identify test events

Step 5: Monitor Deliveries#

  1. Click the Eye icon to view delivery logs
  2. 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.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.extracted':
      console.log(`Data extracted: ${data.conversation_id}`);
      // Push extracted fields to your CRM
      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.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.extracted":
        print(f"Data extracted: {data['conversation_id']}")

# 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.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.extracted":
        log.Printf("Data extracted: %v", data["conversation_id"])

    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#

  1. Use raw body for signature verification - Don't parse JSON before verifying
  2. Respond immediately with 200 - Process webhooks asynchronously
  3. Verify signatures in production - Never skip signature verification
  4. Handle all event types - Even if you only need some, log unknown types
  5. Use idempotency - Events may be delivered multiple times; use event.id to deduplicate

API Endpoints#

List Subscriptions#

GET/api/webhooks/subscriptions?workspaceId=ws_abc123

Query Parameters:

Parameter Type Required Description
workspaceId string Required Workspace ID
agentId number Optional 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 Required Workspace ID
name string Required Display name (1-100 chars)
url string Required Webhook endpoint URL (max 2048 chars)
agentId number Optional Filter to specific agent
enabledEvents string[] Required Event types to receive
headers object Optional 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.analyzed", "call.failed"],
  "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#

You can subscribe to five events. These are the only events available for subscriptions today.

Event Fires when Notes
call.analyzed Post-call analysis finishes for a call Fires for every call, answered or not. Carries outcome, summary and extracted fields. Use this as your main "call finished" event
call.ended A connected call ends Includes the transcript. Prefer call.analyzed if you need one event per call
call.failed An outbound call does not connect Busy, no answer, rejected or invalid number
call.extracted Post-call extraction produced data Only sent when extraction returned fields
call.recording_ready The recording has been stored Includes the recording key; download links are signed

Events such as call started, call transferred and voicemail detected are not available as subscription events. Check the call's disposition in call.analyzed instead (for example VOICEMAIL or TRANSFERRED).

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.extracted#

Fired only when post-call extraction produced structured data. The extracted fields follow the extraction template configured on the agent.

{
  "event_type": "call.extracted",
  "conversation_id": "conv_abc123",
  "agent_id": 42,
  "extracted_data": {
    "customer_name": "Asha",
    "interested": true,
    "callback_time": "tomorrow 11am"
  }
}

Signature Verification#

Every webhook payload is signed using HMAC-SHA256. The signature format is:

X-Webhook-Signature: t={timestamp},v1={signature}

Verification Steps#

  1. Extract timestamp and signature from the header
  2. Create the signed payload: {timestamp}.{json_body}
  3. Compute HMAC-SHA256 using your secret
  4. Compare signatures using constant-time comparison
  5. 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 2s 2s
3 4s 6s
4 8s 14s
5 16s 30s

After 5 failed attempts, the delivery is marked as failed. An endpoint that returns 410 Gone is disabled straight away, with no further retries, and the workspace owner is notified.

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 failures
  • FAILING โ†’ 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://voice-agent.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#

  1. Check subscription is enabled: Go to Settings โ†’ Webhooks and ensure the toggle is ON
  2. Verify event types: Make sure you've subscribed to the events you expect (e.g., call.failed for failed calls)
  3. Check subscription status: If status is FAILING, there may be issues with your endpoint
  4. 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#

We provide a free Webhook Tester tool that generates unique URLs to receive and inspect webhooks in real-time. No setup required!

๐Ÿ”— Open Webhook Tester

How to use:

  1. Go to edesy.in/tools/webhook-tester
  2. Click "Generate URL" to create a unique webhook endpoint
  3. Copy the generated URL (e.g., https://edesy.in/wh/abc12345)
  4. Use this URL when creating your webhook subscription
  5. Watch incoming webhooks appear in real-time
  6. 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:

  1. For call.failed: Only fires for outbound calls that fail to connect (busy, no-answer, rejected)
  2. For call.ended: Only fires when the WebSocket connection closes after a connected call
  3. For call.extracted: Only fires when the agent has an extraction template and extraction returned data
  4. Need one event for every call? Subscribe to call.analyzed

Getting Help#

If you're still having issues:

  • Contact support at [email protected] with your webhook subscription ID and delivery logs

Available Event Types Summary#

Event Description When It Fires
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.extracted Data extracted Post-call extraction produced fields

Next Steps#

Was this page helpful?