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

  1. You set a webhook_secret on your assistant (e.g. whsec_abc123).
  2. Each delivery carries two headers:
    • X-EchoAI-Signature: v1=<hex_digest>
    • X-EchoAI-Timestamp: <unix_seconds>
  3. 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.
  4. 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. Both hmac.compare_digest and crypto.timingSafeEqual raise on malformed input, which turns a request that should be a clean 401 into a 500.
  • Compare in constant time (hmac.compare_digest in Python, crypto.timingSafeEqual in 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"
})