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:
- Read the raw body before any JSON parsing. Re-serialising parsed JSON changes the bytes and breaks the signature.
- Compute
"sha256=" + hex(HMAC_SHA256(secret, raw_body)). - Compare with
X-Edesy-Signatureusing a constant-time comparison. - De-duplicate on
data.call_sidplusevent, because retries and replays can deliver the same event twice.
Node.js (Express)#
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)#
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. The status-callback signing secret is not shown in the dashboard yet; ask us for it through /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.