> ## 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. # Cambia lo stato Source: https://docs.dentalspace.ai/api-reference/agenda/cambia-lo-stato /openapi.json 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. # Conferma la presenza Source: https://docs.dentalspace.ai/api-reference/agenda/conferma-la-presenza /openapi.json 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. # Crea un appuntamento fuori calendario Source: https://docs.dentalspace.ai/api-reference/agenda/crea-un-appuntamento-fuori-calendario /openapi.json 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. # Disdici un appuntamento Source: https://docs.dentalspace.ai/api-reference/agenda/disdici-un-appuntamento /openapi.json 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`. # Elenco degli appuntamenti Source: https://docs.dentalspace.ai/api-reference/agenda/elenco-degli-appuntamenti /openapi.json 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. # Modifica titolo, motivo o note Source: https://docs.dentalspace.ai/api-reference/agenda/modifica-titolo-motivo-o-note /openapi.json 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`. # Orari liberi di un calendario Source: https://docs.dentalspace.ai/api-reference/agenda/orari-liberi-di-un-calendario /openapi.json 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`. # Prenota un orario Source: https://docs.dentalspace.ai/api-reference/agenda/prenota-un-orario /openapi.json 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. # Sposta un appuntamento Source: https://docs.dentalspace.ai/api-reference/agenda/sposta-un-appuntamento /openapi.json 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`. # Un appuntamento Source: https://docs.dentalspace.ai/api-reference/agenda/un-appuntamento /openapi.json get /appointments/{id} Scope: `appointments:read`. Con l'ETag da rimandare in If-Match. Note e note cliniche solo con clinical:read. # Briefing prima della chiamata Source: https://docs.dentalspace.ai/api-reference/agente-vocale/briefing-prima-della-chiamata /openapi.json 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. # Passa la chiamata alla segreteria Source: https://docs.dentalspace.ai/api-reference/agente-vocale/passa-la-chiamata-alla-segreteria /openapi.json 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. # Crea un'attivita' Source: https://docs.dentalspace.ai/api-reference/attività/crea-unattivita /openapi.json post /tasks Scope: `tasks:write`. Con `Idempotency-Key` un nuovo tentativo non crea un doppione. Una chiave limitata a delle sedi deve indicare `facilityId`. # Elenco delle attivita' Source: https://docs.dentalspace.ai/api-reference/attività/elenco-delle-attivita /openapi.json get /tasks Scope: `tasks:read`. Ordinate per ultima modifica; `updatedSince` per la sincronizzazione incrementale. # Modifica un'attivita' Source: https://docs.dentalspace.ai/api-reference/attività/modifica-unattivita /openapi.json 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. # Aggiungi un'attivita' a un lead Source: https://docs.dentalspace.ai/api-reference/contatti-commerciali/aggiungi-unattivita-a-un-lead /openapi.json 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. # Converti un lead in paziente Source: https://docs.dentalspace.ai/api-reference/contatti-commerciali/converti-un-lead-in-paziente /openapi.json 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`. # Crea un lead Source: https://docs.dentalspace.ai/api-reference/contatti-commerciali/crea-un-lead /openapi.json 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`. # Elenco dei lead Source: https://docs.dentalspace.ai/api-reference/contatti-commerciali/elenco-dei-lead /openapi.json get /leads Scope: `leads:read`. Ordinati per ultima modifica. Le attivita' si leggono dal dettaglio. # Modifica un lead Source: https://docs.dentalspace.ai/api-reference/contatti-commerciali/modifica-un-lead /openapi.json 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. # Un lead Source: https://docs.dentalspace.ai/api-reference/contatti-commerciali/un-lead /openapi.json get /leads/{id} Scope: `leads:read`. # Un documento del paziente Source: https://docs.dentalspace.ai/api-reference/documenti/un-documento-del-paziente /openapi.json 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. # Crea un link di pagamento Source: https://docs.dentalspace.ai/api-reference/fatture-e-pagamenti/crea-un-link-di-pagamento /openapi.json 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`). # Elenco delle fatture Source: https://docs.dentalspace.ai/api-reference/fatture-e-pagamenti/elenco-delle-fatture /openapi.json get /invoices Scope: `billing:read`. # Gli incassi di una fattura Source: https://docs.dentalspace.ai/api-reference/fatture-e-pagamenti/gli-incassi-di-una-fattura /openapi.json get /invoices/{id}/payments Scope: `billing:read`. # Piani di pagamento e rate Source: https://docs.dentalspace.ai/api-reference/fatture-e-pagamenti/piani-di-pagamento-e-rate /openapi.json get /payment-plans Scope: `billing:read`. state=open (predefinito): rate non pagate · overdue: non pagate e scadute · all: tutte. # Registra un incasso Source: https://docs.dentalspace.ai/api-reference/fatture-e-pagamenti/registra-un-incasso /openapi.json 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`. # Una fattura, con le righe Source: https://docs.dentalspace.ai/api-reference/fatture-e-pagamenti/una-fattura-con-le-righe /openapi.json get /invoices/{id} Scope: `billing:read`. # Elenco delle conversazioni WhatsApp Source: https://docs.dentalspace.ai/api-reference/messaggi-whatsapp/elenco-delle-conversazioni-whatsapp /openapi.json get /conversations Scope: `messages:read`. Ordinate per ultima modifica; `updatedSince` per la sincronizzazione incrementale. # I messaggi di una conversazione Source: https://docs.dentalspace.ai/api-reference/messaggi-whatsapp/i-messaggi-di-una-conversazione /openapi.json 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. # Manda un messaggio WhatsApp Source: https://docs.dentalspace.ai/api-reference/messaggi-whatsapp/manda-un-messaggio-whatsapp /openapi.json 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. # Stato del bot su una conversazione Source: https://docs.dentalspace.ai/api-reference/messaggi-whatsapp/stato-del-bot-su-una-conversazione /openapi.json patch /conversations/{id}/bot Scope: `messages:write`. `{ "state": "attivo" }` lo riaccende; `{ "state": "in_pausa", "pausedUntil": "" }` 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. # La chiave che sta chiamando Source: https://docs.dentalspace.ai/api-reference/meta/la-chiave-che-sta-chiamando /openapi.json get /me Clinica, chiave (scope, sedi, scadenza), limiti e modalita'. Basta una chiave valida, senza scope. # Specifica OpenAPI 3.1 Source: https://docs.dentalspace.ai/api-reference/meta/specifica-openapi-31 /openapi.json get /openapi.json Non richiede chiave. Senza chiave. Generata dagli stessi schemi che validano le richieste. # Stato del servizio Source: https://docs.dentalspace.ai/api-reference/meta/stato-del-servizio /openapi.json get /health Non richiede chiave. Senza chiave. 200 se l'API e il database rispondono, 503 `unavailable` altrimenti. # Archivia un paziente Source: https://docs.dentalspace.ai/api-reference/pazienti/archivia-un-paziente /openapi.json 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. # Cerca un paziente Source: https://docs.dentalspace.ai/api-reference/pazienti/cerca-un-paziente /openapi.json 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. # Chi e' questo numero? Source: https://docs.dentalspace.ai/api-reference/pazienti/chi-e-questo-numero? /openapi.json 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. # Crea un paziente Source: https://docs.dentalspace.ai/api-reference/pazienti/crea-un-paziente /openapi.json 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. # Elenco dei pazienti Source: https://docs.dentalspace.ai/api-reference/pazienti/elenco-dei-pazienti /openapi.json 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`. # Gli appuntamenti di un paziente Source: https://docs.dentalspace.ai/api-reference/pazienti/gli-appuntamenti-di-un-paziente /openapi.json 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. # I dati sanitari di un paziente Source: https://docs.dentalspace.ai/api-reference/pazienti/i-dati-sanitari-di-un-paziente /openapi.json 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. # I documenti di un paziente Source: https://docs.dentalspace.ai/api-reference/pazienti/i-documenti-di-un-paziente /openapi.json 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. # I preventivi di un paziente Source: https://docs.dentalspace.ai/api-reference/pazienti/i-preventivi-di-un-paziente /openapi.json 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. # La scheda di un paziente Source: https://docs.dentalspace.ai/api-reference/pazienti/la-scheda-di-un-paziente /openapi.json 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. # Modifica un paziente Source: https://docs.dentalspace.ai/api-reference/pazienti/modifica-un-paziente /openapi.json 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`. # Riattiva un paziente archiviato Source: https://docs.dentalspace.ai/api-reference/pazienti/riattiva-un-paziente-archiviato /openapi.json post /patients/{id}/restore Scope: `patients:write`. Riporta fra gli attivi un paziente archiviato. Ripeterla non cambia nulla. # Cambia lo stato di un preventivo Source: https://docs.dentalspace.ai/api-reference/preventivi/cambia-lo-stato-di-un-preventivo /openapi.json post /treatment-plans/{id}/status Scope: `treatment_plans:write`. proposed → standby (presentato) · proposed/standby → accepted/rejected. Una bozza non cambia stato via API. # Crea una bozza di preventivo dal listino Source: https://docs.dentalspace.ai/api-reference/preventivi/crea-una-bozza-di-preventivo-dal-listino /openapi.json post /treatment-plans Scope: `treatment_plans:write`. I prezzi sono quelli del listino. Nasce sempre come bozza (`draft`): la conferma una persona nell'app. # Elenco dei preventivi Source: https://docs.dentalspace.ai/api-reference/preventivi/elenco-dei-preventivi /openapi.json get /treatment-plans Scope: `treatment_plans:read`. Senza voci e senza dati clinici; quelli nel cestino non compaiono. # Un preventivo, con le voci Source: https://docs.dentalspace.ai/api-reference/preventivi/un-preventivo-con-le-voci /openapi.json 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). # I calendari prenotabili Source: https://docs.dentalspace.ai/api-reference/studio/i-calendari-prenotabili /openapi.json get /calendars Scope: `clinic:read`. Con le regole di prenotazione: preavviso, orizzonte, disdetta e spostamento consentiti, prestazioni e medici. # I medici della clinica Source: https://docs.dentalspace.ai/api-reference/studio/i-medici-della-clinica /openapi.json 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). # Il listino Source: https://docs.dentalspace.ai/api-reference/studio/il-listino /openapi.json 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). # Le poltrone attive di una sede Source: https://docs.dentalspace.ai/api-reference/studio/le-poltrone-attive-di-una-sede /openapi.json get /facilities/{id}/chairs Scope: `clinic:read`. # Le sedi della clinica Source: https://docs.dentalspace.ai/api-reference/studio/le-sedi-della-clinica /openapi.json get /facilities Scope: `clinic:read`. Indirizzo, telefono e orari di apertura. Una chiave limitata a delle sedi vede solo quelle. # Un calendario Source: https://docs.dentalspace.ai/api-reference/studio/un-calendario /openapi.json get /calendars/{id} Scope: `clinic:read`. # Una prestazione Source: https://docs.dentalspace.ai/api-reference/studio/una-prestazione /openapi.json get /treatments/{id} Scope: `clinic:read`. # Una sede Source: https://docs.dentalspace.ai/api-reference/studio/una-sede /openapi.json get /facilities/{id} Scope: `clinic:read`. Dove siete e che orari fate. # Elenca le telefonate Source: https://docs.dentalspace.ai/api-reference/telefonate/elenca-le-telefonate /openapi.json get /calls Scope: `calls:read`. In ordine di registrazione. updatedSince guarda l'istante di registrazione (una telefonata non cambia). # Registra una telefonata conclusa Source: https://docs.dentalspace.ai/api-reference/telefonate/registra-una-telefonata-conclusa /openapi.json 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. # Una telefonata Source: https://docs.dentalspace.ai/api-reference/telefonate/una-telefonata /openapi.json get /calls/{id} Scope: `calls:read`. Con lo scope clinical:read anche la trascrizione (l'accesso finisce nel registro). # Crea una destinazione dei webhook Source: https://docs.dentalspace.ai/api-reference/webhook-ed-eventi/crea-una-destinazione-dei-webhook /openapi.json 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. # Elenca gli eventi Source: https://docs.dentalspace.ai/api-reference/webhook-ed-eventi/elenca-gli-eventi /openapi.json 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. # Elenca le consegne dei webhook Source: https://docs.dentalspace.ai/api-reference/webhook-ed-eventi/elenca-le-consegne-dei-webhook /openapi.json get /webhook-deliveries Scope: `webhooks:read`. Le consegne degli ultimi 30 giorni alle destinazioni di questa chiave. # Elenca le destinazioni dei webhook Source: https://docs.dentalspace.ai/api-reference/webhook-ed-eventi/elenca-le-destinazioni-dei-webhook /openapi.json get /webhook-endpoints Scope: `webhooks:read`. Le destinazioni create da questa chiave (o da quella che ha rigenerato). # Elimina una destinazione Source: https://docs.dentalspace.ai/api-reference/webhook-ed-eventi/elimina-una-destinazione /openapi.json 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. # Manda un webhook di prova Source: https://docs.dentalspace.ai/api-reference/webhook-ed-eventi/manda-un-webhook-di-prova /openapi.json post /webhook-endpoints/{id}/test Scope: `webhooks:write`. Un evento webhook.test con data {}, firmato come gli altri. Funziona anche su una destinazione spenta. # Modifica una destinazione Source: https://docs.dentalspace.ai/api-reference/webhook-ed-eventi/modifica-una-destinazione /openapi.json patch /webhook-endpoints/{id} Scope: `webhooks:write`. URL, eventi, descrizione, accesa/spenta. Con If-Match (l'ETag della GET) non sovrascrive modifiche altrui. # Rimanda una consegna Source: https://docs.dentalspace.ai/api-reference/webhook-ed-eventi/rimanda-una-consegna /openapi.json 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. # Ruota il segreto di una destinazione Source: https://docs.dentalspace.ai/api-reference/webhook-ed-eventi/ruota-il-segreto-di-una-destinazione /openapi.json post /webhook-endpoints/{id}/rotate-secret Scope: `webhooks:write`. # Una consegna, con i suoi tentativi Source: https://docs.dentalspace.ai/api-reference/webhook-ed-eventi/una-consegna-con-i-suoi-tentativi /openapi.json get /webhook-deliveries/{id} Scope: `webhooks:read`. # Una destinazione Source: https://docs.dentalspace.ai/api-reference/webhook-ed-eventi/una-destinazione /openapi.json get /webhook-endpoints/{id} Scope: `webhooks:read`. # Autenticazione Source: https://docs.dentalspace.ai/api/autenticazione Chiavi API, header, scope per area, sedi, chiavi di prova, creazione, rigenerazione e revoca. Ogni richiesta porta una chiave API nell'header `Authorization`, con schema `Bearer`. Fanno eccezione `GET /health` e `GET /openapi.json`, che non chiedono la chiave. ```bash theme={null} curl "https://api.dentalspace.ai/v1/me" \ -H "Authorization: Bearer $DS_KEY" ``` `GET /me` restituisce la clinica, gli scope, le sedi, la scadenza e i limiti della chiave che chiama. ## Header | Header | Valore | Obbligatorio | | - | - | - | | `Authorization` | `Bearer ` | Sì, tranne `GET /health` e `GET /openapi.json` | | `Content-Type` | `application/json` | Sì, sulle richieste con corpo | | `Idempotency-Key` | Stringa scelta da te, al massimo 255 caratteri | Su alcune creazioni è obbligatoria. Vedi [Idempotenza](/api/idempotenza) | | `If-Match` | L'`ETag` ricevuto leggendo la risorsa | No. Sulle modifiche: passa solo se nessuno ha cambiato la risorsa | ## Formato della chiave | Prefisso | Tipo | | - | - | | `dsk_live_` | Chiave della clinica | | `dsk_test_` | Chiave di una clinica di prova. Vedi [Chiavi di prova](#chiavi-di-prova) | Dopo il prefisso seguono 64 caratteri esadecimali. La chiave è segreta: usala solo da un server, mai nel codice di una pagina web o di un'app. ## Una chiave vede una clinica La chiave appartiene alla clinica in cui è stata creata e vede solo i suoi dati. Una risorsa di un'altra clinica risponde `404 not_found`, mai `403`: dall'esterno non si capisce nemmeno se esiste. Una chiave può essere **limitata ad alcune sedi**. In quel caso vede le righe di quelle sedi e quelle senza sede, e scrive solo nelle sue sedi. Una risorsa di un'altra sede risponde `404 not_found`. ## Scope Ogni endpoint chiede uno o più scope, indicati nella sua pagina come `Scope: `. Una chiave senza lo scope richiesto riceve `403 insufficient_scope` prima che la richiesta legga qualunque dato; gli scope mancanti sono in `details.requiredScopes`. Nell'app gli scope si scelgono per area, con tre livelli: **Nessuno**, **Leggi**, **Scrivi**. **Scrivi** comprende **Leggi**. | Area nell'app | Leggi | Scrivi | Cosa apre | | - | - | - | - | | Pazienti | `patients:read` | `patients:write` | Anagrafica e contatti, ricerca per telefono e per nome | | Dati clinici | `clinical:read` | `clinical:write` | Allergie, farmaci, condizioni, note cliniche. Spenti di default. Vedi [Dati clinici](/api/dati-clinici) | | Agenda | `appointments:read` | `appointments:write` | Appuntamenti, orari liberi, prenotazioni | | Studio | `clinic:read` | — | Sedi, poltrone, medici, calendari, listino. Solo lettura | | Preventivi e piani di cura | `treatment_plans:read` | `treatment_plans:write` | Preventivi e loro voci | | Fatture e pagamenti | `billing:read` | `billing:write` | Fatture, incassi, piani di pagamento, link di pagamento | | Documenti | `documents:read` | `documents:write` | Documenti da firmare dei pazienti | | Attività | `tasks:read` | `tasks:write` | Le cose da fare dello studio | | Contatti commerciali | `leads:read` | `leads:write` | Persone interessate che non sono ancora pazienti | | Messaggi WhatsApp | `messages:read` | `messages:write` | Conversazioni, messaggi, stato del bot | | Telefonate | `calls:read` | `calls:write` | Registro delle telefonate dell'agente vocale | | Avvisi automatici | `webhooks:read` | `webhooks:write` | Webhook, consegne e storico degli eventi | Una chiave non può fare più di chi la crea: nell'app si possono dare solo i permessi che si hanno. ### Modelli pronti Nella finestra di creazione, **Parti da:** riempie i livelli con un modello. | Modello | Livelli | | - | - | | **Agente vocale** | Pazienti, Agenda, Attività e Telefonate in scrittura; Studio in lettura. Niente dati clinici | | **Gestionale esterno** | Tutte le aree in lettura, tranne i dati clinici | ## Creare una chiave Nell'app apri **Impostazioni → Chiavi API**. Serve il permesso di gestire le integrazioni e il piano **Clinic**. Si apre la finestra **Nuova chiave API**. Dai un nome che dica quale programma la userà, per esempio `Agente vocale`. In **Cosa può fare** scegli il livello di ogni area, oppure parti da un modello. Accendi **Solo alcune sedi** per limitarla. **Scadenza** è facoltativa: vuota, la chiave non scade. Clicca **Crea chiave**. La chiave compare una volta sola: copiala subito e conservala in un posto sicuro. dentalspace conserva solo l'impronta della chiave, non la chiave. Una chiave persa non si recupera: creane un'altra e revoca quella vecchia. ## Rigenerare una chiave **Rigenera** dà una chiave nuova con gli stessi permessi. Scegli quando la vecchia smette di funzionare: **Subito**, **Fra 24 ore** o **Fra 7 giorni**. Nel frattempo funzionano tutte e due, così aggiorni il programma collegato senza interruzioni. Una chiave già rigenerata non si rigenera di nuovo: si rigenera quella nuova. ## Revocare una chiave **Revoca** spegne la chiave subito e per sempre. Le richieste successive ricevono `401 api_key_revoked`. Una chiave scaduta risponde `401 api_key_revoked` con `details.reason: "expired"`. ## Chiavi di prova Una clinica di prova ha chiavi `dsk_test_`. Le richieste funzionano come con una chiave vera, con queste differenze: * `GET /me` restituisce `livemode: false`; * nessun messaggio parte verso l'esterno: WhatsApp, email, Sistema TS e fatturazione elettronica non vengono chiamati; * `POST /payment-links` controlla tutto ma non crea il link: risponde `201` con `url: null`; * `POST /webhook-endpoints/{id}/test` non spedisce niente. La clinica di prova la attiva l'assistenza di dentalspace. ## Errori di autenticazione e di permesso | Status | `code` | Causa | | - | - | - | | `401` | `unauthenticated` | Header `Authorization` assente o senza `Bearer` | | `401` | `api_key_invalid` | Chiave sconosciuta o malformata | | `401` | `api_key_revoked` | Chiave revocata, sostituita o scaduta (`details.reason: "expired"`) | | `403` | `plan_required` | La clinica non ha il piano **Clinic** attivo | | `403` | `insufficient_scope` | La chiave non ha lo scope dell'endpoint | | `404` | `not_found` | La risorsa non esiste o non è visibile alla chiave | # Dati clinici e registro accessi Source: https://docs.dentalspace.ai/api/dati-clinici Gli scope clinical:*, cosa aprono, perché sono spenti di default e come ogni accesso finisce nel registro della clinica. I dati sanitari dei pazienti hanno scope a parte: `clinical:read` e `clinical:write`. Sono **spenti di default** in ogni chiave e in ogni modello, e ogni accesso viene registrato. ## Come si accendono Nella finestra **Nuova chiave API**, area **Dati clinici**. L'app mostra un avviso prima di darli. Accenderli accende anche **Pazienti** in lettura: `clinical:read` da solo non apre niente. Accendili solo se il programma collegato ne ha davvero bisogno. Il modello **Agente vocale** non li comprende mai. ## Cosa aprono | Endpoint | Senza `clinical:read` | Con `clinical:read` | | - | - | - | | `GET /patients/{id}` | Anagrafica | Anche allergie, farmaci, condizioni e note | | `GET /patients/{id}/clinical` | `403 insufficient_scope` | Allergie, farmaci, condizioni e odontogramma | | `GET /appointments/{id}` | Senza note | Anche note e note cliniche | | `GET /treatment-plans/{id}` | Voci e importi | Anche denti, superfici, odontogramma e diagnosi | | `GET /documents/{id}` | Tipo e stato della firma | Anche il contenuto e la firma | | `GET /calls/{id}` | La telefonata | Anche la trascrizione | | `GET /conversations/{id}/messages` | I messaggi | Anche la trascrizione dei vocali, solo per i pazienti | `clinical:write` serve per scrivere allergie, farmaci, condizioni e note del paziente (`POST /patients`, `PATCH /patients/{id}`), trascrizioni e note cliniche. Gli elenchi (`GET /patients`, `GET /appointments` e gli altri) non contengono mai dati sanitari, nemmeno con `clinical:read`: si leggono solo nel dettaglio. Anche i [webhook](/api/webhook) non li portano mai. ## Registro accessi Ogni richiesta autenticata finisce nel registro accessi della clinica: quale chiave, quale endpoint, cosa ha cercato, con che esito. Quando la risposta contiene dati sanitari, il registro segna anche **quali pazienti** sono stati letti. Finiscono nel registro come accesso a dati sanitari: * ogni lettura fatta con `clinical:read` che restituisce dati clinici; * ogni scrittura fatta con `clinical:write`; * le letture di testo libero che può contenere informazioni sulla salute, anche con uno scope normale: il testo dei messaggi WhatsApp, titolo e motivo degli appuntamenti, il titolo delle attività e dei documenti. Se il registro non si riesce a scrivere, la risposta è `500 internal_error` e i dati non escono: un dato sanitario senza traccia non lascia mai dentalspace. Il registro si conserva 24 mesi. Revocare o cancellare una chiave non cancella le sue tracce. ## Documenti L'API non espone file da scaricare né accetta file da caricare: i documenti da firmare sono testo e firma, non file. `documents:write` oggi non apre nessun endpoint. # Errori Source: https://docs.dentalspace.ai/api/errori Formato della risposta di errore, codici HTTP, codici errore e motivi più comuni. L'API usa gli status HTTP standard: `2xx` successo, `4xx` errore nella richiesta, `5xx` errore del server. ## Formato Tutti gli errori, su tutti gli endpoint, hanno questa forma: ```json theme={null} { "error": { "code": "invalid_request", "message": "Richiesta non valida: guarda details.issues.", "details": { "in": "body", "issues": [{ "path": "start", "message": "Invalid ISO datetime" }] }, "requestId": "req_6f1c2a0e4b8d4c6e9a512f3b7d8e9a10" } } ``` | Campo | Tipo | Descrizione | | - | - | - | | `error.code` | string | Codice stabile. Usalo nella logica del tuo programma | | `error.message` | string | Spiegazione in italiano per una persona. Può cambiare: non confrontarla nel codice | | `error.details` | object \| null | Dati aggiuntivi, per esempio il campo sbagliato o il motivo (`reason`) | | `error.requestId` | string | Uguale all'header `X-Request-Id`. Citalo all'assistenza | ## Codici | Status | `code` | Causa | Cosa fare | | - | - | - | - | | `400` | `invalid_request` | Parametri, corpo o percorso non validi. `details.in` dice dove (`path`, `query`, `body`), `details.issues` campo per campo | Correggere la richiesta | | `401` | `unauthenticated` | Header `Authorization` assente | Mandare `Authorization: Bearer dsk_live_...` | | `401` | `api_key_invalid` | Chiave sconosciuta o malformata | Controllare la chiave | | `401` | `api_key_revoked` | Chiave revocata, sostituita o scaduta | Non ripetere. Creare una nuova chiave | | `403` | `plan_required` | La clinica non ha il piano **Clinic** attivo | Non ripetere | | `403` | `insufficient_scope` | La chiave non ha lo scope dell'endpoint. `details.requiredScopes` elenca quelli che mancano | Usare una chiave con quello scope | | `403` | `forbidden` | Operazione non permessa per una regola della clinica. Vedi `details.reason` | Non ripetere | | `404` | `not_found` | La risorsa non esiste, è di un'altra clinica o di una sede che la chiave non vede | Controllare gli id | | `409` | `no_availability` | L'orario non è più prenotabile. `details.reason` e `details.alternatives` | Proporre un altro orario | | `409` | `conflict` | La richiesta urta lo stato attuale della risorsa. Vedi `details.reason` | Rileggere la risorsa e decidere | | `409` | `idempotency_in_progress` | Una richiesta con la stessa `Idempotency-Key` è ancora in corso | Ripetere dopo qualche secondo con la stessa chiave | | `422` | `idempotency_key_reused` | La `Idempotency-Key` è già stata usata per un'altra operazione, un altro corpo o un'altra chiave API | Usare una chiave nuova. Vedi [Idempotenza](/api/idempotenza) | | `429` | `rate_limited` | Troppe richieste | Aspettare i secondi di `Retry-After`. Vedi [Limiti](/api/limiti) | | `500` | `internal_error` | Errore del server | Ripetere. Se continua, segnalarlo con il `requestId` | | `503` | `unavailable` | Un servizio necessario non risponde | Ripetere dopo qualche secondo | Lo status dipende solo da `code`. I codici possibili di ogni endpoint sono nella sua pagina. ## Motivi più comuni `details.reason` spiega un `403 forbidden` o un `409`. I principali: | `reason` | Codice | Significato | | - | - | - | | `version_mismatch` | `409 conflict` | La risorsa è cambiata dopo che l'hai letta (`If-Match`). Rileggila e riprova | | `concurrent_request` | `409 conflict` | Un'altra richiesta sta creando la stessa persona (stesso telefono o email). Riprova: troverai quella creata | | `duplicate_patient` | `409 conflict` | Esiste già un paziente con lo stesso nome e lo stesso telefono o email. `details.patientId` è il suo id | | `response_not_stored` | `409 conflict` | La prima risposta conteneva un segreto mostrato una volta e non è stata conservata. Vedi [Idempotenza](/api/idempotenza) | | `slot_taken` | `409 no_availability` | L'orario è stato preso nel frattempo | | `plan_limit` | `403 forbidden` | Il piano ha raggiunto il numero massimo di pazienti | | `ai_disabled` | `403 forbidden` | La clinica ha spento l'elaborazione AI: l'agente vocale non è disponibile | I motivi specifici di un endpoint, come `redeliver_limit` o `stripe_error`, sono descritti nella sua pagina. ## Esiti negativi con `200` Alcuni endpoint rispondono a una domanda: un «no» è una risposta, non un errore. | Endpoint | Condizione | Risposta | | - | - | - | | `GET /patients/lookup` | Nessuno con quel numero | `200` con `found: false` | | `GET /calendars/{id}/availability` | Nessun orario libero | `200` con `data: []`. `window: null` se le regole del calendario non lasciano niente da cercare | # Idempotenza Source: https://docs.dentalspace.ai/api/idempotenza Header Idempotency-Key per ripetere una richiesta senza creare doppioni. Una richiesta può fallire per la rete dopo che il server l'ha già eseguita. Se la ripeti, rischi di prenotare due volte o di registrare due volte lo stesso incasso. L'header `Idempotency-Key` lo impedisce: la stessa chiave ripete la prima risposta invece di rifare l'operazione. ```http theme={null} Idempotency-Key: chiamata-8f2a-prenota ``` ## Dove si usa Su questi endpoint è **obbligatoria**, perché l'operazione costa o non si annulla: | Endpoint | Operazione | | - | - | | `POST /calendars/{id}/bookings` | Prenota un orario | | `POST /appointments` | Crea un appuntamento fuori calendario | | `POST /invoices/{id}/payments` | Registra un incasso | | `POST /payment-links` | Crea un link di pagamento | | `POST /conversations/{id}/messages` | Manda un messaggio WhatsApp | Senza l'header, questi endpoint rispondono `400 invalid_request`. È **facoltativa** sulle altre creazioni e azioni: `POST /patients`, `POST /appointments/{id}/move`, `/cancel`, `/confirm` e `/status`, `POST /treatment-plans`, `POST /tasks`, `POST /leads`, `POST /leads/{id}/activities` e `/convert`, `POST /calls`, `POST /voice/handoff`, `POST /webhook-deliveries/{id}/redeliver`. Gli endpoint che non la prevedono la ignorano. ## Comportamento | Caso | Risposta | | - | - | | Prima richiesta con la chiave | Eseguita normalmente | | Stessa chiave, prima richiesta riuscita (`2xx`) | La stessa risposta della prima, stesso status e stesso corpo, con l'header `Idempotent-Replayed: true`. L'operazione non si ripete | | Stessa chiave, prima richiesta ancora in corso | `409 idempotency_in_progress`. Riprova dopo qualche secondo con la stessa chiave | | Stessa chiave con un'altra operazione, un altro corpo o un'altra chiave API | `422 idempotency_key_reused` | | Prima richiesta finita con un errore | La chiave si libera: la richiesta successiva con la stessa chiave viene eseguita da capo | La prima risposta si conserva per **24 ore**. Dopo, la stessa chiave vale come nuova. La chiave è legata alla chiave API, all'operazione e al corpo. Cliniche diverse possono usare la stessa stringa senza disturbarsi. ### Risposte con un segreto `POST /webhook-endpoints` e `POST /webhook-endpoints/{id}/rotate-secret` mostrano un segreto una volta sola, e quella risposta non viene conservata. Una seconda richiesta con la stessa chiave risponde `409 conflict` con `details.reason: "response_not_stored"`, senza generare un altro segreto. ## Scegliere la chiave Una chiave per ogni operazione, non per ogni tentativo. Per un agente vocale: l'id della telefonata più l'azione, per esempio `-prenota`. Ogni ripetizione della stessa prenotazione usa la stessa chiave; una seconda prenotazione nella stessa telefonata ne usa un'altra. ```bash theme={null} curl -s -X POST "https://api.dentalspace.ai/v1/invoices/$FATTURA/payments" \ -H "Authorization: Bearer $DS_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: incasso-pos-000123" \ -d '{"amount":150,"method":"credit_card"}' ``` I campi esatti del corpo sono nella pagina dell'endpoint. # Introduzione all'API Source: https://docs.dentalspace.ai/api/introduzione API REST v1 di dentalspace: indirizzo, formato di richieste e risposte, versioni, elenco delle risorse. L'API v1 collega altri programmi alla tua clinica: agenti vocali, gestionali, CRM, siti di prenotazione, strumenti di analisi. Espone pazienti, agenda, preventivi, fatture, documenti, attività, contatti commerciali, messaggi WhatsApp, telefonate, webhook ed eventi. L'API è inclusa nel piano **Clinic**. Con un altro piano ogni richiesta risponde `403 plan_required`. ## Indirizzo ```text theme={null} https://api.dentalspace.ai/v1 ``` Tutte le richieste usano HTTPS. ## Formato | Elemento | Formato | | - | - | | Corpo di richiesta e risposta | JSON, `Content-Type: application/json`. Al massimo 1 MB | | Nomi dei campi | `camelCase`, in inglese: `firstName`, `startTime` | | Date | `YYYY-MM-DD` | | Istanti (`startTime`, `createdAt`, `updatedAt`) | ISO 8601 con fuso | | Telefono in ingresso | Qualsiasi formato italiano o internazionale; il server lo normalizza | | Telefono in uscita | E.164, ad esempio `+393391112233` | | Importi | Euro, numero decimale | | ID | UUID | ## Versioni La versione è nel percorso: `/v1`. Non esiste un header di versione. Una modifica che rompe i client esistenti esce come `/v2`; i campi nuovi possono comparire in `/v1` in qualsiasi momento, quindi il tuo client deve ignorare i campi che non conosce. ## Risorse | Area | Endpoint | | - | - | | Meta | `/me`, `/health`, `/openapi.json` | | Studio | `/facilities`, `/doctors`, `/calendars`, `/treatments` | | Pazienti | `/patients`, `/patients/lookup`, `/patients/search`, `/patients/{id}/clinical` | | Agenda | `/calendars/{id}/availability`, `/calendars/{id}/bookings`, `/appointments` | | Preventivi | `/treatment-plans` | | Fatture e pagamenti | `/invoices`, `/payment-plans`, `/payment-links` | | Documenti | `/documents/{id}`, `/patients/{id}/documents` | | Attività | `/tasks` | | Contatti commerciali | `/leads` | | Messaggi WhatsApp | `/conversations` | | Telefonate e agente vocale | `/calls`, `/voice/context`, `/voice/handoff` | | Webhook ed eventi | `/events`, `/webhook-endpoints`, `/webhook-deliveries`. Vedi [Webhook](/api/webhook) | Ogni endpoint ha una pagina con parametri, risposte e playground nella barra laterale. Il [riferimento completo](/api/riferimento-completo) li riporta tutti in una pagina. La specifica OpenAPI 3.1 è servita anche dall'API: `GET /openapi.json`. ## Regole generali * **Una chiave, una clinica.** La chiave vede solo la sua clinica, e solo alcune sedi se è limitata. Una risorsa di un'altra clinica risponde sempre `404 not_found`, mai `403`. Vedi [Autenticazione](/api/autenticazione). * **Scope per area.** Ogni endpoint chiede uno scope, indicato nella sua pagina. I dati clinici hanno scope separati, spenti di default. Vedi [Dati clinici](/api/dati-clinici). * **Elenchi a cursore.** Gli elenchi restituiscono `{ "data", "nextCursor" }`. Vedi [Paginazione](/api/paginazione). * **Esiti negativi con `200`.** Alcune risposte dicono «non trovato» o «occupato» senza essere errori: `GET /patients/lookup` con un numero sconosciuto risponde `200` con `found: false`. * **Modifiche sicure.** Le risorse modificabili restituiscono un `ETag`: rimandalo in `If-Match` e la modifica passa solo se nessuno ha cambiato la risorsa nel frattempo. Altrimenti `409 conflict` con `details.reason: "version_mismatch"`. * **Errori uniformi.** `{ "error": { "code", "message", "details", "requestId" } }`. Vedi [Errori](/api/errori). * **Identificativo della richiesta.** Ogni risposta porta `X-Request-Id`. Citalo all'assistenza. ## Esempio: prenotare una visita ```bash theme={null} export DS_KEY="dsk_live_..." export CAL="" # 1. Chi è questo numero? curl -s "https://api.dentalspace.ai/v1/patients/lookup?phone=3391112233" \ -H "Authorization: Bearer $DS_KEY" # 2. Orari liberi della prossima settimana curl -s "https://api.dentalspace.ai/v1/calendars/$CAL/availability?from=2026-10-12&to=2026-10-16" \ -H "Authorization: Bearer $DS_KEY" # 3. Prenota uno degli orari restituiti curl -s -X POST "https://api.dentalspace.ai/v1/calendars/$CAL/bookings" \ -H "Authorization: Bearer $DS_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: chiamata-8f2a-prenota" \ -d '{"patientId":"","start":"2026-10-13T09:30:00+02:00"}' ``` I campi esatti di ogni corpo sono nella pagina dell'endpoint. Chiavi, scope per area, chiavi di prova. Formato dell'errore e codici. Ripetere una creazione senza doppioni. Elenchi a cursore e sincronizzazione. Quante richieste, header `X-RateLimit-*`. Eventi, firma, ritentativi. # Limiti di richiesta Source: https://docs.dentalspace.ai/api/limiti Quante richieste per clinica e per chiave, header X-RateLimit-* e Retry-After, risposta 429. ## Limiti | Di chi | Ogni 10 secondi | Al giorno | | - | - | - | | Clinica, sommando tutte le sue chiavi | 100 | 20.000 | | Singola chiave | 60 | 12.000 | Il giorno è il giorno di calendario UTC: riparte a mezzanotte UTC. Una richiesta respinta con `429` non consuma il limite. Il tetto per chiave impedisce a un solo programma di consumare tutto il limite della clinica: due programmi collegati con chiavi diverse non si fermano a vicenda. ## Header Ogni risposta a una richiesta con una chiave valida, errori compresi, porta questi header: | Header | Valore | | - | - | | `X-RateLimit-Limit` | Il tetto della finestra più stretta in quel momento, fra le quattro della tabella sopra | | `X-RateLimit-Remaining` | Le richieste rimaste in quella finestra | | `X-RateLimit-Reset` | Quando quella finestra riparte, in secondi Unix (UTC) | | `X-RateLimit-Daily-Limit` | Il tetto giornaliero più stretto | | `X-RateLimit-Daily-Remaining` | Le richieste rimaste oggi | | `X-RateLimit-Daily-Reset` | Quando riparte il giorno, in secondi Unix | | `Retry-After` | Solo sul `429`: i secondi da aspettare, almeno `1` | Una richiesta senza chiave o con una chiave sconosciuta non riceve gli header `X-RateLimit-*`. Ogni risposta porta invece `X-Request-Id`. ```http theme={null} HTTP/1.1 200 OK X-Request-Id: req_6f1c2a0e4b8d4c6e9a512f3b7d8e9a10 X-RateLimit-Limit: 60 X-RateLimit-Remaining: 57 X-RateLimit-Reset: 1790000060 ``` ## Risposta 429 Oltre il limite l'API risponde `429 rate_limited`. In `details`: `limit` è il tetto che ha fermato la richiesta, `window` quale finestra (`10s` o `day`), `scope` di chi è il limite (`organization` per la clinica, `api_key` per la chiave), `resetAt` quando riparte, in ISO 8601. ```http theme={null} HTTP/1.1 429 Too Many Requests Retry-After: 7 X-RateLimit-Limit: 60 X-RateLimit-Remaining: 0 X-RateLimit-Reset: 1790000007 ``` ```json theme={null} { "error": { "code": "rate_limited", "message": "Troppe richieste: rallenta e riprova dopo Retry-After secondi.", "details": { "limit": 60, "window": "10s", "scope": "api_key", "resetAt": "2026-10-02T14:14:07.000Z" }, "requestId": "req_6f1c2a0e4b8d4c6e9a512f3b7d8e9a10" } } ``` Aspetta i secondi di `Retry-After`, poi ripeti. Per non arrivarci, rallenta quando `X-RateLimit-Remaining` si avvicina a zero. Per ripetere una creazione dopo un `429`, usa la stessa [`Idempotency-Key`](/api/idempotenza). # Paginazione Source: https://docs.dentalspace.ai/api/paginazione Elenchi a cursore, sincronizzazione incrementale e risposte che dicono quanto coprono. Gli endpoint che restituiscono un elenco usano la paginazione a cursore. La risposta ha sempre questa forma: ```json theme={null} { "data": [ { "id": "…" } ], "nextCursor": "eyJ2IjoiMjAyNi0xMC0wMlQwOTozMDowMC4wMDBaIiwiaWQiOiI…" } ``` | Campo | Tipo | Descrizione | | - | - | - | | `data` | array | Gli elementi della pagina | | `nextCursor` | string \| null | Il cursore della pagina successiva. `null` = ultima pagina | ## Parametri | Parametro | Tipo | Descrizione | | - | - | - | | `limit` | integer | Elementi per pagina, da `1` a `200`. Predefinito `50` | | `cursor` | string | Il `nextCursor` della risposta precedente, tale e quale | | `updatedSince` | ISO 8601 con fuso | Solo gli elementi modificati da questo istante in poi, compreso | Un `limit` fuori intervallo o un cursore non riconosciuto rispondono `400 invalid_request`. Il cursore è opaco: il suo contenuto non fa parte del contratto. Usalo solo con lo stesso endpoint e gli stessi filtri della richiesta che l'ha prodotto. Una pagina può contenere meno di `limit` elementi e avere comunque un `nextCursor`: succede con le chiavi limitate ad alcune sedi. Per sapere se ci sono altre pagine guarda solo `nextCursor`, mai quanti elementi sono arrivati. ## Scorrere tutte le pagine ```bash theme={null} cursor="" while :; do page=$(curl -s "https://api.dentalspace.ai/v1/patients?limit=200&cursor=$cursor" \ -H "Authorization: Bearer $DS_KEY") echo "$page" | jq -c '.data[]' cursor=$(echo "$page" | jq -r '.nextCursor // empty') [ -z "$cursor" ] && break done ``` ## Ordine Gli elenchi sono ordinati per ultima modifica e `id`, crescente. Una risorsa modificata mentre scorri le pagine si sposta in fondo e ricompare in una pagina successiva: nessuna risorsa si perde fra due pagine, ma la stessa può comparire due volte. Deduplica per `id` tenendo la versione più recente. Fanno eccezione: * `GET /appointments?sort=start`: per orario di inizio. Con questo ordine `updatedSince` non è disponibile; * `GET /events`: per istante di pubblicazione dell'evento. ## Sincronizzazione incrementale 1. La prima volta scorri tutte le pagine senza `updatedSince`. 2. Salva l'`updatedAt` più recente che hai ricevuto. 3. Le volte successive passa quel valore in `updatedSince`. Per sapere cosa è cambiato senza interrogare ogni elenco, usa i [webhook](/api/webhook) o `GET /events`, e rileggi solo le risorse toccate. ## Risposte che dicono quanto coprono Una risposta non deve sembrare completa quando non lo è. Per questo: | Dove | Come lo capisci | | - | - | | Elenchi | `nextCursor` non nullo = ce ne sono altri | | Elementi dentro una risorsa (righe di una fattura, voci di un preventivo, rate di un piano) | Sempre tutti, mai tagliati | | `GET /calendars/{id}/availability` | `window` è la finestra davvero cercata, dopo preavviso e orizzonte del calendario (`null` = niente di prenotabile in quella chiesta). `complete: true` = ci sono tutti gli orari liberi di `window`; con `complete: false` continua con `from` uguale a `nextFrom` | | `GET /events` | Un evento che non è nato lascia al suo posto un `events.skipped`. Vedi [Webhook](/api/webhook#eventi-non-nati) | # Riferimento completo Source: https://docs.dentalspace.ai/api/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: +\\. | | `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: +\\. | | `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: +\\. | | `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: +\\. | | `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: +\\. | | `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: +\\. | | `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": "<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. | # Webhook Source: https://docs.dentalspace.ai/api/webhook Avvisi a un tuo indirizzo quando succede qualcosa: destinazioni, busta, catalogo degli eventi, firma HMAC, ritentativi, storico. Un webhook è una richiesta `POST` che dentalspace manda a un tuo indirizzo HTTPS quando succede qualcosa nella clinica: un appuntamento prenotato, una fattura pagata, un messaggio WhatsApp ricevuto. Così il tuo programma non deve chiedere ogni minuto se è cambiato qualcosa. ## Destinazioni Una destinazione è un indirizzo più l'elenco degli eventi che vuole ricevere. Si crea con l'API: ```bash theme={null} curl -s -X POST "https://api.dentalspace.ai/v1/webhook-endpoints" \ -H "Authorization: Bearer $DS_KEY" \ -H "Content-Type: application/json" \ -d '{"url":"https://esempio.it/hook","events":["appointment.created","appointment.cancelled"],"description":"Gestionale"}' ``` La risposta contiene `secret`, il segreto di firma (`whsec_…`). **Si vede solo lì**: conservalo subito. | Regola | Valore | | - | - | | Indirizzo | Solo `https`, porta 443, nome pubblico. Indirizzi privati, locali o con credenziali sono rifiutati | | Eventi per destinazione | Da 1 a 50 | | Destinazioni per clinica | Al massimo 20 | | Chi riceve | La destinazione riceve gli eventi che la chiave che l'ha creata può vedere: i suoi scope e le sue sedi | Per creare o modificare una destinazione servono `webhooks:write` e lo scope di ogni evento scelto: senza, `403 insufficient_scope` con gli scope mancanti in `details.requiredScopes`. Se poi la chiave viene revocata o scade, la destinazione smette di ricevere. ## Richiesta ```http theme={null} POST /hook HTTP/1.1 Content-Type: application/json User-Agent: DentalSpace-Webhooks/1 X-DentalSpace-Signature: v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd X-DentalSpace-Timestamp: 1790000000 X-DentalSpace-Idempotency-Key: 0b8e4c1a-7d2f-4e9b-a1c3-5f6d7e8a9b0c ``` | Header | Valore | | - | - | | `X-DentalSpace-Signature` | `v1=`. Durante una rotazione del segreto, due firme separate da virgola | | `X-DentalSpace-Timestamp` | Istante della firma, in secondi Unix | | `X-DentalSpace-Idempotency-Key` | L'id della consegna, uguale a ogni tentativo | La consegna riesce se rispondi `2xx` entro **10 secondi**. Un redirect (`3xx`) è un fallimento: non viene seguito. Del tuo indirizzo si registra solo lo status HTTP, mai il corpo della risposta. ## Busta ```json theme={null} { "id": "0b8e4c1a-7d2f-4e9b-a1c3-5f6d7e8a9b0c", "eventId": "7c2e1f0a-3b4d-4e5f-8a9b-0c1d2e3f4a5b", "type": "appointment.created", "createdAt": "2026-10-02T09:12:44.000Z", "apiVersion": "v1", "livemode": true, "facilityId": "b1e2c3d4-0000-4000-8000-000000000001", "data": { "id": "e4f5a6b7-0000-4000-8000-000000000042", "status": "scheduled", "startTime": "2026-10-13T09:30:00+02:00", "endTime": "2026-10-13T10:00:00+02:00", "patientId": "a1b2c3d4-0000-4000-8000-000000000007", "doctorId": "d1e2f3a4-0000-4000-8000-000000000003", "chairId": null, "facilityId": "b1e2c3d4-0000-4000-8000-000000000001", "calendarId": "c1d2e3f4-0000-4000-8000-000000000002", "treatmentPlanId": null, "origin": "api", "createdAt": "2026-10-02T09:12:44.000Z", "updatedAt": "2026-10-02T09:12:44.000Z" } } ``` | Campo | Descrizione | | - | - | | `id` | Nel webhook: l'id della consegna, uguale a `X-DentalSpace-Idempotency-Key`. In `GET /events`: l'id dell'evento | | `eventId` | L'id dell'evento. Usalo per riconoscere i doppioni | | `type` | Il tipo di evento. Vedi [Catalogo](#catalogo-degli-eventi) | | `createdAt` | Quando è successo, ISO 8601 UTC | | `apiVersion` | La versione della forma di `data`: `v1` | | `livemode` | `true` | | `facilityId` | La sede dell'evento, o `null` | | `data` | La risorsa com'era al momento dell'evento | `data` non contiene mai dati sanitari, e le risorse collegate sono solo id. Per i dettagli rileggi la risorsa con la tua chiave. ## Catalogo degli eventi | `type` | Quando | Scope per riceverlo | | - | - | - | | `patient.created` | Nasce un paziente | `patients:read` | | `patient.updated` | Cambia l'anagrafica. `data.changedFields` elenca i campi | `patients:read` | | `patient.archived` | Un paziente viene archiviato o cancellato. `data.reason`: `archived` o `erased` | `patients:read` | | `appointment.created` | Nasce un appuntamento | `appointments:read` | | `appointment.rescheduled` | Cambiano orario, medico o sede. `data.previous` ha i valori di prima | `appointments:read` | | `appointment.confirmed` | L'appuntamento passa a confermato | `appointments:read` | | `appointment.cancelled` | L'appuntamento viene disdetto | `appointments:read` | | `appointment.checked_in` | Il paziente è arrivato | `appointments:read` | | `appointment.completed` | La visita è conclusa | `appointments:read` | | `appointment.no_show` | Il paziente non si è presentato | `appointments:read` | | `treatment_plan.presented` | Un preventivo viene presentato al paziente | `treatment_plans:read` | | `treatment_plan.accepted` | Il paziente accetta il preventivo | `treatment_plans:read` | | `treatment_plan.rejected` | Il paziente rifiuta il preventivo | `treatment_plans:read` | | `invoice.issued` | Una fattura viene emessa | `billing:read` | | `invoice.paid` | Una fattura è pagata per intero | `billing:read` | | `payment.received` | Arriva un incasso | `billing:read` | | `installment.overdue` | Una rata è scaduta senza pagamento | `billing:read` | | `lead.created` | Nasce un contatto commerciale | `leads:read` | | `lead.converted` | Un contatto commerciale diventa paziente. `data.patientId` è il paziente | `leads:read` | | `task.created` | Nasce un'attività | `tasks:read` | | `task.completed` | Un'attività è fatta | `tasks:read` | | `conversation.message_received` | Arriva un messaggio WhatsApp. `data` ha gli id, non il testo | `messages:read` | | `conversation.handoff_requested` | Una conversazione passa dal bot allo studio | `messages:read` | | `call.logged` | Si registra una telefonata | `calls:read` | Per ricevere qualunque evento la chiave deve avere anche `webhooks:read`. Due tipi non si sottoscrivono: * `webhook.test`: arriva solo quando lo chiedi con `POST /webhook-endpoints/{id}/test`, con `data` vuoto; * `events.skipped`: vedi sotto. ### Eventi non nati Se un evento non può nascere, al suo posto ne nasce uno `events.skipped`, così sai che ti manca qualcosa invece di non accorgertene. `data.reason` dice perché: | `data.reason` | Quando | | - | - | | `bulk_import` | Più di 50 creazioni dello stesso tipo insieme, per esempio un'importazione. `data.count` dice quante | | `no_active_key` | Per un periodo la clinica non aveva chiavi attive. `data.from` e `data.to` lo delimitano | | `error` | Un errore interno ha impedito l'evento | `data.type` è il tipo dell'evento mancato e `data.scope` lo scope che serve per vederlo. Una destinazione riceve `events.skipped` se è iscritta a `data.type`. Quando lo ricevi, risincronizza con `updatedSince` sugli elenchi. Vedi [Paginazione](/api/paginazione#sincronizzazione-incrementale). ## Firma La firma è un HMAC-SHA256 calcolato con il segreto su questa stringa, quattro righe separate da `\n`: ```text theme={null} v1 ``` Il risultato, in esadecimale minuscolo, viaggia in `X-DentalSpace-Signature` con il prefisso `v1=`. Il timestamp è dentro la firma: una richiesta ripetuta con un timestamp diverso non verifica. ### Verifica 1. Leggi il corpo come byte grezzi, prima di qualsiasi parsing JSON. 2. Rifiuta la richiesta se `X-DentalSpace-Timestamp` dista più di 5 minuti dall'ora attuale. 3. Calcola la firma attesa sulla stringa `v1\n\n\n`. 4. Confrontala con ciascuna firma dell'header, a tempo costante. Ne basta una valida. 5. Se `X-DentalSpace-Idempotency-Key` l'hai già elaborata, rispondi `200` senza rifare il lavoro. ```javascript Node.js theme={null} import { createHmac, timingSafeEqual } from "node:crypto"; import express from "express"; const SECRET = process.env.DENTALSPACE_WEBHOOK_SECRET; const app = express(); app.post("/hook", express.raw({ type: "application/json" }), (req, res) => { const timestamp = req.get("X-DentalSpace-Timestamp") ?? ""; const key = req.get("X-DentalSpace-Idempotency-Key") ?? ""; const header = req.get("X-DentalSpace-Signature") ?? ""; if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return res.sendStatus(400); const signed = `v1\n${timestamp}\n${key}\n${req.body.toString("utf8")}`; const expected = Buffer.from("v1=" + createHmac("sha256", SECRET).update(signed).digest("hex")); const valid = header.split(",").some((signature) => { const received = Buffer.from(signature.trim()); return received.length === expected.length && timingSafeEqual(received, expected); }); if (!valid) return res.sendStatus(401); const event = JSON.parse(req.body.toString("utf8")); // Elabora event.type ed event.data, saltando le key gia' viste. res.sendStatus(200); }); ``` ```python Python theme={null} import hashlib import hmac import os import time from flask import Flask, abort, request SECRET = os.environ["DENTALSPACE_WEBHOOK_SECRET"].encode() app = Flask(__name__) @app.post("/hook") def hook(): timestamp = request.headers.get("X-DentalSpace-Timestamp", "") key = request.headers.get("X-DentalSpace-Idempotency-Key", "") header = request.headers.get("X-DentalSpace-Signature", "") body = request.get_data() if not timestamp.isdigit() or abs(time.time() - int(timestamp)) > 300: abort(400) signed = b"v1\n" + timestamp.encode() + b"\n" + key.encode() + b"\n" + body expected = "v1=" + hmac.new(SECRET, signed, hashlib.sha256).hexdigest() if not any(hmac.compare_digest(expected, s.strip()) for s in header.split(",")): abort(401) event = request.get_json() # Elabora event["type"] ed event["data"], saltando le key gia' viste. return "", 200 ``` ### Rotazione del segreto `POST /webhook-endpoints/{id}/rotate-secret` genera un segreto nuovo e lo mostra una volta sola. Il vecchio resta valido per `overlapMinutes` minuti, da `0` a `10080` (una settimana), predefinito `1440` (un giorno). In quel periodo ogni webhook porta due firme: `v1=,v1=`. Aggiorna il segreto nel tuo programma prima di `previousSecretExpiresAt`. Con `overlapMinutes: 0` il vecchio smette subito. ## Ritentativi Una consegna fallita (rete, timeout, risposta diversa da `2xx`) viene ritentata. Corpo e `X-DentalSpace-Idempotency-Key` restano uguali; cambiano timestamp e firma. | Tentativo | Quando | | - | - | | 1 | All'evento | | 2 | 1 minuto dopo | | 3 | 5 minuti dopo | | 4 | 15 minuti dopo | | 5 | 1 ora dopo | Le attese variano del 10% in più o in meno, perché i ritentativi di molte consegne non arrivino tutti insieme. Dopo il quinto fallimento la consegna diventa `discarded`. Dopo **50 consegne scartate di fila** la destinazione si spegne da sola: `enabled: false`, `disabledReason: "too_many_failures"`. Una consegna riuscita azzera il conteggio. Per riaccenderla: `PATCH /webhook-endpoints/{id}` con `enabled: true`. Ogni clinica ha al massimo 5 consegne in corso alla volta, e le cliniche sono servite a turno: un tuo indirizzo lento rallenta solo le tue consegne. ## Storico e consegne | Endpoint | Scope | A cosa serve | | - | - | - | | `GET /events` | `webhooks:read` | Gli eventi degli ultimi 30 giorni, nella stessa busta. Filtro `type`, paginazione e `updatedSince`. Utile se preferisci leggere invece di ricevere | | `GET /webhook-deliveries` | `webhooks:read` | Le consegne e il loro stato: `pending`, `delivering`, `succeeded`, `discarded`. Filtri `endpointId` e `status` | | `GET /webhook-deliveries/{id}` | `webhooks:read` | Una consegna con tutti i suoi tentativi | | `POST /webhook-deliveries/{id}/redeliver` | `webhooks:write` | Rimanda una consegna `succeeded` o `discarded`, con lo stesso corpo e la stessa `X-DentalSpace-Idempotency-Key`. Al massimo 3 volte per consegna, poi `409 conflict` con `reason: "redeliver_limit"` | | `POST /webhook-endpoints/{id}/test` | `webhooks:write` | Manda subito un `webhook.test` firmato e restituisce l'esito. Niente ritentativi | Con una chiave di prova (`dsk_test_`) il test non spedisce niente e risponde `skipped: "test_mode"`. Cancellata una destinazione, le sue consegne restano leggibili per 30 giorni. # Fissare un appuntamento Source: https://docs.dentalspace.ai/guide/nuovo-appuntamento Inserire un appuntamento in agenda. Nella barra laterale clicca **Appuntamenti**. Clicca **Nuovo**. Si apre la finestra **Nuovo Appuntamento**. Cerca il **Paziente** e seleziona il **Dottore**. Imposta **Data** e **Ora inizio**, poi aggiungi la **Prestazione**. L'**Ora fine** si calcola dalla durata dei trattamenti; senza trattamenti vale 30 minuti. Se serve, scegli la **Poltrona** e aggiungi le **Note**. Poi clicca **Crea Appuntamento**. ## Viste dell'agenda * **Settimana**: tutti gli appuntamenti della settimana. Le frecce passano alla settimana precedente o successiva, **Oggi** torna a quella corrente. * **Poltrona**: gli appuntamenti divisi per poltrona. La vista Poltrona funziona solo se le poltrone sono configurate in **Impostazioni**. # Aggiungere un paziente Source: https://docs.dentalspace.ai/guide/nuovo-paziente Creare la scheda di un nuovo paziente. Nella barra laterale clicca **Pazienti**. In alto a destra clicca **Nuovo paziente**. Si apre la finestra **Nuovo Paziente**. Inserisci i dati del paziente e salva. La scheda compare nella **Lista pazienti**. ## Ritrovare un paziente Usa la ricerca **Cerca per nome, email o telefono**. I filtri restringono la lista: | Filtro | Valori | | - | - | | Tipo | Tutti · Nuovi · Abituali | | Stato | Attivi · Archiviati | | Ordina | A → Z · Z → A · Recenti | Hai già i pazienti in un altro gestionale? Usa **Importa**, accanto a Nuovo paziente. # Preparare un piano di cura Source: https://docs.dentalspace.ai/guide/nuovo-piano-cura Creare un piano di trattamento per un paziente. Nella barra laterale clicca **Piani Cura**. Clicca **Nuovo Piano**. Si apre la finestra **Nuovo Piano di Trattamento**. Cerca il **Paziente** e dai un **Nome Piano**, per esempio *Piano implantologico*. La **Descrizione** è facoltativa. Clicca **Crea Piano**. Il piano parte in stato **Bozza**. ## Stati del piano | Stato | Significato | | - | - | | Bozza | In preparazione | | Attivo | Accettato, cure in corso | | Completato | Cure concluse | # Documentazione per LLM e agenti Source: https://docs.dentalspace.ai/ia/panoramica URL da dare a un LLM per leggere tutta la documentazione in una richiesta, indice llms.txt, pagine Markdown, specifica OpenAPI e server MCP. ## Documentazione completa in un file Per dare a un LLM tutta la documentazione in una sola richiesta: ```text theme={null} https://docs.dentalspace.ai/llms-full.txt ``` Il file contiene, in Markdown, il testo di ogni pagina pubblicata: guida all'app, pagine dell'API e il [riferimento completo](/api/riferimento-completo) con tutti gli endpoint, i parametri, i campi e i codici di errore. Si rigenera a ogni pubblicazione. ## Formati disponibili | Risorsa | URL | Contenuto | | - | - | - | | Documentazione completa | [`/llms-full.txt`](https://docs.dentalspace.ai/llms-full.txt) | Tutte le pagine, riferimento API compreso | | Indice | [`/llms.txt`](https://docs.dentalspace.ai/llms.txt) | Elenco delle pagine con titolo, descrizione e link alla versione Markdown | | Pagina singola | URL della pagina + `.md`, per esempio [`/api/errori.md`](https://docs.dentalspace.ai/api/errori.md) | Una pagina in Markdown | | Specifica OpenAPI 3.1 | [`openapi.json`](https://raw.githubusercontent.com/SQUADD26/dentalspace-docs/main/openapi.json) | Endpoint, parametri, schemi e risposte | | Server MCP | `https://docs.dentalspace.ai/mcp` | Ricerca e lettura delle pagine da un client MCP | La specifica è servita anche dall'API, sempre aggiornata: `GET https://api.dentalspace.ai/v1/openapi.json`. ## Server MCP Il server MCP permette a Claude, Cursor, VS Code e agli altri client MCP di cercare e leggere questa documentazione. ```bash theme={null} claude mcp add --transport http dentalspace-docs https://docs.dentalspace.ai/mcp ``` Usa **Collega a Cursor** o **Collega a VS Code** dal menu in cima alla pagina. Il server MCP legge solo la documentazione. Non vede i dati della clinica e non fa operazioni: per quello serve l'[API](/api/introduzione) con una chiave. ## Specifica OpenAPI [`openapi.json`](https://raw.githubusercontent.com/SQUADD26/dentalspace-docs/main/openapi.json) è la fonte di verità per endpoint, campi e codici di errore. Usi tipici: * generare un client tipizzato (`openapi-generator`, `openapi-typescript`); * importarla in Postman, Insomnia o Bruno; * definire gli strumenti di un agente. Prompt pronto per l'integrazione: [Prompt per agenti](/ia/prompt-agenti). # Prompt per agenti Source: https://docs.dentalspace.ai/ia/prompt-agenti Prompt da incollare in Claude, ChatGPT o Cursor per integrare l'API v1 di dentalspace. Sostituisci le parti fra `<>` e incolla il prompt nell'assistente (Claude, ChatGPT, Cursor, Claude Code). ```markdown Prompt theme={null} Integra l'API v1 di dentalspace (gestionale per studi e cliniche dentistiche) in . ## Fonti Leggi queste fonti prima di scrivere codice. Non usare endpoint, campi o codici di errore che non compaiono nella specifica. - Documentazione completa, riferimento API compreso: https://docs.dentalspace.ai/llms-full.txt - Specifica OpenAPI 3.1 (fonte di verità): https://api.dentalspace.ai/v1/openapi.json ## Dati - Indirizzo: https://api.dentalspace.ai/v1 - Autenticazione: header `Authorization: Bearer dsk_live_...`. Leggi la chiave dalla variabile d'ambiente DENTALSPACE_API_KEY. - La chiave vede una sola clinica; GET /me dice quali scope e quali sedi ha. - Date `YYYY-MM-DD`; istanti ISO 8601 con fuso; telefoni in uscita in E.164. ## Regole 1. Per riconoscere chi chiama usa GET /patients/lookup?phone=... `found: false` (status 200) = numero sconosciuto. `kind: "family"` = più pazienti con lo stesso numero: chiedi per chi è. 2. Un numero sconosciuto si salva come contatto commerciale: POST /leads con `source: "phone"`. Se poi prenota, prima convertilo con POST /leads/{id}/convert, poi prenota con il `patientId` restituito. 3. Per prenotare: GET /calendars/{id}/availability, poi POST /calendars/{id}/bookings con uno degli orari restituiti, tale e quale. Se `complete` è false, continua con from=`nextFrom`. 4. Su ogni POST che crea o cambia qualcosa manda `Idempotency-Key`: una chiave per operazione (es. `-prenota`), identica a ogni ripetizione. Su bookings, appointments, payments, payment-links e messaggi è obbligatoria. - Stesso status e stesso corpo con `Idempotent-Replayed: true`: è la risposta della prima richiesta. - 409 `idempotency_in_progress`: ripeti dopo qualche secondo con la stessa chiave. - 422 `idempotency_key_reused`: chiave usata per un'altra operazione; errore del client. 5. Gli errori hanno la forma `{ "error": { "code", "message", "details", "requestId" } }`. La logica usa `code` e `details.reason`, mai `message`. - 409 `no_availability`: l'orario non è più libero; proponi `details.alternatives`. - 409 `conflict` con `reason: "version_mismatch"`: rileggi la risorsa e riprova con il nuovo `ETag` in `If-Match`. - 401 `api_key_revoked`, 403 `plan_required`, 403 `insufficient_scope`: non ripetere, segnala. - 429 `rate_limited`: aspetta i secondi di `Retry-After`. - 500 e 503: ripeti con backoff esponenziale. 6. Negli elenchi guarda solo `nextCursor` per sapere se ci sono altre pagine, mai il numero di elementi. 7. Non chiedere scope `clinical:*` se non servono: sono spenti di default e ogni lettura finisce nel registro accessi della clinica. ## Consegna - Client tipizzato, una funzione per endpoint, generato dalla specifica o scritto a mano. - Gestione di tutti i valori di `code` della specifica. - Test per: numero sconosciuto; orario preso nel frattempo; ripetizione idempotente; 429. - Nessun segreto nel codice. ``` ## Server MCP Se l'assistente supporta MCP, collega anche il server della documentazione: l'agente cerca le pagine che gli servono mentre lavora. ```text theme={null} https://docs.dentalspace.ai/mcp ``` Istruzioni per i client: [Server MCP](/ia/panoramica#server-mcp). ## Regole aggiuntive per un agente vocale ```markdown theme={null} - Presentati come assistente virtuale dello studio: non fingerti una persona. - Frasi brevi, una domanda per volta. - Prima di prenotare, spostare o disdire, ripeti giorno, ora e motivo e aspetta un sì esplicito. - Non leggere al telefono dati sanitari. La chiave dell'agente vocale non ha gli scope clinical:*. - Se chi chiama vuole parlare con lo studio, o non sai rispondere: POST /voice/handoff, che crea un'attività di richiamo. - A fine telefonata registra la chiamata con POST /calls, usando l'id della telefonata come externalCallId. ``` # dentalspace Source: https://docs.dentalspace.ai/index Il gestionale per studi e cliniche dentistiche. Le guide spiegano, passo per passo, le operazioni di tutti i giorni in dentalspace. Aggiungere un paziente e ritrovarlo. Fissare un appuntamento. Preparare un piano di trattamento. ## Per chi collega altri programmi Collegare un agente vocale, un gestionale o un CRM alla clinica. Dare questa documentazione a un assistente IA.