Every request IVR Studio sends to you, from HTTP Request nodes and status callbacks, carries an X-Edesy-Signature header. It is sha256= followed by the hex HMAC-SHA256 of the X-Edesy-Timestamp value, a dot, and the raw request body, keyed with your workspace signing secret. Recompute it, compare in constant time, and reject timestamps older than 5 minutes.
Signature format#
| Header | Value |
|---|---|
X-Edesy-Timestamp |
Unix time in seconds, e.g. 1761646500 |
X-Edesy-Signature |
sha256= + hex(HMAC-SHA256(secret, timestamp + "." + raw_body)) |
X-Edesy-Idempotency-Key |
Same value for the same visit to a node. Use it to drop duplicates |
X-Edesy-Call-Sid |
The call |
The signed body is the exact bytes sent. For a GET or DELETE with no body, the signed string is just timestamp + ".".
Verify step by step#
- Read the raw body bytes before any JSON parsing.
- Read
X-Edesy-Timestampand check it is within 300 seconds of now. - Compute
HMAC-SHA256(secret, timestamp + "." + raw_body), hex-encode it, and addsha256=in front. - Compare with
X-Edesy-Signatureusing a constant-time comparison. - Reject the request (for example with
401) if either check fails.
Node.js#
// Express: mount express.raw({ type: '*/*' }) on this route so req.body is a Buffer.
const crypto = require('crypto');
function verifyEdesy(req, secret) {
const ts = req.get('X-Edesy-Timestamp') || '';
const got = req.get('X-Edesy-Signature') || '';
if (!ts || Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false;
const body = Buffer.isBuffer(req.body) ? req.body : Buffer.from(req.body || '');
const expected = 'sha256=' + crypto
.createHmac('sha256', secret)
.update(ts + '.')
.update(body)
.digest('hex');
return got.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(got), Buffer.from(expected));
}
Python#
import hashlib
import hmac
import time
def verify_edesy(headers, raw_body: bytes, secret: str) -> bool:
ts = headers.get("X-Edesy-Timestamp", "")
got = headers.get("X-Edesy-Signature", "")
if not ts or abs(time.time() - int(ts)) > 300:
return False
mac = hmac.new(secret.encode(), ts.encode() + b"." + raw_body, hashlib.sha256)
expected = "sha256=" + mac.hexdigest()
return hmac.compare_digest(expected, got)
With Flask, pass request.get_data() as raw_body. With Django, pass request.body.
Go#
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"math"
"net/http"
"strconv"
"time"
)
func verifyEdesy(r *http.Request, rawBody []byte, secret string) bool {
ts := r.Header.Get("X-Edesy-Timestamp")
unix, err := strconv.ParseInt(ts, 10, 64)
if err != nil || math.Abs(float64(time.Now().Unix()-unix)) > 300 {
return false
}
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(ts + "."))
mac.Write(rawBody)
expected := "sha256=" + hex.EncodeToString(mac.Sum(nil))
return hmac.Equal([]byte(expected), []byte(r.Header.Get("X-Edesy-Signature")))
}
Find and rotate the secret#
In the portal, open API Keys and the Developer section. It shows whether a secret exists, the signature format and sample code.
| Method | Path | Returns |
|---|---|---|
| GET | /v1/ivr/settings/signing-secret |
{ "data": { "has_secret": true, "masked": "..." } }. Never the full secret |
| POST | /v1/ivr/settings/signing-secret/rotate |
{ "data": { "secret": "..." } }. The new secret, shown once |
Rotating replaces the secret at once, so requests sent after the rotation are signed with the new one. Update your server straight after rotating. Copy the new secret when it is shown: it won't be shown again.
Other protections#
- Requests are
https://only. Redirects are not followed, and private or internal addresses are refused. - Header values that look like secrets are masked in HTTP Logs.
User-Agentisedesy-ivr/1.0.