Threll.ai
Guides

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:

Syncblocking
Threll waits for your response and uses it. call.worker_request expects worker configuration; call.tool_call expects a tool result. The voice session pauses on your answer — respond fast.
Asyncfire-and-forget
Notifications delivered via EventBridge: 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:

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:

Webhook endpoint
// 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 "", 200

Verify 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:

  1. Threll computes HMAC-SHA256 of the payload with your signing secret.
  2. The hex digest is sent in the X-Threll-Signature header.
  3. Your server computes the same HMAC over the raw body it received.
  4. Compare the two digests with a constant-time comparison.
Signature verification
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:

StatusMeaning
PendingQueued and waiting to be sent.
SentDelivered successfully (HTTP 2xx response).
FailedDelivery failed — non-2xx response or network error.
CancelledDelivery 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

Next steps