Back to app

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 https and 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 deprecated release.* 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 for version.* 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": null or {} 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.

  1. Read the raw body before any JSON parsing. Re-serialized JSON will not match.
  2. Split the header on , and read t and every v1 (there are two during a secret rotation, one per secret).
  3. Reject the event if t is more than 5 minutes away from your clock (replay protection).
  4. Compute the HMAC and accept the event if it matches any v1, compared in constant time.
  5. 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 2xx status 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 the CallQA-Delivery header).
  • 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
  1. The answer carries the new secret (shown once) and previous_secret_expires_at.
  2. Until that date, every delivery carries two signatures, CallQA-Signature: t=...,v1=<new>,v1=<old>: receivers that accept any matching v1 (as above) keep working with either secret.
  3. 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.