Webhook Signatures
HostDesk signs every outgoing webhook with HMAC-SHA256 so you can verify a request really came from us and was not replayed or tampered with.
How it works
- You set a
webhook_secreton your assistant (e.g.whsec_abc123). - Each delivery carries two headers:
X-EchoAI-Signature: v1=<hex_digest>X-EchoAI-Timestamp: <unix_seconds>
- The digest is HMAC-SHA256 over the string
"{timestamp}.{raw_body}", keyed by your secret. The timestamp is inside the signed message, so it cannot be altered to replay an old delivery. - Your server recomputes the digest and rejects anything that does not match, or whose timestamp is more than 5 minutes from now.
Verify against the raw body, exactly as received. Do not parse the JSON and re-serialize it before hashing — key order and whitespace would change and every signature would fail. Both examples below read the body as raw bytes for this reason.
Verifying in Python
Using the SDK
from echoai import verify_webhook
WEBHOOK_SECRET = "whsec_abc123"
# FastAPI
@app.post("/webhook")
async def handle(request: Request):
payload = (await request.body()).decode("utf-8")
signature = request.headers.get("X-EchoAI-Signature", "")
timestamp = request.headers.get("X-EchoAI-Timestamp", "0")
if not verify_webhook(payload, signature, WEBHOOK_SECRET, int(timestamp)):
return JSONResponse({"error": "Invalid signature"}, status_code=401)
# Signature is valid — process the webhook
verify_webhook returns a bool; it does not raise on a bad signature. Default the
headers to "" and "0" as above — a missing X-EchoAI-Signature would otherwise be
None, and None.startswith(...) raises inside the helper instead of returning False.
Manual verification
Equivalent to the SDK helper, if you would rather not add the dependency. It takes the
headers exactly as your framework hands them over — possibly None — and answers
True or False for every input rather than raising:
import hashlib
import hmac
import time
TOLERANCE_SECONDS = 300
def verify(payload: str, signature: str | None, secret: str, timestamp: str | None) -> bool:
# Reject before comparing. hmac.compare_digest raises TypeError on None, so a
# request that simply omits the header would 500 instead of 401.
if not isinstance(signature, str) or not signature.startswith("v1="):
return False
try:
ts = int(timestamp) # type: ignore[arg-type]
except (TypeError, ValueError):
return False
if abs(int(time.time()) - ts) > TOLERANCE_SECONDS:
return False
message = f"{ts}.{payload}"
expected = "v1=" + hmac.new(
secret.encode("utf-8"), message.encode("utf-8"), hashlib.sha256
).hexdigest()
return hmac.compare_digest(signature, expected)
Flask
request.get_data() returns the raw bytes, before any JSON parsing:
@app.post("/webhook")
def handle():
payload = request.get_data(as_text=True)
if not verify(
payload,
request.headers.get("X-EchoAI-Signature"),
WEBHOOK_SECRET,
request.headers.get("X-EchoAI-Timestamp"),
):
return {"error": "Invalid signature"}, 401
event = request.get_json()
# Process webhook...
return "", 204
FastAPI
from fastapi import Request
from fastapi.responses import JSONResponse
@app.post("/webhook")
async def handle(request: Request):
payload = (await request.body()).decode("utf-8")
if not verify(
payload,
request.headers.get("X-EchoAI-Signature"),
WEBHOOK_SECRET,
request.headers.get("X-EchoAI-Timestamp"),
):
return JSONResponse({"error": "Invalid signature"}, status_code=401)
event = json.loads(payload)
# Process webhook...
Verifying in Node.js
const crypto = require("crypto");
const TOLERANCE_SECONDS = 300;
function verifyWebhook(payload, signature, secret, timestamp) {
// Validate shape first. crypto.timingSafeEqual throws a RangeError when the two
// buffers differ in length, and Buffer.from throws on undefined — so an attacker
// sending a short or absent signature would crash the handler rather than get a 401.
if (typeof signature !== "string" || !signature.startsWith("v1=")) return false;
const ts = Number.parseInt(timestamp, 10);
if (!Number.isFinite(ts)) return false;
if (Math.abs(Math.floor(Date.now() / 1000) - ts) > TOLERANCE_SECONDS) return false;
const expected =
"v1=" +
crypto.createHmac("sha256", secret).update(`${ts}.${payload}`).digest("hex");
const received = Buffer.from(signature);
const computed = Buffer.from(expected);
return (
received.length === computed.length &&
crypto.timingSafeEqual(received, computed)
);
}
Express
Express gives you no raw body by default — express.json() consumes the stream and
leaves only the parsed object, which cannot be re-serialized back to the exact bytes we
signed. Mount express.raw() on the webhook route so the handler receives a Buffer:
app.post(
"/webhook",
express.raw({ type: "application/json" }),
(req, res) => {
const payload = req.body.toString("utf8");
if (
!verifyWebhook(
payload,
req.headers["x-echoai-signature"],
"whsec_abc123",
req.headers["x-echoai-timestamp"]
)
) {
return res.status(401).json({ error: "Invalid signature" });
}
const event = JSON.parse(payload);
// Process webhook...
res.sendStatus(204);
}
);
Keep express.json() for the rest of your app — register this route before it, or scope
the JSON parser to your other paths.
Signature header format
X-EchoAI-Signature: v1=5d41402abc4b2a76b9719d911017c592...
X-EchoAI-Timestamp: 1747001234
The signature is always prefixed with v1= followed by the hex-encoded HMAC-SHA256
digest of "{timestamp}.{raw_body}".
Best practices
- Always verify signatures in production to prevent spoofed requests.
- Use a unique secret per assistant so compromising one doesn't affect the others.
- Validate the header before comparing. Check that it is present, is a string, and
carries the
v1=prefix. Bothhmac.compare_digestandcrypto.timingSafeEqualraise on malformed input, which turns a request that should be a clean 401 into a 500. - Compare in constant time (
hmac.compare_digestin Python,crypto.timingSafeEqualin Node.js) to prevent timing attacks. - Enforce the timestamp tolerance. Without it a captured request stays replayable forever, since the signature itself never expires.
- Store secrets in environment variables, not hardcoded strings.
Setting the webhook secret
Via the API:
curl -X PATCH /api/v1/assistants/{id} \
-H "Authorization: Bearer ek_..." \
-d '{"provider_config": {"webhook_secret": "whsec_your_secret"}}'
Via the dashboard: edit the assistant and fill in the "Webhook Secret" field.
Via the SDK:
client.assistants.update(assistant_id, provider_config={
"webhook_secret": "whsec_your_secret"
})