> ## 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

> Bridge an agent and a customer on a real phone call with one server-to-server request — no SIP client, no browser, no microphone

## Overview

**Click-to-Call** places a two-leg phone call from a single authenticated HTTPS request. It rings your **agent** first on your own rented number, and only once the agent answers does it ring the **customer** — then it bridges the two. Both legs present your DID as the caller ID.

There is no WebRTC, no SIP registration and no microphone involved, which is the point: it works from a phone, from a laptop with no headset, and for an agent who isn't logged into your app at all.

```
your backend ──POST /dialer/click2call──► 60db
                                            │
                                            ├─ 1. rings the AGENT on your DID
                                            ├─ 2. agent answers → rings the CUSTOMER
                                            ├─ 3. bridges the two legs
                                            │
                        ◄──webhook: call.answered────┤   (only if they were bridged)
                        ◄──webhook: terminal event───┘   (exactly one, always)
```

<Info>
  **This is not the same endpoint as [Reserve Call](/api-reference/dialer/reserve-call).** `POST /dialer/calls` reserves an id for a call your *own SIP client* places. Click-to-Call dials both phones itself. They share your trunk, your numbers, your wallet and the carrier-health gate — nothing else.
</Info>

<CardGroup cols={2}>
  <Card title="One request, two phones" icon="phone-arrow-up-right">
    No softphone or SIP stack on your side
  </Card>

  <Card title="Agent-first dialling" icon="user-check">
    Your agent is committed before the customer's phone rings
  </Card>

  <Card title="Signed webhooks" icon="shield-check">
    Every call event relayed to your URL, HMAC-signed with your own secret
  </Card>

  <Card title="Per-second billing" icon="receipt">
    ₹0.30/min (\$0.003/min) on talk time only — unanswered calls are free
  </Card>
</CardGroup>

## Prerequisites

<Steps>
  <Step title="Trunk setup (Gate T)">
    Dashboard → Dialer → Trunks. Without a provisioned trunk every click-to-call endpoint returns **409 `DIALER_NOT_PROVISIONED`**.
  </Step>

  <Step title="At least one rented number">
    The `did` you dial out on must be assigned to your workspace and to the trunk you're calling over. Buy one via `POST /dialer/pool/allocate` (requires KYC approval, Gate K).
  </Step>

  <Step title="A positive wallet balance">
    A balance of `0` or less refuses the *next* call with **402 `RECHARGE_REQUIRED`**. A call already in progress always finishes and is always billed.
  </Step>

  <Step title="A public HTTPS webhook (optional, recommended)">
    Pass `callback_url` + `callback_secret` on each call to receive its events. Without them, poll `GET /dialer/click2call/{id}` instead.
  </Step>
</Steps>

## Quickstart

<Tabs>
  <Tab title="cURL">
    ```bash theme={null}
    # Place the call — rings the agent first, then the customer
    curl -X POST https://api.60db.ai/dialer/click2call \
      -H "Authorization: Bearer $SIXTYDB_API_KEY" \
      -H "Content-Type: application/json" \
      -d '{
        "agent_number": "9812345678",
        "customer_number": "9876543210",
        "callback_url": "https://your-app.example.com/webhooks/qcall",
        "callback_secret": "a-long-random-string-you-choose",
        "metadata": { "lead_id": "L-991" }
      }'

    # Poll the outcome (or just wait for the webhook)
    curl "https://api.60db.ai/dialer/click2call/CALL_ID?refresh=1" \
      -H "Authorization: Bearer $SIXTYDB_API_KEY"
    ```
  </Tab>

  <Tab title="Node.js">
    ```javascript theme={null}
    const res = await fetch('https://api.60db.ai/dialer/click2call', {
      method: 'POST',
      headers: {
        Authorization: `Bearer ${process.env.SIXTYDB_API_KEY}`,
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({
        agent_number: '9812345678',
        customer_number: '9876543210',
        callback_url: 'https://your-app.example.com/webhooks/qcall',
        callback_secret: process.env.QCALL_WEBHOOK_SECRET,
        metadata: { lead_id: 'L-991' },
      }),
    });

    const { data } = await res.json();
    // res.status === 201 → a call was placed
    // res.status === 200 → this reference_id was already used; nothing new was dialled
    console.log(data.call.call_id, data.call.state, data.placed, data.replayed);
    ```
  </Tab>

  <Tab title="Python">
    ```python theme={null}
    import os, requests

    res = requests.post(
        "https://api.60db.ai/dialer/click2call",
        headers={"Authorization": f"Bearer {os.environ['SIXTYDB_API_KEY']}"},
        json={
            "agent_number": "9812345678",
            "customer_number": "9876543210",
            "callback_url": "https://your-app.example.com/webhooks/qcall",
            "callback_secret": os.environ["QCALL_WEBHOOK_SECRET"],
            "metadata": {"lead_id": "L-991"},
        },
    )

    body = res.json()["data"]
    # res.status_code == 201 → placed;  200 → replayed, nothing new dialled
    print(body["call"]["call_id"], body["call"]["state"], body["placed"])
    ```
  </Tab>
