# Webhooks

## Endpoints

Console: `/console/v2/projects/{project_id}/webhooks`. Machine:
`/v2/projects/{project_id}/webhooks` with `webhook:read` to list and
`webhook:write` to create, test or disable (the path project
must match the key's project).

`POST` with `{ "name", "url", "event_types" }` creates an endpoint. The URL
must be public HTTPS on port 443 with no credentials or fragment. Event types are
a closed set for this voice release: `call.completed`, `call.failed`,
`webhook.ping`, and the wildcards `call.*`, `webhook.*`, `*`. The `201`
contains `signing_secret` **once**; no route returns it again. Up to 10
endpoints per project.

`GET` lists endpoints (`include_disabled=true` to see disabled ones, which
keep `secret_hint` and `revision`). `DELETE .../{endpoint_id}` disables the
endpoint, bumps its revision, and cancels queued deliveries. `POST
.../{endpoint_id}/test` queues a signed `webhook.ping` and returns `202`
with `delivery_id` and `queued` — recorded, not yet sent. `GET
.../dead-letters` lists deliveries that exhausted their retries with
`event_type`, `endpoint_id`, `attempts`, `max_attempts`, and `last_error`;
payloads are not retained on terminal rows.

## Delivery and signature

Every delivery is an HTTP `POST` with a JSON body and these headers:

| Header | Value |
| --- | --- |
| `X-Webhook-Event` | the event name, e.g. `call.completed` |
| `X-Webhook-Timestamp` | unix seconds when the delivery was signed |
| `X-Webhook-Signature` | `t=<timestamp>,v1=<hex HMAC-SHA256>` |
| `X-Webhook-Delivery` | the delivery id; deduplicate retries on it |
| `X-Dvaarik-Event`, `X-Dvaarik-Signature` | legacy aliases; `sha256=<hex>` over the body alone — do not build new verifiers on them |

The signed bytes are `"<timestamp>.<raw body>"` and the key is the endpoint's
signing secret. Verify before parsing, compare in constant time, and reject a
timestamp outside your tolerance window (five minutes is usual) to stop replays.
Respond `2xx` promptly; anything else is retried with backoff and then
dead-lettered.

```js
import { createHmac, timingSafeEqual } from "node:crypto";

export function verifyDvaarikWebhook(req, secret, toleranceSeconds = 300) {
  const parts = Object.fromEntries(String(req.get("X-Webhook-Signature") ?? "").split(",").map((kv) => kv.split("=")));
  const timestamp = req.get("X-Webhook-Timestamp");
  if (!parts.t || !parts.v1 || parts.t !== timestamp) return false;
  if (Math.abs(Date.now() / 1000 - Number(timestamp)) > toleranceSeconds) return false;
  const expected = createHmac("sha256", secret).update(`${timestamp}.`).update(req.body).digest("hex");
  const a = Buffer.from(expected, "hex"), b = Buffer.from(parts.v1, "hex");
  return a.length === b.length && timingSafeEqual(a, b);
}
```

```python
import hmac, hashlib, time

def verify_dvaarik_webhook(headers, raw_body: bytes, secret: str, tolerance_s=300) -> bool:
    parts = dict(kv.split("=", 1) for kv in headers.get("X-Webhook-Signature", "").split(",") if "=" in kv)
    timestamp = headers.get("X-Webhook-Timestamp", "")
    if not parts.get("t") or not parts.get("v1") or parts["t"] != timestamp:
        return False
    if abs(time.time() - int(timestamp)) > tolerance_s:
        return False
    expected = hmac.new(secret.encode(), timestamp.encode() + b"." + raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, parts["v1"])
```

The agent-level `webhook_url` (set on the agent) is signed the same way with
the agent's own `webhook_secret`.

---

Source: https://developers.dvaarik.com/docs/webhooks · every page as one file: https://developers.dvaarik.com/docs.md
