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

# Receiving call outcomes

> Register a webhook endpoint and verify HMAC-signed event deliveries, with working Node.js and Python receivers.

The fastest way to react to calls is to let us push events to you. This guide registers a webhook endpoint, verifies signatures, and handles the three v1 events: `call.completed`, `call.recording.ready`, and `call_request.completed`.

## 1. Register an endpoint

Requires the `webhooks:manage` scope. Your URL must be HTTPS and publicly reachable.

```bash theme={null}
curl -X POST https://voice.growdental.ai/api/partner/v1/webhook-endpoints \
  -H "Authorization: Bearer $GROWDENTAL_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: 2f6a1c2e-9d3b-4a41-9a63-1de1a0a1b2c3" \
  -d '{
    "url": "https://api.example-pms.com/growdental/webhooks",
    "enabled_events": ["call.completed", "call.recording.ready", "call_request.completed"]
  }'
```

The `201` response includes the endpoint's HMAC signing `secret` — **this is the only time it is shown**. Store it next to your API key in a secret manager.

```json theme={null}
{
  "id": "0b5c7d5e-3f0a-4f6d-9d1a-8f2f6f6f0a1b",
  "url": "https://api.example-pms.com/growdental/webhooks",
  "enabled_events": ["call.completed", "call.recording.ready", "call_request.completed"],
  "status": "active",
  "failure_count": 0,
  "created_at": "2026-07-04T16:40:00Z",
  "updated_at": "2026-07-04T16:40:00Z",
  "secret": "gdwh_..."
}
```

## 2. Understand a delivery

Every delivery is an HTTPS POST with a JSON body and two headers:

| Header                     | Purpose                                                                   |
| -------------------------- | ------------------------------------------------------------------------- |
| `X-GrowDental-Signature`   | `t=<unix seconds>,v1=<hex HMAC-SHA256>` — verify before trusting anything |
| `X-GrowDental-Delivery-Id` | Unique per delivery, **stable across retries** — de-duplicate on it       |

The event type is carried in the body envelope's `type` field — route on that after verifying the signature.

The body is an envelope:

```json theme={null}
{
  "id": "7f0d2a4e-1b3c-4d5e-8f90-a1b2c3d4e5f6",
  "type": "call.completed",
  "created_at": "2026-07-04T17:32:11Z",
  "practice_id": "9a8b7c6d-5e4f-4a3b-8c9d-0e1f2a3b4c5d",
  "data": {
    "call_id": "6e1f9c2b-7a8d-4e3f-b012-3c4d5e6f7a8b",
    "practice_id": "9a8b7c6d-5e4f-4a3b-8c9d-0e1f2a3b4c5d",
    "direction": "outbound",
    "outcome": "appointment_booked",
    "duration_seconds": 143,
    "call_request_id": "c1d2e3f4-a5b6-4c7d-8e9f-0a1b2c3d4e5f",
    "contact_external_ref": "your-patient-4821",
    "links": {
      "call": "https://voice.growdental.ai/api/partner/v1/calls/6e1f9c2b-7a8d-4e3f-b012-3c4d5e6f7a8b",
      "recording": "https://voice.growdental.ai/api/partner/v1/calls/6e1f9c2b-7a8d-4e3f-b012-3c4d5e6f7a8b/recording"
    }
  }
}
```

`call_request_id` and `contact_external_ref` appear only when the call originated from one of your call requests (and, for the latter, when you supplied `external_ref` on the contact).

Payloads are deliberately **PHI-minimal** — IDs, outcomes, and API links only. To get the summary, transcript, or recording, follow the link with your API key (subject to your scopes and BAA).

Delivery semantics:

* **At-least-once** — the same event can arrive more than once. De-duplicate on `X-GrowDental-Delivery-Id`.
* **Unordered** — use the envelope's `created_at`, not arrival order.
* **Retried** — non-2xx responses and timeouts (10 s) are retried with exponential backoff. Endpoints that fail persistently are auto-disabled (`status: "disabled"`); re-enable with `PATCH /webhook-endpoints/{endpointId}` once your receiver is healthy.

