# Status Callbacks

> The status_callback webhook for Edesy calls - Twilio-style PascalCase payload fields, the lifecycle events you receive, and HMAC signature verification in Node.js and Python.

Source: https://edesy.in/docs/voice-api/calls/status-callbacks

Set `status_callback` on `POST /v1/calls` (or on an application) and Edesy sends your URL an HTTP request each time the call changes state: initiated, ringing, answered and ended. The payload uses Twilio's PascalCase field names, such as `CallSid`, `CallStatus` and `Duration`, and the final event adds `HangupReason`, `CallOutcome` and `SipResponseCode`.

## Payload

With the default `status_method` of `POST`, the body is JSON:

```json
{
  "CallSid": "CA_6f1c2d3e-0000-4000-8000-000000000000",
  "AccountSid": "2b7e0a1c-0000-4000-8000-000000000000",
  "From": "+9180XXXXXXXX",
  "To": "+9198XXXXXXXX",
  "CallStatus": "no-answer",
  "Direction": "outbound",
  "Duration": 0,
  "SipResponseCode": 480,
  "HangupReason": "unavailable",
  "CallOutcome": "unreachable",
  "Timestamp": "2026-10-28T09:30:41Z",
  "Custom": { "order_id": "ORD-1042" }
}
```

| Field | When | Description |
|---|---|---|
| `CallSid` | always | The `call_sid` returned by `POST /v1/calls` |
| `AccountSid` | always | Your workspace ID |
| `From`, `To` | always | E.164 numbers |
| `CallStatus` | always | `initiated`, `ringing`, `in-progress`, `completed`, `busy`, `no-answer`, `failed` or `canceled` |
| `Direction` | always | Direction of the call leg |
| `Duration` | final event | Answered seconds |
| `SipResponseCode` | final event | Final SIP response code |
| `HangupReason` | final event | Carrier-neutral reason. See [Call Events and Outcomes](https://edesy.in/docs/voice-api/calls/call-events-and-outcomes) |
| `CallOutcome` | final event | Who or what the call ended at: `answered`, `callee`, `unreachable`, `invalid_number`, `network`, `account`, `platform` |
| `Timestamp` | always | RFC 3339 time the event was sent |
| `Custom` | when set | Your `custom_parameters` from the request |

On the final event, `CallStatus` is derived from the SIP answer, not only the carrier's coarse status. A phone that is switched off (SIP 480) is reported as `no-answer` with `HangupReason: unavailable`, not as `failed`.

With `status_method: GET`, the fields arrive as query parameters instead: `CallSid`, `AccountSid`, `From`, `To`, `CallStatus`, `Direction`, and `Duration` once it is known. Use `POST` if you need `HangupReason`, `CallOutcome`, `SipResponseCode` or `Custom`.

## Events you receive

| Moment | `CallStatus` |
|---|---|
| Call placed | `initiated` |
| Destination ringing | `ringing` |
| Callee answered | `in-progress` |
| Call ended | `completed`, `busy`, `no-answer`, `failed` or `canceled` |

You may not get every intermediate event (a call that fails immediately never rings). Treat the event with a terminal `CallStatus` as the result and key your updates on `CallSid`.

## Delivery

- Reply with any `2xx` quickly. The response body is ignored.
- Delivery never blocks the call: a slow or failing endpoint does not delay or drop it.
- Failed deliveries are retried with backoff.
- The same call can be read at any time with `GET /v1/calls/{CallSid}`, which is the safest source of truth if you miss an event.

## Verify the signature

When signing is enabled, every status callback (and every answer URL request) carries two headers:

```
X-Webhook-Timestamp: 1761643841
X-Webhook-Signature: v1=5f2c...e91a
```

The signature is `v1=` followed by the hex HMAC-SHA256 of `"<timestamp>.<raw body>"`, keyed with the signing secret. The status-callback signing secret is not shown in the dashboard yet; ask us for it through [/contact](https://edesy.in/contact). Until you have it, confirm an event by reading the call back with `GET /v1/calls/{CallSid}`.

For event subscriptions, which you sign with your own secret, see [Verify Signatures](https://edesy.in/docs/voice-api/webhooks/verify-signatures).

**Node.js (Express)**

```js
const crypto = require('crypto');
const express = require('express');

const app = express();
const SECRET = process.env.EDESY_WEBHOOK_SECRET;

// Keep the raw body: the signature covers the exact bytes we sent.
app.post('/edesy/status', express.raw({ type: '*/*' }), (req, res) => {
  const ts = req.get('X-Webhook-Timestamp') || '';
  const sig = req.get('X-Webhook-Signature') || '';

  const expected = 'v1=' + crypto
    .createHmac('sha256', SECRET)
    .update(`${ts}.${req.body.toString('utf8')}`)
    .digest('hex');

  const fresh = Math.abs(Date.now() / 1000 - Number(ts)) < 300; // 5 minutes
  const ok = sig.length === expected.length &&
    crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected));

  if (!fresh || !ok) return res.sendStatus(401);

  const event = JSON.parse(req.body.toString('utf8'));
  console.log(event.CallSid, event.CallStatus, event.HangupReason);
  res.sendStatus(200);
});

app.listen(3000);
```

**Python (Flask)**

```python
import hashlib, hmac, os, time
from flask import Flask, request, abort

app = Flask(__name__)
SECRET = os.environ["EDESY_WEBHOOK_SECRET"].encode()

@app.post("/edesy/status")
def status():
    ts = request.headers.get("X-Webhook-Timestamp", "")
    sig = request.headers.get("X-Webhook-Signature", "")
    body = request.get_data()  # raw bytes, before any parsing

    expected = "v1=" + hmac.new(SECRET, f"{ts}.".encode() + body, hashlib.sha256).hexdigest()
    fresh = ts.isdigit() and abs(time.time() - int(ts)) < 300

    if not fresh or not hmac.compare_digest(sig, expected):
        abort(401)

    event = request.get_json()
    print(event["CallSid"], event["CallStatus"], event.get("HangupReason"))
    return "", 200
```

The answer URL request is form-encoded rather than JSON. Verify it the same way, over the raw form body.
