> ## Documentation Index
> Fetch the complete documentation index at: https://docs.dentalspace.ai/llms.txt
> Use this file to discover all available pages before exploring further.

> ## Agent Instructions
> Documentazione di dentalspace. Il testo completo, incluso il riferimento API, è in /llms-full.txt. Per endpoint, campi e codici di errore la fonte di verità è openapi.json: non usare endpoint o campi non documentati.

# Webhook

> Avvisi a un tuo indirizzo quando succede qualcosa: destinazioni, busta, catalogo degli eventi, firma HMAC, ritentativi, storico.

Un webhook è una richiesta `POST` che dentalspace manda a un tuo indirizzo HTTPS quando succede qualcosa nella clinica: un appuntamento prenotato, una fattura pagata, un messaggio WhatsApp ricevuto. Così il tuo programma non deve chiedere ogni minuto se è cambiato qualcosa.

## Destinazioni

Una destinazione è un indirizzo più l'elenco degli eventi che vuole ricevere. Si crea con l'API:

```bash theme={null}
curl -s -X POST "https://api.dentalspace.ai/v1/webhook-endpoints" \
  -H "Authorization: Bearer $DS_KEY" \
  -H "Content-Type: application/json" \
  -d '{"url":"https://esempio.it/hook","events":["appointment.created","appointment.cancelled"],"description":"Gestionale"}'
```

La risposta contiene `secret`, il segreto di firma (`whsec_…`). **Si vede solo lì**: conservalo subito.

| Regola | Valore |
| - | - |
| Indirizzo | Solo `https`, porta 443, nome pubblico. Indirizzi privati, locali o con credenziali sono rifiutati |
| Eventi per destinazione | Da 1 a 50 |
| Destinazioni per clinica | Al massimo 20 |
| Chi riceve | La destinazione riceve gli eventi che la chiave che l'ha creata può vedere: i suoi scope e le sue sedi |

Per creare o modificare una destinazione servono `webhooks:write` e lo scope di ogni evento scelto: senza, `403 insufficient_scope` con gli scope mancanti in `details.requiredScopes`. Se poi la chiave viene revocata o scade, la destinazione smette di ricevere.

## Richiesta

```http theme={null}
POST /hook HTTP/1.1
Content-Type: application/json
User-Agent: DentalSpace-Webhooks/1
X-DentalSpace-Signature: v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
X-DentalSpace-Timestamp: 1790000000
X-DentalSpace-Idempotency-Key: 0b8e4c1a-7d2f-4e9b-a1c3-5f6d7e8a9b0c
```

| Header | Valore |
| - | - |
| `X-DentalSpace-Signature` | `v1=<HMAC-SHA256 esadecimale>`. Durante una rotazione del segreto, due firme separate da virgola |
| `X-DentalSpace-Timestamp` | Istante della firma, in secondi Unix |
| `X-DentalSpace-Idempotency-Key` | L'id della consegna, uguale a ogni tentativo |

La consegna riesce se rispondi `2xx` entro **10 secondi**. Un redirect (`3xx`) è un fallimento: non viene seguito. Del tuo indirizzo si registra solo lo status HTTP, mai il corpo della risposta.

## Busta

```json theme={null}
{
  "id": "0b8e4c1a-7d2f-4e9b-a1c3-5f6d7e8a9b0c",
  "eventId": "7c2e1f0a-3b4d-4e5f-8a9b-0c1d2e3f4a5b",
  "type": "appointment.created",
  "createdAt": "2026-10-02T09:12:44.000Z",
  "apiVersion": "v1",
  "livemode": true,
  "facilityId": "b1e2c3d4-0000-4000-8000-000000000001",
  "data": {
    "id": "e4f5a6b7-0000-4000-8000-000000000042",
    "status": "scheduled",
    "startTime": "2026-10-13T09:30:00+02:00",
    "endTime": "2026-10-13T10:00:00+02:00",
    "patientId": "a1b2c3d4-0000-4000-8000-000000000007",
    "doctorId": "d1e2f3a4-0000-4000-8000-000000000003",
    "chairId": null,
    "facilityId": "b1e2c3d4-0000-4000-8000-000000000001",
    "calendarId": "c1d2e3f4-0000-4000-8000-000000000002",
    "treatmentPlanId": null,
    "origin": "api",
    "createdAt": "2026-10-02T09:12:44.000Z",
    "updatedAt": "2026-10-02T09:12:44.000Z"
  }
}
```

