Guide to Dentally Webhooks: Features and Best Practices
Dentally webhooks tell your application when appointments, patients and payments change in a dental practice's Dentally account. If you build booking tools, patient communications or reporting on top of Dentally, webhooks let you react to a new appointment or a recorded payment as it happens instead of polling the API.
This guide covers how Dentally webhooks are created and signed, the nine events and their payload, how retries and auto-disabling work, and the practices that keep a Dentally receiver reliable.
What are Dentally webhooks?
A Dentally webhook is an endpoint you register in Developer Settings or through the API, with a list of events and a secret you choose. When one of those events happens, Dentally sends a POST to every enabled webhook subscribed to it. Unlike many thin-payload systems, the body contains the full record, along with the Dentally user who caused the change.
Each request is signed. The X-Dentally-Signature header is the hex HMAC-SHA256 of the request body, keyed with the webhook's secret. There is no prefix, no timestamp and no event ID.
Dentally webhook features
| Feature | Details |
|---|---|
| Configuration | Developer Settings, or POST /v1/webhooks |
| Subscription | An events list per webhook |
| Endpoint URL | HTTPS. Plain HTTP is accepted only in the sandbox |
| Signature header | X-Dentally-Signature |
| Scheme | HMAC-SHA256, lowercase hex, no prefix |
| Signed content | The raw request body only |
| Secret | The secret string you set on the webhook |
| Other headers | User-Agent: Dentally-Webhook, Content-Type: application/json |
| Success response | Any 2xx, such as 200 or 204 |
| Retries | Up to 10 times at increasing intervals |
| Auto-disable | After more than 50 failed deliveries, until re-enabled in Developer Settings |
| Ordering | Not guaranteed |
Dentally publishes no response timeout, retry intervals or source IP list.
Common events
Dentally documents nine events, three for each kind of record.
| Event | Sent when |
|---|---|
appointment.created | An appointment is booked |
appointment.updated | An appointment changes |
appointment.deleted | An appointment is deleted |
patient.created | A patient record is created |
patient.updated | A patient record changes |
patient.deleted | A patient record is deleted |
payment.created | A payment is recorded |
payment.updated | A payment changes |
payment.deleted | A payment is deleted |
Dentally says new events can be added without that counting as a breaking change, and asks receivers to ignore events they don't recognise and return 204 No Content.
The payload has four top-level fields. Here is Dentally's appointment.created example, trimmed:
{
"data": {
"id": 2181,
"patient_id": 1,
"state": "Pending",
"start_time": "2015-07-08 09:30:00 +0100",
"reason": "Exam"
},
"event": "appointment.created",
"object": "appointment",
"user": {
"id": 1,
"first_name": "Gregory",
"last_name": "Hays",
"role": "Dentist"
}
}
eventis the event name. Route on it.objectis the record type:appointment,patientorpayment.datais the record itself, so its shape depends onobject.useris the Dentally user who triggered the event, when there is one.
Setting up Dentally webhooks
Dentally doesn't generate a signing secret. You choose it when you create the webhook, so generate a long random value first, for example with openssl rand -hex 32, and store it server-side as DENTALLY_WEBHOOK_SECRET.
Then create the webhook in Developer Settings, or through the API. Parameters are wrapped in a webhook object:
curl https://api.dentally.co/v1/webhooks \
-H "Authorization: Bearer $DENTALLY_ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-H "User-Agent: my-integration" \
-d '{
"webhook": {
"active": true,
"name": "Appointments sync",
"url": "https://example.com/webhooks/dentally",
"events": ["appointment.created", "appointment.updated", "appointment.deleted"],
"secret": "'"$DENTALLY_WEBHOOK_SECRET"'"
}
}'
Larger practices can generate several thousand webhooks a day, so subscribe only to the events you use.
If you use Hookdeck, create a source with the Dentally source type, paste the same secret into it, and use the Hookdeck source URL as the webhook's url.
Securing Dentally webhooks
Read X-Dentally-Signature, compute the HMAC-SHA256 of the raw request body with your secret as the key, hex-encode it, and compare the two in constant time.
const crypto = require('crypto');
const express = require('express');
function verifyDentallySignature(rawBody, signatureHeader, secret) {
if (!signatureHeader || !secret) return false;
const expected = Buffer.from(
crypto.createHmac('sha256', secret).update(rawBody).digest('hex'),
'utf8'
);
const received = Buffer.from(signatureHeader.trim().toLowerCase(), 'utf8');
return received.length === expected.length && crypto.timingSafeEqual(received, expected);
}
const app = express();
app.post('/webhooks/dentally', express.raw({ type: '*/*' }), (req, res) => {
if (!verifyDentallySignature(req.body, req.get('x-dentally-signature'), process.env.DENTALLY_WEBHOOK_SECRET)) {
return res.sendStatus(401);
}
const event = JSON.parse(req.body.toString('utf8'));
// Route on event.event. Return 204 for events you don't handle.
res.sendStatus(204);
});
The same check in Python:
import hashlib
import hmac
def verify_dentally_signature(raw_body: bytes, signature_header, secret: str) -> bool:
if not signature_header or not secret:
return False
expected = hmac.new(secret.encode("utf-8"), raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(
signature_header.strip().lower().encode("utf-8"), expected.encode("utf-8")
)
# In FastAPI: raw_body = await request.body()
# verify_dentally_signature(raw_body, request.headers.get("x-dentally-signature"),
# os.environ["DENTALLY_WEBHOOK_SECRET"])
The signature covers only the body and there is no timestamp, so there is no replay window to check.
Make Dentally webhooks production-ready. Hookdeck Event Gateway verifies the
X-Dentally-Signatureheader on every delivery, answers Dentally straight away, and queues each appointment, patient and payment event until your service has processed it.
Dentally webhook limitations and pain points
50 failures and the webhook is off
The Problem: Your integration goes quiet after a few bad days, and no new events arrive.
Why It Happens: Dentally retries a failed delivery up to 10 times. Once more than 50 deliveries have failed, it disables the webhook, and it stays disabled until someone re-enables it in Developer Settings. With thousands of events a day, a short outage is enough to reach 50.
Workarounds:
- Return a
2xxas soon as the request is verified and stored, and do the work afterwards. - Return
204for events you don't handle, rather than an error. - Check the webhook's status in Developer Settings after any incident, and reconcile through the API for the period it was off.
How Hookdeck Can Help: Hookdeck accepts each delivery as soon as it arrives, so failures on your side don't count against Dentally's limit. Events wait in Hookdeck and are delivered with retries you configure.
No event ID
The Problem: You can't tell a retry from a genuinely new event.
Why It Happens: The body has no event or delivery ID, and no header carries one. A retried appointment.updated looks the same as a second update.
Workarounds:
- Make processing idempotent: upsert the record in
databy itsidrather than appending changes. - If you need to detect exact repeats, hash the raw body and remember recent hashes.
How Hookdeck Can Help: Hookdeck's deduplication can drop exact repeats, or compare the body fields you choose, before they reach your service.
Out-of-order deliveries
The Problem: An older version of an appointment overwrites a newer one.
Why It Happens: Dentally warns that webhooks may arrive out of order, and retries make this more likely.
Workarounds:
- Compare an update timestamp in
datawith the one you have stored, if the record carries one, and skip older versions. - Fetch the record from the API when the order matters.
How Hookdeck Can Help: Hookdeck logs each request with the time it arrived, so you can see the sequence Dentally actually sent when debugging.
No replay protection
The Problem: A captured request still verifies if someone sends it again.
Why It Happens: Only the body is signed, with no timestamp.
Workarounds:
- Keep the endpoint on HTTPS and the secret out of logs.
- Treat processing as idempotent so a repeat does no harm.
How Hookdeck Can Help: Hookdeck's deduplication drops identical bodies within a window you choose.
Best practices
Verify against the raw body
Use express.raw({ type: '*/*' }) in Express, await request.text() in Next.js and await request.body() in FastAPI. Parse JSON only after the signature check passes.
Ignore unknown events politely
Dentally can add events at any time. Log anything you don't recognise and return 204, as Dentally asks.
Subscribe narrowly
A busy practice sends thousands of webhooks a day. Subscribe only to the events your integration uses.
Acknowledge fast and process asynchronously
Verify, store the event, return a 2xx, and do the work afterwards. See why to process webhooks asynchronously and our guide to webhook idempotency.
Conclusion
Dentally signs each webhook with X-Dentally-Signature, a bare hex HMAC-SHA256 of the raw body keyed with the secret you chose. Verify it against the raw bytes, upsert records idempotently because there is no event ID and ordering isn't guaranteed, return 204 for events you don't handle, and keep failures low, because more than 50 of them disable the webhook.
Hookdeck Event Gateway verifies every Dentally signature and stores each event until your service has handled it, so an outage on your side doesn't switch the webhook off.
Get started with Hookdeck for free and handle Dentally webhooks reliably in minutes.