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

Source: https://edesy.in/docs/voice-api/webhooks/call-events

Call Events v1 is a webhook format for calls placed with [click-to-call](https://edesy.in/docs/voice-api/calls/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](https://www.standardwebhooks.com/) headers.

## Subscribe

Create a [webhook subscription](https://edesy.in/docs/voice-api/webhooks/subscriptions) with `format` set to `edesy.call.v1`:

```bash
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

```json
{
  "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`)

```json
"recording": {
  "url": "https://...",
  "url_expires_at": "...",
  "auth": "none",
  "duration_sec": 184,
  "channels": 2,
  "channel_map": { "0": "agent", "1": "customer" }
}
```

### `transcript` (from `transcript.ready`)

```json
"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`)

```json
"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

```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

```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](https://edesy.in/docs/voice-api/webhooks/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.

## Related

- [Click-to-Call](https://edesy.in/docs/voice-api/calls/click-to-call)
- [Webhook Subscriptions](https://edesy.in/docs/voice-api/webhooks/subscriptions)
- [CRM integrations](https://edesy.in/docs/crm-integrations)
