Call Events v1 is a webhook format for calls placed with click-to-call and calls on your forwarding numbers. Each event carries a full snapshot of what is known about the call so far, so you can upsert by call.id in any arrival order. Deliveries are signed with Standard Webhooks headers.
Subscribe#
Create a webhook subscription with format set to edesy.call.v1:
curl -X POST https://voice-api.edesy.in/v1/webhooks/subscriptions \
-H "Authorization: Bearer $EDESY_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://crm.example.com/edesy/call-events",
"format": "edesy.call.v1",
"event_types": ["call.completed", "recording.ready", "transcript.ready", "call.analyzed"]
}'
A v1 subscription always has a signing secret. Send your own whsec_ secret (base64 of at least 16 bytes) or leave secret out and Edesy generates one. The create response is the only time the secret is returned. Empty event_types means all eight events.
Events#
| Type | When |
|---|---|
call.initiated |
The call was started |
call.ringing |
A phone is ringing |
call.answered |
The call was answered |
call.completed |
The call ended. call.outcome says how |
recording.ready |
The recording is available. Adds recording |
transcript.ready |
The transcript is ready. Adds transcript |
call.analyzed |
The summary is ready. Adds analysis |
disposition.submitted |
A disposition was recorded for the call |
call.status only moves forward: initiated, ringing, answered, completed.
Envelope#
{
"specversion": "edesy.call.v1",
"id": "evt_...",
"type": "call.completed",
"occurred_at": "2026-11-05T10:31:22.120Z",
"call": {
"id": "CA0001",
"provider": "edesy-voice",
"direction": "outbound",
"mode": "bridge",
"status": "completed",
"outcome": "answered",
"from": "+918069000000",
"to": "+919900000000",
"started_at": "2026-11-05T10:28:00.000Z",
"answered_at": "2026-11-05T10:28:18.000Z",
"ended_at": "2026-11-05T10:31:22.000Z",
"duration_sec": 202,
"talk_sec": 184,
"agent": { "phone": "+919800000000" },
"correlation": { "crm_call_id": "clcall0001" }
}
}
call fields#
| Field | Description |
|---|---|
id |
The call. Same as call_id from POST/v1/click-to-call |
direction |
outbound or inbound |
mode |
bridge, webrtc, device, api or ai_agent |
status |
initiated, ringing, answered, completed |
outcome |
On completion: answered, no_answer, busy, failed, canceled or voicemail |
from, to, caller_id |
E.164 numbers |
started_at, answered_at, ended_at |
RFC 3339, UTC, milliseconds |
duration_sec, talk_sec |
Whole call and connected time |
hangup_by |
agent, customer or system |
agent |
external_id, email, phone |
correlation |
The keys you sent when placing the call |
cost |
amount (decimal string) and currency |
recording (from recording.ready)#
"recording": {
"url": "https://...",
"url_expires_at": "...",
"auth": "none",
"duration_sec": 184,
"channels": 2,
"channel_map": { "0": "agent", "1": "customer" }
}
transcript (from transcript.ready)#
"transcript": {
"language": "en",
"segments": [
{ "speaker": "agent", "start_ms": 0, "end_ms": 2400, "text": "Hi, this is Priya from Acme Homes." },
{ "speaker": "customer", "start_ms": 2600, "end_ms": 4100, "text": "Yes, I filled the form yesterday." }
]
}
speaker is agent, customer or unknown.
analysis (from call.analyzed)#
"analysis": {
"summary": "Customer wants the 2BHK price list; send PDF and call back Friday.",
"sentiment": "positive",
"action_items": ["Send price list PDF", "Call back on Friday"],
"disposition": "callback"
}
| Field | Values |
|---|---|
sentiment |
positive, neutral, negative, mixed |
disposition |
interested, callback, not_reachable, qualified, converted, not_interested, wrong_number, do_not_call |
Transcripts and summaries are produced for recorded calls.
Verify the signature#
Every delivery carries:
webhook-id: evt_...
webhook-timestamp: 1762338682
webhook-signature: v1,1tVl0MjJQ87VA4qGmttAdLP/bJlY5H81NvWOQoNi+eI=
X-Edesy-Event: call.completed
- Take the raw body, before any JSON parsing.
- Build
"<webhook-id>.<webhook-timestamp>.<raw body>". - HMAC-SHA256 it. The key is the base64-decoded part of your secret after
whsec_(a secret without the prefix is used as plain UTF-8). - Base64 the result and compare it, in constant time, with each space-separated
v1,value inwebhook-signature. Any one match is enough (it allows secret rotation). - Reject the delivery if
webhook-timestampis more than 5 minutes from now.
Node.js#
const crypto = require('crypto');
function verifyCallEvent(secret, headers, rawBody) {
const id = headers['webhook-id'];
const ts = headers['webhook-timestamp'];
if (!id || !ts || Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false;
const key = secret.startsWith('whsec_')
? Buffer.from(secret.slice(6), 'base64')
: Buffer.from(secret, 'utf8');
const want = Buffer.from(
crypto.createHmac('sha256', key).update(`${id}.${ts}.${rawBody}`).digest('base64'));
return (headers['webhook-signature'] || '').split(' ').some((s) => {
const got = Buffer.from(s.slice(3));
return s.startsWith('v1,') && got.length === want.length && crypto.timingSafeEqual(got, want);
});
}
Python#
import base64, hashlib, hmac, time
def verify_call_event(secret: str, headers: dict, raw_body: bytes) -> bool:
msg_id = headers.get("webhook-id", "")
ts = headers.get("webhook-timestamp", "")
if not msg_id or not ts.isdigit() or abs(time.time() - int(ts)) > 300:
return False
key = base64.b64decode(secret[6:]) if secret.startswith("whsec_") else secret.encode()
signed = f"{msg_id}.{ts}.".encode() + raw_body
want = base64.b64encode(hmac.new(key, signed, hashlib.sha256).digest()).decode()
return any(
s.startswith("v1,") and hmac.compare_digest(s[3:], want)
for s in headers.get("webhook-signature", "").split()
)
Responding and retries#
Reply with any 2xx within 5 seconds and do slow work afterwards. Failed deliveries are retried with backoff and can be replayed, the same as other subscriptions: see Retries and Replay. webhook-id is the same on every retry of an event, so de-duplicate on it; the timestamp and signature are fresh on each attempt.