Call Events v1

Edesy Call Events v1 (format edesy.call.v1) - eight signed webhook events from call.initiated to call.analyzed, full call snapshots, recording, transcript and summary payloads, and Standard Webhooks signature verification in Node.js and Python.

3 min read

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
  1. Take the raw body, before any JSON parsing.
  2. Build "<webhook-id>.<webhook-timestamp>.<raw body>".
  3. 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).
  4. Base64 the result and compare it, in constant time, with each space-separated v1, value in webhook-signature. Any one match is enough (it allows secret rotation).
  5. Reject the delivery if webhook-timestamp is 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.

Was this page helpful?