</Tabs>

`outbound_trunk_id` and `did` are optional: they default to your workspace's active trunk and its saved caller ID, so the common case is a two-field request.

## Phone numbers

`agent_number`, `customer_number` and `did` accept Indian numbers in any of these forms. Spaces, hyphens, dots and round brackets are stripped before validation.

| Form                  | Example         |
| --------------------- | --------------- |
| E.164                 | `+919812345678` |
| Country code, no plus | `919812345678`  |
| 10-digit national     | `9812345678`    |

The number must normalise to `+91` followed by ten digits whose first digit is `2`–`9`. Everything is stored and returned as **`+E.164`** — send `9812345678`, read back `+919812345678`.

* `agent_number` and `customer_number` must differ (**400 `SAME_NUMBER`**) — there'd be nobody to bridge.
* `did` must be a number your workspace owns, on the trunk you're calling over (**403 `CALLER_ID_NOT_OWNED`**).
* There is no allowlist of agent numbers. Any number passing the rules above can be the agent.

## Call lifecycle

The `state` field on a call is the coarse state your UI renders:

| `state`     | Meaning                                                                    | `call_done` |
| ----------- | -------------------------------------------------------------------------- | ----------- |
| `initiated` | Admitted; dialling the agent, then the customer                            | `false`     |
| `bridged`   | Both legs are up and talking                                               | `false`     |
| `completed` | Was answered, and has ended                                                | `true`      |
| `failed`    | Ended without a conversation (nobody answered, or it could not be placed)  | `true`      |
| `rejected`  | The platform refused the placement — nothing was dialled                   | `true`      |
| `unknown`   | The platform never confirmed the placement; reconciliation will resolve it | `false`     |
| `pending`   | Reserved locally, not yet sent upstream (transient)                        | `false`     |

When a call settles, `terminal_event` names *how*, and `hangup_cause` and `leg` say *why* and *on which side*:

| `terminal_event`       | Meaning                                     |
| ---------------------- | ------------------------------------------- |
| `call.completed`       | Answered and ended normally                 |
| `call.not_answered`    | Nobody answered, busy, or declined          |
| `call.failed`          | The call could not be placed                |
| `call.temporaryfailed` | Transient failure — retry as a **new** call |

<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>

## Idempotency: `reference_id`

`reference_id` is the idempotency key, unique per trunk. **Omit it and one is minted for you** (`qc2c-<uuid>`), which is the right default unless you're retrying.

Supply your own (a CRM task id, a ticket id) when you need a request to be safely repeatable:

| You send                                              | Result                                                                                                 |
| ----------------------------------------------------- | ------------------------------------------------------------------------------------------------------ |
| A new `reference_id`                                  | **201** — a call is placed                                                                             |
| The same `reference_id` while that call is still live | **200** — the original call is returned, `placed: false`, `replayed: true`. **Nothing new is dialled** |
| The same `reference_id` after that call has finished  | **409 `REFERENCE_ALREADY_USED`**                                                                       |
| A `reference_id` belonging to another workspace       | **404 `REFERENCE_NOT_OWNED`**                                                                          |

<Warning>
  **A reference is permanent, and a failed call still consumes it.** To try again after a call that rang out or failed, place a **new** call with a **new** `reference_id`. Re-sending the old one replays a dead record and dials nothing — this is the most common cause of "the API said success but the phone never rang".

  The exception is a refusal that provably placed no call — rate limiting, no free channel, carrier down, or a misconfigured server. Those leave the reference unconsumed, so retry with the **same** one.
