Webhooks
CallQA sends an HTTPS POST to your endpoint when a call or a run finishes, fails, or when a
score drops. Use webhooks instead of polling, and to pipe results into your own systems. To
warn people in Slack or by email, you do not need a webhook: use alerts.
Create an endpoint
In the dashboard (Developer > Webhooks, then + Webhook), or with an admin key:
curl -sS -X POST https://app.callqa.dev/api/v1/webhooks \
-H "Authorization: Bearer $CALLQA_ADMIN_KEY" \
-H "Content-Type: application/json" \
-d '{"url": "https://example.com/hooks/callqa", "events": ["suite_run.completed", "call.failed"], "description": "Prod alerts"}'
{
"id": "4a1c...", "url": "https://example.com/hooks/callqa",
"events": ["suite_run.completed", "call.failed"], "description": "Prod alerts",
"enabled": true, "created_at": "2026-09-26T10:00:00Z",
"filters": {"scenario_ids": [], "tags": [], "environments": []},
"previous_secret_expires_at": null,
"secret": "whsec_Qm9..."
}
The signing secret is returned once. Store it with your receiver.
- The URL must use
httpsand resolve to public IP addresses (no private, loopback or link-local addresses). Redirects are not followed. - Unknown event names are refused (
400,code: unknown_event), and at least one event is required. ["*"]subscribes to every event, including future ones (but not the deprecatedrelease.*names, see below).- Change the URL, the events or the description, or pause the endpoint, with
PATCH /webhooks/{id}(only the fields you send change). A paused endpoint ("enabled": false) receives nothing; events are not queued for it.
curl -sS -X PATCH "https://app.callqa.dev/api/v1/webhooks/$WEBHOOK" \
-H "Authorization: Bearer $CALLQA_ADMIN_KEY" \
-H "Content-Type: application/json" \
-d '{"events": ["suite_run.failed", "wallet.low_balance"], "enabled": true}'
| Request | Scope |
|---|---|
GET /webhooks, GET /webhooks/{id} |
read |
POST /webhooks |
admin |
PATCH /webhooks/{id} |
admin |
POST /webhooks/{id}/rotate-secret |
admin |
DELETE /webhooks/{id} |
admin |
GET /webhooks/{id}/deliveries |
read |
POST /webhooks/{id}/test |
admin |
Events
| Event | Sent when | data |
|---|---|---|
call.completed |
A call reached a final state (completed or error) | call |
call.failed |
A call ended with verdict fail or error (sent in addition to call.completed) |
call |
call.blocked |
A call was not placed (blocked_policy or blocked_insufficient_credit) |
call |
call.regressed |
A test case passed on its previous call in the same environment and now fails (fail or error) |
call + previous_verdict |
call.recovered |
A test case failed on its previous call in the same environment and now passes | call + previous_verdict |
suite_run.started |
The first call of a run was dialed | run |
suite_run.completed |
A run completed | run |
suite_run.failed |
A run completed with verdict fail (sent in addition to suite_run.completed) |
run |
score.dropped |
A run's score is lower than the previous completed run of the same scenario in the same environment | run + previous_score |
version.created |
A version was created in an environment (dashboard, API, or a run naming a new version) | version |
version.promoted |
A version was promoted to another environment | version + from_environment |
wallet.low_balance |
The wallet balance fell under your threshold (see below) | wallet |
ping |
Sent only by POST /webhooks/{id}/test |
{"message": "CallQA test event"} |
A canceled run does not send suite_run.completed. Event names and payloads keep the
older name of a run, "suite run" (suite_run.*, suite_run_id): a suite run is a run, the
same object as /runs.
release.created and release.promoted are the deprecated names of version.created and
version.promoted (see Versioning). An endpoint that lists them explicitly
(and not the new name, nor *) still receives the event under the old name, with the old
payload keys (release_id, version, released_at) followed by the new ones. Switch to the
version.* names.
Filter an endpoint by scenario, tag or environment
Rather than one endpoint per scenario, give an endpoint a scope with filters:
{"url": "https://example.com/hooks/callqa", "events": ["call.regressed", "suite_run.failed"],
"filters": {"environments": ["production"], "tags": ["checkout"], "scenario_ids": []}}
- Each non-empty list must match: any value inside a list, every list that has values.
scenario_ids: the run's scenario.tags: tags of the scenario or of the run (the ones you send when you trigger it), or of the version forversion.*events.environments: slug, name or id (see Environments); answers list slugs.- A scenario, tag or environment that does not exist in your organization is refused
(
400,code: invalid_filters). - Call, run and version events are filtered (a version has no scenario, so a scenario filter
holds its events back); organization events (
wallet.low_balance) always go through. PATCH /webhooks/{id}with"filters": nullor{}removes the scope. In the dashboard, the scope is under "Only some scenarios, tags or environments" on the webhook form.
Payload
Every delivery has the same envelope:
{
"id": "3f6e2b1a-9c1d-4c55-9a0e-1b2c3d4e5f60",
"type": "suite_run.failed",
"created": 1790000000,
"data": { }
}
id is unique per event and endpoint, and stays the same across retries: use it to
deduplicate. created is a Unix timestamp (seconds).
data for call.* events:
{
"call_id": "0d7e...",
"suite_run_id": "7b0c...",
"status": "completed",
"verdict": "fail",
"score": 34,
"block_reason": null,
"title": "Asks for an unavailable slot",
"target": "+14155550101",
"latency_p95_ms": 1840,
"cost_cents": 0,
"variant_id": "5a1f...",
"scenario_id": "9c2d...",
"environment": "production",
"version": "v2.4.0",
"release": "v2.4.0",
"tags": ["smoke"],
"url": "https://app.callqa.dev/o/acme/calls/0d7e.../"
}
data for suite_run.* and score.dropped events:
{
"suite_run_id": "7b0c...",
"number": 247,
"scenario": "Book appointment - edge case",
"status": "completed",
"verdict": "fail",
"score": 58,
"total": 6, "passed": 5, "failed": 1, "warned": 0, "errored": 0, "blocked": 0,
"cost_cents": 126,
"scenario_id": "9c2d...",
"environment": "production",
"version": "v2.4.0",
"release": "v2.4.0",
"tags": ["smoke"],
"url": "https://app.callqa.dev/o/acme/runs/7b0c.../",
"previous_score": 87
}
previous_score is only present on score.dropped. scenario is null for replay runs.
environment is the environment slug. version is the version the run names, else the one
live in its environment when the run started; release is the same value under its
deprecated name. call.regressed and call.recovered add previous_verdict.
data for version.* events:
{
"version_id": "e41b...",
"label": "v2.4.0",
"environment": "production",
"deployed_at": "2026-09-27T09:12:00+00:00",
"notes": "calendar tool",
"git_ref": "main",
"git_sha": "9f1c2ab",
"source_url": null,
"target": null,
"tags": ["checkout"],
"url": "https://app.callqa.dev/o/acme/versions/",
"from_environment": "staging"
}
from_environment is only present on version.promoted. target is the phone number the
version concerns, null when it concerns every number of the environment.
data for wallet.low_balance:
{
"balance_cents": 1500,
"held_cents": 400,
"available_cents": 1100,
"threshold_cents": 2000,
"currency": "usd",
"url": "https://app.callqa.dev/o/acme/billing/"
}
It is sent when a charge takes the wallet balance under the threshold of your organization
($20 by default; change it or turn it off with PATCH /billing/settings, see
Billing). It is sent once per crossing: not again until a
top-up brings the balance back to the threshold or above, and never twice within 6 hours.
Headers
| Header | Value |
|---|---|
Content-Type |
application/json |
User-Agent |
CallQA-Webhooks/1 |
CallQA-Event |
the event type, e.g. suite_run.failed |
CallQA-Delivery |
the event id |
CallQA-Signature |
t=<unix seconds>,v1=<hex signature>, with a second v1 during a secret rotation |
Verify the signature
The signature is an HMAC-SHA256, keyed with your endpoint secret, of the timestamp, a dot, and the raw request body:
v1 = hex( HMAC_SHA256( secret, "<t>" + "." + <raw body bytes> ) )
This is the same scheme as Stripe webhooks, so existing verification code adapts easily.
- Read the raw body before any JSON parsing. Re-serialized JSON will not match.
- Split the header on
,and readtand everyv1(there are two during a secret rotation, one per secret). - Reject the event if
tis more than 5 minutes away from your clock (replay protection). - Compute the HMAC and accept the event if it matches any
v1, compared in constant time. - Parse the JSON and deduplicate on
id.
Python
import hashlib, hmac, json, time
def verify(secret: str, header: str, body: bytes, tolerance_s: int = 300) -> dict:
ts, signatures = None, []
for part in header.split(","):
key, _, value = part.strip().partition("=")
if key == "t":
ts = value
elif key == "v1":
signatures.append(value)
if ts is None or abs(time.time() - int(ts)) > tolerance_s:
raise ValueError("timestamp outside the tolerance window")
expected = hmac.new(secret.encode(), ts.encode() + b"." + body, hashlib.sha256).hexdigest()
if not any(hmac.compare_digest(expected, sig) for sig in signatures):
raise ValueError("bad signature")
return json.loads(body)
With Flask, use request.get_data(); with Django, request.body; with FastAPI,
await request.body(). Complete receiver:
examples/webhooks/python/verify_webhook.py.
Node.js
const crypto = require("node:crypto");
function verify(secret, header, rawBody, toleranceS = 300) {
let t = null;
const signatures = [];
for (const part of header.split(",")) {
const [key, value] = part.trim().split(/=(.*)/s);
if (key === "t") t = value;
else if (key === "v1") signatures.push(Buffer.from(value, "hex"));
}
if (t === null || Math.abs(Date.now() / 1000 - Number(t)) > toleranceS) throw new Error("too old");
const expected = crypto.createHmac("sha256", secret)
.update(Buffer.concat([Buffer.from(`${t}.`), rawBody])).digest();
const ok = signatures.some((g) => g.length === expected.length && crypto.timingSafeEqual(g, expected));
if (!ok) throw new Error("bad signature");
return JSON.parse(rawBody.toString("utf8"));
}
With Express, mount express.raw({ type: "application/json" }) on the webhook route so that
req.body is the raw Buffer. Complete receiver:
examples/webhooks/node/verify-webhook.js.
Go
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(ts + "."))
mac.Write(body) // raw body bytes
expected := mac.Sum(nil)
given, _ := hex.DecodeString(v1)
ok := hmac.Equal(given, expected) // accept if any v1 of the header matches
Complete receiver: examples/webhooks/go/verify_webhook.go.
The Python and Node receivers are tested against the server's own signing code.
Delivery and retries
- Answer with any
2xxstatus within 10 seconds. Do the slow work asynchronously. - Any other status, a timeout or a connection error is retried after 1 min, 5 min, 30 min,
2 h and 6 h (6 attempts in total). After that the delivery is marked
failed. - Deliveries are not guaranteed to arrive in order, and the same event can arrive more than
once. Deduplicate on
id(or theCallQA-Deliveryheader). - The delivery log shows every attempt:
curl -sS -H "Authorization: Bearer $CALLQA_API_KEY" \
"https://app.callqa.dev/api/v1/webhooks/$WEBHOOK/deliveries" \
| jq '.items[] | {event_type, status, attempts, last_status_code, last_error}'
status is pending, success or failed.
Test an endpoint
POST /webhooks/{id}/test sends a signed ping event right away and returns the delivery,
with the status code your endpoint answered:
curl -sS -X POST -H "Authorization: Bearer $CALLQA_ADMIN_KEY" \
"https://app.callqa.dev/api/v1/webhooks/$WEBHOOK/test" | jq '{status, last_status_code, last_error}'
Rotate the signing secret
Rotate the secret if it leaked, or as routine hygiene, without losing a single event:
curl -sS -X POST "https://app.callqa.dev/api/v1/webhooks/$WEBHOOK/rotate-secret" \
-H "Authorization: Bearer $CALLQA_ADMIN_KEY" \
-H "Content-Type: application/json" \
-d '{"grace_period_hours": 24}' | jq -r .secret
- The answer carries the new
secret(shown once) andprevious_secret_expires_at. - Until that date, every delivery carries two signatures,
CallQA-Signature: t=...,v1=<new>,v1=<old>: receivers that accept any matchingv1(as above) keep working with either secret. - Deploy the new secret to your receiver before the grace period ends. After it, only the new secret signs.
grace_period_hours is 0 to 168 (default 24); 0 switches at once. In the dashboard:
Developer > Webhooks > Edit > Rotate secret.
Slack and PagerDuty
For Slack and email there is a built-in channel: see Alerts. For PagerDuty or
another tool, webhook payloads are CallQA JSON: put a small relay in between (a serverless
function is enough), verify the signature, then post to the PagerDuty Events API. Use
suite_run.failed and score.dropped for incidents, and suite_run.completed for reports.