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.
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.
Reject if |now − t| exceeds 300 seconds (replay protection).
Compute HMAC-SHA256(secret, t + "." + rawBody) over the raw, unparsed request bytes.
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);
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):
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: