Skip to content
TriedDocs
Webhooks

Secure webhooks

Verify the x-tried-signature header, choose the webhook signing key and allowlist Tried's egress addresses.

Anyone who learns your webhook URL can send it requests. Every webhook Tried sends carries an HMAC signature so you can prove the request came from your workspace and was not changed or replayed.

Signature headers#

Each request has two headers with the same value:

Content-Type: application/json
User-Agent: Tried-Webhooks/1.0
x-tried-signature: v=1760672019481,d=6f1d0c2b9e8a47f3a5c1d8e2b7f04a6c9d3e1b5a7c2f8e4d6b0a9c3e5f7d1b2a
x-retell-signature: v=1760672019481,d=6f1d0c2b9e8a47f3a5c1d8e2b7f04a6c9d3e1b5a7c2f8e4d6b0a9c3e5f7d1b2a

x-retell-signature is sent so code written against Retell's header name keeps working. Read either one.

The value has two parts:

Part Meaning
v The signing time in Unix milliseconds
d Lowercase hex HMAC-SHA256 digest

The digest is computed as:

d = hex( HMAC-SHA256( key = webhook signing key, message = raw_body + v ) )
  • raw_body is the exact request body string, as sent.
  • v is appended as decimal text with no separator. For a body {"event":"test","timestamp":1760672019481} signed at 1760672019481, the HMAC message is {"event":"test","timestamp":1760672019481}1760672019481.
  • The key is the full plaintext of your webhook API key (the key_... string).

The same headers and algorithm are used for every event on the overview, the inbound call webhook and requests from custom functions.

The webhook signing key#

One API key in the workspace is the webhook key. It has a Webhook badge on Settings, API Keys.

The API Keys page with the Webhook badge on the signing key
  • The first key created with a workspace is the webhook key.
  • To change it, open the key's menu and choose Set as webhook key. Only one key holds the badge, so the previous one loses it.
  • You can also mark a key when you create it, or later with the API: is_webhook_key: true on POST /api-keys or PATCH /api-keys/:id.
  • If no key has the badge, Tried signs with the oldest key in the workspace.
  • If the workspace has no API keys at all, webhooks are sent without signature headers.
curl -X PATCH https://api.tried.com.au/api-keys/key_9b68a1d4c2e7f05b3a8d6c12 \
  -H "Authorization: Bearer $TRIED_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"is_webhook_key": true}'
Rotating the webhook key

Signatures change the moment another key gets the badge, and deleting the webhook key makes the oldest remaining key the signer. Deploy the new key to your verifier first, then move the badge.

Verify a request#

Verification has four checks, in this order:

  1. The header matches v=<digits>,d=<hex>.
  2. v is within 5 minutes (300,000 ms) of your clock, in either direction. This rejects replays.
  3. Recompute the HMAC over the raw body plus v.
  4. Compare digests in constant time.

Always verify against the raw bytes you received. Parsing the JSON and serialising it again can reorder keys or change spacing, and the digest will not match.

import { createHmac, timingSafeEqual } from "node:crypto";

/** Same checks Tried uses in its own verifier. */
export function verifyWebhook(rawBody: string, apiKey: string, signature: string, toleranceMs = 5 * 60_000): boolean {
  const m = /^v=(\d+),d=([0-9a-f]+)$/.exec(signature);
  if (!m) return false;
  const ts = Number(m[1]);
  if (Math.abs(Date.now() - ts) > toleranceMs) return false;
  const expected = createHmac("sha256", apiKey).update(rawBody + ts).digest("hex");
  const a = Buffer.from(expected, "hex");
  const b = Buffer.from(m[2]!, "hex");
  return a.length === b.length && timingSafeEqual(a, b);
}
import hashlib
import hmac
import re
import time

SIG = re.compile(r"v=(\d+),d=([0-9a-f]+)")