| Campo | Descrizione |
| - | - |
| `id` | Nel webhook: l'id della consegna, uguale a `X-DentalSpace-Idempotency-Key`. In `GET /events`: l'id dell'evento |
| `eventId` | L'id dell'evento. Usalo per riconoscere i doppioni |
| `type` | Il tipo di evento. Vedi [Catalogo](#catalogo-degli-eventi) |
| `createdAt` | Quando è successo, ISO 8601 UTC |
| `apiVersion` | La versione della forma di `data`: `v1` |
| `livemode` | `true` |
| `facilityId` | La sede dell'evento, o `null` |
| `data` | La risorsa com'era al momento dell'evento |

`data` non contiene mai dati sanitari, e le risorse collegate sono solo id. Per i dettagli rileggi la risorsa con la tua chiave.

## Catalogo degli eventi

| `type` | Quando | Scope per riceverlo |
| - | - | - |
| `patient.created` | Nasce un paziente | `patients:read` |
| `patient.updated` | Cambia l'anagrafica. `data.changedFields` elenca i campi | `patients:read` |
| `patient.archived` | Un paziente viene archiviato o cancellato. `data.reason`: `archived` o `erased` | `patients:read` |
| `appointment.created` | Nasce un appuntamento | `appointments:read` |
| `appointment.rescheduled` | Cambiano orario, medico o sede. `data.previous` ha i valori di prima | `appointments:read` |
| `appointment.confirmed` | L'appuntamento passa a confermato | `appointments:read` |
| `appointment.cancelled` | L'appuntamento viene disdetto | `appointments:read` |
| `appointment.checked_in` | Il paziente è arrivato | `appointments:read` |
| `appointment.completed` | La visita è conclusa | `appointments:read` |
| `appointment.no_show` | Il paziente non si è presentato | `appointments:read` |
| `treatment_plan.presented` | Un preventivo viene presentato al paziente | `treatment_plans:read` |
| `treatment_plan.accepted` | Il paziente accetta il preventivo | `treatment_plans:read` |
| `treatment_plan.rejected` | Il paziente rifiuta il preventivo | `treatment_plans:read` |
| `invoice.issued` | Una fattura viene emessa | `billing:read` |
| `invoice.paid` | Una fattura è pagata per intero | `billing:read` |
| `payment.received` | Arriva un incasso | `billing:read` |
| `installment.overdue` | Una rata è scaduta senza pagamento | `billing:read` |
| `lead.created` | Nasce un contatto commerciale | `leads:read` |
| `lead.converted` | Un contatto commerciale diventa paziente. `data.patientId` è il paziente | `leads:read` |
| `task.created` | Nasce un'attività | `tasks:read` |
| `task.completed` | Un'attività è fatta | `tasks:read` |
| `conversation.message_received` | Arriva un messaggio WhatsApp. `data` ha gli id, non il testo | `messages:read` |
| `conversation.handoff_requested` | Una conversazione passa dal bot allo studio | `messages:read` |
| `call.logged` | Si registra una telefonata | `calls:read` |

Per ricevere qualunque evento la chiave deve avere anche `webhooks:read`.

Due tipi non si sottoscrivono:

* `webhook.test`: arriva solo quando lo chiedi con `POST /webhook-endpoints/{id}/test`, con `data` vuoto;
* `events.skipped`: vedi sotto.

### Eventi non nati

Se un evento non può nascere, al suo posto ne nasce uno `events.skipped`, così sai che ti manca qualcosa invece di non accorgertene. `data.reason` dice perché:

| `data.reason` | Quando |
| - | - |
| `bulk_import` | Più di 50 creazioni dello stesso tipo insieme, per esempio un'importazione. `data.count` dice quante |
| `no_active_key` | Per un periodo la clinica non aveva chiavi attive. `data.from` e `data.to` lo delimitano |
| `error` | Un errore interno ha impedito l'evento |

`data.type` è il tipo dell'evento mancato e `data.scope` lo scope che serve per vederlo. Una destinazione riceve `events.skipped` se è iscritta a `data.type`. Quando lo ricevi, risincronizza con `updatedSince` sugli elenchi. Vedi [Paginazione](/api/paginazione#sincronizzazione-incrementale).

## Firma

La firma è un HMAC-SHA256 calcolato con il segreto su questa stringa, quattro righe separate da `\n`:

```text theme={null}
v1
<X-DentalSpace-Timestamp>
<X-DentalSpace-Idempotency-Key>
<corpo della richiesta, byte per byte>
```

Il risultato, in esadecimale minuscolo, viaggia in `X-DentalSpace-Signature` con il prefisso `v1=`. Il timestamp è dentro la firma: una richiesta ripetuta con un timestamp diverso non verifica.

### Verifica

1. Leggi il corpo come byte grezzi, prima di qualsiasi parsing JSON.
2. Rifiuta la richiesta se `X-DentalSpace-Timestamp` dista più di 5 minuti dall'ora attuale.
3. Calcola la firma attesa sulla stringa `v1\n<timestamp>\n<idempotency-key>\n<corpo>`.
4. Confrontala con ciascuna firma dell'header, a tempo costante. Ne basta una valida.
5. Se `X-DentalSpace-Idempotency-Key` l'hai già elaborata, rispondi `200` senza rifare il lavoro.

<CodeGroup>
  ```javascript Node.js theme={null}
  import { createHmac, timingSafeEqual } from "node:crypto";
  import express from "express";

  const SECRET = process.env.DENTALSPACE_WEBHOOK_SECRET;
  const app = express();

  app.post("/hook", express.raw({ type: "application/json" }), (req, res) => {
    const timestamp = req.get("X-DentalSpace-Timestamp") ?? "";
    const key = req.get("X-DentalSpace-Idempotency-Key") ?? "";
    const header = req.get("X-DentalSpace-Signature") ?? "";

    if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return res.sendStatus(400);

    const signed = `v1\n${timestamp}\n${key}\n${req.body.toString("utf8")}`;
    const expected = Buffer.from("v1=" + createHmac("sha256", SECRET).update(signed).digest("hex"));
    const valid = header.split(",").some((signature) => {
      const received = Buffer.from(signature.trim());
      return received.length === expected.length && timingSafeEqual(received, expected);
    });
    if (!valid) return res.sendStatus(401);

    const event = JSON.parse(req.body.toString("utf8"));
    // Elabora event.type ed event.data, saltando le key gia' viste.
    res.sendStatus(200);
  });
  ```

  ```python Python theme={null}
  import hashlib
  import hmac
  import os
  import time

  from flask import Flask, abort, request

  SECRET = os.environ["DENTALSPACE_WEBHOOK_SECRET"].encode()
  app = Flask(__name__)


  @app.post("/hook")
  def hook():
      timestamp = request.headers.get("X-DentalSpace-Timestamp", "")
      key = request.headers.get("X-DentalSpace-Idempotency-Key", "")
      header = request.headers.get("X-DentalSpace-Signature", "")
      body = request.get_data()

      if not timestamp.isdigit() or abs(time.time() - int(timestamp)) > 300:
          abort(400)

      signed = b"v1\n" + timestamp.encode() + b"\n" + key.encode() + b"\n" + body
      expected = "v1=" + hmac.new(SECRET, signed, hashlib.sha256).hexdigest()
      if not any(hmac.compare_digest(expected, s.strip()) for s in header.split(",")):
          abort(401)

      event = request.get_json()
      # Elabora event["type"] ed event["data"], saltando le key gia' viste.
      return "", 200
  ```
</CodeGroup>

### Rotazione del segreto

`POST /webhook-endpoints/{id}/rotate-secret` genera un segreto nuovo e lo mostra una volta sola. Il vecchio resta valido per `overlapMinutes` minuti, da `0` a `10080` (una settimana), predefinito `1440` (un giorno). In quel periodo ogni webhook porta due firme: `v1=<nuova>,v1=<vecchia>`. Aggiorna il segreto nel tuo programma prima di `previousSecretExpiresAt`. Con `overlapMinutes: 0` il vecchio smette subito.

## Ritentativi

Una consegna fallita (rete, timeout, risposta diversa da `2xx`) viene ritentata. Corpo e `X-DentalSpace-Idempotency-Key` restano uguali; cambiano timestamp e firma.

| Tentativo | Quando |
| - | - |
| 1 | All'evento |
| 2 | 1 minuto dopo |
| 3 | 5 minuti dopo |
| 4 | 15 minuti dopo |
| 5 | 1 ora dopo |

Le attese variano del 10% in più o in meno, perché i ritentativi di molte consegne non arrivino tutti insieme. Dopo il quinto fallimento la consegna diventa `discarded`.

Dopo **50 consegne scartate di fila** la destinazione si spegne da sola: `enabled: false`, `disabledReason: "too_many_failures"`. Una consegna riuscita azzera il conteggio. Per riaccenderla: `PATCH /webhook-endpoints/{id}` con `enabled: true`.

Ogni clinica ha al massimo 5 consegne in corso alla volta, e le cliniche sono servite a turno: un tuo indirizzo lento rallenta solo le tue consegne.

## Storico e consegne

| Endpoint | Scope | A cosa serve |
| - | - | - |
| `GET /events` | `webhooks:read` | Gli eventi degli ultimi 30 giorni, nella stessa busta. Filtro `type`, paginazione e `updatedSince`. Utile se preferisci leggere invece di ricevere |
| `GET /webhook-deliveries` | `webhooks:read` | Le consegne e il loro stato: `pending`, `delivering`, `succeeded`, `discarded`. Filtri `endpointId` e `status` |
| `GET /webhook-deliveries/{id}` | `webhooks:read` | Una consegna con tutti i suoi tentativi |
| `POST /webhook-deliveries/{id}/redeliver` | `webhooks:write` | Rimanda una consegna `succeeded` o `discarded`, con lo stesso corpo e la stessa `X-DentalSpace-Idempotency-Key`. Al massimo 3 volte per consegna, poi `409 conflict` con `reason: "redeliver_limit"` |
| `POST /webhook-endpoints/{id}/test` | `webhooks:write` | Manda subito un `webhook.test` firmato e restituisce l'esito. Niente ritentativi |

Con una chiave di prova (`dsk_test_`) il test non spedisce niente e risponde `skipped: "test_mode"`.

Cancellata una destinazione, le sue consegne restano leggibili per 30 giorni.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.