</Warning>

Allowed characters: letters, digits, `.`, `_`, `:`, `-`; 1–128 characters (**400 `INVALID_REFERENCE`** otherwise).

## Webhooks

Pass `callback_url` and `callback_secret` on a call and every event for that call is forwarded to your endpoint, signed with the secret you chose.

<Info>
  The secret is **per call and yours to pick** — 16 to 256 characters of anything random. It is required whenever you send a `callback_url`: an unsigned webhook is one your receiver cannot tell from a forgery. It is encrypted at rest and never shown again; `callback_secret_last4` on the call tells you which one we hold.
</Info>

### Headers on every delivery

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

### 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: use the secret **as you sent it** (no decoding step), and 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'));
        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=")
        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)
        enqueue(request.get_json())   # process asynchronously
        return "", 200
    ```
  </Tab>
</Tabs>

Reject anything that fails verification with a `4xx`. Never process an unverified payload.

### 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 |
| ---------------------- | ------------------- | -------- |
| `call.answered`        | `200`               | No       |
| `call.completed`       | `200`               | Yes      |
| `call.not_answered`    | `480`, `486`, `603` | Yes      |
| `call.failed`          | `404`, `403`        | Yes      |
| `call.temporaryfailed` | `503`               | Yes      |

### Payload

```json theme={null}
{
  "event_id": "610c57d4-ab26-80e6-812a-20cd77bd2959",
  "sequence": 2,
  "status_code": 200,
  "message": "Call completed.",
  "error": null,
  "event": "call.completed",
  "data": {
    "call_id": "7d004ba2-565f-4ee2-9aa2-19523d8db7ae",
    "reference_id": "qc2c-8f1c1e2a-...",
    "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": "qc2c-8f1c1e2a-...",
    "event_id": "610c57d4-ab26-80e6-812a-20cd77bd2959",
    "delivered_at": "2026-09-23T14:13:00.410Z"
  }
}
```

The `qcall` block is added by 60db and names the call in **your** terms — the ids you got back from `POST /dialer/click2call`. Match on those rather than on anything else in the envelope.

<Warning>
  `duration` and `answer_duration` are **strings**, and their meanings differ by surface. On a webhook, `duration` is seconds since the call was admitted and `answer_duration` is talk time. The REST call object avoids the ambiguity with `total_seconds` and `talk_seconds` — both integers.
</Warning>

### Receiver requirements

* Respond `2xx` within **5 seconds** — acknowledge first, process asynchronously.
* Deduplicate on `x-qcall-event-id`; the same event can arrive more than once.
* Make processing idempotent.
* 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 validated addresses) 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. Any other `4xx` is treated as a permanent rejection and not retried.

Design for at-least-once delivery, out-of-order arrival, and the possibility that an event never arrives at all — then use [`GET /dialer/click2call/{id}/events`](/api-reference/click2call/list-events) to see exactly what was sent and what your endpoint replied, and `?refresh=1` on a single call as the backstop.

## Recording

Pass `record: true` to ask for audio. Recording is produced by the call platform and uploaded **after** the call ends, so it appears on the call a little later than the terminal event.

```bash theme={null}
# 1. Is it there yet? refresh=1 polls upstream for a finished call with no recording yet
curl "https://api.60db.ai/dialer/click2call/CALL_ID?refresh=1" \
  -H "Authorization: Bearer $SIXTYDB_API_KEY"
# → "record_requested": true, "recording_available": true, "recording_id": "..."

# 2. Mint a short-lived playback URL (POST — it mints a credential)
curl -X POST https://api.60db.ai/dialer/click2call/CALL_ID/recording \
  -H "Authorization: Bearer $SIXTYDB_API_KEY"