Respond `2xx` as fast as possible — enqueue the payload and process it out of band.

## 3. Verify the signature

The signature is computed as:

```text theme={null}
X-GrowDental-Signature: t=<unix seconds>,v1=<hex HMAC-SHA256(secret, "<t>" + "." + <raw body>)>
```

To verify:

1. Parse `t` and `v1` from the header.
2. Reject if `|now − t|` exceeds 300 seconds (replay protection).
3. Compute `HMAC-SHA256(secret, t + "." + rawBody)` over the **raw, unparsed** request bytes.
4. Compare against `v1` with a **constant-time** comparison.

<Warning>
  Verify against the raw request body, byte for byte. If your framework parses
  JSON before you can read the raw body (or re-serializes it), the HMAC will not
  match — configure a raw-body route for the webhook path, as both samples below do.
</Warning>

<CodeGroup>
  ```javascript Node.js (Express) theme={null}
  import crypto from "node:crypto";
  import express from "express";

  const app = express();
  const WEBHOOK_SECRET = process.env.GROWDENTAL_WEBHOOK_SECRET;
  const TOLERANCE_SECONDS = 300;

  /**
   * Verify an X-GrowDental-Signature header against the raw request body.
   * Format: t=<unix seconds>,v1=<hex HMAC-SHA256(secret, `${t}.${rawBody}`)>
   */
  function verifyGrowDentalSignature(rawBody, signatureHeader, secret) {
    if (!signatureHeader || typeof signatureHeader !== "string") return false;

    const parts = {};
    for (const piece of signatureHeader.split(",")) {
      const idx = piece.indexOf("=");
      if (idx > 0) parts[piece.slice(0, idx).trim()] = piece.slice(idx + 1).trim();
    }

    const timestamp = Number.parseInt(parts.t ?? "", 10);
    const expected = parts.v1 ?? "";
    if (!Number.isFinite(timestamp) || !/^[0-9a-f]{64}$/.test(expected)) return false;

    // Replay protection: reject stale or future-dated deliveries.
    const nowSeconds = Math.floor(Date.now() / 1000);
    if (Math.abs(nowSeconds - timestamp) > TOLERANCE_SECONDS) return false;

    const computed = crypto
      .createHmac("sha256", secret)
      .update(`${parts.t}.`)
      .update(rawBody) // Buffer of the raw request bytes
      .digest("hex");

    // Constant-time comparison.
    const a = Buffer.from(computed, "hex");
    const b = Buffer.from(expected, "hex");
    return a.length === b.length && crypto.timingSafeEqual(a, b);
  }

  // express.raw() keeps req.body as a Buffer for this route only.
  app.post(
    "/growdental/webhooks",
    express.raw({ type: "application/json" }),
    (req, res) => {
      const signature = req.header("X-GrowDental-Signature");
      if (!verifyGrowDentalSignature(req.body, signature, WEBHOOK_SECRET)) {
        return res.status(401).send("invalid signature");
      }

      const deliveryId = req.header("X-GrowDental-Delivery-Id");
      // TODO: skip if deliveryId was already processed (at-least-once delivery).

      const event = JSON.parse(req.body.toString("utf8"));
      switch (event.type) {
        case "call.completed":
          console.log(`Call ${event.data.call_id} -> ${event.data.outcome}`);
          break;
        case "call.recording.ready":
          console.log(`Recording ready for call ${event.data.call_id}`);
          break;
        case "call_request.completed":
          console.log(`Call request ${event.data.call_request_id} finished`);
          break;
      }

      // Acknowledge fast; do real work on a queue.
      res.status(204).end();
    }
  );

  app.listen(3000);
  ```

  ```python Python (Flask) theme={null}
  import hashlib
  import hmac
  import json
  import os
  import re
  import time

  from flask import Flask, request

  app = Flask(__name__)
  WEBHOOK_SECRET = os.environ["GROWDENTAL_WEBHOOK_SECRET"]
  TOLERANCE_SECONDS = 300
  _HEX64 = re.compile(r"^[0-9a-f]{64}$")


  def verify_growdental_signature(raw_body: bytes, signature_header: str, secret: str) -> bool:
      """Verify an X-GrowDental-Signature header against the raw request body.

      Format: t=<unix seconds>,v1=<hex HMAC-SHA256(secret, f"{t}.{raw_body}")>
      """
      if not signature_header:
          return False

      parts = {}
      for piece in signature_header.split(","):
          key, sep, value = piece.partition("=")
          if sep:
              parts[key.strip()] = value.strip()

      timestamp_str = parts.get("t", "")
      expected = parts.get("v1", "")
      if not timestamp_str.isdigit() or not _HEX64.match(expected):
          return False

      # Replay protection: reject stale or future-dated deliveries.
      if abs(time.time() - int(timestamp_str)) > TOLERANCE_SECONDS:
          return False

      signed_payload = timestamp_str.encode("ascii") + b"." + raw_body
      computed = hmac.new(secret.encode("utf-8"), signed_payload, hashlib.sha256).hexdigest()

      # Constant-time comparison.
      return hmac.compare_digest(computed, expected)


  @app.post("/growdental/webhooks")
  def growdental_webhook():
      raw_body = request.get_data()  # raw bytes, before any JSON parsing
      signature = request.headers.get("X-GrowDental-Signature", "")

      if not verify_growdental_signature(raw_body, signature, WEBHOOK_SECRET):
          return "invalid signature", 401

      delivery_id = request.headers.get("X-GrowDental-Delivery-Id")
      # TODO: skip if delivery_id was already processed (at-least-once delivery).

      event = json.loads(raw_body)
      if event["type"] == "call.completed":
          print(f"Call {event['data']['call_id']} -> {event['data']['outcome']}")
      elif event["type"] == "call.recording.ready":
          print(f"Recording ready for call {event['data']['call_id']}")
      elif event["type"] == "call_request.completed":
          print(f"Call request {event['data']['call_request_id']} finished")

      # Acknowledge fast; do real work on a queue.
      return "", 204
  ```
