> ## Documentation Index
> Fetch the complete documentation index at: https://docs.60db.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Click-to-Call Webhook Events

> Payloads, headers and signature verification for the events 60db posts to your callback_url

Pass `callback_url` and `callback_secret` to [Place Click-to-Call](/api-reference/click2call/place-call) and every event for that call is posted to your endpoint, signed with the secret you chose.

<Info>
  The webhook URL is **per call**, not per account. Nothing is registered in advance, so a new environment, a new tenant, or a tunnel URL that changes every morning all work with no configuration change on our side. The only thing you reuse is the secret.
</Info>

## Events

A call emits `call.answered` **only if the two parties were bridged**, and **exactly one terminal event, always**. A call nobody answered produces one event; a call that connects produces two.

| `event`                | `status_code`       | Terminal | Meaning                                                               |
| ---------------------- | ------------------- | -------- | --------------------------------------------------------------------- |
| `call.answered`        | `200`               | No       | Customer answered; the legs are bridged                               |
| `call.completed`       | `200`               | **Yes**  | The call was answered and has ended                                   |
| `call.not_answered`    | `480`, `486`, `603` | **Yes**  | Nobody answered, busy, or declined                                    |
| `call.failed`          | `404`, `403`        | **Yes**  | The call could not be placed                                          |
| `call.temporaryfailed` | `503`               | **Yes**  | Transient failure — retry as a **new** call with a new `reference_id` |

<Note>
  A decline and an unanswered call are not reliably distinguishable on this carrier route, so both usually report as `call.not_answered` with `hangup_cause: "NO_ANSWER"`.
</Note>

## Headers

| Header                     | Meaning                                         |
| -------------------------- | ----------------------------------------------- |
| `content-type`             | `application/json`                              |
| `user-agent`               | `qcall-webhook-relay/1`                         |
| `x-qcall-event-id`         | Stable id for this event. **Deduplicate on it** |
| `x-qcall-event`            | The event name, e.g. `call.completed`           |
| `x-qcall-call-id`          | The call this event belongs to                  |
| `x-qcall-delivery-attempt` | Attempt number, starting at `1`                 |
| `x-qcall-timestamp`        | Unix seconds, signed alongside the body         |
| `x-qcall-signature`        | `v1=<64 hex characters>`                        |

## Payload

<ResponseField name="event" type="string">
  One of the five event names above
</ResponseField>

<ResponseField name="event_id" type="string">
  Matches `x-qcall-event-id`
</ResponseField>

<ResponseField name="sequence" type="integer">
  `1` for the first event of a call, `2` for the second
</ResponseField>

<ResponseField name="status_code" type="integer">
  Status code for the event, per the table above
</ResponseField>

<ResponseField name="message" type="string">
  Human-readable summary, e.g. `Call completed.`
</ResponseField>

<ResponseField name="error" type="string | null">
  Mirrors `data.error`; `null` on success
</ResponseField>

<ResponseField name="data" type="object">
  <Expandable title="properties">
    <ResponseField name="call_id" type="string">The call id you got back when you placed the call</ResponseField>
    <ResponseField name="reference_id" type="string">Your idempotency key</ResponseField>
    <ResponseField name="did" type="string">Caller ID, digits without a leading `+`</ResponseField>
    <ResponseField name="agent_number" type="string">Digits without a leading `+`</ResponseField>
    <ResponseField name="customer_number" type="string">Digits without a leading `+`</ResponseField>
    <ResponseField name="call_type" type="string">Always `C2C`</ResponseField>
    <ResponseField name="duration" type="string">**String.** Seconds since the call was admitted, measured when the event was emitted</ResponseField>
    <ResponseField name="answer_duration" type="string | null">**String.** Talk time at emit; `null` before the call is answered</ResponseField>
    <ResponseField name="start_time" type="string">ISO 8601</ResponseField>
    <ResponseField name="answer_time" type="string | null">ISO 8601. When the legs were bridged</ResponseField>
    <ResponseField name="end_time" type="string">ISO 8601. Terminal events only</ResponseField>
    <ResponseField name="hangup_cause" type="string">Terminal events only. See the table below</ResponseField>
    <ResponseField name="leg" type="string">Failures only: `agent` or `customer`</ResponseField>
    <ResponseField name="metadata" type="object">Exactly what you sent; omitted if you sent none</ResponseField>
  </Expandable>
