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:
{
"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 |
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
2xxquickly. 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. 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.
Node.js (Express)
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)
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.