</CodeGroup>

## 4. Pull the details you need

The webhook tells you *that* something happened; the API tells you *what*. On `call.completed`, fetch the call for its summary (and transcript, with `transcripts:read` + BAA):

```bash theme={null}
curl https://voice.growdental.ai/api/partner/v1/calls/6e1f9c2b-7a8d-4e3f-b012-3c4d5e6f7a8b \
  -H "Authorization: Bearer $GROWDENTAL_API_KEY"
```

On `call.recording.ready`, fetch the audio (requires `recordings:read` + BAA). The endpoint answers `302` with a short-lived signed URL — let your HTTP client follow it, and never persist the redirect target:

```bash theme={null}
curl -L -o call-recording.mp3 \
  https://voice.growdental.ai/api/partner/v1/calls/6e1f9c2b-7a8d-4e3f-b012-3c4d5e6f7a8b/recording \
  -H "Authorization: Bearer $GROWDENTAL_API_KEY"
```

## Troubleshooting

| Symptom                                        | Likely cause                                                                                                                               |
| ---------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ |
| Signatures never match                         | Body was parsed/re-serialized before hashing — verify against the raw bytes                                                                |
| Signatures match locally but not in production | A proxy or middleware is mutating the body (compression, re-encoding)                                                                      |
| Deliveries stopped arriving                    | Endpoint was auto-disabled after sustained failures — check `GET /webhook-endpoints`, fix your receiver, then `PATCH` `status` to `active` |
| Duplicate processing                           | You're deduplicating on event `id` per attempt instead of persisting seen `X-GrowDental-Delivery-Id`s                                      |