</ResponseField>

<ResponseField name="qcall" type="object">
  Added by 60db, naming the call in **your** terms — match on these rather than anything else in the envelope

  <Expandable title="properties">
    <ResponseField name="call_id" type="string">The `call_id` from your placement response</ResponseField>
    <ResponseField name="reference_id" type="string">Your `reference_id`</ResponseField>
    <ResponseField name="event_id" type="string">Same as the top-level `event_id`</ResponseField>
    <ResponseField name="delivered_at" type="string">ISO 8601 — when this delivery was attempted</ResponseField>
  </Expandable>
</ResponseField>

<Warning>
  `duration` and `answer_duration` are **strings**, and they mean different things here than on the REST call object. On a webhook, `duration` is seconds since admission and `answer_duration` is talk time. [`GET /dialer/click2call/{id}`](/api-reference/click2call/get-call) avoids the ambiguity with `total_seconds` and `talk_seconds` — both integers.
</Warning>

```json theme={null}
{
  "event_id": "c4d79b8d-5b2d-8ff1-84b0-5943d59b21a0",
  "sequence": 2,
  "status_code": 200,
  "message": "Call completed.",
  "error": null,
  "event": "call.completed",
  "data": {
    "call_id": "7d004ba2-565f-4ee2-9aa2-19523d8db7ae",
    "reference_id": "crm-task-91823",
    "did": "917900000000",
    "agent_number": "919812345678",
    "customer_number": "919876543210",
    "call_type": "C2C",
    "duration": "92",
    "answer_duration": "66",
    "start_time": "2026-09-23T14:11:27.134Z",
    "answer_time": "2026-09-23T14:11:53.392Z",
    "end_time": "2026-09-23T14:12:59.921Z",
    "hangup_cause": "NORMAL_CLEARING",
    "metadata": { "lead_id": "L-991" }
  },
  "qcall": {
    "call_id": "7d004ba2-565f-4ee2-9aa2-19523d8db7ae",
    "reference_id": "crm-task-91823",
    "event_id": "c4d79b8d-5b2d-8ff1-84b0-5943d59b21a0",
    "delivered_at": "2026-09-23T14:13:00.410Z"
  }
}
```

## Verifying the signature

```
signature = "v1=" + HMAC-SHA256(
    key     = your callback_secret, as raw UTF-8 bytes,
    message = x-qcall-timestamp + "." + raw_request_body
)
```

Two details decide whether this works:

1. **Use the secret exactly as you sent it.** There is no decoding step — the HMAC key is the string's own UTF-8 bytes.
2. **Use the raw body bytes.** Re-serialising parsed JSON changes whitespace and key order and breaks the signature. Capture the raw body before your JSON middleware touches it.

<Tabs>
  <Tab title="Node.js (Express)">
    ```javascript theme={null}
    const crypto = require('node:crypto');

    const SECRET = process.env.QCALL_WEBHOOK_SECRET; // the same string you sent

    function verify(rawBody, headers) {
      const ts = Number(headers['x-qcall-timestamp']);
      // Reject stale or future timestamps to blunt replay attempts.
      if (!Number.isInteger(ts) || Math.abs(Date.now() / 1000 - ts) > 300) return false;

      const got = String(headers['x-qcall-signature'] || '').replace(/^v1=/, '');
      if (!/^[0-9a-f]{64}$/.test(got)) return false;

      const expected = crypto.createHmac('sha256', Buffer.from(SECRET, 'utf8'))
        .update(String(ts)).update('.').update(rawBody).digest('hex');

      return crypto.timingSafeEqual(Buffer.from(got, 'hex'), Buffer.from(expected, 'hex'));
    }

    app.post('/webhooks/qcall',
      express.raw({ type: 'application/json' }),   // raw body, not express.json()
      (req, res) => {
        if (!verify(req.body, req.headers)) return res.sendStatus(401);

        const event = JSON.parse(req.body.toString('utf8'));
        if (seen.has(req.headers['x-qcall-event-id'])) return res.sendStatus(200);

        res.sendStatus(200);   // acknowledge first — you have 5 seconds
        enqueue(event);        // then do the work
      });
    ```
  </Tab>

  <Tab title="Python (Flask)">
    ```python theme={null}
    import hmac, hashlib, os, time
    from flask import request, abort

    SECRET = os.environ["QCALL_WEBHOOK_SECRET"].encode()

    def verify(raw_body: bytes, headers) -> bool:
        ts = headers.get("x-qcall-timestamp", "")
        if not ts.isdigit() or abs(time.time() - int(ts)) > 300:
            return False
        got = headers.get("x-qcall-signature", "").removeprefix("v1=")
        if len(got) != 64:
            return False
        expected = hmac.new(
            SECRET, ts.encode() + b"." + raw_body, hashlib.sha256
        ).hexdigest()
        return hmac.compare_digest(got, expected)

    @app.post("/webhooks/qcall")
    def qcall_webhook():
        if not verify(request.get_data(), request.headers):
            abort(401)

        event_id = request.headers["x-qcall-event-id"]
        if not already_seen(event_id):
            enqueue(request.get_json())   # process asynchronously

        return "", 200
    ```
  </Tab>
