> ## 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.

# Riferimento completo

> Tutti gli endpoint dell'API v1 in una pagina: parametri, corpo, risposte ed errori.

Indirizzo: `https://api.dentalspace.ai/v1`. Autenticazione: header `Authorization: Bearer dsk_live_...` su ogni richiesta.
Specifica sorgente: [`openapi.json`](https://raw.githubusercontent.com/SQUADD26/dentalspace-docs/main/openapi.json).

## La chiave che sta chiamando

`GET /me`

Clinica, chiave (scope, sedi, scadenza), limiti e modalita'. Basta una chiave valida, senza scope.

### Risposte

| Status | Descrizione |
| - | - |
| `200` | La chiave e la sua clinica. |
| `401` | Codici: `unauthenticated`, `api_key_invalid`, `api_key_revoked`. |
| `403` | Codici: `plan_required`. |
| `429` | Codici: `rate_limited`. |
| `500` | Codici: `internal_error`. |
| `503` | Codici: `unavailable`. |

### Campi della risposta

| Campo | Tipo | Sempre presente | Descrizione |
| - | - | - | - |
| `organization` | object | sì | |
| `organization.id` | string (uuid) | sì | |
| `organization.name` | string | sì | |
| `apiKey` | object | sì | |
| `apiKey.id` | string (uuid) | sì | |
| `apiKey.name` | string | sì | |
| `apiKey.prefix` | string | sì | L'inizio della chiave, per riconoscerla: dsk\_live\_ab12. |
| `apiKey.last4` | string | sì | Le ultime 4 cifre. |
| `apiKey.scopes` | array di string | sì | |
| `apiKey.facilityIds` | array di string (uuid) \| null | sì | Le sedi a cui e' limitata; null = tutte. |
| `apiKey.expiresAt` | string \| null | sì | |
| `livemode` | boolean | sì | false per una chiave dsk\_test\_: nessun messaggio parte verso l'esterno. |
| `rateLimit` | object | sì | |
| `rateLimit.organization` | object | sì | |
| `rateLimit.organization.per10Seconds` | integer | sì | |
| `rateLimit.organization.perDay` | integer | sì | |
| `rateLimit.apiKey` | object | sì | |
| `rateLimit.apiKey.per10Seconds` | integer | sì | |
| `rateLimit.apiKey.perDay` | integer | sì | |

## Stato del servizio

`GET /health`

Non richiede chiave.

Senza chiave. 200 se l'API e il database rispondono, 503 `unavailable` altrimenti.

### Risposte

| Status | Descrizione |
| - | - |
| `200` | Il servizio risponde. |
| `500` | Codici: `internal_error`. |
| `503` | Codici: `unavailable`. |

### Campi della risposta

| Campo | Tipo | Sempre presente | Descrizione |
| - | - | - | - |
| `status` | string | sì | |
| `time` | string | sì | L'ora del server (ISO 8601, UTC). |

## Specifica OpenAPI 3.1

`GET /openapi.json`

Non richiede chiave.

Senza chiave. Generata dagli stessi schemi che validano le richieste.

### Risposte

| Status | Descrizione |
| - | - |
| `200` | La specifica. |
| `500` | Codici: `internal_error`. |
| `503` | Codici: `unavailable`. |

## Le sedi della clinica

`GET /facilities`

Scope: `clinic:read`.

Indirizzo, telefono e orari di apertura. Una chiave limitata a delle sedi vede solo quelle.

### Parametri

| Nome | Posizione | Tipo | Obbligatorio | Descrizione |
| - | - | - | - | - |
| `limit` | query | integer | no | Quante righe per pagina (1-200). |
| `cursor` | query | string | no | Il nextCursor della pagina precedente, tale e quale. |
| `updatedSince` | query | string (date-time) | no | Solo le righe modificate da questo istante in poi (sincronizzazione incrementale). |

### Risposte

| Status | Descrizione |
| - | - |
| `200` | Una pagina di sedi. |
| `400` | Codici: `invalid_request`. |
| `401` | Codici: `unauthenticated`, `api_key_invalid`, `api_key_revoked`. |
| `403` | Codici: `plan_required`, `insufficient_scope`. |
| `429` | Codici: `rate_limited`. |
| `500` | Codici: `internal_error`. |
| `503` | Codici: `unavailable`. |

### Campi della risposta

| Campo | Tipo | Sempre presente | Descrizione |
| - | - | - | - |
| `data` | array di object | sì | |
| `data[].id` | string (uuid) | sì | |
| `data[].name` | string | sì | |
| `data[].street` | string \| null | sì | |
| `data[].city` | string \| null | sì | |
| `data[].province` | string \| null | sì | |
| `data[].zipCode` | string \| null | sì | |
| `data[].phone` | string \| null | sì | |
| `data[].email` | string \| null | sì | |
| `data[].isMainLocation` | boolean | sì | |
| `data[].avgAppointmentMinutes` | number \| null | sì | |
| `data[].workingSchedule` | object | sì | Gli orari di apertura, come li salva l'app. |
| `data[].updatedAt` | string \| null | sì | |
| `nextCursor` | string \| null | sì | null = ultima pagina. |

## Una sede

`GET /facilities/{id}`

Scope: `clinic:read`.

Dove siete e che orari fate.

### Parametri

| Nome | Posizione | Tipo | Obbligatorio | Descrizione |
| - | - | - | - | - |
| `id` | path | string (uuid) | sì | Identificativo della risorsa. |

### Risposte

| Status | Descrizione |
| - | - |
| `200` | La sede. |
| `400` | Codici: `invalid_request`. |
| `401` | Codici: `unauthenticated`, `api_key_invalid`, `api_key_revoked`. |
| `403` | Codici: `plan_required`, `insufficient_scope`. |
| `404` | Codici: `not_found`. |
| `429` | Codici: `rate_limited`. |
| `500` | Codici: `internal_error`. |
| `503` | Codici: `unavailable`. |

### Campi della risposta

| Campo | Tipo | Sempre presente | Descrizione |
| - | - | - | - |
| `id` | string (uuid) | sì | |
| `name` | string | sì | |
| `street` | string \| null | sì | |
| `city` | string \| null | sì | |
| `province` | string \| null | sì | |
| `zipCode` | string \| null | sì | |
| `phone` | string \| null | sì | |
| `email` | string \| null | sì | |
| `isMainLocation` | boolean | sì | |
| `avgAppointmentMinutes` | number \| null | sì | |
| `workingSchedule` | object | sì | Gli orari di apertura, come li salva l'app. |
| `updatedAt` | string \| null | sì | |

## Le poltrone attive di una sede

`GET /facilities/{id}/chairs`

Scope: `clinic:read`.

### Parametri

| Nome | Posizione | Tipo | Obbligatorio | Descrizione |
| - | - | - | - | - |
| `id` | path | string (uuid) | sì | Identificativo della risorsa. |

### Risposte

| Status | Descrizione |
| - | - |
| `200` | Le poltrone attive (non paginato: nextCursor e' sempre null). |
| `400` | Codici: `invalid_request`. |
| `401` | Codici: `unauthenticated`, `api_key_invalid`, `api_key_revoked`. |
| `403` | Codici: `plan_required`, `insufficient_scope`. |
| `404` | Codici: `not_found`. |
| `429` | Codici: `rate_limited`. |
| `500` | Codici: `internal_error`. |
| `503` | Codici: `unavailable`. |

### Campi della risposta

| Campo | Tipo | Sempre presente | Descrizione |
| - | - | - | - |
| `data` | array di object | sì | |
| `data[].id` | string (uuid) | sì | |
| `data[].facilityId` | string (uuid) | sì | |
| `data[].name` | string | sì | |
| `data[].color` | string \| null | sì | |
| `nextCursor` | string \| null | sì | null = ultima pagina. |

## I medici della clinica

`GET /doctors`

Scope: `clinic:read`.

Nome, specialita' e sedi in cui lavorano; nessun dato di contatto. Una chiave limitata a delle sedi vede solo i medici di quelle sedi (una pagina puo' quindi contenere meno di `limit` medici).

### Parametri

| Nome | Posizione | Tipo | Obbligatorio | Descrizione |
| - | - | - | - | - |
| `limit` | query | integer | no | Quante righe per pagina (1-200). |
| `cursor` | query | string | no | Il nextCursor della pagina precedente, tale e quale. |
| `updatedSince` | query | string (date-time) | no | Solo le righe modificate da questo istante in poi (sincronizzazione incrementale). |

### Risposte

| Status | Descrizione |
| - | - |
| `200` | Una pagina di medici. |
| `400` | Codici: `invalid_request`. |
| `401` | Codici: `unauthenticated`, `api_key_invalid`, `api_key_revoked`. |
| `403` | Codici: `plan_required`, `insufficient_scope`. |
| `429` | Codici: `rate_limited`. |
| `500` | Codici: `internal_error`. |
| `503` | Codici: `unavailable`. |

### Campi della risposta

| Campo | Tipo | Sempre presente | Descrizione |
| - | - | - | - |
| `data` | array di object | sì | |
| `data[].id` | string (uuid) | sì | |
| `data[].firstName` | string \| null | sì | |
| `data[].lastName` | string \| null | sì | |
| `data[].specialties` | array di string | sì | |
| `data[].facilityIds` | array di string (uuid) | sì | Le sedi in cui lavora (appartenenza attiva). |
| `data[].updatedAt` | string \| null | sì | |
| `nextCursor` | string \| null | sì | null = ultima pagina. |

## I calendari prenotabili

`GET /calendars`

Scope: `clinic:read`.

Con le regole di prenotazione: preavviso, orizzonte, disdetta e spostamento consentiti, prestazioni e medici.

### Parametri

| Nome | Posizione | Tipo | Obbligatorio | Descrizione |
| - | - | - | - | - |
| `limit` | query | integer | no | Quante righe per pagina (1-200). |
| `cursor` | query | string | no | Il nextCursor della pagina precedente, tale e quale. |
| `updatedSince` | query | string (date-time) | no | Solo le righe modificate da questo istante in poi (sincronizzazione incrementale). |
| `facilityId` | query | string (uuid) | no | Solo i calendari di questa sede. |

### Risposte

| Status | Descrizione |
| - | - |
| `200` | Una pagina di calendari. |
| `400` | Codici: `invalid_request`. |
| `401` | Codici: `unauthenticated`, `api_key_invalid`, `api_key_revoked`. |
| `403` | Codici: `plan_required`, `insufficient_scope`. |
| `429` | Codici: `rate_limited`. |
| `500` | Codici: `internal_error`. |
| `503` | Codici: `unavailable`. |

### Campi della risposta

| Campo | Tipo | Sempre presente | Descrizione |
| - | - | - | - |
| `data` | array di object | sì | |
| `data[].id` | string (uuid) | sì | |
| `data[].facilityId` | string (uuid) | sì | |
| `data[].name` | string | sì | |
| `data[].durationMinutes` | integer | sì | |
| `data[].minNoticeHours` | integer | sì | Preavviso minimo per prenotare. |
| `data[].bookingHorizonDays` | integer | sì | Quanti giorni avanti si puo' prenotare. |
| `data[].allowBooking` | boolean | sì | |
| `data[].allowReschedule` | boolean | sì | |
| `data[].allowCancel` | boolean | sì | |
| `data[].doctorAssignment` | string | sì | auto: sceglie il programma; patient\_choice: sceglie il paziente. |
| `data[].treatmentIds` | array di string (uuid) | sì | Le prestazioni prenotabili su questo calendario. |
| `data[].doctorIds` | array di string (uuid) | sì | I medici del calendario. |
| `data[].updatedAt` | string | sì | |
| `nextCursor` | string \| null | sì | null = ultima pagina. |

## Un calendario

`GET /calendars/{id}`

Scope: `clinic:read`.

### Parametri

| Nome | Posizione | Tipo | Obbligatorio | Descrizione |
| - | - | - | - | - |
| `id` | path | string (uuid) | sì | Identificativo della risorsa. |

### Risposte

| Status | Descrizione |
| - | - |
| `200` | Il calendario con le prestazioni ammesse. |
| `400` | Codici: `invalid_request`. |
| `401` | Codici: `unauthenticated`, `api_key_invalid`, `api_key_revoked`. |
| `403` | Codici: `plan_required`, `insufficient_scope`. |
| `404` | Codici: `not_found`. |
| `429` | Codici: `rate_limited`. |
| `500` | Codici: `internal_error`. |
| `503` | Codici: `unavailable`. |

### Campi della risposta

| Campo | Tipo | Sempre presente | Descrizione |
| - | - | - | - |
| `id` | string (uuid) | sì | |
| `facilityId` | string (uuid) | sì | |
| `name` | string | sì | |
| `durationMinutes` | integer | sì | |
| `minNoticeHours` | integer | sì | Preavviso minimo per prenotare. |
| `bookingHorizonDays` | integer | sì | Quanti giorni avanti si puo' prenotare. |
| `allowBooking` | boolean | sì | |
| `allowReschedule` | boolean | sì | |
| `allowCancel` | boolean | sì | |
| `doctorAssignment` | string | sì | auto: sceglie il programma; patient\_choice: sceglie il paziente. |
| `treatmentIds` | array di string (uuid) | sì | Le prestazioni prenotabili su questo calendario. |
| `doctorIds` | array di string (uuid) | sì | I medici del calendario. |
| `updatedAt` | string | sì | |

## Il listino

`GET /treatments`

Scope: `clinic:read`.

Codice, nome, durata e prezzo delle prestazioni. Di default solo quelle attive. L'elenco e' ordinato per id: `updatedSince` non e' disponibile (400).

### Parametri

| Nome | Posizione | Tipo | Obbligatorio | Descrizione |
| - | - | - | - | - |
| `limit` | query | integer | no | Quante righe per pagina (1-200). |
| `cursor` | query | string | no | Il nextCursor della pagina precedente, tale e quale. |
| `updatedSince` | query | string (date-time) | no | Solo le righe modificate da questo istante in poi (sincronizzazione incrementale). |
| `active` | query | string: `true`, `false` | no | `false` = anche le prestazioni disattivate. |

### Risposte

| Status | Descrizione |
| - | - |
| `200` | Una pagina di prestazioni. |
| `400` | Codici: `invalid_request`. |
| `401` | Codici: `unauthenticated`, `api_key_invalid`, `api_key_revoked`. |
| `403` | Codici: `plan_required`, `insufficient_scope`. |
| `429` | Codici: `rate_limited`. |
| `500` | Codici: `internal_error`. |
| `503` | Codici: `unavailable`. |

### Campi della risposta

| Campo | Tipo | Sempre presente | Descrizione |
| - | - | - | - |
| `data` | array di object | sì | |
| `data[].id` | string (uuid) | sì | |
| `data[].code` | string \| null | sì | |
| `data[].name` | string | sì | |
| `data[].category` | string \| null | sì | |
| `data[].description` | string \| null | sì | |
| `data[].defaultDurationMinutes` | number \| null | sì | |
| `data[].price` | number \| null | sì | Prezzo di listino in euro. |
| `data[].vatRate` | number \| null | sì | |
| `data[].isActive` | boolean | sì | |
| `nextCursor` | string \| null | sì | null = ultima pagina. |

## Una prestazione

`GET /treatments/{id}`

Scope: `clinic:read`.

### Parametri

| Nome | Posizione | Tipo | Obbligatorio | Descrizione |
| - | - | - | - | - |
| `id` | path | string (uuid) | sì | Identificativo della risorsa. |

### Risposte

| Status | Descrizione |
| - | - |
| `200` | La prestazione. |
| `400` | Codici: `invalid_request`. |
| `401` | Codici: `unauthenticated`, `api_key_invalid`, `api_key_revoked`. |
| `403` | Codici: `plan_required`, `insufficient_scope`. |
| `404` | Codici: `not_found`. |
| `429` | Codici: `rate_limited`. |
| `500` | Codici: `internal_error`. |
| `503` | Codici: `unavailable`. |

### Campi della risposta

| Campo | Tipo | Sempre presente | Descrizione |
| - | - | - | - |
| `id` | string (uuid) | sì | |
| `code` | string \| null | sì | |
| `name` | string | sì | |
| `category` | string \| null | sì | |
| `description` | string \| null | sì | |
| `defaultDurationMinutes` | number \| null | sì | |
| `price` | number \| null | sì | Prezzo di listino in euro. |
| `vatRate` | number \| null | sì | |
| `isActive` | boolean | sì | |

## Chi e' questo numero?

`GET /patients/lookup`

Scope: `patients:read`.

Confronto esatto sul numero (in qualunque formato). `kind`: `patient` se e' di un paziente, `family` se lo stesso numero e' di piu' pazienti (genitore e figli), `lead` se non e' di nessun paziente ma di un contatto commerciale, `null` se non lo conosciamo. I candidati hanno solo nome, anno di nascita e ultime 4 cifre.

### Parametri

| Nome | Posizione | Tipo | Obbligatorio | Descrizione |
| - | - | - | - | - |
| `phone` | query | string | sì | |

### Risposte

| Status | Descrizione |
| - | - |
| `200` | Chi corrisponde a quel numero. |
| `400` | Codici: `invalid_request`. |
| `401` | Codici: `unauthenticated`, `api_key_invalid`, `api_key_revoked`. |
| `403` | Codici: `plan_required`, `insufficient_scope`. |
| `429` | Codici: `rate_limited`. |
| `500` | Codici: `internal_error`. |
| `503` | Codici: `unavailable`. |

### Campi della risposta

| Campo | Tipo | Sempre presente | Descrizione |
| - | - | - | - |
| `found` | boolean | sì | |
| `kind` | string: `patient`, `family`, `lead` \| null | sì | |
| `candidates` | array di object | sì | |
| `candidates[].id` | string (uuid) | sì | |
| `candidates[].kind` | string: `patient`, `lead` | sì | |
| `candidates[].name` | string | sì | |
| `candidates[].birthYear` | integer \| null | sì | |
| `candidates[].phoneLast4` | string | sì | Le ultime 4 cifre del numero. |
| `candidates[].archived` | boolean | sì | |

## Cerca un paziente

`GET /patients/search`

Scope: `patients:read`.

Trova la persona anche scritta male: nome, telefono in qualunque forma, codice fiscale, email. Restituisce pochi candidati con il punteggio; `closeMatches` dice quanti sono altrettanto probabili del migliore.

### Parametri

| Nome | Posizione | Tipo | Obbligatorio | Descrizione |
| - | - | - | - | - |
| `q` | query | string | sì | |
| `facilityId` | query | string (uuid) | no | Solo i pazienti di questa sede. |
| `limit` | query | integer | no | |

### Risposte

| Status | Descrizione |
| - | - |
| `200` | I candidati, dal piu' probabile. |
| `400` | Codici: `invalid_request`. |
| `401` | Codici: `unauthenticated`, `api_key_invalid`, `api_key_revoked`. |
| `403` | Codici: `plan_required`, `insufficient_scope`. |
| `429` | Codici: `rate_limited`. |
| `500` | Codici: `internal_error`. |
| `503` | Codici: `unavailable`. |

### Campi della risposta

| Campo | Tipo | Sempre presente | Descrizione |
| - | - | - | - |
| `data` | array di object | sì | |
| `data[].id` | string (uuid) | sì | |
| `data[].name` | string | sì | |
| `data[].birthYear` | integer \| null | sì | |
| `data[].phoneLast4` | string | sì | Le ultime 4 cifre del numero. |
| `data[].archived` | boolean | sì | |
| `data[].score` | number | sì | Da 0 a 1: quanto somiglia a cio' che hai cercato. |
| `data[].matchedBy` | string: `name`, `phone`, `taxCode`, `email` | sì | |
| `data[].lastVisitAt` | string \| null | sì | |
| `closeMatches` | integer | sì | |

## Elenco dei pazienti

`GET /patients`

Scope: `patients:read`.

Anagrafica, senza dati sanitari (quelli stanno solo nella scheda e nel `/clinical`, con `clinical:read`). Di default solo i pazienti attivi. Per cercare per nome usa `/patients/search`.

### Parametri

| Nome | Posizione | Tipo | Obbligatorio | Descrizione |
| - | - | - | - | - |
| `limit` | query | integer | no | Quante righe per pagina (1-200). |
| `cursor` | query | string | no | Il nextCursor della pagina precedente, tale e quale. |
| `updatedSince` | query | string (date-time) | no | Solo le righe modificate da questo istante in poi (sincronizzazione incrementale). |
| `phone` | query | string | no | In qualunque formato: si confronta quello canonico. |
| `email` | query | string | no | Senza distinguere maiuscole. |
| `facilityId` | query | string (uuid) | no | |
| `archived` | query | string: `true`, `false` | no | `true` = solo gli archiviati. |

### Risposte

| Status | Descrizione |
| - | - |
| `200` | Una pagina di pazienti. |
| `400` | Codici: `invalid_request`. |
| `401` | Codici: `unauthenticated`, `api_key_invalid`, `api_key_revoked`. |
| `403` | Codici: `plan_required`, `insufficient_scope`. |
| `429` | Codici: `rate_limited`. |
| `500` | Codici: `internal_error`. |
| `503` | Codici: `unavailable`. |

### Campi della risposta

| Campo | Tipo | Sempre presente | Descrizione |
| - | - | - | - |
| `data` | array di object | sì | |
| `data[].id` | string (uuid) | sì | |
| `data[].firstName` | string | sì | |
| `data[].lastName` | string | sì | |
| `data[].email` | string \| null | sì | |
| `data[].phone` | string \| null | sì | In forma canonica: +\<paese>\<numero>. |
| `data[].dateOfBirth` | string \| null | sì | |
| `data[].gender` | string \| null | sì | |
| `data[].taxCode` | string \| null | sì | |
| `data[].address` | object | sì | |
| `data[].address.street` | string \| null | sì | |
| `data[].address.city` | string \| null | sì | |
| `data[].address.zip` | string \| null | sì | |
| `data[].address.country` | string \| null | sì | |
| `data[].facilityId` | string (uuid) \| null | sì | |
| `data[].referralSource` | string \| null | sì | |
| `data[].isActive` | boolean | sì | false = archiviato. |
| `data[].archivedAt` | string \| null | sì | |
| `data[].createdAt` | string \| null | sì | |
| `data[].updatedAt` | string | sì | |
| `data[].allergies` | array di string | no | Solo con clinical:read. |
| `data[].medications` | array di string | no | Solo con clinical:read. |
| `data[].conditions` | array di string | no | Solo con clinical:read. |
| `data[].notes` | string \| null | no | Solo con clinical:read. |
| `nextCursor` | string \| null | sì | null = ultima pagina. |

## Crea un paziente

`POST /patients`

Scope: `patients:write`.

409 `conflict` (`details.patientId`) se esiste gia' un paziente con lo stesso nome e la stessa email o lo stesso telefono. Allergie, farmaci, condizioni e note richiedono anche `clinical:write`. Con `Idempotency-Key` un nuovo tentativo non crea un secondo paziente.

### Parametri

| Nome | Posizione | Tipo | Obbligatorio | Descrizione |
| - | - | - | - | - |
| `Idempotency-Key` | header | string | no | Facoltativa. La stessa chiave entro 24 ore ripete la prima risposta 2xx. |

### Corpo (JSON)

| Campo | Tipo | Obbligatorio | Descrizione |
| - | - | - | - |
| `firstName` | string | sì | |
| `lastName` | string | sì | |
| `email` | string (email) | no | |
| `phone` | string | no | In qualunque formato: si salva quello canonico. |
| `dateOfBirth` | string (date) | no | |
| `gender` | string: `male`, `female`, `other`, `prefer_not_to_say` | no | |
| `taxCode` | string | no | |
| `address` | object | no | |
| `address.street` | string | no | |
| `address.city` | string | no | |
| `address.zip` | string | no | |
| `address.country` | string | no | |
| `facilityId` | string (uuid) | no | Obbligatoria se la chiave e' limitata a delle sedi. |
| `referralSource` | string: `walk_in`, `internet`, `social`, `companies`, `referral` | no | |
| `allergies` | array di string | no | Richiede clinical:write. |
| `medications` | array di string | no | Richiede clinical:write. |
| `conditions` | array di string | no | Richiede clinical:write. |
| `notes` | string | no | Richiede clinical:write. |

### Risposte

| Status | Descrizione |
| - | - |
| `201` | Il paziente creato (header ETag). |
| `400` | Codici: `invalid_request`. |
| `401` | Codici: `unauthenticated`, `api_key_invalid`, `api_key_revoked`. |
| `403` | Codici: `forbidden`, `plan_required`, `insufficient_scope`. |
| `409` | Codici: `conflict`, `idempotency_in_progress`. |
| `422` | Codici: `idempotency_key_reused`. |
| `429` | Codici: `rate_limited`. |
| `500` | Codici: `internal_error`. |
| `503` | Codici: `unavailable`. |

### Campi della risposta

| Campo | Tipo | Sempre presente | Descrizione |
| - | - | - | - |
| `id` | string (uuid) | sì | |
| `firstName` | string | sì | |
| `lastName` | string | sì | |
| `email` | string \| null | sì | |
| `phone` | string \| null | sì | In forma canonica: +\<paese>\<numero>. |
| `dateOfBirth` | string \| null | sì | |
| `gender` | string \| null | sì | |
| `taxCode` | string \| null | sì | |
| `address` | object | sì | |
| `address.street` | string \| null | sì | |
| `address.city` | string \| null | sì | |
| `address.zip` | string \| null | sì | |
| `address.country` | string \| null | sì | |
| `facilityId` | string (uuid) \| null | sì | |
| `referralSource` | string \| null | sì | |
| `isActive` | boolean | sì | false = archiviato. |
| `archivedAt` | string \| null | sì | |
| `createdAt` | string \| null | sì | |
| `updatedAt` | string | sì | |
| `allergies` | array di string | no | Solo con clinical:read. |
| `medications` | array di string | no | Solo con clinical:read. |
| `conditions` | array di string | no | Solo con clinical:read. |
| `notes` | string \| null | no | Solo con clinical:read. |

## La scheda di un paziente

`GET /patients/{id}`

Scope: `patients:read`.

Con `clinical:read` la scheda include allergie, farmaci, condizioni e note, e la lettura entra nel registro accessi. L'header `ETag` va rimandato come `If-Match` per modificarla.

### Parametri

| Nome | Posizione | Tipo | Obbligatorio | Descrizione |
| - | - | - | - | - |
| `id` | path | string (uuid) | sì | Identificativo della risorsa. |

### Risposte

| Status | Descrizione |
| - | - |
| `200` | Il paziente. |
| `400` | Codici: `invalid_request`. |
| `401` | Codici: `unauthenticated`, `api_key_invalid`, `api_key_revoked`. |
| `403` | Codici: `plan_required`, `insufficient_scope`. |
| `404` | Codici: `not_found`. |
| `429` | Codici: `rate_limited`. |
| `500` | Codici: `internal_error`. |
| `503` | Codici: `unavailable`. |

### Campi della risposta

| Campo | Tipo | Sempre presente | Descrizione |
| - | - | - | - |
| `id` | string (uuid) | sì | |
| `firstName` | string | sì | |
| `lastName` | string | sì | |
| `email` | string \| null | sì | |
| `phone` | string \| null | sì | In forma canonica: +\<paese>\<numero>. |
| `dateOfBirth` | string \| null | sì | |
| `gender` | string \| null | sì | |
| `taxCode` | string \| null | sì | |
| `address` | object | sì | |
| `address.street` | string \| null | sì | |
| `address.city` | string \| null | sì | |
| `address.zip` | string \| null | sì | |
| `address.country` | string \| null | sì | |
| `facilityId` | string (uuid) \| null | sì | |
| `referralSource` | string \| null | sì | |
| `isActive` | boolean | sì | false = archiviato. |
| `archivedAt` | string \| null | sì | |
| `createdAt` | string \| null | sì | |
| `updatedAt` | string | sì | |
| `allergies` | array di string | no | Solo con clinical:read. |
| `medications` | array di string | no | Solo con clinical:read. |
| `conditions` | array di string | no | Solo con clinical:read. |
| `notes` | string \| null | no | Solo con clinical:read. |

## Modifica un paziente

`PATCH /patients/{id}`

Scope: `patients:write`.

Solo i campi indicati; `null` svuota un campo (non il nome). `If-Match` col valore di `ETag` della scheda: se e' cambiata, 409. Allergie, farmaci, condizioni e note richiedono anche `clinical:write`.

### Parametri

| Nome | Posizione | Tipo | Obbligatorio | Descrizione |
| - | - | - | - | - |
| `id` | path | string (uuid) | sì | Identificativo della risorsa. |
| `If-Match` | header | string | no | L'ETag ricevuto: la modifica passa solo se nessuno ha cambiato la risorsa (altrimenti 409 conflict, details.reason = version\_mismatch). |

### Corpo (JSON)

| Campo | Tipo | Obbligatorio | Descrizione |
| - | - | - | - |
| `firstName` | string | no | |
| `lastName` | string | no | |
| `email` | string (email) \| null | no | |
| `phone` | string \| null | no | |
| `dateOfBirth` | string (date) \| null | no | |
| `gender` | string: `male`, `female`, `other`, `prefer_not_to_say` \| null | no | |
| `taxCode` | string \| null | no | |
| `address` | object | no | |
| `address.street` | string \| null | no | |
| `address.city` | string \| null | no | |
| `address.zip` | string \| null | no | |
| `address.country` | string \| null | no | |
| `facilityId` | string (uuid) | no | |
| `referralSource` | string: `walk_in`, `internet`, `social`, `companies`, `referral` \| null | no | |
| `allergies` | array di string | no | Richiede clinical:write. |
| `medications` | array di string | no | Richiede clinical:write. |
| `conditions` | array di string | no | Richiede clinical:write. |
| `notes` | string \| null | no | Richiede clinical:write. |

### Risposte

| Status | Descrizione |
| - | - |
| `200` | La scheda aggiornata (header ETag). |
| `400` | Codici: `invalid_request`. |
| `401` | Codici: `unauthenticated`, `api_key_invalid`, `api_key_revoked`. |
| `403` | Codici: `plan_required`, `insufficient_scope`. |
| `404` | Codici: `not_found`. |
| `409` | Codici: `conflict`. |
| `429` | Codici: `rate_limited`. |
| `500` | Codici: `internal_error`. |
| `503` | Codici: `unavailable`. |

### Campi della risposta

| Campo | Tipo | Sempre presente | Descrizione |
| - | - | - | - |
| `id` | string (uuid) | sì | |
| `firstName` | string | sì | |
| `lastName` | string | sì | |
| `email` | string \| null | sì | |
| `phone` | string \| null | sì | In forma canonica: +\<paese>\<numero>. |
| `dateOfBirth` | string \| null | sì | |
| `gender` | string \| null | sì | |
| `taxCode` | string \| null | sì | |
| `address` | object | sì | |
| `address.street` | string \| null | sì | |
| `address.city` | string \| null | sì | |
| `address.zip` | string \| null | sì | |
| `address.country` | string \| null | sì | |
| `facilityId` | string (uuid) \| null | sì | |
| `referralSource` | string \| null | sì | |
| `isActive` | boolean | sì | false = archiviato. |
| `archivedAt` | string \| null | sì | |
| `createdAt` | string \| null | sì | |
| `updatedAt` | string | sì | |
| `allergies` | array di string | no | Solo con clinical:read. |
| `medications` | array di string | no | Solo con clinical:read. |
| `conditions` | array di string | no | Solo con clinical:read. |
| `notes` | string \| null | no | Solo con clinical:read. |

## Archivia un paziente

`POST /patients/{id}/archive`

Scope: `patients:write`.

Non cancella niente: il paziente esce dagli elenchi (`archived=true` per vederli) e si puo' riattivare. Ripeterla non cambia nulla.

### Parametri

| Nome | Posizione | Tipo | Obbligatorio | Descrizione |
| - | - | - | - | - |
| `id` | path | string (uuid) | sì | Identificativo della risorsa. |

### Risposte

| Status | Descrizione |
| - | - |
| `200` | La scheda aggiornata (header ETag). |
| `400` | Codici: `invalid_request`. |
| `401` | Codici: `unauthenticated`, `api_key_invalid`, `api_key_revoked`. |
| `403` | Codici: `plan_required`, `insufficient_scope`. |
| `404` | Codici: `not_found`. |
| `409` | Codici: `conflict`. |
| `429` | Codici: `rate_limited`. |
| `500` | Codici: `internal_error`. |
| `503` | Codici: `unavailable`. |

### Campi della risposta

| Campo | Tipo | Sempre presente | Descrizione |
| - | - | - | - |
| `id` | string (uuid) | sì | |
| `firstName` | string | sì | |
| `lastName` | string | sì | |
| `email` | string \| null | sì | |
| `phone` | string \| null | sì | In forma canonica: +\<paese>\<numero>. |
| `dateOfBirth` | string \| null | sì | |
| `gender` | string \| null | sì | |
| `taxCode` | string \| null | sì | |
| `address` | object | sì | |
| `address.street` | string \| null | sì | |
| `address.city` | string \| null | sì | |
| `address.zip` | string \| null | sì | |
| `address.country` | string \| null | sì | |
| `facilityId` | string (uuid) \| null | sì | |
| `referralSource` | string \| null | sì | |
| `isActive` | boolean | sì | false = archiviato. |
| `archivedAt` | string \| null | sì | |
| `createdAt` | string \| null | sì | |
| `updatedAt` | string | sì | |
| `allergies` | array di string | no | Solo con clinical:read. |
| `medications` | array di string | no | Solo con clinical:read. |
| `conditions` | array di string | no | Solo con clinical:read. |
| `notes` | string \| null | no | Solo con clinical:read. |

## Riattiva un paziente archiviato

`POST /patients/{id}/restore`

Scope: `patients:write`.

Riporta fra gli attivi un paziente archiviato. Ripeterla non cambia nulla.

### Parametri

| Nome | Posizione | Tipo | Obbligatorio | Descrizione |
| - | - | - | - | - |
| `id` | path | string (uuid) | sì | Identificativo della risorsa. |

### Risposte

| Status | Descrizione |
| - | - |
| `200` | La scheda aggiornata (header ETag). |
| `400` | Codici: `invalid_request`. |
| `401` | Codici: `unauthenticated`, `api_key_invalid`, `api_key_revoked`. |
| `403` | Codici: `plan_required`, `insufficient_scope`. |
| `404` | Codici: `not_found`. |
| `409` | Codici: `conflict`. |
| `429` | Codici: `rate_limited`. |
| `500` | Codici: `internal_error`. |
| `503` | Codici: `unavailable`. |

### Campi della risposta

| Campo | Tipo | Sempre presente | Descrizione |
| - | - | - | - |
| `id` | string (uuid) | sì | |
| `firstName` | string | sì | |
| `lastName` | string | sì | |
| `email` | string \| null | sì | |
| `phone` | string \| null | sì | In forma canonica: +\<paese>\<numero>. |
| `dateOfBirth` | string \| null | sì | |
| `gender` | string \| null | sì | |
| `taxCode` | string \| null | sì | |
| `address` | object | sì | |
| `address.street` | string \| null | sì | |
| `address.city` | string \| null | sì | |
| `address.zip` | string \| null | sì | |
| `address.country` | string \| null | sì | |
| `facilityId` | string (uuid) \| null | sì | |
| `referralSource` | string \| null | sì | |
| `isActive` | boolean | sì | false = archiviato. |
| `archivedAt` | string \| null | sì | |
| `createdAt` | string \| null | sì | |
| `updatedAt` | string | sì | |
| `allergies` | array di string | no | Solo con clinical:read. |
| `medications` | array di string | no | Solo con clinical:read. |
| `conditions` | array di string | no | Solo con clinical:read. |
| `notes` | string \| null | no | Solo con clinical:read. |

## Gli appuntamenti di un paziente

`GET /patients/{id}/appointments`

Scope: `patients:read` e `appointments:read`.

`upcoming=true`: solo quelli che devono ancora avvenire e non sono disdetti, saltati o conclusi. Senza note cliniche. Titolo e motivo sono testo libero: la lettura finisce nel registro accessi. Ordinati per ultima modifica.

### Parametri

| Nome | Posizione | Tipo | Obbligatorio | Descrizione |
| - | - | - | - | - |
| `id` | path | string (uuid) | sì | Identificativo della risorsa. |
| `limit` | query | integer | no | Quante righe per pagina (1-200). |
| `cursor` | query | string | no | Il nextCursor della pagina precedente, tale e quale. |
| `updatedSince` | query | string (date-time) | no | Solo le righe modificate da questo istante in poi (sincronizzazione incrementale). |
| `upcoming` | query | string: `true`, `false` | no | |

### Risposte

| Status | Descrizione |
| - | - |
| `200` | Una pagina di appuntamenti. |
| `400` | Codici: `invalid_request`. |
| `401` | Codici: `unauthenticated`, `api_key_invalid`, `api_key_revoked`. |
| `403` | Codici: `plan_required`, `insufficient_scope`. |
| `404` | Codici: `not_found`. |
| `429` | Codici: `rate_limited`. |
| `500` | Codici: `internal_error`. |
| `503` | Codici: `unavailable`. |

### Campi della risposta

| Campo | Tipo | Sempre presente | Descrizione |
| - | - | - | - |
| `data` | array di object | sì | |
| `data[].id` | string (uuid) | sì | |
| `data[].patientId` | string (uuid) | sì | |
| `data[].startTime` | string | sì | |
| `data[].endTime` | string | sì | |
| `data[].status` | string | sì | |
| `data[].type` | string \| null | sì | |
| `data[].title` | string \| null | sì | |
| `data[].reason` | string \| null | sì | |
| `data[].doctorId` | string (uuid) \| null | sì | |
| `data[].chairId` | string (uuid) \| null | sì | |
| `data[].facilityId` | string (uuid) \| null | sì | |
| `data[].calendarId` | string (uuid) \| null | sì | |
| `data[].origin` | string | sì | |
| `data[].updatedAt` | string \| null | sì | |
| `nextCursor` | string \| null | sì | null = ultima pagina. |

## I preventivi di un paziente

`GET /patients/{id}/treatment-plans`

Scope: `patients:read` e `treatment_plans:read`.

Riepilogo senza voci, denti e diagnosi (il dettaglio e' in `/treatment-plans/\{id\}`). Esclusi quelli eliminati.

### Parametri

| Nome | Posizione | Tipo | Obbligatorio | Descrizione |
| - | - | - | - | - |
| `id` | path | string (uuid) | sì | Identificativo della risorsa. |
| `limit` | query | integer | no | Quante righe per pagina (1-200). |
| `cursor` | query | string | no | Il nextCursor della pagina precedente, tale e quale. |
| `updatedSince` | query | string (date-time) | no | Solo le righe modificate da questo istante in poi (sincronizzazione incrementale). |

### Risposte

| Status | Descrizione |
| - | - |
| `200` | Una pagina di preventivi. |
| `400` | Codici: `invalid_request`. |
| `401` | Codici: `unauthenticated`, `api_key_invalid`, `api_key_revoked`. |
| `403` | Codici: `plan_required`, `insufficient_scope`. |
| `404` | Codici: `not_found`. |
| `429` | Codici: `rate_limited`. |
| `500` | Codici: `internal_error`. |
| `503` | Codici: `unavailable`. |

### Campi della risposta

| Campo | Tipo | Sempre presente | Descrizione |
| - | - | - | - |
| `data` | array di object | sì | |
| `data[].id` | string (uuid) | sì | |
| `data[].patientId` | string (uuid) | sì | |
| `data[].name` | string | sì | |
| `data[].status` | string \| null | sì | |
| `data[].totalCost` | number \| null | sì | |
| `data[].presentedAmount` | number \| null | sì | |
| `data[].acceptedAmount` | number \| null | sì | |
| `data[].presentedAt` | string \| null | sì | |
| `data[].acceptedAt` | string \| null | sì | |
| `data[].assignedDoctorId` | string (uuid) \| null | sì | |
| `data[].facilityId` | string (uuid) \| null | sì | |
| `data[].createdAt` | string \| null | sì | |
| `data[].updatedAt` | string \| null | sì | |
| `nextCursor` | string \| null | sì | null = ultima pagina. |

## I dati sanitari di un paziente

`GET /patients/{id}/clinical`

Scope: `clinical:read`.

Allergie, farmaci, condizioni e odontogramma. Serve lo scope `clinical:read` (spento di default) e ogni lettura viene registrata nel registro accessi della clinica.

### Parametri

| Nome | Posizione | Tipo | Obbligatorio | Descrizione |
| - | - | - | - | - |
| `id` | path | string (uuid) | sì | Identificativo della risorsa. |

### Risposte

| Status | Descrizione |
| - | - |
| `200` | I dati sanitari. |
| `400` | Codici: `invalid_request`. |
| `401` | Codici: `unauthenticated`, `api_key_invalid`, `api_key_revoked`. |
| `403` | Codici: `plan_required`, `insufficient_scope`. |
| `404` | Codici: `not_found`. |
| `429` | Codici: `rate_limited`. |
| `500` | Codici: `internal_error`. |
| `503` | Codici: `unavailable`. |

### Campi della risposta

| Campo | Tipo | Sempre presente | Descrizione |
| - | - | - | - |
| `patientId` | string (uuid) | sì | |
| `allergies` | array di string | sì | |
| `medications` | array di string | sì | |
| `declaredConditions` | array di string | sì | Le condizioni scritte a mano sulla scheda. |
| `conditions` | array di object | sì | Le condizioni del catalogo diagnosticate al paziente. |
| `conditions[].conditionId` | string (uuid) | sì | |
| `conditions[].name` | string | sì | |
| `conditions[].severity` | string \| null | sì | |
| `conditions[].isBiohazard` | boolean | sì | |
| `conditions[].diagnosedAt` | string \| null | sì | |
| `conditions[].notes` | string \| null | sì | |
| `notes` | string \| null | sì | |
| `odontograms` | array di object | sì | |
| `odontograms[].id` | string (uuid) | sì | |
| `odontograms[].odontogramType` | string | sì | |
| `odontograms[].toothData` | object | sì | Lo stato dei denti, come lo salva l'app. |
| `odontograms[].notes` | string \| null | sì | |
| `odontograms[].updatedAt` | string | sì | |

## I documenti di un paziente

`GET /patients/{id}/documents`

Scope: `documents:read`.

I documenti da firmare del paziente (consensi, moduli) con lo stato della firma. Solo i metadati: il contenuto e' in `/documents/\{id\}`. Il titolo e' testo libero: la lettura finisce nel registro accessi. Ordinati per data di creazione.

### Parametri

| Nome | Posizione | Tipo | Obbligatorio | Descrizione |
| - | - | - | - | - |
| `id` | path | string (uuid) | sì | Identificativo della risorsa. |
| `limit` | query | integer | no | Quante righe per pagina (1-200). |
| `cursor` | query | string | no | Il nextCursor della pagina precedente, tale e quale. |

### Risposte

| Status | Descrizione |
| - | - |
| `200` | Una pagina di documenti. |
| `400` | Codici: `invalid_request`. |
| `401` | Codici: `unauthenticated`, `api_key_invalid`, `api_key_revoked`. |
| `403` | Codici: `plan_required`, `insufficient_scope`. |
| `404` | Codici: `not_found`. |
| `429` | Codici: `rate_limited`. |
| `500` | Codici: `internal_error`. |
| `503` | Codici: `unavailable`. |

### Campi della risposta

| Campo | Tipo | Sempre presente | Descrizione |
| - | - | - | - |
| `data` | array di object | sì | |
| `data[].id` | string (uuid) | sì | |
| `data[].name` | string | sì | |
| `data[].status` | string: `draft`, `sent`, `signed`, `voided` \| null | sì | Stato della firma: draft, sent (inviato da firmare), signed, voided (annullato). |
| `data[].mimeType` | string \| null | sì | Sempre null: il file non si scarica dall'API. |
| `data[].sizeBytes` | number \| null | sì | Sempre null: il file non si scarica dall'API. |
| `data[].createdAt` | string \| null | sì | |
| `data[].updatedAt` | string \| null | sì | |
| `nextCursor` | string \| null | sì | null = ultima pagina. |

## Orari liberi di un calendario

`GET /calendars/{id}/availability`

Scope: `appointments:read`.

Gli orari prenotabili fra `from` e `to`, con le regole del calendario (medici, fasce, durata, preavviso, orizzonte). In un calendario «scelta del paziente» lo stesso orario compare una volta per ogni medico libero, e ognuno conta in `limit`. La risposta dice la finestra davvero cercata (`window`) e se gli orari sono tutti (`complete`); se no, si continua con from=`nextFrom`. Una pagina non spezza un orario fra i suoi medici: se a uno stesso orario i medici liberi sono piu' di `limit`, 400 su `limit`.

### Parametri

| Nome | Posizione | Tipo | Obbligatorio | Descrizione |
| - | - | - | - | - |
| `id` | path | string (uuid) | sì | Identificativo della risorsa. |
| `from` | query | string (date-time) | sì | Da quando cercare (ISO 8601). Il preavviso del calendario vale comunque. |
| `to` | query | string (date-time) | sì | Fino a quando (ISO 8601), al massimo 31 giorni dopo from. |
| `doctorId` | query | string (uuid) | no | Solo gli orari di questo medico (deve ricevere nel calendario). |
| `patientId` | query | string (uuid) | no | Il paziente: nei calendari ad assegnazione automatica, prima il suo medico abituale. |
| `limit` | query | integer | no | Quanti orari al massimo (1-200). |

### Risposte

| Status | Descrizione |
| - | - |
| `200` | Gli orari liberi. |
| `400` | Codici: `invalid_request`. |
| `401` | Codici: `unauthenticated`, `api_key_invalid`, `api_key_revoked`. |
| `403` | Codici: `plan_required`, `insufficient_scope`. |
| `404` | Codici: `not_found`. |
| `429` | Codici: `rate_limited`. |
| `500` | Codici: `internal_error`. |
| `503` | Codici: `unavailable`. |

### Campi della risposta

| Campo | Tipo | Sempre presente | Descrizione |
| - | - | - | - |
| `data` | array di object | sì | In ordine di inizio: tutti gli orari liberi di `window` (se complete) o quelli che iniziano prima di `nextFrom`. |
| `data[].start` | string | sì | Inizio (ISO 8601). |
| `data[].end` | string | sì | Fine (ISO 8601). |
| `data[].doctorId` | string (uuid) | sì | Il medico che lo riceverebbe. |
| `data[].chairId` | string (uuid) \| null | sì | La poltrona di default di quel medico in quella sede. |
| `window` | object \| null | sì | La finestra davvero cercata: quella chiesta, stretta da preavviso e orizzonte del calendario. null = le regole del calendario non lasciano niente in quella chiesta (tutta oltre l'orizzonte o dentro il preavviso). |
| `window.from` | string | sì | |
| `window.to` | string | sì | |
| `complete` | boolean | sì | true: `data` contiene tutti gli orari liberi di `window`. false: ce ne sono altri, chiedili con from=nextFrom. |
| `nextFrom` | string \| null | sì | Se complete=false: il `from` della richiesta successiva (stesso `to`). Altrimenti null. |

## Prenota un orario

`POST /calendars/{id}/bookings`

Scope: `appointments:write`.

Ricontrolla l'orario con le regole del calendario e lo prenota (stato `scheduled`). 409 `no_availability` se non e' piu' libero (`reason: slot_taken`) o non rispetta il preavviso (`reason: min_notice`), con `details.alternatives`; 409 `conflict` con `reason: booking_disabled` se il calendario non accetta prenotazioni da fuori.

### Parametri

| Nome | Posizione | Tipo | Obbligatorio | Descrizione |
| - | - | - | - | - |
| `id` | path | string (uuid) | sì | Identificativo della risorsa. |
| `Idempotency-Key` | header | string | sì | Obbligatoria. La stessa chiave entro 24 ore ripete la prima risposta 2xx. |

### Corpo (JSON)

| Campo | Tipo | Obbligatorio | Descrizione |
| - | - | - | - |
| `patientId` | string (uuid) | sì | |
| `start` | string (date-time) | sì | L'inizio di uno degli orari di /availability, tale e quale. |
| `doctorId` | string (uuid) | no | Il medico scelto; senza, lo sceglie il calendario (abituale, poi il primo libero). |
| `title` | string | no | Senza, il nome del calendario. |
| `reason` | string | no | Il motivo della visita, come lo dice il paziente. |
| `origin` | string: `api`, `voice` | no | voice se prenota l'agente vocale. |

### Risposte

| Status | Descrizione |
| - | - |
| `201` | L'appuntamento prenotato. |
| `400` | Codici: `invalid_request`. |
| `401` | Codici: `unauthenticated`, `api_key_invalid`, `api_key_revoked`. |
| `403` | Codici: `plan_required`, `insufficient_scope`. |
| `404` | Codici: `not_found`. |
| `409` | Codici: `no_availability`, `conflict`, `idempotency_in_progress`. |
| `422` | Codici: `idempotency_key_reused`. |
| `429` | Codici: `rate_limited`. |
| `500` | Codici: `internal_error`. |
| `503` | Codici: `unavailable`. |

### Campi della risposta

| Campo | Tipo | Sempre presente | Descrizione |
| - | - | - | - |
| `id` | string (uuid) | sì | |
| `patientId` | string (uuid) | sì | |
| `facilityId` | string (uuid) \| null | sì | |
| `doctorId` | string (uuid) \| null | sì | |
| `chairId` | string (uuid) \| null | sì | |
| `calendarId` | string (uuid) \| null | sì | Il calendario da cui e' nato: le sue regole valgono per spostarlo e disdirlo. null = fuori calendario. |
| `start` | string | sì | ISO 8601 con fuso (UTC). |
| `end` | string | sì | ISO 8601 con fuso (UTC). |
| `status` | string: `draft`, `scheduled`, `confirmed`, `checked_in`, `in_progress`, `completed`, `cancelled`, `no_show`, `pending`, `postponed` | sì | |
| `type` | string: `checkup`, `treatment`, `consultation`, `emergency`, `other` \| null | sì | |
| `category` | string: `recall`, `treatment_plan`, `first_visit`, `case_study`, `standard` \| null | sì | |
| `title` | string \| null | sì | Ogni lettura finisce nel registro accessi (testo libero). |
| `reason` | string \| null | sì | Il motivo della visita. Ogni lettura finisce nel registro accessi (testo libero). |
| `notes` | string \| null | no | Solo con lo scope clinical:read (l'accesso finisce nel registro). |
| `origin` | string: `manual`, `bot`, `voice`, `api` | sì | Da dove e' nato: manual (agenda), bot (WhatsApp), voice (agente vocale), api. |
| `treatmentPlanId` | string (uuid) \| null | sì | |
| `createdAt` | string \| null | sì | |
| `updatedAt` | string \| null | sì | |
| `clinicalNotes` | string \| null | no | Solo con lo scope clinical:read (l'accesso finisce nel registro). |
| `completionNotes` | string \| null | no | Solo con lo scope clinical:read. |

## Elenco degli appuntamenti

`GET /appointments`

Scope: `appointments:read`.

In ordine di ultima modifica (updatedSince per sincronizzare), o di orario con sort=start. Senza note ne' note cliniche: si leggono dal dettaglio, con clinical:read. Titolo e motivo sono testo libero: la lettura finisce nel registro accessi.

### Parametri

| Nome | Posizione | Tipo | Obbligatorio | Descrizione |
| - | - | - | - | - |
| `limit` | query | integer | no | Quante righe per pagina (1-200). |
| `cursor` | query | string | no | Il nextCursor della pagina precedente, tale e quale. |
| `updatedSince` | query | string (date-time) | no | Solo le righe modificate da questo istante in poi (sincronizzazione incrementale). |
| `from` | query | string (date-time) | no | Solo gli appuntamenti che iniziano da qui (incluso). |
| `to` | query | string (date-time) | no | Solo quelli che iniziano prima di qui (escluso). |
| `facilityId` | query | string (uuid) | no | |
| `doctorId` | query | string (uuid) | no | |
| `patientId` | query | string (uuid) | no | |
| `status` | query | string | no | Uno o piu' stati separati da virgola: draft, scheduled, confirmed, checked\_in, in\_progress, completed, cancelled, no\_show, pending, postponed. |
| `sort` | query | string: `updated`, `start` | no | updated (predefinito): per ultima modifica, con updatedSince. start: per orario di inizio, senza updatedSince. |

### Risposte

| Status | Descrizione |
| - | - |
| `200` | Una pagina di appuntamenti. |
| `400` | Codici: `invalid_request`. |
| `401` | Codici: `unauthenticated`, `api_key_invalid`, `api_key_revoked`. |
| `403` | Codici: `plan_required`, `insufficient_scope`. |
| `429` | Codici: `rate_limited`. |
| `500` | Codici: `internal_error`. |
| `503` | Codici: `unavailable`. |

### Campi della risposta

| Campo | Tipo | Sempre presente | Descrizione |
| - | - | - | - |
| `data` | array di object | sì | |
| `data[].id` | string (uuid) | sì | |
| `data[].patientId` | string (uuid) | sì | |
| `data[].facilityId` | string (uuid) \| null | sì | |
| `data[].doctorId` | string (uuid) \| null | sì | |
| `data[].chairId` | string (uuid) \| null | sì | |
| `data[].calendarId` | string (uuid) \| null | sì | Il calendario da cui e' nato: le sue regole valgono per spostarlo e disdirlo. null = fuori calendario. |
| `data[].start` | string | sì | ISO 8601 con fuso (UTC). |
| `data[].end` | string | sì | ISO 8601 con fuso (UTC). |
| `data[].status` | string: `draft`, `scheduled`, `confirmed`, `checked_in`, `in_progress`, `completed`, `cancelled`, `no_show`, `pending`, `postponed` | sì | |
| `data[].type` | string: `checkup`, `treatment`, `consultation`, `emergency`, `other` \| null | sì | |
| `data[].category` | string: `recall`, `treatment_plan`, `first_visit`, `case_study`, `standard` \| null | sì | |
| `data[].title` | string \| null | sì | Ogni lettura finisce nel registro accessi (testo libero). |
| `data[].reason` | string \| null | sì | Il motivo della visita. Ogni lettura finisce nel registro accessi (testo libero). |
| `data[].notes` | string \| null | no | Solo con lo scope clinical:read (l'accesso finisce nel registro). |
| `data[].origin` | string: `manual`, `bot`, `voice`, `api` | sì | Da dove e' nato: manual (agenda), bot (WhatsApp), voice (agente vocale), api. |
| `data[].treatmentPlanId` | string (uuid) \| null | sì | |
| `data[].createdAt` | string \| null | sì | |
| `data[].updatedAt` | string \| null | sì | |
| `data[].clinicalNotes` | string \| null | no | Solo con lo scope clinical:read (l'accesso finisce nel registro). |
| `data[].completionNotes` | string \| null | no | Solo con lo scope clinical:read. |
| `nextCursor` | string \| null | sì | null = ultima pagina. |

## Crea un appuntamento fuori calendario

`POST /appointments`

Scope: `appointments:write`.

Per i gestionali che sanno gia' medico, sede e orario: nessuna regola di calendario, solo il controllo di sovrapposizione su medico e poltrona (409 `no_availability`, `reason: slot_taken`). Per prenotare per conto di un paziente usa /calendars/\{id}/bookings.

### Parametri

| Nome | Posizione | Tipo | Obbligatorio | Descrizione |
| - | - | - | - | - |
| `Idempotency-Key` | header | string | sì | Obbligatoria. La stessa chiave entro 24 ore ripete la prima risposta 2xx. |

### Corpo (JSON)

| Campo | Tipo | Obbligatorio | Descrizione |
| - | - | - | - |
| `patientId` | string (uuid) | sì | |
| `facilityId` | string (uuid) | sì | |
| `doctorId` | string (uuid) | sì | |
| `chairId` | string (uuid) | no | Deve stare nella sede. |
| `start` | string (date-time) | sì | |
| `end` | string (date-time) | sì | Dopo start, entro 12 ore. |
| `status` | string: `draft`, `scheduled`, `confirmed` | no | |
| `type` | string: `checkup`, `treatment`, `consultation`, `emergency`, `other` | no | |
| `title` | string | no | |
| `reason` | string | no | |
| `notes` | string | no | |
| `origin` | string: `api`, `voice` | no | |

### Risposte

| Status | Descrizione |
| - | - |
| `201` | L'appuntamento creato. |
| `400` | Codici: `invalid_request`. |
| `401` | Codici: `unauthenticated`, `api_key_invalid`, `api_key_revoked`. |
| `403` | Codici: `plan_required`, `insufficient_scope`. |
| `409` | Codici: `no_availability`, `idempotency_in_progress`. |
| `422` | Codici: `idempotency_key_reused`. |
| `429` | Codici: `rate_limited`. |
| `500` | Codici: `internal_error`. |
| `503` | Codici: `unavailable`. |

### Campi della risposta

| Campo | Tipo | Sempre presente | Descrizione |
| - | - | - | - |
| `id` | string (uuid) | sì | |
| `patientId` | string (uuid) | sì | |
| `facilityId` | string (uuid) \| null | sì | |
| `doctorId` | string (uuid) \| null | sì | |
| `chairId` | string (uuid) \| null | sì | |
| `calendarId` | string (uuid) \| null | sì | Il calendario da cui e' nato: le sue regole valgono per spostarlo e disdirlo. null = fuori calendario. |
| `start` | string | sì | ISO 8601 con fuso (UTC). |
| `end` | string | sì | ISO 8601 con fuso (UTC). |
| `status` | string: `draft`, `scheduled`, `confirmed`, `checked_in`, `in_progress`, `completed`, `cancelled`, `no_show`, `pending`, `postponed` | sì | |
| `type` | string: `checkup`, `treatment`, `consultation`, `emergency`, `other` \| null | sì | |
| `category` | string: `recall`, `treatment_plan`, `first_visit`, `case_study`, `standard` \| null | sì | |
| `title` | string \| null | sì | Ogni lettura finisce nel registro accessi (testo libero). |
| `reason` | string \| null | sì | Il motivo della visita. Ogni lettura finisce nel registro accessi (testo libero). |
| `notes` | string \| null | no | Solo con lo scope clinical:read (l'accesso finisce nel registro). |
| `origin` | string: `manual`, `bot`, `voice`, `api` | sì | Da dove e' nato: manual (agenda), bot (WhatsApp), voice (agente vocale), api. |
| `treatmentPlanId` | string (uuid) \| null | sì | |
| `createdAt` | string \| null | sì | |
| `updatedAt` | string \| null | sì | |
| `clinicalNotes` | string \| null | no | Solo con lo scope clinical:read (l'accesso finisce nel registro). |
| `completionNotes` | string \| null | no | Solo con lo scope clinical:read. |

## Un appuntamento

`GET /appointments/{id}`

Scope: `appointments:read`.

Con l'ETag da rimandare in If-Match. Note e note cliniche solo con clinical:read.

### Parametri

| Nome | Posizione | Tipo | Obbligatorio | Descrizione |
| - | - | - | - | - |
| `id` | path | string (uuid) | sì | Identificativo della risorsa. |

### Risposte

| Status | Descrizione |
| - | - |
| `200` | L'appuntamento. |
| `400` | Codici: `invalid_request`. |
| `401` | Codici: `unauthenticated`, `api_key_invalid`, `api_key_revoked`. |
| `403` | Codici: `plan_required`, `insufficient_scope`. |
| `404` | Codici: `not_found`. |
| `429` | Codici: `rate_limited`. |
| `500` | Codici: `internal_error`. |
| `503` | Codici: `unavailable`. |

### Campi della risposta

| Campo | Tipo | Sempre presente | Descrizione |
| - | - | - | - |
| `id` | string (uuid) | sì | |
| `patientId` | string (uuid) | sì | |
| `facilityId` | string (uuid) \| null | sì | |
| `doctorId` | string (uuid) \| null | sì | |
| `chairId` | string (uuid) \| null | sì | |
| `calendarId` | string (uuid) \| null | sì | Il calendario da cui e' nato: le sue regole valgono per spostarlo e disdirlo. null = fuori calendario. |
| `start` | string | sì | ISO 8601 con fuso (UTC). |
| `end` | string | sì | ISO 8601 con fuso (UTC). |
| `status` | string: `draft`, `scheduled`, `confirmed`, `checked_in`, `in_progress`, `completed`, `cancelled`, `no_show`, `pending`, `postponed` | sì | |
| `type` | string: `checkup`, `treatment`, `consultation`, `emergency`, `other` \| null | sì | |
| `category` | string: `recall`, `treatment_plan`, `first_visit`, `case_study`, `standard` \| null | sì | |
| `title` | string \| null | sì | Ogni lettura finisce nel registro accessi (testo libero). |
| `reason` | string \| null | sì | Il motivo della visita. Ogni lettura finisce nel registro accessi (testo libero). |
| `notes` | string \| null | no | Solo con lo scope clinical:read (l'accesso finisce nel registro). |
| `origin` | string: `manual`, `bot`, `voice`, `api` | sì | Da dove e' nato: manual (agenda), bot (WhatsApp), voice (agente vocale), api. |
| `treatmentPlanId` | string (uuid) \| null | sì | |
| `createdAt` | string \| null | sì | |
| `updatedAt` | string \| null | sì | |
| `clinicalNotes` | string \| null | no | Solo con lo scope clinical:read (l'accesso finisce nel registro). |
| `completionNotes` | string \| null | no | Solo con lo scope clinical:read. |

## Modifica titolo, motivo o note

`PATCH /appointments/{id}`

Scope: `appointments:write`.

Solo i testi: per l'orario c'e' /move, per lo stato /status. Con If-Match (l'ETag ricevuto) la modifica passa solo se nessuno ha cambiato l'appuntamento nel frattempo, altrimenti 409 `conflict`.

### Parametri

| Nome | Posizione | Tipo | Obbligatorio | Descrizione |
| - | - | - | - | - |
| `id` | path | string (uuid) | sì | Identificativo della risorsa. |
| `If-Match` | header | string | no | L'ETag ricevuto: la modifica passa solo se nessuno ha cambiato la risorsa (altrimenti 409 conflict, details.reason = version\_mismatch). |

### Corpo (JSON)

| Campo | Tipo | Obbligatorio | Descrizione |
| - | - | - | - |
| `title` | string \| null | no | |
| `reason` | string \| null | no | |
| `notes` | string \| null | no | |

### Risposte

| Status | Descrizione |
| - | - |
| `200` | L'appuntamento modificato. |
| `400` | Codici: `invalid_request`. |
| `401` | Codici: `unauthenticated`, `api_key_invalid`, `api_key_revoked`. |
| `403` | Codici: `plan_required`, `insufficient_scope`. |
| `404` | Codici: `not_found`. |
| `409` | Codici: `conflict`. |
| `429` | Codici: `rate_limited`. |
| `500` | Codici: `internal_error`. |
| `503` | Codici: `unavailable`. |

### Campi della risposta

| Campo | Tipo | Sempre presente | Descrizione |
| - | - | - | - |
| `id` | string (uuid) | sì | |
| `patientId` | string (uuid) | sì | |
| `facilityId` | string (uuid) \| null | sì | |
| `doctorId` | string (uuid) \| null | sì | |
| `chairId` | string (uuid) \| null | sì | |
| `calendarId` | string (uuid) \| null | sì | Il calendario da cui e' nato: le sue regole valgono per spostarlo e disdirlo. null = fuori calendario. |
| `start` | string | sì | ISO 8601 con fuso (UTC). |
| `end` | string | sì | ISO 8601 con fuso (UTC). |
| `status` | string: `draft`, `scheduled`, `confirmed`, `checked_in`, `in_progress`, `completed`, `cancelled`, `no_show`, `pending`, `postponed` | sì | |
| `type` | string: `checkup`, `treatment`, `consultation`, `emergency`, `other` \| null | sì | |
| `category` | string: `recall`, `treatment_plan`, `first_visit`, `case_study`, `standard` \| null | sì | |
| `title` | string \| null | sì | Ogni lettura finisce nel registro accessi (testo libero). |
| `reason` | string \| null | sì | Il motivo della visita. Ogni lettura finisce nel registro accessi (testo libero). |
| `notes` | string \| null | no | Solo con lo scope clinical:read (l'accesso finisce nel registro). |
| `origin` | string: `manual`, `bot`, `voice`, `api` | sì | Da dove e' nato: manual (agenda), bot (WhatsApp), voice (agente vocale), api. |
| `treatmentPlanId` | string (uuid) \| null | sì | |
| `createdAt` | string \| null | sì | |
| `updatedAt` | string \| null | sì | |
| `clinicalNotes` | string \| null | no | Solo con lo scope clinical:read (l'accesso finisce nel registro). |
| `completionNotes` | string \| null | no | Solo con lo scope clinical:read. |

## Sposta un appuntamento

`POST /appointments/{id}/move`

Scope: `appointments:write`.

Cambia solo l'orario (medico, sede e durata restano), con le regole del calendario di provenienza. 409 `no_availability` (`slot_taken` / `min_notice`) con `details.alternatives`; 409 `conflict` con `reason` `reschedule_disabled`, `status_not_movable`, `doctor_not_in_calendar` o `no_calendar`.

### Parametri

| Nome | Posizione | Tipo | Obbligatorio | Descrizione |
| - | - | - | - | - |
| `id` | path | string (uuid) | sì | Identificativo della risorsa. |
| `Idempotency-Key` | header | string | no | Facoltativa. La stessa chiave entro 24 ore ripete la prima risposta 2xx. |

### Corpo (JSON)

| Campo | Tipo | Obbligatorio | Descrizione |
| - | - | - | - |
| `start` | string (date-time) | sì | Il nuovo inizio: uno degli orari di /availability del suo calendario. |

### Risposte

| Status | Descrizione |
| - | - |
| `200` | L'appuntamento spostato (o gia' a quell'ora). |
| `400` | Codici: `invalid_request`. |
| `401` | Codici: `unauthenticated`, `api_key_invalid`, `api_key_revoked`. |
| `403` | Codici: `plan_required`, `insufficient_scope`. |
| `404` | Codici: `not_found`. |
| `409` | Codici: `no_availability`, `conflict`, `idempotency_in_progress`. |
| `422` | Codici: `idempotency_key_reused`. |
| `429` | Codici: `rate_limited`. |
| `500` | Codici: `internal_error`. |
| `503` | Codici: `unavailable`. |

### Campi della risposta

| Campo | Tipo | Sempre presente | Descrizione |
| - | - | - | - |
| `id` | string (uuid) | sì | |
| `patientId` | string (uuid) | sì | |
| `facilityId` | string (uuid) \| null | sì | |
| `doctorId` | string (uuid) \| null | sì | |
| `chairId` | string (uuid) \| null | sì | |
| `calendarId` | string (uuid) \| null | sì | Il calendario da cui e' nato: le sue regole valgono per spostarlo e disdirlo. null = fuori calendario. |
| `start` | string | sì | ISO 8601 con fuso (UTC). |
| `end` | string | sì | ISO 8601 con fuso (UTC). |
| `status` | string: `draft`, `scheduled`, `confirmed`, `checked_in`, `in_progress`, `completed`, `cancelled`, `no_show`, `pending`, `postponed` | sì | |
| `type` | string: `checkup`, `treatment`, `consultation`, `emergency`, `other` \| null | sì | |
| `category` | string: `recall`, `treatment_plan`, `first_visit`, `case_study`, `standard` \| null | sì | |
| `title` | string \| null | sì | Ogni lettura finisce nel registro accessi (testo libero). |
| `reason` | string \| null | sì | Il motivo della visita. Ogni lettura finisce nel registro accessi (testo libero). |
| `notes` | string \| null | no | Solo con lo scope clinical:read (l'accesso finisce nel registro). |
| `origin` | string: `manual`, `bot`, `voice`, `api` | sì | Da dove e' nato: manual (agenda), bot (WhatsApp), voice (agente vocale), api. |
| `treatmentPlanId` | string (uuid) \| null | sì | |
| `createdAt` | string \| null | sì | |
| `updatedAt` | string \| null | sì | |
| `clinicalNotes` | string \| null | no | Solo con lo scope clinical:read (l'accesso finisce nel registro). |
| `completionNotes` | string \| null | no | Solo con lo scope clinical:read. |

## Disdici un appuntamento

`POST /appointments/{id}/cancel`

Scope: `appointments:write`.

La disdetta del paziente, con le regole del calendario di provenienza. 409 `conflict` con `reason` `cancel_disabled`, `min_notice`, `status_not_cancellable` o `no_calendar`. Lo staff che annulla senza regole usa /status con `cancelled`.

### Parametri

| Nome | Posizione | Tipo | Obbligatorio | Descrizione |
| - | - | - | - | - |
| `id` | path | string (uuid) | sì | Identificativo della risorsa. |
| `Idempotency-Key` | header | string | no | Facoltativa. La stessa chiave entro 24 ore ripete la prima risposta 2xx. |

### Risposte

| Status | Descrizione |
| - | - |
| `200` | L'appuntamento disdetto (o gia' disdetto). |
| `400` | Codici: `invalid_request`. |
| `401` | Codici: `unauthenticated`, `api_key_invalid`, `api_key_revoked`. |
| `403` | Codici: `plan_required`, `insufficient_scope`. |
| `404` | Codici: `not_found`. |
| `409` | Codici: `conflict`, `idempotency_in_progress`. |
| `422` | Codici: `idempotency_key_reused`. |
| `429` | Codici: `rate_limited`. |
| `500` | Codici: `internal_error`. |
| `503` | Codici: `unavailable`. |

### Campi della risposta

| Campo | Tipo | Sempre presente | Descrizione |
| - | - | - | - |
| `id` | string (uuid) | sì | |
| `patientId` | string (uuid) | sì | |
| `facilityId` | string (uuid) \| null | sì | |
| `doctorId` | string (uuid) \| null | sì | |
| `chairId` | string (uuid) \| null | sì | |
| `calendarId` | string (uuid) \| null | sì | Il calendario da cui e' nato: le sue regole valgono per spostarlo e disdirlo. null = fuori calendario. |
| `start` | string | sì | ISO 8601 con fuso (UTC). |
| `end` | string | sì | ISO 8601 con fuso (UTC). |
| `status` | string: `draft`, `scheduled`, `confirmed`, `checked_in`, `in_progress`, `completed`, `cancelled`, `no_show`, `pending`, `postponed` | sì | |
| `type` | string: `checkup`, `treatment`, `consultation`, `emergency`, `other` \| null | sì | |
| `category` | string: `recall`, `treatment_plan`, `first_visit`, `case_study`, `standard` \| null | sì | |
| `title` | string \| null | sì | Ogni lettura finisce nel registro accessi (testo libero). |
| `reason` | string \| null | sì | Il motivo della visita. Ogni lettura finisce nel registro accessi (testo libero). |
| `notes` | string \| null | no | Solo con lo scope clinical:read (l'accesso finisce nel registro). |
| `origin` | string: `manual`, `bot`, `voice`, `api` | sì | Da dove e' nato: manual (agenda), bot (WhatsApp), voice (agente vocale), api. |
| `treatmentPlanId` | string (uuid) \| null | sì | |
| `createdAt` | string \| null | sì | |
| `updatedAt` | string \| null | sì | |
| `clinicalNotes` | string \| null | no | Solo con lo scope clinical:read (l'accesso finisce nel registro). |
| `completionNotes` | string \| null | no | Solo con lo scope clinical:read. |

## Conferma la presenza

`POST /appointments/{id}/confirm`

Scope: `appointments:write`.

Porta l'appuntamento in `confirmed` (da `draft` o `scheduled`). 409 `conflict` con `reason: transition_not_allowed`, `details.status` e `details.allowedTransitions`; 409 `no_availability` se rimetterlo in agenda si sovrapporrebbe a un altro.

### Parametri

| Nome | Posizione | Tipo | Obbligatorio | Descrizione |
| - | - | - | - | - |
| `id` | path | string (uuid) | sì | Identificativo della risorsa. |
| `Idempotency-Key` | header | string | no | Facoltativa. La stessa chiave entro 24 ore ripete la prima risposta 2xx. |

### Risposte

| Status | Descrizione |
| - | - |
| `200` | L'appuntamento confermato (o gia' confermato). |
| `400` | Codici: `invalid_request`. |
| `401` | Codici: `unauthenticated`, `api_key_invalid`, `api_key_revoked`. |
| `403` | Codici: `plan_required`, `insufficient_scope`. |
| `404` | Codici: `not_found`. |
| `409` | Codici: `conflict`, `no_availability`, `idempotency_in_progress`. |
| `422` | Codici: `idempotency_key_reused`. |
| `429` | Codici: `rate_limited`. |
| `500` | Codici: `internal_error`. |
| `503` | Codici: `unavailable`. |

### Campi della risposta

| Campo | Tipo | Sempre presente | Descrizione |
| - | - | - | - |
| `id` | string (uuid) | sì | |
| `patientId` | string (uuid) | sì | |
| `facilityId` | string (uuid) \| null | sì | |
| `doctorId` | string (uuid) \| null | sì | |
| `chairId` | string (uuid) \| null | sì | |
| `calendarId` | string (uuid) \| null | sì | Il calendario da cui e' nato: le sue regole valgono per spostarlo e disdirlo. null = fuori calendario. |
| `start` | string | sì | ISO 8601 con fuso (UTC). |
| `end` | string | sì | ISO 8601 con fuso (UTC). |
| `status` | string: `draft`, `scheduled`, `confirmed`, `checked_in`, `in_progress`, `completed`, `cancelled`, `no_show`, `pending`, `postponed` | sì | |
| `type` | string: `checkup`, `treatment`, `consultation`, `emergency`, `other` \| null | sì | |
| `category` | string: `recall`, `treatment_plan`, `first_visit`, `case_study`, `standard` \| null | sì | |
| `title` | string \| null | sì | Ogni lettura finisce nel registro accessi (testo libero). |
| `reason` | string \| null | sì | Il motivo della visita. Ogni lettura finisce nel registro accessi (testo libero). |
| `notes` | string \| null | no | Solo con lo scope clinical:read (l'accesso finisce nel registro). |
| `origin` | string: `manual`, `bot`, `voice`, `api` | sì | Da dove e' nato: manual (agenda), bot (WhatsApp), voice (agente vocale), api. |
| `treatmentPlanId` | string (uuid) \| null | sì | |
| `createdAt` | string \| null | sì | |
| `updatedAt` | string \| null | sì | |
| `clinicalNotes` | string \| null | no | Solo con lo scope clinical:read (l'accesso finisce nel registro). |
| `completionNotes` | string \| null | no | Solo con lo scope clinical:read. |

## Cambia lo stato

`POST /appointments/{id}/status`

Scope: `appointments:write`.

Il cambio di stato dello staff, lungo lo stesso grafo dell'app (`completed` no: lo scrive il completamento della visita). Nessuna regola di calendario: la disdetta del paziente e' /cancel. 409 `conflict` con `reason: transition_not_allowed`, `details.status` e `details.allowedTransitions`; 409 `no_availability` se rimetterlo in agenda si sovrapporrebbe a un altro.

### Parametri

| Nome | Posizione | Tipo | Obbligatorio | Descrizione |
| - | - | - | - | - |
| `id` | path | string (uuid) | sì | Identificativo della risorsa. |
| `Idempotency-Key` | header | string | no | Facoltativa. La stessa chiave entro 24 ore ripete la prima risposta 2xx. |

### Corpo (JSON)

| Campo | Tipo | Obbligatorio | Descrizione |
| - | - | - | - |
| `status` | string: `confirmed`, `scheduled`, `cancelled`, `postponed`, `draft`, `in_progress`, `no_show` | sì | |

### Risposte

| Status | Descrizione |
| - | - |
| `200` | L'appuntamento nel nuovo stato (o gia' in quello). |
| `400` | Codici: `invalid_request`. |
| `401` | Codici: `unauthenticated`, `api_key_invalid`, `api_key_revoked`. |
| `403` | Codici: `plan_required`, `insufficient_scope`. |
| `404` | Codici: `not_found`. |
| `409` | Codici: `conflict`, `no_availability`, `idempotency_in_progress`. |
| `422` | Codici: `idempotency_key_reused`. |
| `429` | Codici: `rate_limited`. |
| `500` | Codici: `internal_error`. |
| `503` | Codici: `unavailable`. |

### Campi della risposta

| Campo | Tipo | Sempre presente | Descrizione |
| - | - | - | - |
| `id` | string (uuid) | sì | |
| `patientId` | string (uuid) | sì | |
| `facilityId` | string (uuid) \| null | sì | |
| `doctorId` | string (uuid) \| null | sì | |
| `chairId` | string (uuid) \| null | sì | |
| `calendarId` | string (uuid) \| null | sì | Il calendario da cui e' nato: le sue regole valgono per spostarlo e disdirlo. null = fuori calendario. |
| `start` | string | sì | ISO 8601 con fuso (UTC). |
| `end` | string | sì | ISO 8601 con fuso (UTC). |
| `status` | string: `draft`, `scheduled`, `confirmed`, `checked_in`, `in_progress`, `completed`, `cancelled`, `no_show`, `pending`, `postponed` | sì | |
| `type` | string: `checkup`, `treatment`, `consultation`, `emergency`, `other` \| null | sì | |
| `category` | string: `recall`, `treatment_plan`, `first_visit`, `case_study`, `standard` \| null | sì | |
| `title` | string \| null | sì | Ogni lettura finisce nel registro accessi (testo libero). |
| `reason` | string \| null | sì | Il motivo della visita. Ogni lettura finisce nel registro accessi (testo libero). |
| `notes` | string \| null | no | Solo con lo scope clinical:read (l'accesso finisce nel registro). |
| `origin` | string: `manual`, `bot`, `voice`, `api` | sì | Da dove e' nato: manual (agenda), bot (WhatsApp), voice (agente vocale), api. |
| `treatmentPlanId` | string (uuid) \| null | sì | |
| `createdAt` | string \| null | sì | |
| `updatedAt` | string \| null | sì | |
| `clinicalNotes` | string \| null | no | Solo con lo scope clinical:read (l'accesso finisce nel registro). |
| `completionNotes` | string \| null | no | Solo con lo scope clinical:read. |

## Elenco dei preventivi

`GET /treatment-plans`

Scope: `treatment_plans:read`.

Senza voci e senza dati clinici; quelli nel cestino non compaiono.

### Parametri

| Nome | Posizione | Tipo | Obbligatorio | Descrizione |
| - | - | - | - | - |
| `limit` | query | integer | no | Quante righe per pagina (1-200). |
| `cursor` | query | string | no | Il nextCursor della pagina precedente, tale e quale. |
| `updatedSince` | query | string (date-time) | no | Solo le righe modificate da questo istante in poi (sincronizzazione incrementale). |
| `patientId` | query | string (uuid) | no | Solo i preventivi di questo paziente. |
| `status` | query | string: `draft`, `proposed`, `standby`, `accepted`, `rejected`, `in_progress`, `completed` | no | |

### Risposte

| Status | Descrizione |
| - | - |
| `200` | Una pagina di preventivi. |
| `400` | Codici: `invalid_request`. |
| `401` | Codici: `unauthenticated`, `api_key_invalid`, `api_key_revoked`. |
| `403` | Codici: `plan_required`, `insufficient_scope`. |
| `429` | Codici: `rate_limited`. |
| `500` | Codici: `internal_error`. |
| `503` | Codici: `unavailable`. |

### Campi della risposta

| Campo | Tipo | Sempre presente | Descrizione |
| - | - | - | - |
| `data` | array di object | sì | |
| `data[].id` | string (uuid) | sì | |
| `data[].patientId` | string (uuid) | sì | |
| `data[].facilityId` | string (uuid) \| null | sì | |
| `data[].name` | string | sì | |
| `data[].description` | string \| null | sì | |
| `data[].status` | string: `draft`, `proposed`, `standby`, `accepted`, `rejected`, `in_progress`, `completed` | sì | |
| `data[].totalCost` | number \| null | sì | |
| `data[].presentedAmount` | number \| null | sì | |
| `data[].presentedAt` | string \| null | sì | |
| `data[].acceptedAmount` | number \| null | sì | |
| `data[].acceptedAt` | string \| null | sì | |
| `data[].doctorId` | string (uuid) \| null | sì | |
| `data[].createdAt` | string | sì | |
| `data[].updatedAt` | string | sì | |
| `nextCursor` | string \| null | sì | null = ultima pagina. |

## Crea una bozza di preventivo dal listino

`POST /treatment-plans`

Scope: `treatment_plans:write`.

I prezzi sono quelli del listino. Nasce sempre come bozza (`draft`): la conferma una persona nell'app.

### Parametri

| Nome | Posizione | Tipo | Obbligatorio | Descrizione |
| - | - | - | - | - |
| `Idempotency-Key` | header | string | no | Facoltativa. La stessa chiave entro 24 ore ripete la prima risposta 2xx. |

### Corpo (JSON)

| Campo | Tipo | Obbligatorio | Descrizione |
| - | - | - | - |
| `patientId` | string (uuid) | sì | |
| `items` | array di object | sì | |
| `items[].treatmentId` | string (uuid) | sì | La prestazione del listino. |
| `items[].quantity` | integer | no | |
| `items[].tooth` | integer | no | Dente (FDI). Chiede clinical:write. |
| `items[].surface` | string | no | Superficie. Chiede clinical:write. |
| `name` | string | no | |
| `description` | string | no | |
| `doctorId` | string (uuid) | no | |
| `facilityId` | string (uuid) | no | Predefinita: la sede del paziente. |

### Risposte

| Status | Descrizione |
| - | - |
| `201` | La bozza creata. |
| `400` | Codici: `invalid_request`. |
| `401` | Codici: `unauthenticated`, `api_key_invalid`, `api_key_revoked`. |
| `403` | Codici: `forbidden`, `plan_required`, `insufficient_scope`. |
| `404` | Codici: `not_found`. |
| `409` | Codici: `idempotency_in_progress`. |
| `422` | Codici: `idempotency_key_reused`. |
| `429` | Codici: `rate_limited`. |
| `500` | Codici: `internal_error`. |
| `503` | Codici: `unavailable`. |

### Campi della risposta

| Campo | Tipo | Sempre presente | Descrizione |
| - | - | - | - |
| `id` | string (uuid) | sì | |
| `patientId` | string (uuid) | sì | |
| `facilityId` | string (uuid) \| null | sì | |
| `name` | string | sì | |
| `description` | string \| null | sì | |
| `status` | string: `draft`, `proposed`, `standby`, `accepted`, `rejected`, `in_progress`, `completed` | sì | |
| `totalCost` | number \| null | sì | |
| `presentedAmount` | number \| null | sì | |
| `presentedAt` | string \| null | sì | |
| `acceptedAmount` | number \| null | sì | |
| `acceptedAt` | string \| null | sì | |
| `doctorId` | string (uuid) \| null | sì | |
| `createdAt` | string | sì | |
| `updatedAt` | string | sì | |
| `items` | array di object | sì | |
| `items[].id` | string (uuid) | sì | |
| `items[].treatmentId` | string (uuid) \| null | sì | |
| `items[].name` | string \| null | sì | |
| `items[].cost` | number \| null | sì | |
| `items[].status` | string \| null | sì | |
| `items[].priority` | integer \| null | sì | |
| `items[].doctorId` | string (uuid) \| null | sì | |
| `items[].tooth` | integer \| null | no | Solo con clinical:read. |
| `items[].surface` | string \| null | no | Solo con clinical:read. |
| `items[].treatmentArea` | string \| null | no | Solo con clinical:read. |
| `items[].diagnosisId` | string (uuid) \| null | no | Solo con clinical:read. |
| `odontogramType` | string \| null | no | Solo con clinical:read. |
| `toothData` | object \| null | no | Solo con clinical:read. |
| `diagnoses` | array di object | no | Solo con clinical:read. |
| `diagnoses[].id` | string (uuid) | sì | |
| `diagnoses[].tooth` | integer \| null | sì | |
| `diagnoses[].issueType` | string \| null | sì | |
| `diagnoses[].issueCategory` | string \| null | sì | |
| `diagnoses[].treatmentArea` | string \| null | sì | |
| `diagnoses[].surfaces` | array di string \| null | sì | |
| `diagnoses[].severity` | string \| null | sì | |
| `diagnoses[].note` | string \| null | sì | |
| `diagnoses[].status` | string \| null | sì | |

## Un preventivo, con le voci

`GET /treatment-plans/{id}`

Scope: `treatment_plans:read`.

Con lo scope clinical:read anche denti, superfici, odontogramma e diagnosi (e l'accesso finisce nel registro).

### Parametri

| Nome | Posizione | Tipo | Obbligatorio | Descrizione |
| - | - | - | - | - |
| `id` | path | string (uuid) | sì | Identificativo della risorsa. |

### Risposte

| Status | Descrizione |
| - | - |
| `200` | Il preventivo. |
| `400` | Codici: `invalid_request`. |
| `401` | Codici: `unauthenticated`, `api_key_invalid`, `api_key_revoked`. |
| `403` | Codici: `plan_required`, `insufficient_scope`. |
| `404` | Codici: `not_found`. |
| `429` | Codici: `rate_limited`. |
| `500` | Codici: `internal_error`. |
| `503` | Codici: `unavailable`. |

### Campi della risposta

| Campo | Tipo | Sempre presente | Descrizione |
| - | - | - | - |
| `id` | string (uuid) | sì | |
| `patientId` | string (uuid) | sì | |
| `facilityId` | string (uuid) \| null | sì | |
| `name` | string | sì | |
| `description` | string \| null | sì | |
| `status` | string: `draft`, `proposed`, `standby`, `accepted`, `rejected`, `in_progress`, `completed` | sì | |
| `totalCost` | number \| null | sì | |
| `presentedAmount` | number \| null | sì | |
| `presentedAt` | string \| null | sì | |
| `acceptedAmount` | number \| null | sì | |
| `acceptedAt` | string \| null | sì | |
| `doctorId` | string (uuid) \| null | sì | |
| `createdAt` | string | sì | |
| `updatedAt` | string | sì | |
| `items` | array di object | sì | |
| `items[].id` | string (uuid) | sì | |
| `items[].treatmentId` | string (uuid) \| null | sì | |
| `items[].name` | string \| null | sì | |
| `items[].cost` | number \| null | sì | |
| `items[].status` | string \| null | sì | |
| `items[].priority` | integer \| null | sì | |
| `items[].doctorId` | string (uuid) \| null | sì | |
| `items[].tooth` | integer \| null | no | Solo con clinical:read. |
| `items[].surface` | string \| null | no | Solo con clinical:read. |
| `items[].treatmentArea` | string \| null | no | Solo con clinical:read. |
| `items[].diagnosisId` | string (uuid) \| null | no | Solo con clinical:read. |
| `odontogramType` | string \| null | no | Solo con clinical:read. |
| `toothData` | object \| null | no | Solo con clinical:read. |
| `diagnoses` | array di object | no | Solo con clinical:read. |
| `diagnoses[].id` | string (uuid) | sì | |
| `diagnoses[].tooth` | integer \| null | sì | |
| `diagnoses[].issueType` | string \| null | sì | |
| `diagnoses[].issueCategory` | string \| null | sì | |
| `diagnoses[].treatmentArea` | string \| null | sì | |
| `diagnoses[].surfaces` | array di string \| null | sì | |
| `diagnoses[].severity` | string \| null | sì | |
| `diagnoses[].note` | string \| null | sì | |
| `diagnoses[].status` | string \| null | sì | |

## Cambia lo stato di un preventivo

`POST /treatment-plans/{id}/status`

Scope: `treatment_plans:write`.

proposed → standby (presentato) · proposed/standby → accepted/rejected. Una bozza non cambia stato via API.

### Parametri

| Nome | Posizione | Tipo | Obbligatorio | Descrizione |
| - | - | - | - | - |
| `id` | path | string (uuid) | sì | Identificativo della risorsa. |

### Corpo (JSON)

| Campo | Tipo | Obbligatorio | Descrizione |
| - | - | - | - |
| `status` | string: `standby`, `accepted`, `rejected` | sì | standby = presentato al paziente. |
| `acceptedAmount` | number | no | Solo con accepted: l'importo accettato, maggiore di 0 e non oltre il totale del preventivo (altrimenti 400). Predefinito: il totale. |

### Risposte

| Status | Descrizione |
| - | - |
| `200` | Il preventivo aggiornato. |
| `400` | Codici: `invalid_request`. |
| `401` | Codici: `unauthenticated`, `api_key_invalid`, `api_key_revoked`. |
| `403` | Codici: `plan_required`, `insufficient_scope`. |
| `404` | Codici: `not_found`. |
| `409` | Codici: `conflict`. |
| `429` | Codici: `rate_limited`. |
| `500` | Codici: `internal_error`. |
| `503` | Codici: `unavailable`. |

### Campi della risposta

| Campo | Tipo | Sempre presente | Descrizione |
| - | - | - | - |
| `id` | string (uuid) | sì | |
| `patientId` | string (uuid) | sì | |
| `facilityId` | string (uuid) \| null | sì | |
| `name` | string | sì | |
| `description` | string \| null | sì | |
| `status` | string: `draft`, `proposed`, `standby`, `accepted`, `rejected`, `in_progress`, `completed` | sì | |
| `totalCost` | number \| null | sì | |
| `presentedAmount` | number \| null | sì | |
| `presentedAt` | string \| null | sì | |
| `acceptedAmount` | number \| null | sì | |
| `acceptedAt` | string \| null | sì | |
| `doctorId` | string (uuid) \| null | sì | |
| `createdAt` | string | sì | |
| `updatedAt` | string | sì | |
| `items` | array di object | sì | |
| `items[].id` | string (uuid) | sì | |
| `items[].treatmentId` | string (uuid) \| null | sì | |
| `items[].name` | string \| null | sì | |
| `items[].cost` | number \| null | sì | |
| `items[].status` | string \| null | sì | |
| `items[].priority` | integer \| null | sì | |
| `items[].doctorId` | string (uuid) \| null | sì | |
| `items[].tooth` | integer \| null | no | Solo con clinical:read. |
| `items[].surface` | string \| null | no | Solo con clinical:read. |
| `items[].treatmentArea` | string \| null | no | Solo con clinical:read. |
| `items[].diagnosisId` | string (uuid) \| null | no | Solo con clinical:read. |
| `odontogramType` | string \| null | no | Solo con clinical:read. |
| `toothData` | object \| null | no | Solo con clinical:read. |
| `diagnoses` | array di object | no | Solo con clinical:read. |
| `diagnoses[].id` | string (uuid) | sì | |
| `diagnoses[].tooth` | integer \| null | sì | |
| `diagnoses[].issueType` | string \| null | sì | |
| `diagnoses[].issueCategory` | string \| null | sì | |
| `diagnoses[].treatmentArea` | string \| null | sì | |
| `diagnoses[].surfaces` | array di string \| null | sì | |
| `diagnoses[].severity` | string \| null | sì | |
| `diagnoses[].note` | string \| null | sì | |
| `diagnoses[].status` | string \| null | sì | |

## Elenco delle fatture

`GET /invoices`

Scope: `billing:read`.

### Parametri

| Nome | Posizione | Tipo | Obbligatorio | Descrizione |
| - | - | - | - | - |
| `limit` | query | integer | no | Quante righe per pagina (1-200). |
| `cursor` | query | string | no | Il nextCursor della pagina precedente, tale e quale. |
| `updatedSince` | query | string (date-time) | no | Solo le righe modificate da questo istante in poi (sincronizzazione incrementale). |
| `patientId` | query | string (uuid) | no | |
| `status` | query | string: `draft`, `issued`, `paid`, `overdue`, `cancelled` | no | |
| `from` | query | string (date) | no | Emesse da questo giorno (compreso). |
| `to` | query | string (date) | no | Emesse fino a questo giorno (compreso). |

### Risposte

| Status | Descrizione |
| - | - |
| `200` | Una pagina di fatture. |
| `400` | Codici: `invalid_request`. |
| `401` | Codici: `unauthenticated`, `api_key_invalid`, `api_key_revoked`. |
| `403` | Codici: `plan_required`, `insufficient_scope`. |
| `429` | Codici: `rate_limited`. |
| `500` | Codici: `internal_error`. |
| `503` | Codici: `unavailable`. |

### Campi della risposta

| Campo | Tipo | Sempre presente | Descrizione |
| - | - | - | - |
| `data` | array di object | sì | |
| `data[].id` | string (uuid) | sì | |
| `data[].patientId` | string (uuid) \| null | sì | |
| `data[].facilityId` | string (uuid) \| null | sì | |
| `data[].number` | integer \| null | sì | Il numero, assegnato all'emissione. |
| `data[].status` | string: `draft`, `issued`, `paid`, `overdue`, `cancelled` | sì | |
| `data[].totalAmount` | number | sì | |
| `data[].amountPaid` | number | sì | |
| `data[].balanceDue` | number \| null | sì | |
| `data[].dueDate` | string \| null | sì | |
| `data[].issuedDate` | string \| null | sì | |
| `data[].paymentMethod` | string \| null | sì | |
| `data[].paymentTraced` | boolean \| null | sì | |
| `data[].payerIsPatient` | boolean \| null | sì | |
| `data[].payerName` | string \| null | sì | |
| `data[].patientName` | string \| null | sì | |
| `data[].doctorId` | string (uuid) \| null | sì | |
| `data[].installmentNumber` | integer \| null | sì | |
| `data[].createdAt` | string | sì | |
| `data[].updatedAt` | string | sì | |
| `nextCursor` | string \| null | sì | null = ultima pagina. |

## Una fattura, con le righe

`GET /invoices/{id}`

Scope: `billing:read`.

### Parametri

| Nome | Posizione | Tipo | Obbligatorio | Descrizione |
| - | - | - | - | - |
| `id` | path | string (uuid) | sì | Identificativo della risorsa. |

### Risposte

| Status | Descrizione |
| - | - |
| `200` | La fattura. |
| `400` | Codici: `invalid_request`. |
| `401` | Codici: `unauthenticated`, `api_key_invalid`, `api_key_revoked`. |
| `403` | Codici: `plan_required`, `insufficient_scope`. |
| `404` | Codici: `not_found`. |
| `429` | Codici: `rate_limited`. |
| `500` | Codici: `internal_error`. |
| `503` | Codici: `unavailable`. |

### Campi della risposta

| Campo | Tipo | Sempre presente | Descrizione |
| - | - | - | - |
| `id` | string (uuid) | sì | |
| `patientId` | string (uuid) \| null | sì | |
| `facilityId` | string (uuid) \| null | sì | |
| `number` | integer \| null | sì | Il numero, assegnato all'emissione. |
| `status` | string: `draft`, `issued`, `paid`, `overdue`, `cancelled` | sì | |
| `totalAmount` | number | sì | |
| `amountPaid` | number | sì | |
| `balanceDue` | number \| null | sì | |
| `dueDate` | string \| null | sì | |
| `issuedDate` | string \| null | sì | |
| `paymentMethod` | string \| null | sì | |
| `paymentTraced` | boolean \| null | sì | |
| `payerIsPatient` | boolean \| null | sì | |
| `payerName` | string \| null | sì | |
| `patientName` | string \| null | sì | |
| `doctorId` | string (uuid) \| null | sì | |
| `installmentNumber` | integer \| null | sì | |
| `createdAt` | string | sì | |
| `updatedAt` | string | sì | |
| `lines` | array di object | sì | |
| `lines[].id` | string (uuid) | sì | |
| `lines[].description` | string \| null | sì | |
| `lines[].quantity` | integer \| null | sì | |
| `lines[].unitPrice` | number \| null | sì | |
| `lines[].vatRate` | number \| null | sì | |
| `lines[].total` | number \| null | sì | |
| `lines[].treatmentItemId` | string (uuid) \| null | sì | |

## Gli incassi di una fattura

`GET /invoices/{id}/payments`

Scope: `billing:read`.

### Parametri

| Nome | Posizione | Tipo | Obbligatorio | Descrizione |
| - | - | - | - | - |
| `id` | path | string (uuid) | sì | Identificativo della risorsa. |

### Risposte

| Status | Descrizione |
| - | - |
| `200` | Gli incassi. |
| `400` | Codici: `invalid_request`. |
| `401` | Codici: `unauthenticated`, `api_key_invalid`, `api_key_revoked`. |
| `403` | Codici: `plan_required`, `insufficient_scope`. |
| `404` | Codici: `not_found`. |
| `429` | Codici: `rate_limited`. |
| `500` | Codici: `internal_error`. |
| `503` | Codici: `unavailable`. |

### Campi della risposta

| Campo | Tipo | Sempre presente | Descrizione |
| - | - | - | - |
| `data` | array di object | sì | |
| `data[].id` | string (uuid) | sì | |
| `data[].invoiceId` | string (uuid) \| null | sì | |
| `data[].amount` | number | sì | |
| `data[].method` | string: `cash`, `credit_card`, `debit_card`, `bank_transfer`, `insurance`, `other` \| null | sì | |
| `data[].reference` | string \| null | sì | |
| `data[].notes` | string \| null | sì | |
| `data[].processedAt` | string \| null | sì | |

## Registra un incasso

`POST /invoices/{id}/payments`

Scope: `billing:write`.

Solo su fatture emesse o scadute, fino al saldo. Chiede Idempotency-Key. Saldata, la fattura passa a `paid`.

### Parametri

| Nome | Posizione | Tipo | Obbligatorio | Descrizione |
| - | - | - | - | - |
| `id` | path | string (uuid) | sì | Identificativo della risorsa. |
| `Idempotency-Key` | header | string | sì | Obbligatoria. La stessa chiave entro 24 ore ripete la prima risposta 2xx. |

### Corpo (JSON)

| Campo | Tipo | Obbligatorio | Descrizione |
| - | - | - | - |
| `amount` | number | sì | In euro, al centesimo. |
| `method` | string: `cash`, `credit_card`, `debit_card`, `bank_transfer`, `insurance`, `other` | sì | |
| `notes` | string | no | |

### Risposte

| Status | Descrizione |
| - | - |
| `201` | L'incasso e la fattura aggiornata. |
| `400` | Codici: `invalid_request`. |
| `401` | Codici: `unauthenticated`, `api_key_invalid`, `api_key_revoked`. |
| `403` | Codici: `plan_required`, `insufficient_scope`. |
| `404` | Codici: `not_found`. |
| `409` | Codici: `conflict`, `idempotency_in_progress`. |
| `422` | Codici: `idempotency_key_reused`. |
| `429` | Codici: `rate_limited`. |
| `500` | Codici: `internal_error`. |
| `503` | Codici: `unavailable`. |

### Campi della risposta

| Campo | Tipo | Sempre presente | Descrizione |
| - | - | - | - |
| `payment` | object | sì | |
| `payment.id` | string (uuid) | sì | |
| `payment.invoiceId` | string (uuid) \| null | sì | |
| `payment.amount` | number | sì | |
| `payment.method` | string: `cash`, `credit_card`, `debit_card`, `bank_transfer`, `insurance`, `other` \| null | sì | |
| `payment.reference` | string \| null | sì | |
| `payment.notes` | string \| null | sì | |
| `payment.processedAt` | string \| null | sì | |
| `invoice` | object | sì | |
| `invoice.id` | string (uuid) | sì | |
| `invoice.patientId` | string (uuid) \| null | sì | |
| `invoice.facilityId` | string (uuid) \| null | sì | |
| `invoice.number` | integer \| null | sì | Il numero, assegnato all'emissione. |
| `invoice.status` | string: `draft`, `issued`, `paid`, `overdue`, `cancelled` | sì | |
| `invoice.totalAmount` | number | sì | |
| `invoice.amountPaid` | number | sì | |
| `invoice.balanceDue` | number \| null | sì | |
| `invoice.dueDate` | string \| null | sì | |
| `invoice.issuedDate` | string \| null | sì | |
| `invoice.paymentMethod` | string \| null | sì | |
| `invoice.paymentTraced` | boolean \| null | sì | |
| `invoice.payerIsPatient` | boolean \| null | sì | |
| `invoice.payerName` | string \| null | sì | |
| `invoice.patientName` | string \| null | sì | |
| `invoice.doctorId` | string (uuid) \| null | sì | |
| `invoice.installmentNumber` | integer \| null | sì | |
| `invoice.createdAt` | string | sì | |
| `invoice.updatedAt` | string | sì | |

## Piani di pagamento e rate

`GET /payment-plans`

Scope: `billing:read`.

state=open (predefinito): rate non pagate · overdue: non pagate e scadute · all: tutte.

### Parametri

| Nome | Posizione | Tipo | Obbligatorio | Descrizione |
| - | - | - | - | - |
| `limit` | query | integer | no | Quante righe per pagina (1-200). |
| `cursor` | query | string | no | Il nextCursor della pagina precedente, tale e quale. |
| `updatedSince` | query | string (date-time) | no | Solo le righe modificate da questo istante in poi (sincronizzazione incrementale). |
| `patientId` | query | string (uuid) | no | |
| `state` | query | string: `open`, `overdue`, `all` | no | |

### Risposte

| Status | Descrizione |
| - | - |
| `200` | Una pagina di piani. |
| `400` | Codici: `invalid_request`. |
| `401` | Codici: `unauthenticated`, `api_key_invalid`, `api_key_revoked`. |
| `403` | Codici: `plan_required`, `insufficient_scope`. |
| `429` | Codici: `rate_limited`. |
| `500` | Codici: `internal_error`. |
| `503` | Codici: `unavailable`. |

### Campi della risposta

| Campo | Tipo | Sempre presente | Descrizione |
| - | - | - | - |
| `data` | array di object | sì | |
| `data[].id` | string (uuid) | sì | |
| `data[].patientId` | string (uuid) \| null | sì | |
| `data[].treatmentPlanId` | string (uuid) \| null | sì | |
| `data[].type` | string: `prepaid`, `on_account`, `split_60_20_20`, `per_treatment`, `pos`, `downpayment`, `financing` \| null | sì | |
| `data[].totalAmount` | number \| null | sì | |
| `data[].installmentCount` | integer \| null | sì | |
| `data[].createdAt` | string | sì | |
| `data[].installments` | array di object | sì | |
| `data[].installments[].id` | string (uuid) | sì | |
| `data[].installments[].amount` | number | sì | |
| `data[].installments[].amountPaid` | number \| null | sì | |
| `data[].installments[].dueDate` | string \| null | sì | |
| `data[].installments[].status` | string \| null | sì | pending / paid. |
| `data[].installments[].paidAt` | string \| null | sì | |
| `data[].installments[].paymentMethod` | string: `cash`, `credit_card`, `debit_card`, `bank_transfer`, `insurance`, `other` \| null | sì | |
| `data[].installments[].overdue` | boolean | sì | Non pagata e con la scadenza passata. |
| `nextCursor` | string \| null | sì | null = ultima pagina. |

## Crea un link di pagamento

`POST /payment-links`

Scope: `billing:write`.

Un link Stripe (Checkout, sul conto collegato della clinica) per il residuo di una fattura emessa o scaduta, o di una rata non pagata. L'importo non si sceglie. Non invia niente: restituisce l'URL. Chiede Idempotency-Key. Con una chiave di prova verifica tutto ma non crea il link (`livemode: false`, `url: null`).

### Parametri

| Nome | Posizione | Tipo | Obbligatorio | Descrizione |
| - | - | - | - | - |
| `Idempotency-Key` | header | string | sì | Obbligatoria. La stessa chiave entro 24 ore ripete la prima risposta 2xx. |

### Corpo (JSON)

| Campo | Tipo | Obbligatorio | Descrizione |
| - | - | - | - |
| `invoiceId` | string (uuid) | no | La fattura da incassare (emessa o scaduta). |
| `installmentId` | string (uuid) | no | Oppure la rata di un piano di pagamento. |
| `description` | string | no | Cio' che il paziente legge su Stripe. Predefinito: «Prestazione odontoiatrica». |

### Risposte

| Status | Descrizione |
| - | - |
| `201` | Il link creato (o, con una chiave di prova, quello che sarebbe). |
| `400` | Codici: `invalid_request`. |
| `401` | Codici: `unauthenticated`, `api_key_invalid`, `api_key_revoked`. |
| `403` | Codici: `plan_required`, `insufficient_scope`. |
| `409` | Codici: `conflict`, `idempotency_in_progress`. |
| `422` | Codici: `idempotency_key_reused`. |
| `429` | Codici: `rate_limited`. |
| `500` | Codici: `internal_error`. |
| `503` | Codici: `unavailable`. |

### Campi della risposta

| Campo | Tipo | Sempre presente | Descrizione |
| - | - | - | - |
| `id` | string (uuid) \| null | sì | null con una chiave di prova: nessun link creato. |
| `url` | string \| null | sì | L'indirizzo di Stripe da mandare al paziente; scade dopo un'ora. null con una chiave di prova. |
| `livemode` | boolean | sì | false con una chiave di prova (dsk\_test\_): niente passa da Stripe. |
| `amount` | number | sì | In euro: il residuo della fattura o della rata al momento della creazione. |
| `currency` | string | sì | |
| `expiresAt` | string \| null | sì | |
| `patientId` | string (uuid) | sì | |
| `invoiceId` | string (uuid) \| null | sì | |
| `installmentId` | string (uuid) \| null | sì | |

## Un documento del paziente

`GET /documents/{id}`

Scope: `documents:read`.

Un documento da firmare (consenso, modulo, preventivo) con lo stato della firma. Non e' un file: con clinical:read esce il contenuto (HTML) e la firma. Niente download e niente upload: vedi la guida.

### Parametri

| Nome | Posizione | Tipo | Obbligatorio | Descrizione |
| - | - | - | - | - |
| `id` | path | string (uuid) | sì | Identificativo della risorsa. |

### Risposte

| Status | Descrizione |
| - | - |
| `200` | Il documento. |
| `400` | Codici: `invalid_request`. |
| `401` | Codici: `unauthenticated`, `api_key_invalid`, `api_key_revoked`. |
| `403` | Codici: `plan_required`, `insufficient_scope`. |
| `404` | Codici: `not_found`. |
| `429` | Codici: `rate_limited`. |
| `500` | Codici: `internal_error`. |
| `503` | Codici: `unavailable`. |

### Campi della risposta

| Campo | Tipo | Sempre presente | Descrizione |
| - | - | - | - |
| `id` | string (uuid) | sì | |
| `patientId` | string (uuid) \| null | sì | |
| `name` | string | sì | |
| `type` | string: `preventivo`, `privacy_consent`, `clinical_consent`, `extraction_consent`, `generic` \| null | sì | |
| `status` | string: `draft`, `sent`, `signed`, `voided` \| null | sì | draft, sent (inviato da firmare), signed, voided (annullato). |
| `signedAt` | string \| null | sì | |
| `signingExpiresAt` | string \| null | sì | Fino a quando vale il link di firma; solo per `sent`. |
| `createdAt` | string \| null | sì | |
| `updatedAt` | string \| null | sì | |
| `content` | object | no | Solo con clinical:read; la lettura finisce nel registro accessi. |
| `content.html` | string \| null | sì | Il testo del documento, in HTML. |
| `content.signatureImage` | string \| null | sì | La firma, come data URL PNG; null se non firmato. |

## Elenco delle attivita'

`GET /tasks`

Scope: `tasks:read`.

Ordinate per ultima modifica; `updatedSince` per la sincronizzazione incrementale.

### Parametri

| Nome | Posizione | Tipo | Obbligatorio | Descrizione |
| - | - | - | - | - |
| `limit` | query | integer | no | Quante righe per pagina (1-200). |
| `cursor` | query | string | no | Il nextCursor della pagina precedente, tale e quale. |
| `updatedSince` | query | string (date-time) | no | Solo le righe modificate da questo istante in poi (sincronizzazione incrementale). |
| `status` | query | string: `todo`, `in_progress`, `done` | no | |
| `assigneeId` | query | string (uuid) | no | |
| `patientId` | query | string (uuid) | no | |
| `dueBefore` | query | string (date-time) | no | Solo con scadenza prima di questo istante. |

### Risposte

| Status | Descrizione |
| - | - |
| `200` | Una pagina di attivita'. |
| `400` | Codici: `invalid_request`. |
| `401` | Codici: `unauthenticated`, `api_key_invalid`, `api_key_revoked`. |
| `403` | Codici: `plan_required`, `insufficient_scope`. |
| `429` | Codici: `rate_limited`. |
| `500` | Codici: `internal_error`. |
| `503` | Codici: `unavailable`. |

### Campi della risposta

| Campo | Tipo | Sempre presente | Descrizione |
| - | - | - | - |
| `data` | array di object | sì | |
| `data[].id` | string (uuid) | sì | |
| `data[].title` | string | sì | |
| `data[].status` | string: `todo`, `in_progress`, `done` | sì | |
| `data[].dueDate` | string \| null | sì | Scadenza, istante ISO 8601. |
| `data[].assigneeId` | string (uuid) \| null | sì | Il profilo a cui e' assegnata. |
| `data[].patientId` | string (uuid) \| null | sì | |
| `data[].facilityId` | string (uuid) \| null | sì | |
| `data[].type` | string \| null | sì | manual, recall, o un tipo creato dall'app. |
| `data[].createdAt` | string \| null | sì | |
| `data[].updatedAt` | string \| null | sì | |
| `nextCursor` | string \| null | sì | null = ultima pagina. |

## Crea un'attivita'

`POST /tasks`

Scope: `tasks:write`.

Con `Idempotency-Key` un nuovo tentativo non crea un doppione. Una chiave limitata a delle sedi deve indicare `facilityId`.

### Parametri

| Nome | Posizione | Tipo | Obbligatorio | Descrizione |
| - | - | - | - | - |
| `Idempotency-Key` | header | string | no | Facoltativa. La stessa chiave entro 24 ore ripete la prima risposta 2xx. |

### Corpo (JSON)

| Campo | Tipo | Obbligatorio | Descrizione |
| - | - | - | - |
| `title` | string | sì | |
| `dueDate` | string (date-time) | no | |
| `assigneeId` | string (uuid) | no | |
| `patientId` | string (uuid) | no | |
| `facilityId` | string (uuid) | no | |
| `type` | string: `manual`, `recall` | no | |

### Risposte

| Status | Descrizione |
| - | - |
| `201` | L'attivita' creata. |
| `400` | Codici: `invalid_request`. |
| `401` | Codici: `unauthenticated`, `api_key_invalid`, `api_key_revoked`. |
| `403` | Codici: `plan_required`, `insufficient_scope`. |
| `409` | Codici: `idempotency_in_progress`. |
| `422` | Codici: `idempotency_key_reused`. |
| `429` | Codici: `rate_limited`. |
| `500` | Codici: `internal_error`. |
| `503` | Codici: `unavailable`. |

### Campi della risposta

| Campo | Tipo | Sempre presente | Descrizione |
| - | - | - | - |
| `id` | string (uuid) | sì | |
| `title` | string | sì | |
| `status` | string: `todo`, `in_progress`, `done` | sì | |
| `dueDate` | string \| null | sì | Scadenza, istante ISO 8601. |
| `assigneeId` | string (uuid) \| null | sì | Il profilo a cui e' assegnata. |
| `patientId` | string (uuid) \| null | sì | |
| `facilityId` | string (uuid) \| null | sì | |
| `type` | string \| null | sì | manual, recall, o un tipo creato dall'app. |
| `createdAt` | string \| null | sì | |
| `updatedAt` | string \| null | sì | |

## Modifica un'attivita'

`PATCH /tasks/{id}`

Scope: `tasks:write`.

Solo i campi mandati cambiano; `null` toglie scadenza o assegnatario. Con `If-Match` (l'ETag ricevuto) la modifica fallisce con 409 se qualcuno l'ha cambiata nel frattempo.

### Parametri

| Nome | Posizione | Tipo | Obbligatorio | Descrizione |
| - | - | - | - | - |
| `id` | path | string (uuid) | sì | Identificativo della risorsa. |
| `If-Match` | header | string | no | L'ETag ricevuto: la modifica passa solo se nessuno ha cambiato la risorsa (altrimenti 409 conflict, details.reason = version\_mismatch). |

### Corpo (JSON)

| Campo | Tipo | Obbligatorio | Descrizione |
| - | - | - | - |
| `title` | string | no | |
| `status` | string: `todo`, `in_progress`, `done` | no | |
| `dueDate` | string (date-time) \| null | no | |
| `assigneeId` | string (uuid) \| null | no | |

### Risposte

| Status | Descrizione |
| - | - |
| `200` | L'attivita' aggiornata. |
| `400` | Codici: `invalid_request`. |
| `401` | Codici: `unauthenticated`, `api_key_invalid`, `api_key_revoked`. |
| `403` | Codici: `plan_required`, `insufficient_scope`. |
| `404` | Codici: `not_found`. |
| `429` | Codici: `rate_limited`. |
| `500` | Codici: `internal_error`. |
| `503` | Codici: `unavailable`. |

### Campi della risposta

| Campo | Tipo | Sempre presente | Descrizione |
| - | - | - | - |
| `id` | string (uuid) | sì | |
| `title` | string | sì | |
| `status` | string: `todo`, `in_progress`, `done` | sì | |
| `dueDate` | string \| null | sì | Scadenza, istante ISO 8601. |
| `assigneeId` | string (uuid) \| null | sì | Il profilo a cui e' assegnata. |
| `patientId` | string (uuid) \| null | sì | |
| `facilityId` | string (uuid) \| null | sì | |
| `type` | string \| null | sì | manual, recall, o un tipo creato dall'app. |
| `createdAt` | string \| null | sì | |
| `updatedAt` | string \| null | sì | |

## Elenco dei lead

`GET /leads`

Scope: `leads:read`.

Ordinati per ultima modifica. Le attivita' si leggono dal dettaglio.

### Parametri

| Nome | Posizione | Tipo | Obbligatorio | Descrizione |
| - | - | - | - | - |
| `limit` | query | integer | no | Quante righe per pagina (1-200). |
| `cursor` | query | string | no | Il nextCursor della pagina precedente, tale e quale. |
| `updatedSince` | query | string (date-time) | no | Solo le righe modificate da questo istante in poi (sincronizzazione incrementale). |
| `status` | query | string: `new`, `contacted`, `qualified`, `proposal`, `won`, `lost` | no | |
| `source` | query | string: `social_media`, `website`, `referral_link`, `manual`, `import`, `phone`, `walk_in`, `other`, `whatsapp` | no | |
| `assignedTo` | query | string (uuid) | no | |

### Risposte

| Status | Descrizione |
| - | - |
| `200` | Una pagina di lead. |
| `400` | Codici: `invalid_request`. |
| `401` | Codici: `unauthenticated`, `api_key_invalid`, `api_key_revoked`. |
| `403` | Codici: `plan_required`, `insufficient_scope`. |
| `429` | Codici: `rate_limited`. |
| `500` | Codici: `internal_error`. |
| `503` | Codici: `unavailable`. |

### Campi della risposta

| Campo | Tipo | Sempre presente | Descrizione |
| - | - | - | - |
| `data` | array di object | sì | |
| `data[].id` | string (uuid) | sì | |
| `data[].facilityId` | string (uuid) \| null | sì | |
| `data[].firstName` | string | sì | |
| `data[].lastName` | string \| null | sì | |
| `data[].email` | string \| null | sì | |
| `data[].phone` | string \| null | sì | |
| `data[].source` | string: `social_media`, `website`, `referral_link`, `manual`, `import`, `phone`, `walk_in`, `other`, `whatsapp` | sì | |
| `data[].sourceDetail` | string \| null | sì | |
| `data[].status` | string: `new`, `contacted`, `qualified`, `proposal`, `won`, `lost` | sì | |
| `data[].assignedTo` | string (uuid) \| null | sì | |
| `data[].estimatedValue` | number \| null | sì | |
| `data[].notes` | string \| null | sì | |
| `data[].nextFollowUpAt` | string \| null | sì | |
| `data[].lastContactedAt` | string \| null | sì | |
| `data[].lostReason` | string \| null | sì | |
| `data[].convertedPatientId` | string (uuid) \| null | sì | |
| `data[].convertedAt` | string \| null | sì | |
| `data[].createdAt` | string | sì | |
| `data[].updatedAt` | string | sì | |
| `data[].activities` | array di object | no | Solo nel dettaglio. |
| `data[].activities[].id` | string | sì | |
| `data[].activities[].kind` | string: `call`, `email`, `whatsapp`, `meeting`, `note` | sì | |
| `data[].activities[].body` | string | sì | |
| `data[].activities[].at` | string | sì | Istante ISO 8601. |
| `nextCursor` | string \| null | sì | null = ultima pagina. |

## Crea un lead

`POST /leads`

Scope: `leads:write`.

Se esiste gia' un lead con lo stesso telefono (qualunque formato: si confronta quello normalizzato) o la stessa email, NON ne crea un altro: risponde 200 con quello esistente e `deduplicated: true`, senza modificarlo. Una chiamata da un numero sconosciuto e' un lead con `source: "phone"`. Una chiave limitata a delle sedi deve indicare `facilityId`.

### Parametri

| Nome | Posizione | Tipo | Obbligatorio | Descrizione |
| - | - | - | - | - |
| `Idempotency-Key` | header | string | no | Facoltativa. La stessa chiave entro 24 ore ripete la prima risposta 2xx. |

### Corpo (JSON)

| Campo | Tipo | Obbligatorio | Descrizione |
| - | - | - | - |
| `firstName` | string | sì | |
| `lastName` | string | no | |
| `email` | string (email) | no | |
| `phone` | string | no | |
| `source` | string: `social_media`, `website`, `referral_link`, `manual`, `import`, `phone`, `walk_in`, `other`, `whatsapp` | no | |
| `sourceDetail` | string | no | |
| `status` | string: `new`, `contacted`, `qualified`, `proposal`, `won`, `lost` | no | |
| `notes` | string | no | |
| `assignedTo` | string (uuid) | no | |
| `estimatedValue` | number | no | |
| `nextFollowUpAt` | string (date-time) | no | |
| `facilityId` | string (uuid) | no | |

### Risposte

| Status | Descrizione |
| - | - |
| `201` | Il lead creato (201) o quello gia' esistente (200). |
| `400` | Codici: `invalid_request`. |
| `401` | Codici: `unauthenticated`, `api_key_invalid`, `api_key_revoked`. |
| `403` | Codici: `plan_required`, `insufficient_scope`. |
| `409` | Codici: `conflict`, `idempotency_in_progress`. |
| `422` | Codici: `idempotency_key_reused`. |
| `429` | Codici: `rate_limited`. |
| `500` | Codici: `internal_error`. |
| `503` | Codici: `unavailable`. |

### Campi della risposta

| Campo | Tipo | Sempre presente | Descrizione |
| - | - | - | - |
| `id` | string (uuid) | sì | |
| `facilityId` | string (uuid) \| null | sì | |
| `firstName` | string | sì | |
| `lastName` | string \| null | sì | |
| `email` | string \| null | sì | |
| `phone` | string \| null | sì | |
| `source` | string: `social_media`, `website`, `referral_link`, `manual`, `import`, `phone`, `walk_in`, `other`, `whatsapp` | sì | |
| `sourceDetail` | string \| null | sì | |
| `status` | string: `new`, `contacted`, `qualified`, `proposal`, `won`, `lost` | sì | |
| `assignedTo` | string (uuid) \| null | sì | |
| `estimatedValue` | number \| null | sì | |
| `notes` | string \| null | sì | |
| `nextFollowUpAt` | string \| null | sì | |
| `lastContactedAt` | string \| null | sì | |
| `lostReason` | string \| null | sì | |
| `convertedPatientId` | string (uuid) \| null | sì | |
| `convertedAt` | string \| null | sì | |
| `createdAt` | string | sì | |
| `updatedAt` | string | sì | |
| `activities` | array di object | no | Solo nel dettaglio. |
| `activities[].id` | string | sì | |
| `activities[].kind` | string: `call`, `email`, `whatsapp`, `meeting`, `note` | sì | |
| `activities[].body` | string | sì | |
| `activities[].at` | string | sì | Istante ISO 8601. |
| `deduplicated` | boolean | sì | true = c'era gia': nessun lead creato, nessuna modifica. |

## Un lead

`GET /leads/{id}`

Scope: `leads:read`.

### Parametri

| Nome | Posizione | Tipo | Obbligatorio | Descrizione |
| - | - | - | - | - |
| `id` | path | string (uuid) | sì | Identificativo della risorsa. |

### Risposte

| Status | Descrizione |
| - | - |
| `200` | Il lead, con le attivita'. |
| `400` | Codici: `invalid_request`. |
| `401` | Codici: `unauthenticated`, `api_key_invalid`, `api_key_revoked`. |
| `403` | Codici: `plan_required`, `insufficient_scope`. |
| `404` | Codici: `not_found`. |
| `429` | Codici: `rate_limited`. |
| `500` | Codici: `internal_error`. |
| `503` | Codici: `unavailable`. |

### Campi della risposta

| Campo | Tipo | Sempre presente | Descrizione |
| - | - | - | - |
| `id` | string (uuid) | sì | |
| `facilityId` | string (uuid) \| null | sì | |
| `firstName` | string | sì | |
| `lastName` | string \| null | sì | |
| `email` | string \| null | sì | |
| `phone` | string \| null | sì | |
| `source` | string: `social_media`, `website`, `referral_link`, `manual`, `import`, `phone`, `walk_in`, `other`, `whatsapp` | sì | |
| `sourceDetail` | string \| null | sì | |
| `status` | string: `new`, `contacted`, `qualified`, `proposal`, `won`, `lost` | sì | |
| `assignedTo` | string (uuid) \| null | sì | |
| `estimatedValue` | number \| null | sì | |
| `notes` | string \| null | sì | |
| `nextFollowUpAt` | string \| null | sì | |
| `lastContactedAt` | string \| null | sì | |
| `lostReason` | string \| null | sì | |
| `convertedPatientId` | string (uuid) \| null | sì | |
| `convertedAt` | string \| null | sì | |
| `createdAt` | string | sì | |
| `updatedAt` | string | sì | |
| `activities` | array di object | no | Solo nel dettaglio. |
| `activities[].id` | string | sì | |
| `activities[].kind` | string: `call`, `email`, `whatsapp`, `meeting`, `note` | sì | |
| `activities[].body` | string | sì | |
| `activities[].at` | string | sì | Istante ISO 8601. |

## Modifica un lead

`PATCH /leads/{id}`

Scope: `leads:write`.

Solo i campi mandati cambiano; `null` svuota un campo facoltativo. Un cambio di `status` riparte il conteggio dei giorni nello stadio. Per convertire in paziente usa `POST /leads/\{id\}/convert`. Con `If-Match` la modifica fallisce con 409 se il lead e' cambiato nel frattempo.

### Parametri

| Nome | Posizione | Tipo | Obbligatorio | Descrizione |
| - | - | - | - | - |
| `id` | path | string (uuid) | sì | Identificativo della risorsa. |
| `If-Match` | header | string | no | L'ETag ricevuto: la modifica passa solo se nessuno ha cambiato la risorsa (altrimenti 409 conflict, details.reason = version\_mismatch). |

### Corpo (JSON)

| Campo | Tipo | Obbligatorio | Descrizione |
| - | - | - | - |
| `firstName` | string | no | |
| `lastName` | string \| null | no | |
| `email` | string (email) \| null | no | |
| `phone` | string \| null | no | |
| `sourceDetail` | string \| null | no | |
| `status` | string: `new`, `contacted`, `qualified`, `proposal`, `won`, `lost` | no | |
| `notes` | string \| null | no | |
| `assignedTo` | string (uuid) \| null | no | |
| `estimatedValue` | number \| null | no | |
| `nextFollowUpAt` | string (date-time) \| null | no | |
| `lostReason` | string \| null | no | |

### Risposte

| Status | Descrizione |
| - | - |
| `200` | Il lead aggiornato. |
| `400` | Codici: `invalid_request`. |
| `401` | Codici: `unauthenticated`, `api_key_invalid`, `api_key_revoked`. |
| `403` | Codici: `plan_required`, `insufficient_scope`. |
| `404` | Codici: `not_found`. |
| `429` | Codici: `rate_limited`. |
| `500` | Codici: `internal_error`. |
| `503` | Codici: `unavailable`. |

### Campi della risposta

| Campo | Tipo | Sempre presente | Descrizione |
| - | - | - | - |
| `id` | string (uuid) | sì | |
| `facilityId` | string (uuid) \| null | sì | |
| `firstName` | string | sì | |
| `lastName` | string \| null | sì | |
| `email` | string \| null | sì | |
| `phone` | string \| null | sì | |
| `source` | string: `social_media`, `website`, `referral_link`, `manual`, `import`, `phone`, `walk_in`, `other`, `whatsapp` | sì | |
| `sourceDetail` | string \| null | sì | |
| `status` | string: `new`, `contacted`, `qualified`, `proposal`, `won`, `lost` | sì | |
| `assignedTo` | string (uuid) \| null | sì | |
| `estimatedValue` | number \| null | sì | |
| `notes` | string \| null | sì | |
| `nextFollowUpAt` | string \| null | sì | |
| `lastContactedAt` | string \| null | sì | |
| `lostReason` | string \| null | sì | |
| `convertedPatientId` | string (uuid) \| null | sì | |
| `convertedAt` | string \| null | sì | |
| `createdAt` | string | sì | |
| `updatedAt` | string | sì | |
| `activities` | array di object | no | Solo nel dettaglio. |
| `activities[].id` | string | sì | |
| `activities[].kind` | string: `call`, `email`, `whatsapp`, `meeting`, `note` | sì | |
| `activities[].body` | string | sì | |
| `activities[].at` | string | sì | Istante ISO 8601. |

## Aggiungi un'attivita' a un lead

`POST /leads/{id}/activities`

Scope: `leads:write`.

Una nota o un contatto (chiamata, email, WhatsApp, incontro). I contatti aggiornano anche l'ultimo contatto del lead.

### Parametri

| Nome | Posizione | Tipo | Obbligatorio | Descrizione |
| - | - | - | - | - |
| `id` | path | string (uuid) | sì | Identificativo della risorsa. |
| `Idempotency-Key` | header | string | no | Facoltativa. La stessa chiave entro 24 ore ripete la prima risposta 2xx. |

### Corpo (JSON)

| Campo | Tipo | Obbligatorio | Descrizione |
| - | - | - | - |
| `kind` | string: `call`, `email`, `whatsapp`, `meeting`, `note` | sì | |
| `body` | string | sì | |
| `at` | string (date-time) | no | Quando e' avvenuta; predefinito: adesso. |

### Risposte

| Status | Descrizione |
| - | - |
| `201` | L'attivita' aggiunta. |
| `400` | Codici: `invalid_request`. |
| `401` | Codici: `unauthenticated`, `api_key_invalid`, `api_key_revoked`. |
| `403` | Codici: `plan_required`, `insufficient_scope`. |
| `404` | Codici: `not_found`. |
| `409` | Codici: `conflict`, `idempotency_in_progress`. |
| `422` | Codici: `idempotency_key_reused`. |
| `429` | Codici: `rate_limited`. |
| `500` | Codici: `internal_error`. |
| `503` | Codici: `unavailable`. |

### Campi della risposta

| Campo | Tipo | Sempre presente | Descrizione |
| - | - | - | - |
| `id` | string | sì | |
| `kind` | string: `call`, `email`, `whatsapp`, `meeting`, `note` | sì | |
| `body` | string | sì | |
| `at` | string | sì | Istante ISO 8601. |

## Converti un lead in paziente

`POST /leads/{id}/convert`

Scope: `leads:write` e `patients:write`.

Crea il paziente dai dati del lead e segna il lead come convertito. Serve il cognome: se il lead non ce l'ha, mandalo in `lastName`. Un lead gia' convertito risponde 409. Chiede sia `leads:write` sia `patients:write`.

### Parametri

| Nome | Posizione | Tipo | Obbligatorio | Descrizione |
| - | - | - | - | - |
| `id` | path | string (uuid) | sì | Identificativo della risorsa. |
| `Idempotency-Key` | header | string | no | Facoltativa. La stessa chiave entro 24 ore ripete la prima risposta 2xx. |

### Corpo (JSON)

| Campo | Tipo | Obbligatorio | Descrizione |
| - | - | - | - |
| `lastName` | string | no | |

### Risposte

| Status | Descrizione |
| - | - |
| `201` | Il paziente creato. |
| `400` | Codici: `invalid_request`. |
| `401` | Codici: `unauthenticated`, `api_key_invalid`, `api_key_revoked`. |
| `403` | Codici: `plan_required`, `insufficient_scope`. |
| `404` | Codici: `not_found`. |
| `409` | Codici: `conflict`, `idempotency_in_progress`. |
| `422` | Codici: `idempotency_key_reused`. |
| `429` | Codici: `rate_limited`. |
| `500` | Codici: `internal_error`. |
| `503` | Codici: `unavailable`. |

### Campi della risposta

| Campo | Tipo | Sempre presente | Descrizione |
| - | - | - | - |
| `leadId` | string (uuid) | sì | |
| `patientId` | string (uuid) | sì | |
| `convertedAt` | string | sì | |

## Elenco delle conversazioni WhatsApp

`GET /conversations`

Scope: `messages:read`.

Ordinate per ultima modifica; `updatedSince` per la sincronizzazione incrementale.

### Parametri

| Nome | Posizione | Tipo | Obbligatorio | Descrizione |
| - | - | - | - | - |
| `limit` | query | integer | no | Quante righe per pagina (1-200). |
| `cursor` | query | string | no | Il nextCursor della pagina precedente, tale e quale. |
| `updatedSince` | query | string (date-time) | no | Solo le righe modificate da questo istante in poi (sincronizzazione incrementale). |
| `patientId` | query | string (uuid) | no | |
| `leadId` | query | string (uuid) | no | |

### Risposte

| Status | Descrizione |
| - | - |
| `200` | Una pagina di conversazioni. |
| `400` | Codici: `invalid_request`. |
| `401` | Codici: `unauthenticated`, `api_key_invalid`, `api_key_revoked`. |
| `403` | Codici: `plan_required`, `insufficient_scope`. |
| `429` | Codici: `rate_limited`. |
| `500` | Codici: `internal_error`. |
| `503` | Codici: `unavailable`. |

### Campi della risposta

| Campo | Tipo | Sempre presente | Descrizione |
| - | - | - | - |
| `data` | array di object | sì | |
| `data[].id` | string (uuid) | sì | |
| `data[].patientId` | string (uuid) \| null | sì | |
| `data[].leadId` | string (uuid) \| null | sì | |
| `data[].phone` | string | sì | |
| `data[].botState` | string: `attivo`, `in_pausa`, `allo_studio` | sì | attivo = risponde; in\_pausa = zitto fino a `botPausedUntil`; allo\_studio = spento da una persona. |
| `data[].botPausedUntil` | string \| null | sì | |
| `data[].optedOut` | boolean | sì | true = il paziente ha scritto STOP: non gli si mandano messaggi automatici. |
| `data[].lastInboundAt` | string \| null | sì | |
| `data[].lastOutboundAt` | string \| null | sì | |
| `data[].lastActivityAt` | string | sì | |
| `data[].createdAt` | string | sì | |
| `data[].updatedAt` | string | sì | |
| `nextCursor` | string \| null | sì | null = ultima pagina. |

## I messaggi di una conversazione

`GET /conversations/{id}/messages`

Scope: `messages:read`.

In ordine di arrivo. `updatedSince` filtra per istante di arrivo. La trascrizione dei vocali c'e' solo con lo scope `clinical:read` (e `patients:read`) e solo se la conversazione e' di un paziente. Il testo esce con `messages:read`, ma ogni lettura che lo contiene finisce nel registro accessi come accesso a dati clinici.

### Parametri

| Nome | Posizione | Tipo | Obbligatorio | Descrizione |
| - | - | - | - | - |
| `id` | path | string (uuid) | sì | Identificativo della risorsa. |
| `limit` | query | integer | no | Quante righe per pagina (1-200). |
| `cursor` | query | string | no | Il nextCursor della pagina precedente, tale e quale. |
| `updatedSince` | query | string (date-time) | no | Solo le righe modificate da questo istante in poi (sincronizzazione incrementale). |

### Risposte

| Status | Descrizione |
| - | - |
| `200` | Una pagina di messaggi. |
| `400` | Codici: `invalid_request`. |
| `401` | Codici: `unauthenticated`, `api_key_invalid`, `api_key_revoked`. |
| `403` | Codici: `plan_required`, `insufficient_scope`. |
| `404` | Codici: `not_found`. |
| `429` | Codici: `rate_limited`. |
| `500` | Codici: `internal_error`. |
| `503` | Codici: `unavailable`. |

### Campi della risposta

| Campo | Tipo | Sempre presente | Descrizione |
| - | - | - | - |
| `data` | array di object | sì | |
| `data[].id` | string (uuid) | sì | |
| `data[].direction` | string: `in`, `out` | sì | |
| `data[].author` | string: `paziente`, `bot`, `staff_app`, `staff_telefono`, `automazione` | sì | |
| `data[].type` | string: `testo`, `audio`, `immagine`, `documento`, `video`, `altro` | sì | |
| `data[].body` | string \| null | sì | null se il messaggio e' stato cancellato dal paziente o non ha testo. |
| `data[].occurredAt` | string | sì | |
| `data[].createdAt` | string | sì | |
| `data[].mediaMimeType` | string \| null | sì | |
| `data[].mediaDurationSeconds` | number \| null | sì | |
| `data[].transcriptionStatus` | string: `assente`, `in_corso`, `completata`, `fallita`, `troppo_lunga` | sì | |
| `data[].transcription` | string \| null | no | Trascrizione di un vocale: solo con lo scope clinical:read (e patients:read), e ogni lettura va nel registro accessi. |
| `nextCursor` | string \| null | sì | null = ultima pagina. |

## Manda un messaggio WhatsApp

`POST /conversations/{id}/messages`

Scope: `messages:write`.

Accoda un messaggio di testo al numero della conversazione (parte con il ritmo del numero della clinica, non subito). 409 se il paziente ha scritto STOP o se il bot e' in pausa o spento su quel filo (una persona sta gestendo la conversazione). 409 anche se in coda ci sono gia' 5 messaggi dell'API per quel numero (recipient\_queue\_full) o 200 per la clinica (queue\_full). Passano dopo i promemoria e le automazioni della clinica; revocata la chiave, i suoi messaggi non ancora partiti si annullano. Chiede `Idempotency-Key`: un nuovo tentativo con la stessa chiave non manda due volte. Con una chiave di prova non parte nulla.

### Parametri

| Nome | Posizione | Tipo | Obbligatorio | Descrizione |
| - | - | - | - | - |
| `id` | path | string (uuid) | sì | Identificativo della risorsa. |
| `Idempotency-Key` | header | string | sì | Obbligatoria. La stessa chiave entro 24 ore ripete la prima risposta 2xx. |

### Corpo (JSON)

| Campo | Tipo | Obbligatorio | Descrizione |
| - | - | - | - |
| `text` | string | sì | |

### Risposte

| Status | Descrizione |
| - | - |
| `202` | Il messaggio e' stato accodato. |
| `400` | Codici: `invalid_request`. |
| `401` | Codici: `unauthenticated`, `api_key_invalid`, `api_key_revoked`. |
| `403` | Codici: `plan_required`, `insufficient_scope`. |
| `404` | Codici: `not_found`. |
| `409` | Codici: `conflict`, `idempotency_in_progress`. |
| `422` | Codici: `idempotency_key_reused`. |
| `429` | Codici: `rate_limited`. |
| `500` | Codici: `internal_error`. |
| `503` | Codici: `unavailable`. |

### Campi della risposta

| Campo | Tipo | Sempre presente | Descrizione |
| - | - | - | - |
| `conversationId` | string (uuid) | sì | |
| `status` | string: `queued`, `duplicate`, `not_sent_test_mode` | sì | queued = in coda; duplicate = una richiesta con questa Idempotency-Key era gia' in coda; not\_sent\_test\_mode = chiave di prova, nulla e' partito. |
| `outboxId` | string (uuid) \| null | sì | L'id nella coda d'invio; null se non accodato adesso. |

## Stato del bot su una conversazione

`PATCH /conversations/{id}/bot`

Scope: `messages:write`.

`\{ "state": "attivo" \}` lo riaccende; `\{ "state": "in_pausa", "pausedUntil": "&lt;istante futuro>" \}` lo mette in pausa fino a quell'istante (al massimo 7 giorni). Se una persona l'ha spento (`allo_studio`) si puo' solo riaccendere, non mettere in pausa.

### Parametri

| Nome | Posizione | Tipo | Obbligatorio | Descrizione |
| - | - | - | - | - |
| `id` | path | string (uuid) | sì | Identificativo della risorsa. |
| `If-Match` | header | string | no | L'ETag ricevuto: la modifica passa solo se nessuno ha cambiato la risorsa (altrimenti 409 conflict, details.reason = version\_mismatch). |

### Corpo (JSON)

| Campo | Tipo | Obbligatorio | Descrizione |
| - | - | - | - |
| `state` | string | sì | |

### Risposte

| Status | Descrizione |
| - | - |
| `200` | La conversazione con il bot nel nuovo stato. |
| `400` | Codici: `invalid_request`. |
| `401` | Codici: `unauthenticated`, `api_key_invalid`, `api_key_revoked`. |
| `403` | Codici: `plan_required`, `insufficient_scope`. |
| `404` | Codici: `not_found`. |
| `409` | Codici: `conflict`. |
| `429` | Codici: `rate_limited`. |
| `500` | Codici: `internal_error`. |
| `503` | Codici: `unavailable`. |

### Campi della risposta

| Campo | Tipo | Sempre presente | Descrizione |
| - | - | - | - |
| `id` | string (uuid) | sì | |
| `patientId` | string (uuid) \| null | sì | |
| `leadId` | string (uuid) \| null | sì | |
| `phone` | string | sì | |
| `botState` | string: `attivo`, `in_pausa`, `allo_studio` | sì | attivo = risponde; in\_pausa = zitto fino a `botPausedUntil`; allo\_studio = spento da una persona. |
| `botPausedUntil` | string \| null | sì | |
| `optedOut` | boolean | sì | true = il paziente ha scritto STOP: non gli si mandano messaggi automatici. |
| `lastInboundAt` | string \| null | sì | |
| `lastOutboundAt` | string \| null | sì | |
| `lastActivityAt` | string | sì | |
| `createdAt` | string | sì | |
| `updatedAt` | string | sì | |

## Registra una telefonata conclusa

`POST /calls`

Scope: `calls:write`.

Idempotente su externalCallId: 201 la prima volta, 200 con la riga gia' registrata dopo. Un numero sconosciuto diventa un lead con source=phone.

### Parametri

| Nome | Posizione | Tipo | Obbligatorio | Descrizione |
| - | - | - | - | - |
| `Idempotency-Key` | header | string | no | Facoltativa. La stessa chiave entro 24 ore ripete la prima risposta 2xx. |

### Corpo (JSON)

| Campo | Tipo | Obbligatorio | Descrizione |
| - | - | - | - |
| `externalCallId` | string | sì | L'id della chiamata presso il fornitore: la chiave di idempotenza. |
| `direction` | string: `inbound`, `outbound` | sì | |
| `from` | string | sì | Numero di telefono in qualunque formato; «anonymous» se nascosto. |
| `to` | string | sì | Numero di telefono in qualunque formato; «anonymous» se nascosto. |
| `startedAt` | string (date-time) | sì | |
| `durationSeconds` | integer | sì | |
| `outcome` | string: `booked`, `rescheduled`, `cancelled`, `information`, `callback`, `interested`, `not_interested`, `transferred`, `no_answer`, `voicemail`, `failed`, `other` | sì | |
| `summary` | string | no | |
| `recordingUrl` | string (uri) | no | |
| `transcript` | string | no | Dato clinico: richiede lo scope clinical:write. |
| `patientId` | string (uuid) | no | |
| `leadId` | string (uuid) | no | |
| `facilityId` | string (uuid) | no | |
| `caller` | object | no | Il nome detto al telefono: serve solo se il numero e' sconosciuto e nasce un lead. |
| `caller.firstName` | string | no | |
| `caller.lastName` | string | no | |

### Risposte

| Status | Descrizione |
| - | - |
| `201` | La telefonata registrata (200 se era gia' registrata). |
| `400` | Codici: `invalid_request`. |
| `401` | Codici: `unauthenticated`, `api_key_invalid`, `api_key_revoked`. |
| `403` | Codici: `plan_required`, `insufficient_scope`. |
| `409` | Codici: `conflict`, `idempotency_in_progress`. |
| `422` | Codici: `idempotency_key_reused`. |
| `429` | Codici: `rate_limited`. |
| `500` | Codici: `internal_error`. |
| `503` | Codici: `unavailable`. |

### Campi della risposta

| Campo | Tipo | Sempre presente | Descrizione |
| - | - | - | - |
| `id` | string (uuid) | sì | |
| `externalCallId` | string | sì | L'id della chiamata presso il fornitore della voce. |
| `direction` | string: `inbound`, `outbound` | sì | |
| `from` | string | sì | |
| `to` | string | sì | |
| `startedAt` | string | sì | Istante ISO 8601. |
| `durationSeconds` | integer | sì | |
| `outcome` | string: `booked`, `rescheduled`, `cancelled`, `information`, `callback`, `interested`, `not_interested`, `transferred`, `no_answer`, `voicemail`, `failed`, `other` | sì | |
| `summary` | string \| null | sì | |
| `recordingUrl` | string \| null | sì | Link alla registrazione presso il fornitore. |
| `patientId` | string (uuid) \| null | sì | |
| `leadId` | string (uuid) \| null | sì | |
| `facilityId` | string (uuid) \| null | sì | |
| `createdAt` | string | sì | |
| `transcript` | string \| null | no | Solo nel dettaglio e solo con lo scope clinical:read (dato clinico, finisce nel registro accessi). |

## Elenca le telefonate

`GET /calls`

Scope: `calls:read`.

In ordine di registrazione. updatedSince guarda l'istante di registrazione (una telefonata non cambia).

### Parametri

| Nome | Posizione | Tipo | Obbligatorio | Descrizione |
| - | - | - | - | - |
| `limit` | query | integer | no | Quante righe per pagina (1-200). |
| `cursor` | query | string | no | Il nextCursor della pagina precedente, tale e quale. |
| `updatedSince` | query | string (date-time) | no | Solo le righe modificate da questo istante in poi (sincronizzazione incrementale). |
| `patientId` | query | string (uuid) | no | |
| `leadId` | query | string (uuid) | no | |
| `outcome` | query | string: `booked`, `rescheduled`, `cancelled`, `information`, `callback`, `interested`, `not_interested`, `transferred`, `no_answer`, `voicemail`, `failed`, `other` | no | |
| `from` | query | string (date-time) | no | Solo le chiamate iniziate da questo istante. |
| `to` | query | string (date-time) | no | Solo le chiamate iniziate prima di questo istante. |

### Risposte

| Status | Descrizione |
| - | - |
| `200` | Una pagina di telefonate, senza trascrizione. |
| `400` | Codici: `invalid_request`. |
| `401` | Codici: `unauthenticated`, `api_key_invalid`, `api_key_revoked`. |
| `403` | Codici: `plan_required`, `insufficient_scope`. |
| `429` | Codici: `rate_limited`. |
| `500` | Codici: `internal_error`. |
| `503` | Codici: `unavailable`. |

### Campi della risposta

| Campo | Tipo | Sempre presente | Descrizione |
| - | - | - | - |
| `data` | array di object | sì | |
| `data[].id` | string (uuid) | sì | |
| `data[].externalCallId` | string | sì | L'id della chiamata presso il fornitore della voce. |
| `data[].direction` | string: `inbound`, `outbound` | sì | |
| `data[].from` | string | sì | |
| `data[].to` | string | sì | |
| `data[].startedAt` | string | sì | Istante ISO 8601. |
| `data[].durationSeconds` | integer | sì | |
| `data[].outcome` | string: `booked`, `rescheduled`, `cancelled`, `information`, `callback`, `interested`, `not_interested`, `transferred`, `no_answer`, `voicemail`, `failed`, `other` | sì | |
| `data[].summary` | string \| null | sì | |
| `data[].recordingUrl` | string \| null | sì | Link alla registrazione presso il fornitore. |
| `data[].patientId` | string (uuid) \| null | sì | |
| `data[].leadId` | string (uuid) \| null | sì | |
| `data[].facilityId` | string (uuid) \| null | sì | |
| `data[].createdAt` | string | sì | |
| `data[].transcript` | string \| null | no | Solo nel dettaglio e solo con lo scope clinical:read (dato clinico, finisce nel registro accessi). |
| `nextCursor` | string \| null | sì | null = ultima pagina. |

## Una telefonata

`GET /calls/{id}`

Scope: `calls:read`.

Con lo scope clinical:read anche la trascrizione (l'accesso finisce nel registro).

### Parametri

| Nome | Posizione | Tipo | Obbligatorio | Descrizione |
| - | - | - | - | - |
| `id` | path | string (uuid) | sì | Identificativo della risorsa. |

### Risposte

| Status | Descrizione |
| - | - |
| `200` | La telefonata. |
| `400` | Codici: `invalid_request`. |
| `401` | Codici: `unauthenticated`, `api_key_invalid`, `api_key_revoked`. |
| `403` | Codici: `plan_required`, `insufficient_scope`. |
| `404` | Codici: `not_found`. |
| `429` | Codici: `rate_limited`. |
| `500` | Codici: `internal_error`. |
| `503` | Codici: `unavailable`. |

### Campi della risposta

| Campo | Tipo | Sempre presente | Descrizione |
| - | - | - | - |
| `id` | string (uuid) | sì | |
| `externalCallId` | string | sì | L'id della chiamata presso il fornitore della voce. |
| `direction` | string: `inbound`, `outbound` | sì | |
| `from` | string | sì | |
| `to` | string | sì | |
| `startedAt` | string | sì | Istante ISO 8601. |
| `durationSeconds` | integer | sì | |
| `outcome` | string: `booked`, `rescheduled`, `cancelled`, `information`, `callback`, `interested`, `not_interested`, `transferred`, `no_answer`, `voicemail`, `failed`, `other` | sì | |
| `summary` | string \| null | sì | |
| `recordingUrl` | string \| null | sì | Link alla registrazione presso il fornitore. |
| `patientId` | string (uuid) \| null | sì | |
| `leadId` | string (uuid) \| null | sì | |
| `facilityId` | string (uuid) \| null | sì | |
| `createdAt` | string | sì | |
| `transcript` | string \| null | no | Solo nel dettaglio e solo con lo scope clinical:read (dato clinico, finisce nel registro accessi). |

## Briefing prima della chiamata

`POST /voice/context`

Scope: `patients:read` e `appointments:read`.

Chi e' il numero e cosa ha in sospeso, senza dati clinici. 403 reason ai\_disabled se la clinica ha spento l'AI.

### Corpo (JSON)

| Campo | Tipo | Obbligatorio | Descrizione |
| - | - | - | - |
| `phone` | string | sì | Numero di telefono in qualunque formato; «anonymous» se nascosto. |

### Risposte

| Status | Descrizione |
| - | - |
| `200` | Il briefing. |
| `400` | Codici: `invalid_request`. |
| `401` | Codici: `unauthenticated`, `api_key_invalid`, `api_key_revoked`. |
| `403` | Codici: `plan_required`, `insufficient_scope`. |
| `429` | Codici: `rate_limited`. |
| `500` | Codici: `internal_error`. |
| `503` | Codici: `unavailable`. |

### Campi della risposta

| Campo | Tipo | Sempre presente | Descrizione |
| - | - | - | - |
| `phone` | string \| null | sì | Il numero normalizzato (E.164); null se non e' un numero. |
| `match` | string: `patient`, `multiple`, `lead`, `none` | sì | multiple = piu' pazienti sullo stesso numero (famiglia): chiedere per chi chiama. |
| `patient` | object \| null | sì | |
| `patient.id` | string (uuid) | sì | |
| `patient.firstName` | string | sì | |
| `patient.lastName` | string \| null | sì | |
| `candidates` | array di object | sì | |
| `candidates[].id` | string (uuid) | sì | |
| `candidates[].firstName` | string | sì | |
| `lead` | object \| null | sì | |
| `lead.id` | string (uuid) | sì | |
| `lead.firstName` | string | sì | |
| `lead.lastName` | string \| null | sì | |
| `lead.status` | string | sì | |
| `nextAppointment` | object \| null | sì | |
| `nextAppointment.id` | string (uuid) | sì | |
| `nextAppointment.startTime` | string | sì | |
| `nextAppointment.endTime` | string | sì | |
| `nextAppointment.status` | string | sì | |
| `nextAppointment.category` | string \| null | sì | |
| `nextAppointment.doctorId` | string (uuid) \| null | sì | |
| `nextAppointment.facilityId` | string (uuid) \| null | sì | |
| `lastAppointment` | object \| null | sì | |
| `lastAppointment.id` | string (uuid) | sì | |
| `lastAppointment.startTime` | string | sì | |
| `lastAppointment.endTime` | string | sì | |
| `lastAppointment.status` | string | sì | |
| `lastAppointment.category` | string \| null | sì | |
| `lastAppointment.doctorId` | string (uuid) \| null | sì | |
| `lastAppointment.facilityId` | string (uuid) \| null | sì | |
| `openTreatmentPlan` | object \| null | no | Solo con lo scope treatment\_plans:read. |
| `openTreatmentPlan.id` | string (uuid) | sì | |
| `openTreatmentPlan.status` | string | sì | |
| `openTreatmentPlan.totalCost` | number \| null | sì | |
| `openTreatmentPlan.presentedAt` | string \| null | sì | |
| `openTasks` | array di object | no | Solo con lo scope tasks:read. Senza titolo: e' testo libero, anche dettato da chi chiama. |
| `openTasks[].id` | string (uuid) | sì | |
| `openTasks[].type` | string \| null | sì | Il tipo (voice\_handoff = un richiamo chiesto al telefono, recall, manual...). |
| `openTasks[].dueDate` | string \| null | sì | |

## Passa la chiamata alla segreteria

`POST /voice/handoff`

Scope: `tasks:write`.

Crea un'attivita' di richiamo. Un numero sconosciuto diventa un lead con source=phone. 403 reason ai\_disabled se la clinica ha spento l'AI.

### Parametri

| Nome | Posizione | Tipo | Obbligatorio | Descrizione |
| - | - | - | - | - |
| `Idempotency-Key` | header | string | no | Facoltativa. La stessa chiave entro 24 ore ripete la prima risposta 2xx. |

### Corpo (JSON)

| Campo | Tipo | Obbligatorio | Descrizione |
| - | - | - | - |
| `phone` | string | sì | Il numero da richiamare. |
| `reason` | string | sì | Cosa vuole chi ha chiamato, in una riga. |
| `callbackBy` | string (date-time) | no | Entro quando richiamare. |
| `patientId` | string (uuid) | no | |
| `leadId` | string (uuid) | no | |
| `facilityId` | string (uuid) | no | |
| `caller` | object | no | |
| `caller.firstName` | string | no | |
| `caller.lastName` | string | no | |

### Risposte

| Status | Descrizione |
| - | - |
| `201` | L'attivita' creata. |
| `400` | Codici: `invalid_request`. |
| `401` | Codici: `unauthenticated`, `api_key_invalid`, `api_key_revoked`. |
| `403` | Codici: `plan_required`, `insufficient_scope`. |
| `409` | Codici: `idempotency_in_progress`. |
| `422` | Codici: `idempotency_key_reused`. |
| `429` | Codici: `rate_limited`. |
| `500` | Codici: `internal_error`. |
| `503` | Codici: `unavailable`. |

### Campi della risposta

| Campo | Tipo | Sempre presente | Descrizione |
| - | - | - | - |
| `taskId` | string (uuid) | sì | |
| `title` | string | sì | |
| `dueDate` | string \| null | sì | |
| `patientId` | string (uuid) \| null | sì | |
| `leadId` | string (uuid) \| null | sì | |

## Elenca gli eventi

`GET /events`

Scope: `webhooks:read`.

Gli eventi degli ultimi 30 giorni, nella stessa busta dei webhook. updatedSince filtra sull'istante in cui l'evento e' diventato visibile. Si vedono solo i tipi coperti dagli scope della chiave. Gli eventi che non sono nati lasciano un events.skipped con data.reason (bulk\_import: piu' di 50 \*.created insieme; no\_active\_key: un periodo senza chiavi attive, da data.from a data.to; error: il trigger e' fallito), data.type, data.scope e data.count: da li' si risincronizza con updatedSince sugli elenchi.

### Parametri

| Nome | Posizione | Tipo | Obbligatorio | Descrizione |
| - | - | - | - | - |
| `limit` | query | integer | no | Quante righe per pagina (1-200). |
| `cursor` | query | string | no | Il nextCursor della pagina precedente, tale e quale. |
| `updatedSince` | query | string (date-time) | no | Solo le righe modificate da questo istante in poi (sincronizzazione incrementale). |
| `type` | query | string | no | Un tipo di evento del catalogo. |

### Risposte

| Status | Descrizione |
| - | - |
| `200` | Una pagina di eventi. |
| `400` | Codici: `invalid_request`. |
| `401` | Codici: `unauthenticated`, `api_key_invalid`, `api_key_revoked`. |
| `403` | Codici: `plan_required`, `insufficient_scope`. |
| `429` | Codici: `rate_limited`. |
| `500` | Codici: `internal_error`. |
| `503` | Codici: `unavailable`. |

### Campi della risposta

| Campo | Tipo | Sempre presente | Descrizione |
| - | - | - | - |
| `data` | array di object | sì | |
| `data[].id` | string (uuid) | sì | Su /v1/events: l'evento. Nel corpo di un webhook: la consegna. |
| `data[].eventId` | string (uuid) | sì | L'evento: usalo per riconoscere i doppioni. |
| `data[].type` | string | sì | |
| `data[].createdAt` | string | sì | |
| `data[].apiVersion` | string | sì | |
| `data[].livemode` | boolean | sì | |
| `data[].facilityId` | string (uuid) \| null | sì | |
| `data[].data` | object | sì | La risorsa nella forma pubblica. Mai dati sanitari; le risorse collegate sono solo id. |
| `nextCursor` | string \| null | sì | null = ultima pagina. |

## Elenca le destinazioni dei webhook

`GET /webhook-endpoints`

Scope: `webhooks:read`.

Le destinazioni create da questa chiave (o da quella che ha rigenerato).

### Parametri

| Nome | Posizione | Tipo | Obbligatorio | Descrizione |
| - | - | - | - | - |
| `limit` | query | integer | no | Quante righe per pagina (1-200). |
| `cursor` | query | string | no | Il nextCursor della pagina precedente, tale e quale. |
| `updatedSince` | query | string (date-time) | no | Solo le righe modificate da questo istante in poi (sincronizzazione incrementale). |

### Risposte

| Status | Descrizione |
| - | - |
| `200` | Una pagina di destinazioni. |
| `400` | Codici: `invalid_request`. |
| `401` | Codici: `unauthenticated`, `api_key_invalid`, `api_key_revoked`. |
| `403` | Codici: `plan_required`, `insufficient_scope`. |
| `429` | Codici: `rate_limited`. |
| `500` | Codici: `internal_error`. |
| `503` | Codici: `unavailable`. |

### Campi della risposta

| Campo | Tipo | Sempre presente | Descrizione |
| - | - | - | - |
| `data` | array di object | sì | |
| `data[].id` | string (uuid) | sì | |
| `data[].url` | string | sì | |
| `data[].description` | string \| null | sì | |
| `data[].events` | array di string | sì | |
| `data[].enabled` | boolean | sì | |
| `data[].disabledReason` | string: `manual`, `too_many_failures` \| null | sì | too\_many\_failures: spenta dopo 50 consegne scartate di fila. Riaccendila con PATCH enabled=true. |
| `data[].consecutiveFailures` | integer | sì | |
| `data[].previousSecretExpiresAt` | string \| null | sì | Fino a quest'istante si firma anche con il segreto precedente. |
| `data[].secretRotatedAt` | string \| null | sì | |
| `data[].createdAt` | string | sì | |
| `data[].updatedAt` | string | sì | |
| `nextCursor` | string \| null | sì | null = ultima pagina. |

## Crea una destinazione dei webhook

`POST /webhook-endpoints`

Scope: `webhooks:write`.

Al massimo 20 per clinica. Il segreto si vede solo nella risposta. Gli eventi sono filtrati con gli scope e le sedi di QUESTA chiave. Ogni webhook e' un POST JSON firmato: calcola HMAC-SHA256(segreto, "v1\n" + X-DentalSpace-Timestamp + "\n" + X-DentalSpace-Idempotency-Key + "\n" + corpo) e confrontalo con uno dei valori v1= di X-DentalSpace-Signature; rifiuta i timestamp piu' vecchi di 5 minuti e scarta le Idempotency-Key gia' viste. Rispondi 2xx entro 10 secondi; altrimenti si ritenta dopo 1, 5, 15 minuti e 1 ora. I redirect non si seguono.

### Corpo (JSON)

| Campo | Tipo | Obbligatorio | Descrizione |
| - | - | - | - |
| `url` | string | sì | https, con un nome pubblico: indirizzi privati, locali o con credenziali si rifiutano. |
| `events` | array di string | sì | |
| `description` | string | no | |

### Risposte

| Status | Descrizione |
| - | - |
| `201` | La destinazione, con il segreto. |
| `400` | Codici: `invalid_request`. |
| `401` | Codici: `unauthenticated`, `api_key_invalid`, `api_key_revoked`. |
| `403` | Codici: `plan_required`, `insufficient_scope`. |
| `409` | Codici: `conflict`. |
| `429` | Codici: `rate_limited`. |
| `500` | Codici: `internal_error`. |
| `503` | Codici: `unavailable`. |

### Campi della risposta

| Campo | Tipo | Sempre presente | Descrizione |
| - | - | - | - |
| `id` | string (uuid) | sì | |
| `url` | string | sì | |
| `description` | string \| null | sì | |
| `events` | array di string | sì | |
| `enabled` | boolean | sì | |
| `disabledReason` | string: `manual`, `too_many_failures` \| null | sì | too\_many\_failures: spenta dopo 50 consegne scartate di fila. Riaccendila con PATCH enabled=true. |
| `consecutiveFailures` | integer | sì | |
| `previousSecretExpiresAt` | string \| null | sì | Fino a quest'istante si firma anche con il segreto precedente. |
| `secretRotatedAt` | string \| null | sì | |
| `createdAt` | string | sì | |
| `updatedAt` | string | sì | |
| `secret` | string | sì | whsec\_...: si vede SOLO in questa risposta. Conservalo. |

## Una destinazione

`GET /webhook-endpoints/{id}`

Scope: `webhooks:read`.

### Parametri

| Nome | Posizione | Tipo | Obbligatorio | Descrizione |
| - | - | - | - | - |
| `id` | path | string (uuid) | sì | Identificativo della risorsa. |

### Risposte

| Status | Descrizione |
| - | - |
| `200` | La destinazione (senza segreto). |
| `400` | Codici: `invalid_request`. |
| `401` | Codici: `unauthenticated`, `api_key_invalid`, `api_key_revoked`. |
| `403` | Codici: `plan_required`, `insufficient_scope`. |
| `404` | Codici: `not_found`. |
| `429` | Codici: `rate_limited`. |
| `500` | Codici: `internal_error`. |
| `503` | Codici: `unavailable`. |

### Campi della risposta

| Campo | Tipo | Sempre presente | Descrizione |
| - | - | - | - |
| `id` | string (uuid) | sì | |
| `url` | string | sì | |
| `description` | string \| null | sì | |
| `events` | array di string | sì | |
| `enabled` | boolean | sì | |
| `disabledReason` | string: `manual`, `too_many_failures` \| null | sì | too\_many\_failures: spenta dopo 50 consegne scartate di fila. Riaccendila con PATCH enabled=true. |
| `consecutiveFailures` | integer | sì | |
| `previousSecretExpiresAt` | string \| null | sì | Fino a quest'istante si firma anche con il segreto precedente. |
| `secretRotatedAt` | string \| null | sì | |
| `createdAt` | string | sì | |
| `updatedAt` | string | sì | |

## Modifica una destinazione

`PATCH /webhook-endpoints/{id}`

Scope: `webhooks:write`.

URL, eventi, descrizione, accesa/spenta. Con If-Match (l'ETag della GET) non sovrascrive modifiche altrui.

### Parametri

| Nome | Posizione | Tipo | Obbligatorio | Descrizione |
| - | - | - | - | - |
| `id` | path | string (uuid) | sì | Identificativo della risorsa. |
| `If-Match` | header | string | no | L'ETag ricevuto: la modifica passa solo se nessuno ha cambiato la risorsa (altrimenti 409 conflict, details.reason = version\_mismatch). |

### Corpo (JSON)

| Campo | Tipo | Obbligatorio | Descrizione |
| - | - | - | - |
| `url` | string | no | https, con un nome pubblico: indirizzi privati, locali o con credenziali si rifiutano. |
| `events` | array di string | no | |
| `description` | string \| null | no | |
| `enabled` | boolean | no | |

### Risposte

| Status | Descrizione |
| - | - |
| `200` | La destinazione aggiornata. |
| `400` | Codici: `invalid_request`. |
| `401` | Codici: `unauthenticated`, `api_key_invalid`, `api_key_revoked`. |
| `403` | Codici: `plan_required`, `insufficient_scope`. |
| `404` | Codici: `not_found`. |
| `409` | Codici: `conflict`. |
| `429` | Codici: `rate_limited`. |
| `500` | Codici: `internal_error`. |
| `503` | Codici: `unavailable`. |

### Campi della risposta

| Campo | Tipo | Sempre presente | Descrizione |
| - | - | - | - |
| `id` | string (uuid) | sì | |
| `url` | string | sì | |
| `description` | string \| null | sì | |
| `events` | array di string | sì | |
| `enabled` | boolean | sì | |
| `disabledReason` | string: `manual`, `too_many_failures` \| null | sì | too\_many\_failures: spenta dopo 50 consegne scartate di fila. Riaccendila con PATCH enabled=true. |
| `consecutiveFailures` | integer | sì | |
| `previousSecretExpiresAt` | string \| null | sì | Fino a quest'istante si firma anche con il segreto precedente. |
| `secretRotatedAt` | string \| null | sì | |
| `createdAt` | string | sì | |
| `updatedAt` | string | sì | |

## Elimina una destinazione

`DELETE /webhook-endpoints/{id}`

Scope: `webhooks:write`.

Non riceve piu' niente e i suoi segreti si cancellano. Le consegne gia' fatte restano leggibili 30 giorni.

### Parametri

| Nome | Posizione | Tipo | Obbligatorio | Descrizione |
| - | - | - | - | - |
| `id` | path | string (uuid) | sì | Identificativo della risorsa. |
| `If-Match` | header | string | no | L'ETag ricevuto: la modifica passa solo se nessuno ha cambiato la risorsa (altrimenti 409 conflict, details.reason = version\_mismatch). |

### Risposte

| Status | Descrizione |
| - | - |
| `200` | Eliminata. |
| `400` | Codici: `invalid_request`. |
| `401` | Codici: `unauthenticated`, `api_key_invalid`, `api_key_revoked`. |
| `403` | Codici: `plan_required`, `insufficient_scope`. |
| `404` | Codici: `not_found`. |
| `429` | Codici: `rate_limited`. |
| `500` | Codici: `internal_error`. |
| `503` | Codici: `unavailable`. |

### Campi della risposta

| Campo | Tipo | Sempre presente | Descrizione |
| - | - | - | - |
| `id` | string (uuid) | sì | |
| `deleted` | boolean | sì | |

## Ruota il segreto di una destinazione

`POST /webhook-endpoints/{id}/rotate-secret`

Scope: `webhooks:write`.

### Parametri

| Nome | Posizione | Tipo | Obbligatorio | Descrizione |
| - | - | - | - | - |
| `id` | path | string (uuid) | sì | Identificativo della risorsa. |

### Corpo (JSON)

| Campo | Tipo | Obbligatorio | Descrizione |
| - | - | - | - |
| `overlapMinutes` | integer | no | Per quanti minuti si firma anche con il segreto vecchio (0-10080, predefinito 1440 = un giorno). |

### Risposte

| Status | Descrizione |
| - | - |
| `200` | La destinazione, con il segreto nuovo. |
| `400` | Codici: `invalid_request`. |
| `401` | Codici: `unauthenticated`, `api_key_invalid`, `api_key_revoked`. |
| `403` | Codici: `plan_required`, `insufficient_scope`. |
| `404` | Codici: `not_found`. |
| `409` | Codici: `conflict`. |
| `429` | Codici: `rate_limited`. |
| `500` | Codici: `internal_error`. |
| `503` | Codici: `unavailable`. |

### Campi della risposta

| Campo | Tipo | Sempre presente | Descrizione |
| - | - | - | - |
| `id` | string (uuid) | sì | |
| `url` | string | sì | |
| `description` | string \| null | sì | |
| `events` | array di string | sì | |
| `enabled` | boolean | sì | |
| `disabledReason` | string: `manual`, `too_many_failures` \| null | sì | too\_many\_failures: spenta dopo 50 consegne scartate di fila. Riaccendila con PATCH enabled=true. |
| `consecutiveFailures` | integer | sì | |
| `previousSecretExpiresAt` | string \| null | sì | Fino a quest'istante si firma anche con il segreto precedente. |
| `secretRotatedAt` | string \| null | sì | |
| `createdAt` | string | sì | |
| `updatedAt` | string | sì | |
| `secret` | string | sì | whsec\_...: si vede SOLO in questa risposta. Conservalo. |

## Manda un webhook di prova

`POST /webhook-endpoints/{id}/test`

Scope: `webhooks:write`.

Un evento webhook.test con data \{}, firmato come gli altri. Funziona anche su una destinazione spenta.

### Parametri

| Nome | Posizione | Tipo | Obbligatorio | Descrizione |
| - | - | - | - | - |
| `id` | path | string (uuid) | sì | Identificativo della risorsa. |

### Risposte

| Status | Descrizione |
| - | - |
| `200` | Com'e' andata. |
| `400` | Codici: `invalid_request`. |
| `401` | Codici: `unauthenticated`, `api_key_invalid`, `api_key_revoked`. |
| `403` | Codici: `plan_required`, `insufficient_scope`. |
| `404` | Codici: `not_found`. |
| `429` | Codici: `rate_limited`. |
| `500` | Codici: `internal_error`. |
| `503` | Codici: `unavailable`. |

### Campi della risposta

| Campo | Tipo | Sempre presente | Descrizione |
| - | - | - | - |
| `delivered` | boolean | sì | true se la destinazione ha risposto 2xx entro 10 secondi. |
| `statusCode` | integer \| null | sì | |
| `durationMs` | integer | sì | |
| `error` | string \| null | sì | |
| `skipped` | string \| null | sì | test\_mode: chiave di prova, non e' partito niente. |

## Elenca le consegne dei webhook

`GET /webhook-deliveries`

Scope: `webhooks:read`.

Le consegne degli ultimi 30 giorni alle destinazioni di questa chiave.

### Parametri

| Nome | Posizione | Tipo | Obbligatorio | Descrizione |
| - | - | - | - | - |
| `limit` | query | integer | no | Quante righe per pagina (1-200). |
| `cursor` | query | string | no | Il nextCursor della pagina precedente, tale e quale. |
| `updatedSince` | query | string (date-time) | no | Solo le righe modificate da questo istante in poi (sincronizzazione incrementale). |
| `endpointId` | query | string (uuid) | no | |
| `status` | query | string: `pending`, `delivering`, `succeeded`, `discarded` | no | |

### Risposte

| Status | Descrizione |
| - | - |
| `200` | Una pagina di consegne. |
| `400` | Codici: `invalid_request`. |
| `401` | Codici: `unauthenticated`, `api_key_invalid`, `api_key_revoked`. |
| `403` | Codici: `plan_required`, `insufficient_scope`. |
| `429` | Codici: `rate_limited`. |
| `500` | Codici: `internal_error`. |
| `503` | Codici: `unavailable`. |

### Campi della risposta

| Campo | Tipo | Sempre presente | Descrizione |
| - | - | - | - |
| `data` | array di object | sì | |
| `data[].id` | string (uuid) | sì | La consegna. E' anche la X-DentalSpace-Idempotency-Key di ogni suo tentativo. |
| `data[].endpointId` | string (uuid) | sì | |
| `data[].eventId` | string (uuid) | sì | |
| `data[].status` | string: `pending`, `delivering`, `succeeded`, `discarded` | sì | discarded = scartata dopo 5 tentativi: si rimanda con /redeliver. |
| `data[].attempts` | integer | sì | |
| `data[].nextAttemptAt` | string \| null | sì | |
| `data[].lastStatusCode` | integer \| null | sì | |
| `data[].lastError` | string \| null | sì | |
| `data[].deliveredAt` | string \| null | sì | |
| `data[].createdAt` | string | sì | |
| `data[].updatedAt` | string | sì | |
| `nextCursor` | string \| null | sì | null = ultima pagina. |

## Una consegna, con i suoi tentativi

`GET /webhook-deliveries/{id}`

Scope: `webhooks:read`.

### Parametri

| Nome | Posizione | Tipo | Obbligatorio | Descrizione |
| - | - | - | - | - |
| `id` | path | string (uuid) | sì | Identificativo della risorsa. |

### Risposte

| Status | Descrizione |
| - | - |
| `200` | La consegna e il registro dei tentativi. |
| `400` | Codici: `invalid_request`. |
| `401` | Codici: `unauthenticated`, `api_key_invalid`, `api_key_revoked`. |
| `403` | Codici: `plan_required`, `insufficient_scope`. |
| `404` | Codici: `not_found`. |
| `429` | Codici: `rate_limited`. |
| `500` | Codici: `internal_error`. |
| `503` | Codici: `unavailable`. |

### Campi della risposta

| Campo | Tipo | Sempre presente | Descrizione |
| - | - | - | - |
| `id` | string (uuid) | sì | La consegna. E' anche la X-DentalSpace-Idempotency-Key di ogni suo tentativo. |
| `endpointId` | string (uuid) | sì | |
| `eventId` | string (uuid) | sì | |
| `status` | string: `pending`, `delivering`, `succeeded`, `discarded` | sì | discarded = scartata dopo 5 tentativi: si rimanda con /redeliver. |
| `attempts` | integer | sì | |
| `nextAttemptAt` | string \| null | sì | |
| `lastStatusCode` | integer \| null | sì | |
| `lastError` | string \| null | sì | |
| `deliveredAt` | string \| null | sì | |
| `createdAt` | string | sì | |
| `updatedAt` | string | sì | |
| `history` | array di object | sì | |
| `history[].attempt` | integer | sì | |
| `history[].statusCode` | integer \| null | sì | |
| `history[].error` | string \| null | sì | |
| `history[].responseExcerpt` | string \| null | sì | Sempre null: il corpo della risposta della destinazione non si legge ne' si conserva (esito cieco). |
| `history[].durationMs` | integer \| null | sì | |
| `history[].createdAt` | string | sì | |

## Rimanda una consegna

`POST /webhook-deliveries/{id}/redeliver`

Scope: `webhooks:write`.

Solo una consegna succeeded o discarded, verso una destinazione accesa. Parte entro una decina di secondi. Al massimo 3 volte per consegna: oltre, 409 con reason redeliver\_limit.

### Parametri

| Nome | Posizione | Tipo | Obbligatorio | Descrizione |
| - | - | - | - | - |
| `id` | path | string (uuid) | sì | Identificativo della risorsa. |
| `Idempotency-Key` | header | string | no | Facoltativa. La stessa chiave entro 24 ore ripete la prima risposta 2xx. |

### Risposte

| Status | Descrizione |
| - | - |
| `200` | La consegna, di nuovo in coda. |
| `400` | Codici: `invalid_request`. |
| `401` | Codici: `unauthenticated`, `api_key_invalid`, `api_key_revoked`. |
| `403` | Codici: `plan_required`, `insufficient_scope`. |
| `404` | Codici: `not_found`. |
| `409` | Codici: `conflict`, `idempotency_in_progress`. |
| `422` | Codici: `idempotency_key_reused`. |
| `429` | Codici: `rate_limited`. |
| `500` | Codici: `internal_error`. |
| `503` | Codici: `unavailable`. |

### Campi della risposta

| Campo | Tipo | Sempre presente | Descrizione |
| - | - | - | - |
| `id` | string (uuid) | sì | La consegna. E' anche la X-DentalSpace-Idempotency-Key di ogni suo tentativo. |
| `endpointId` | string (uuid) | sì | |
| `eventId` | string (uuid) | sì | |
| `status` | string: `pending`, `delivering`, `succeeded`, `discarded` | sì | discarded = scartata dopo 5 tentativi: si rimanda con /redeliver. |
| `attempts` | integer | sì | |
| `nextAttemptAt` | string \| null | sì | |
| `lastStatusCode` | integer \| null | sì | |
| `lastError` | string \| null | sì | |
| `deliveredAt` | string \| null | sì | |
| `createdAt` | string | sì | |
| `updatedAt` | string | sì | |

## Oggetto errore

Tutte le risposte di errore hanno questo formato.

| Campo | Tipo | Obbligatorio | Descrizione |
| - | - | - | - |
| `error` | object | sì | |
| `error.code` | string | sì | Codice stabile, da usare nel codice del client. |
| `error.message` | string | sì | Spiegazione per una persona (in italiano). Non confrontarla nel codice. |
| `error.details` | object \| null | sì | |
| `error.requestId` | string | sì | Lo stesso valore dell'header X-Request-Id: citalo all'assistenza. |


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