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

Source: https://edesy.in/docs/ivr/api/http-signing-secret

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

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

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

```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](https://edesy.in/docs/ivr/logs/http-logs).
- `User-Agent` is `edesy-ivr/1.0`.
