HTTP Signing Secret

Verify that HTTP Request node calls and status callbacks come from Edesy - X-Edesy-Signature is HMAC-SHA256 of timestamp + "." + raw body. Code for Node.js, Python and Go.

2 min read

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

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#

  1. Read the raw body bytes before any JSON parsing.
  2. Read X-Edesy-Timestamp and check it is within 300 seconds of now.
  3. Compute HMAC-SHA256(secret, timestamp + "." + raw_body), hex-encode it, and add sha256= in front.
  4. Compare with X-Edesy-Signature using a constant-time comparison.
  5. 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-Agent is edesy-ivr/1.0.
Was this page helpful?