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=6f1d0c2b9e8a47f3a5c1d8e2b7f04a6c9d3e1b5a7c2f8e4d6b0a9c3e5f7d1b2ax-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_bodyis the exact request body string, as sent.vis appended as decimal text with no separator. For a body{"event":"test","timestamp":1760672019481}signed at1760672019481, 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 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: trueonPOST /api-keysorPATCH /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}'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:
- The header matches
v=<digits>,d=<hex>. vis within 5 minutes (300,000 ms) of your clock, in either direction. This rejects replays.- Recompute the HMAC over the raw body plus
v. - 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 "", 204Clock 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/16127.0.0.0/8and::1(loopback)169.254.0.0/16andfe80::/10(link local, including cloud metadata addresses)100.64.0.0/10(carrier-grade NAT) andfc00::/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-signatureon every request and reject failures with401. - 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(orchat_id,alert_id) plusevent.