# Verify Signatures

> Verify Edesy webhook signatures - X-Edesy-Signature HMAC-SHA256 for subscriptions and X-Webhook-Signature for status callbacks - with Node.js and Python code.

Source: https://edesy.in/docs/voice-api/webhooks/verify-signatures

When a subscription has a secret, every delivery carries `X-Edesy-Signature: sha256=<hex>`, the HMAC-SHA256 of the raw request body keyed with that secret. Recompute it over the exact bytes you received and compare in constant time; reject the request if it does not match. Status callbacks use a separate, timestamped scheme described at the end.

## Subscription signatures

```
POST /edesy/events
Content-Type: application/json
X-Edesy-Signature: sha256=9d4f0c...b72a
```

Steps:

1. Read the raw body before any JSON parsing. Re-serialising parsed JSON changes the bytes and breaks the signature.
2. Compute `"sha256=" + hex(HMAC_SHA256(secret, raw_body))`.
3. Compare with `X-Edesy-Signature` using a constant-time comparison.
4. De-duplicate on `data.call_sid` plus `event`, because retries and replays can deliver the same event twice.

### Node.js (Express)

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

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

app.post('/edesy/events', express.raw({ type: 'application/json' }), (req, res) => {
  const received = req.get('X-Edesy-Signature') || '';
  const expected = 'sha256=' + crypto.createHmac('sha256', SECRET).update(req.body).digest('hex');

  const valid = received.length === expected.length &&
    crypto.timingSafeEqual(Buffer.from(received), Buffer.from(expected));
  if (!valid) return res.sendStatus(401);

  res.sendStatus(200); // respond first, then process
  const { event, data } = JSON.parse(req.body.toString('utf8'));
  queueForProcessing(event, data);
});

function queueForProcessing(event, data) {
  console.log(event, data.call_sid, data.external_id);
}

app.listen(3000);
```

### Python (Flask)

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

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

@app.post("/edesy/events")
def events():
    received = request.headers.get("X-Edesy-Signature", "")
    expected = "sha256=" + hmac.new(SECRET, request.get_data(), hashlib.sha256).hexdigest()
    if not hmac.compare_digest(received, expected):
        abort(401)

    payload = request.get_json()
    print(payload["event"], payload["data"].get("call_sid"))
    return "", 200
```

## Status callback signatures

Status callbacks and answer URL requests carry:

```
X-Webhook-Timestamp: 1761643841
X-Webhook-Signature: v1=<hex>
```

The value after `v1=` is the hex HMAC-SHA256 of `"<timestamp>.<raw body>"`. Check the signature and reject timestamps older than a few minutes to stop replays. Code for both languages is on [Status Callbacks](https://edesy.in/docs/voice-api/calls/status-callbacks#verify-the-signature). The status-callback signing secret is not shown in the dashboard yet; ask us for it through [/contact](https://edesy.in/contact).

## Other checks

- Only accept HTTPS on your endpoint.
- Treat the webhook as a notification and, for anything money-related, read the call back with `GET /v1/calls/{CallSid}`.
- Rotate secrets by updating the subscription and accepting old and new signatures briefly.
