DoloresDocs
Get started

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:

  1. Pull the raw request body — do not parse it as JSON before verification. Re-serialization changes whitespace and breaks the signature.
  2. Recompute HMAC-SHA256(secret, "{timestamp}.{rawBody}").
  3. Compare against the v1 value with a constant-time comparison.
  4. 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 '', 200

What'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.object is the full appointment resource at the time the event fired.
  • data.previous_attributes lists fields that changed and their prior values (omitted on *.created).
  • created is Unix seconds (not milliseconds).
  • livemode matches 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}/test queues a synthetic appointment.created event, which is useful for shaking out signature verification before real traffic.