How Webhooks Work
Webhooks are HTTP callbacks that push event data to your application in real time. When something happens on a call — a transcript turn, a status change, a tool invocation — Threll sends a POST request to your endpoint.
Two kinds of events: sync and async
Every event is delivered the same way (an HTTP POST with a JSON body), but they differ in what Threll expects back:
call.worker_request expects worker configuration; call.tool_call expects a tool result. The voice session pauses on your answer — respond fast.call.transcript, call.status_update, and call.ended. Return 2xx quickly; the body is ignored.The X-Threll-Delivery header tells you which kind you're handling: sync or async.
Subscriptions
You register webhook subscriptions in the platform. Each subscription has a URL, a signing secret, and a scope:
- Account-wide — receives events for all workers.
- Worker-scoped — receives events for one worker only.
Async events fan out to every matching enabled subscription. Sync events pick exactly one: the worker-scoped subscription if it exists, otherwise the account-wide one.
A subscription can also include arbitrary custom headers that Threll attaches to every delivery — for example an Authorization or X-API-Key header your infrastructure requires.
Headers with the X-Threll- prefix are reserved by the platform and will be ignored if supplied as custom headers.
Build a receiving endpoint
Your endpoint must accept POST requests with a JSON body and respond 200. Keep the raw request body available — you need the exact bytes for signature verification:
// Express — capture the raw body for signature verification
const express = require('express');
const app = express();
app.post('/webhook', express.raw({ type: 'application/json' }), (req, res) => {
if (!verifySignature(req.body, req.get('X-Threll-Signature'))) {
return res.status(401).send('invalid signature');
}
const event = JSON.parse(req.body);
const delivery = req.get('X-Threll-Delivery'); // 'sync' or 'async'
if (delivery === 'async') {
// Acknowledge first, process in the background
res.status(200).end();
handleAsyncEvent(event);
return;
}
// Sync events: the call is waiting on this response
if (event.type === 'call.tool_call') {
res.json({ result: runTool(event.data.name, event.data.arguments) });
} else if (event.type === 'call.worker_request') {
res.json(workerConfigFor(event.data));
}
});
app.listen(3000);# Flask — request.get_data() returns the raw body bytes
from flask import Flask, request, jsonify
app = Flask(__name__)
@app.post("/webhook")
def webhook():
raw = request.get_data()
if not verify_signature(raw, request.headers.get("X-Threll-Signature")):
return "invalid signature", 401
event = request.get_json()
delivery = request.headers.get("X-Threll-Delivery")
if delivery == "async":
queue_for_processing(event) # acknowledge fast, work later
return "", 200
# Sync events: the call is waiting on this response
if event["type"] == "call.tool_call":
data = event["data"]
return jsonify({"result": run_tool(data["name"], data["arguments"])})
if event["type"] == "call.worker_request":
return jsonify(worker_config_for(event["data"]))
return "", 200Verify the signature
Every delivery includes an X-Threll-Signature header: a hex-encoded HMAC-SHA256 of the raw request body, computed with the subscription's signing secret. The secret is shown once at create time and is prefixed with whsec_.
Verification is four steps:
- Threll computes HMAC-SHA256 of the payload with your signing secret.
- The hex digest is sent in the
X-Threll-Signatureheader. - Your server computes the same HMAC over the raw body it received.
- Compare the two digests with a constant-time comparison.
const crypto = require('crypto');
function verifySignature(rawBody, signatureHeader) {
const expected = crypto
.createHmac('sha256', process.env.THRELL_WEBHOOK_SECRET) // whsec_...
.update(rawBody)
.digest('hex');
const received = Buffer.from(signatureHeader || '', 'utf8');
const computed = Buffer.from(expected, 'utf8');
return (
received.length === computed.length &&
crypto.timingSafeEqual(received, computed)
);
}import hashlib, hmac, os
def verify_signature(raw_body: bytes, signature_header: str) -> bool:
secret = os.environ["THRELL_WEBHOOK_SECRET"] # whsec_...
expected = hmac.new(
secret.encode(), raw_body, hashlib.sha256
).hexdigest()
return hmac.compare_digest(expected, signature_header or "")# Compute the digest by hand to spot-check a delivery
echo -n "$RAW_REQUEST_BODY" | \
openssl dgst -sha256 -hmac "whsec_..." | \
awk '{print $2}'
# Compare the output to the X-Threll-Signature header value
# using a constant-time string comparison.Always verify signatures in production. Compute the HMAC over the raw body bytes — parsing and re-serializing the JSON first will change the bytes and break verification.
Delivery, retries, and idempotency
Each delivery attempt has a status you can inspect on the webhook attempts page in the platform:
| Status | Meaning |
|---|---|
Pending | Queued and waiting to be sent. |
Sent | Delivered successfully (HTTP 2xx response). |
Failed | Delivery failed — non-2xx response or network error. |
Cancelled | Delivery was manually cancelled. |
Failed deliveries are retried automatically. You can also manually retry failed attempts or cancel pending ones from the webhook attempts page.
Because of retries, your endpoint may see the same event more than once. Use the X-Threll-Event-Id header — unique per delivery attempt — together with the event payload to deduplicate processing.
Best practices
- Respond fast on async events. Acknowledge with
200immediately and process in the background — a slow handler risks timeouts and retries. - Be even faster on sync events. The caller is literally on the line waiting for
call.tool_callresults. - Verify every request. Reject anything with a missing or invalid
X-Threll-Signature. - Expect out-of-order async events. Use
turnIndexon transcripts andtstimestamps to re-order if sequence matters. - Stop on
call.ended. It's the final event — no further events for that call will be delivered.