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.

3 min read

Base URL
https://voice-api.edesy.in/v1
Auth
Authorization: Bearer sk_...Get a key

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

Was this page helpful?