# 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](alerts.md).

## Create an endpoint

In the dashboard (**Developer > Webhooks**, then **+ Webhook**), or with an `admin` key:

```bash
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"}'
```

```json
{
  "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.

```bash
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](versioning.md)). 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`:

```json
{"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](environments.md)); 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:

```json
{
  "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:

```json
{
  "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:

```json
{
  "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:

```json
{
  "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`:

```json
{
  "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](billing.md#low-balance-warning)). 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

```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`](../../examples/webhooks/python/verify_webhook.py).

### Node.js

```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`](../../examples/webhooks/node/verify-webhook.js).

### Go

```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`](../../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:

```bash
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:

```bash
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:

```bash
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](alerts.md). 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.