```

`record_requested` and `recording_available` are two separate facts on purpose: a call that asked for audio and has none is a different thing from a call that never asked. The playback URL expires in minutes and is never stored — mint a new one when you need it.

## Reconciliation

Webhooks are the primary mechanism; polling is the fallback. **Poll a call you haven't heard about, rather than polling every call on a timer.**

`GET /dialer/click2call/{id}` reads the stored call cheaply. Add `?refresh=1` and it asks the platform for the authoritative state first — which is what repairs a call whose terminal webhook never arrived, and what picks up a recording that finished uploading.

The `{id}` accepts either the `call_id` **or** your `reference_id`, so you can reconcile straight from your own records without storing ours.

Behind the scenes a reconciler runs every few minutes to replay unapplied events, resolve placements whose outcome was never confirmed, poll calls that have outlived all their timeouts, and retry undelivered relays. A call left in `unknown` normally resolves itself within that window.

## Pricing

| Service                   | Cost                                         |
| ------------------------- | -------------------------------------------- |
| Talk time                 | ₹0.30/min (\$0.003/min), prorated per second |
| Unanswered / failed calls | Free — zero talk time is never charged       |
| Ring time                 | Free — only bridged time counts              |

Each call carries its own cost on the call object, so you never have to reconcile against a separate ledger by timestamp:

| `billing_status` | `cost_usd`       | Meaning                                          |
| ---------------- | ---------------- | ------------------------------------------------ |
| `"billed"`       | the amount taken | Charged                                          |
| `null`           | `null`           | Nothing owed (never answered)                    |
| `"failed"`       | `null`           | The charge could not be applied; needs attention |

A call that starts always finishes and is always billed, even if it takes the wallet negative. The balance check gates the **next** call, not the one in flight.

## Limits

| Limit                               | Value                                                   |
| ----------------------------------- | ------------------------------------------------------- |
| New calls per minute, per caller    | **20** (429 `C2C_RATE_LIMITED`)                         |
| Agent ring timeout                  | 5–60 s, default 30                                      |
| Customer ring timeout               | 5–60 s, default 45                                      |
| Talk time per call (`max_duration`) | 30–7200 s, default 3600                                 |
| `metadata`                          | JSON object, ≤ 2048 bytes serialised, no NUL characters |
| `reference_id`                      | 1–128 chars of `A-Za-z0-9._:-`                          |
| `callback_secret`                   | 16–256 characters                                       |
| Webhook response deadline           | 5 s                                                     |
| Webhook delivery attempts           | 6, abandoned after 24 h                                 |

The per-minute limit is keyed to the **caller**, not the IP — a shared office NAT won't throttle a whole floor.

## Error codes

Every failure returns the same envelope. Branch on `code`, never on `message`.

```json theme={null}
{
  "success": false,
  "message": "All call channels are in use right now. Try again in a moment — nothing is wrong with your number or balance.",
  "code": "no_free_channel",
  "request_id": "da497749-d40d-4e99-9b4b-86019f17c371",
  "retry_after": 30
}
```

`request_id` is present on refusals that came from the call platform — **log it**, it is the handle 60db support traces a request on. `retry_after` accompanies `429` and `503` and mirrors the `Retry-After` header, in seconds.

### Your request

| Status | Code                      | Meaning                                                   |
| ------ | ------------------------- | --------------------------------------------------------- |
| 400    | `INVALID_NUMBER`          | A number isn't a valid Indian mobile number               |
| 400    | `SAME_NUMBER`             | `agent_number` and `customer_number` are the same         |
| 400    | `INVALID_RING_TIMEOUT`    | Ring timeout outside 5–60 s                               |
| 400    | `INVALID_MAX_DURATION`    | `max_duration` outside 30–7200 s                          |
| 400    | `INVALID_RECORD`          | `record` isn't a boolean                                  |
| 400    | `INVALID_METADATA`        | Not an object, over 2 KiB, or contains NUL                |
| 400    | `INVALID_REFERENCE`       | `reference_id` fails the character/length rule            |
| 400    | `INVALID_CALLBACK_URL`    | Not HTTPS, not publicly resolvable, or otherwise rejected |
| 400    | `INVALID_CALLBACK_SECRET` | Missing, or outside 16–256 characters                     |
| 403    | `CALLER_ID_NOT_OWNED`     | `did` isn't assigned to this workspace                    |
| 404    | `CALL_NOT_FOUND`          | No such call in this workspace                            |
| 404    | `RECORDING_UNAVAILABLE`   | Not recorded, or the recording hasn't been produced       |
| 404    | `RECORDING_NOT_OWNED`     | The recording belongs to another user                     |
| 404    | `REFERENCE_NOT_OWNED`     | That reference belongs to another workspace               |
| 409    | `REFERENCE_ALREADY_USED`  | The reference is spent — place a new call                 |

### Your account

| Status | Code                               | Meaning                                                                                         |
| ------ | ---------------------------------- | ----------------------------------------------------------------------------------------------- |
| 402    | `RECHARGE_REQUIRED`                | Wallet balance is `0` or less                                                                   |
| 409    | `DIALER_NOT_PROVISIONED`           | Trunk not set up (Gate T)                                                                       |
| 429    | `C2C_RATE_LIMITED`                 | More than 20 calls placed in the last minute                                                    |
| 429    | `rate_limited` / `no_free_channel` | Platform origination limit or capacity. Honour `Retry-After`, retry the **same** `reference_id` |
| 403    | `destination_not_allowed`          | A number is outside your account's permitted prefixes                                           |
| 403    | `agent_not_allowed`                | An operator restricted which agent numbers this trunk may dial                                  |

### Upstream and server-side

| Status | Code                                      | Meaning                                                                                                               |
| ------ | ----------------------------------------- | --------------------------------------------------------------------------------------------------------------------- |
| 503    | `CARRIER_LINE_DOWN`                       | The carrier line is down. Your number and balance are fine                                                            |
| 503    | `carrier_down` / `click2call_unavailable` | Platform-side outage. Retry after `Retry-After`                                                                       |
| 503    | `C2C_NOT_CONFIGURED`                      | Click-to-call isn't finished being set up on the server                                                               |
| 503    | `C2C_CALLBACK_UNREACHABLE`                | The server's own results address isn't reachable from the internet                                                    |
| 504    | `C2C_OUTCOME_UNKNOWN`                     | The platform never answered — we cannot tell whether the call was placed. **Check the agent's phone before retrying** |
| 502    | `C2C_UPSTREAM_ERROR`                      | Anything else the platform refused                                                                                    |

<Note>
  `429` and `503` carry a `Retry-After` header in seconds. A refused request never places a call and never consumes its `reference_id`, so those are safe to retry with the same one.
</Note>

## Integration checklist

**Setup**

* [ ] Trunk provisioned, at least one number rented, wallet funded.
* [ ] API key in a secret manager. Server-side only — never in a browser or mobile app.
* [ ] A `callback_secret` per integration, generated randomly and stored alongside the key.

**Placing calls**

* [ ] Store the returned `call_id` against your own record.
* [ ] Treat **201** and **200** as success, and read `placed` / `replayed` — `200` means nothing new was dialled.
* [ ] After a failed call, place a new one with a **new** `reference_id`.
* [ ] Back off on `429`/`503` using `Retry-After`, retrying the **same** `reference_id`.

**Receiving webhooks**

* [ ] Capture the **raw body** before JSON parsing.
* [ ] Verify `x-qcall-signature` in constant time; reject failures with `4xx`.
* [ ] Check `x-qcall-timestamp` freshness.
* [ ] Deduplicate on `x-qcall-event-id`.
* [ ] Return `2xx` within 5 s and process asynchronously.
* [ ] Handle all five events; expect exactly one terminal event per call.
* [ ] Remember the webhook's `duration` / `answer_duration` are **strings**.

**Resilience**

* [ ] Reconcile with `?refresh=1` for any call with no terminal event after its maximum life.
* [ ] Use `GET /dialer/click2call/{id}/events` when a delivery is in doubt.
* [ ] Alert on repeated `503` and on `429` bursts.

## API Reference

<CardGroup cols={3}>
  <Card title="Place Call" icon="phone-arrow-up-right" href="/api-reference/click2call/place-call">
    Ring the agent, then the customer
  </Card>

  <Card title="Get Call" icon="phone-flip" href="/api-reference/click2call/get-call">
    State, outcome and cost
  </Card>

  <Card title="List Calls" icon="list" href="/api-reference/click2call/list-calls">
    Click-to-call history
  </Card>

  <Card title="List Events" icon="clock-rotate-left" href="/api-reference/click2call/list-events">
    Events and delivery attempts
  </Card>

  <Card title="Recording URL" icon="play" href="/api-reference/click2call/recording-url">
    Short-lived playback link
  </Card>

  <Card title="Get Config" icon="circle-info" href="/api-reference/click2call/get-config">
    Readiness and limits
  </Card>

  <Card title="Webhook Events" icon="webhook" href="/api-reference/click2call/webhook-events">
    Payloads, headers, signatures
  </Card>
</CardGroup>