</Tabs>

Reject anything that fails verification with a `4xx`. Never process an unverified payload — the URL is public, and without the signature anyone who learns it could post a `call.completed` your system then acts on.

## Receiver requirements

* Respond `2xx` within **5 seconds**. Acknowledge first, process asynchronously — a slow handler is a failed delivery.
* Deduplicate on `x-qcall-event-id`; the same event can arrive more than once.
* Make processing idempotent.
* Use a constant-time comparison (`crypto.timingSafeEqual`, `hmac.compare_digest`).
* Check `x-qcall-timestamp` against your clock with a tolerance of a few minutes.
* Serve HTTPS on a publicly resolvable host. Private, loopback, link-local and CGNAT addresses are rejected, and the destination is re-validated — with the socket pinned to the addresses that passed — before **every** delivery, so DNS rebinding can't redirect a delivery after the fact.

## Retries

* Up to **6 attempts** per event, at least two minutes apart.
* Abandoned once the event is **24 hours** old.
* Retried on `408`, `425`, `429`, any `5xx`, and network or timeout errors.
* **Not** retried on other `4xx` — a `401` from a broken signature check is a permanent rejection.

Design for at-least-once delivery, out-of-order arrival, and the possibility that an event never arrives at all.

## When a delivery is in doubt

[`GET /dialer/click2call/{id}/events`](/api-reference/click2call/list-events) shows every event for a call, how many attempts were made, the status your endpoint returned and when it was delivered. [`GET /dialer/click2call/{id}?refresh=1`](/api-reference/click2call/get-call) is the state backstop — poll a call you have not heard about rather than polling every call on a timer.

## `hangup_cause` values

| Value                       | Appears on                               | Meaning                                                                                 |
| --------------------------- | ---------------------------------------- | --------------------------------------------------------------------------------------- |
| `NORMAL_CLEARING`           | `call.completed`                         | Ordinary hangup by either party                                                         |
| `MAX_DURATION`              | `call.completed`, `call.temporaryfailed` | `max_duration` reached                                                                  |
| `NO_ANSWER`                 | `call.not_answered`                      | Ring timeout, or a decline this route cannot distinguish from one                       |
| `USER_BUSY`                 | `call.not_answered`                      | Busy                                                                                    |
| `CALL_REJECTED`             | `call.not_answered`, `call.failed`       | Rejected                                                                                |
| `ORIGINATOR_CANCEL`         | `call.not_answered`                      | Agent hung up while the customer was ringing (`leg: "customer"`)                        |
| `UNALLOCATED_NUMBER`        | `call.failed`                            | Number not in service                                                                   |
| `NORMAL_CIRCUIT_CONGESTION` | `call.temporaryfailed`                   | Carrier congestion or an origination failure                                            |
| `MEDIA_FAILURE`             | `call.temporaryfailed`                   | Both answered but audio only flowed one way; the call was ended rather than left silent |
| `CARRIER_LINK_DOWN`         | `call.temporaryfailed`                   | The carrier line went down                                                              |
