Your first webhook
Register an endpoint, verify the signature, and handle delivery.
Webhooks are how Dolores tells your backend that an appointment changed. The voice agent doesn't have to wait for your system to respond — it just gets on with the call — and your backend reacts to the resulting event in its own time.
Register an endpoint
Pick a publicly reachable HTTPS URL on your side. For local development, webhook.site and smee.io both work.
curl https://app.meetdolores.ai/v1/webhook_endpoints \
-H "Authorization: Bearer $DOLORES_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://api.example.com/dolores/webhook",
"event_filters": ["appointment.*"],
"label": "Production appointments"
}'The response includes a one-time signing secret in the secret field. Save it immediately — we don't store the plaintext, and we can't show it to you again.
{
"id": "whk_01HXY...",
"object": "webhook_endpoint",
"url": "https://api.example.com/dolores/webhook",
"enabled": true,
"event_filters": ["appointment.*"],
"livemode": true,
"secret": "whsec_8fK2xPv...",
"secret_prefix": "whsec_8fK2x",
"created_at": "2026-06-06T14:23:11Z"
}Save it to your environment:
export DOLORES_WEBHOOK_SECRET="whsec_8fK2xPv..."Verify the signature
Every delivery carries a Dolores-Signature header in this format:
Dolores-Signature: t=1717685011,v1=4f8a2b...t is the Unix timestamp at which Dolores signed the body. v1 is the HMAC-SHA256 of ${t}.${rawBody} using your endpoint's signing secret.
To verify:
- Pull the raw request body — do not parse it as JSON before verification. Re-serialization changes whitespace and breaks the signature.
- Recompute
HMAC-SHA256(secret, "{timestamp}.{rawBody}"). - Compare against the
v1value with a constant-time comparison. - Reject deliveries whose timestamp is more than 5 minutes off from server clock (replay protection).
Node.js (Express)
Use express.raw({ type: 'application/json' }) instead of express.json() so req.body stays a Buffer:
const express = require('express');
const crypto = require('crypto');
const app = express();
const SECRET = process.env.DOLORES_WEBHOOK_SECRET;
function verify(rawBody, header, secret, toleranceSec = 300) {
const parts = Object.fromEntries(header.split(',').map(kv => {
const i = kv.indexOf('=');
return [kv.slice(0, i).trim(), kv.slice(i + 1).trim()];
}));
const t = parseInt(parts.t, 10);
if (Math.abs(Date.now() / 1000 - t) > toleranceSec) {
throw new Error('timestamp out of tolerance');
}
const expected = crypto.createHmac('sha256', secret).update(`${t}.${rawBody}`).digest();
const provided = Buffer.from(parts.v1, 'hex');
if (provided.length !== expected.length || !crypto.timingSafeEqual(provided, expected)) {
throw new Error('signature mismatch');
}
}
app.post('/dolores/webhook', express.raw({ type: 'application/json' }), (req, res) => {
try {
verify(req.body.toString('utf8'), req.headers['dolores-signature'], SECRET);
} catch (err) {
return res.status(400).send('bad signature');
}
const event = JSON.parse(req.body.toString('utf8'));
// dispatch event...
res.status(200).send();
});Python (Flask)
import hmac, hashlib, os, time
from flask import Flask, request, abort
app = Flask(__name__)
SECRET = os.environ['DOLORES_WEBHOOK_SECRET'].encode()
def verify(raw_body, header, secret, tolerance_sec=300):
parts = dict(kv.split('=', 1) for kv in header.split(','))
t = int(parts['t'])
if abs(time.time() - t) > tolerance_sec:
raise ValueError('timestamp out of tolerance')
expected = hmac.new(secret, f"{t}.{raw_body}".encode(), hashlib.sha256).digest()
provided = bytes.fromhex(parts['v1'])
if not hmac.compare_digest(expected, provided):
raise ValueError('signature mismatch')
@app.post('/dolores/webhook')
def hook():
try:
verify(request.data.decode('utf-8'), request.headers['Dolores-Signature'], SECRET)
except Exception:
abort(400)
event = request.get_json()
# dispatch event...
return '', 200What's in the delivery
Every event has a consistent envelope:
{
"id": "evt_01HXY...",
"object": "event",
"type": "appointment.confirmed",
"created": 1717685011,
"livemode": true,
"api_version": "2026-06-01",
"data": {
"object": {
"id": "appt_01HXY...",
"object": "appointment",
"status": "confirmed",
"appointment_at": "2026-06-10T15:00:00Z",
"customer_id": "cust_01HXY...",
"session_id": "sess_01HXY...",
"metadata": {},
"livemode": true
},
"previous_attributes": { "status": "pending" }
}
}data.objectis the full appointment resource at the time the event fired.data.previous_attributeslists fields that changed and their prior values (omitted on*.created).createdis Unix seconds (not milliseconds).livemodematches the endpoint's livemode setting — test deliveries never reach live endpoints and vice versa.
Best practices
- Respond 2xx as soon as possible. Do the actual work asynchronously (enqueue a job, drop it on a stream). Dolores treats anything ≥ 4xx (except 408 and 429) as a permanent failure for this delivery attempt.
- Be idempotent. Network blips can produce duplicate deliveries — your handler should produce the same end state whether it sees an event once or three times.
- Verify the signature first. Don't trust any header until the HMAC checks out.
- Use the test endpoint.
POST /v1/webhook_endpoints/{id}/testqueues a syntheticappointment.createdevent, which is useful for shaking out signature verification before real traffic.