def verify_webhook(raw_body: bytes, api_key: str, signature: str, tolerance_ms: int = 5 * 60_000) -> bool:
    m = SIG.fullmatch(signature or "")
    if not m:
        return False
    ts = int(m.group(1))
    if abs(int(time.time() * 1000) - ts) > tolerance_ms:
        return False
    expected = hmac.new(api_key.encode(), raw_body + str(ts).encode(), hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, m.group(2))

Express#

Use express.raw on the webhook route so you get the body as bytes.

import express from "express";
import { verifyWebhook } from "./verify-webhook";

const app = express();

app.post("/tried/webhook", express.raw({ type: "application/json" }), (req, res) => {
  const raw = req.body.toString("utf8");
  const sig = req.get("x-tried-signature") ?? "";
  if (!verifyWebhook(raw, process.env.TRIED_WEBHOOK_KEY!, sig)) return res.sendStatus(401);

  const payload = JSON.parse(raw);
  res.sendStatus(204); // reply first, then work
  queue.push(payload);
});

app.listen(8099);

Flask#

import json
import os
from flask import Flask, request, abort
from verify_webhook import verify_webhook

app = Flask(__name__)


@app.post("/tried/webhook")
def webhook():
    raw = request.get_data()  # bytes, before any JSON parsing
    sig = request.headers.get("x-tried-signature", "")
    if not verify_webhook(raw, os.environ["TRIED_WEBHOOK_KEY"], sig):
        abort(401)
    payload = json.loads(raw)
    enqueue(payload)
    return "", 204

Clock skew#

The 5 minute window is measured against the time on your server. Keep it synced with NTP. If your servers run more than a few minutes off, every request will fail the timestamp check.

Egress IP addresses#

All webhook traffic leaves through Tried's egress path: call, chat, transfer, alert, budget and test events, the inbound call webhook, custom functions and workflow steps.

The Egress IP addresses box on Settings, Webhooks shows what the deployment publishes:

  • When fixed addresses are configured, they are listed with copy buttons and the note "Webhooks, custom functions and workflow steps leave from these addresses."
  • When they are not, the box says "Egress IPs are not fixed in this deployment."

The same data is available from the API:

curl https://api.tried.com.au/workspace/egress \
  -H "Authorization: Bearer $TRIED_API_KEY"
{
  "fixed": true,
  "ips": ["203.0.113.24", "203.0.113.25"],
  "note": "Webhooks, custom functions and workflow steps leave from these addresses."
}
Field Type Description
fixed boolean true when the deployment publishes egress addresses
ips string[] The addresses. Empty when fixed is false
note string The text shown on the Webhooks page

Allowlisting#

If your firewall only accepts known senders, add the listed addresses. An IP allowlist is a second layer, not a replacement for the signature check: verify the signature even when the source address matches.

Destination rules#

Tried refuses to send webhooks to private or reserved networks. The check runs on the address actually dialled, after DNS resolution, so a hostname that resolves to an internal address is blocked too. Blocked ranges include:

  • 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16
  • 127.0.0.0/8 and ::1 (loopback)
  • 169.254.0.0/16 and fe80::/10 (link local, including cloud metadata addresses)
  • 100.64.0.0/10 (carrier-grade NAT) and fc00::/7
  • Multicast, documentation and other reserved ranges

A blocked attempt is logged in the delivery log with an error like Refusing to connect to hooks.internal (10.0.4.12): private or reserved address. Redirects are followed up to 5 hops and every hop is checked the same way.

For local development, use a public tunnel. See Test webhooks.

Checklist#

  • Verify x-tried-signature on every request and reject failures with 401.
  • Verify against the raw body.
  • Reject timestamps more than 5 minutes old.
  • Serve the endpoint over HTTPS so payloads (transcripts, phone numbers) are encrypted in transit.
  • Store the webhook key in your secrets manager, not in source code.
  • Reply fast and process in the background.
  • De-duplicate on call_id (or chat_id, alert_id) plus event.