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
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
const crypto = require('crypto');
function verifySignature(payload, signature, secret) {
const [timestampPart, signaturePart] = signature.split(',');
const timestamp = timestampPart.replace('t=', '');
const receivedSignature = signaturePart.replace('v1=', '');
const signedPayload = `${timestamp}.${payload}`;
const expectedSignature = crypto
.createHmac('sha256', secret)
.update(signedPayload)
.digest('hex');
return crypto.timingSafeEqual(
Buffer.from(receivedSignature),
Buffer.from(expectedSignature)
);
}
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.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 |
Next Steps
- Webhooks Guide - Basic webhook configuration
- REST API - Full API reference
- Call Recording - Recording configuration