Skip to main content
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.
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 secretthis is the only time it is shown. Store it next to your API key in a secret manager.
{
  "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:
HeaderPurpose
X-GrowDental-Signaturet=<unix seconds>,v1=<hex HMAC-SHA256> — verify before trusting anything
X-GrowDental-Delivery-IdUnique 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:
{
  "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:
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.
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.
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);

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

SymptomLikely cause
Signatures never matchBody was parsed/re-serialized before hashing — verify against the raw bytes
Signatures match locally but not in productionA proxy or middleware is mutating the body (compression, re-encoding)
Deliveries stopped arrivingEndpoint was auto-disabled after sustained failures — check GET /webhook-endpoints, fix your receiver, then PATCH status to active
Duplicate processingYou’re deduplicating on event id per attempt instead of persisting seen X-GrowDental-Delivery-Ids