Book into the clinic's agenda
Read the clinic's agenda over the API, find free slots and confirm a time.
ClinicsOnly in clinic workspaces.
GET /clinic/agenda/feed returns the clinic's appointments between two dates. The same ids drive the rest: finding free slots, booking, moving an appointment. Underneath, the diary may live in Medilink, in Reservo or in Vitrina, and the contract you see doesn't change.
curl "https://api.vitrinadev.com/api/v1/clinic/agenda/feed?from=2026-09-23&to=2026-09-24" \
-H "Authorization: Bearer $VITRINA_KEY"The agenda is not the clinical record. Progress notes, signed notes, medical history, consents and documents are health data. The contract marks them sensitive and this chapter doesn't publish them. What comes out of here is the calendar; further down is where the line runs.
Read the agenda
GET /clinic/agenda/feed returns the appointments between from (inclusive) and to (exclusive), two YYYY-MM-DD days in the clinic's time zone. The window may not exceed eight days. Cancelled appointments are included, with their status: an agenda that hides them can't tell you why a gap is there.
Filters take Vitrina ids: professional_id, location_id, resource_id (the room or chair), status and kind. The last two accept comma-separated lists. This is one appointment, exactly as the clinic's API key received it:
{
"data": [
{
"id": "eeeeeeee-0000-4000-8000-000000000001",
"display_id": "A-1",
"engine": "native",
"external_id": null,
"kind": "clinic",
"status": "confirmed",
"status_label": null,
"starts_at": "2026-09-23T13:30:00.000Z",
"ends_at": "2026-09-23T14:00:00.000Z",
"arrival_state": "scheduled",
"professional": { "id": "ffffffff-0000-4000-8000-000000000001", "name": "Ana" },
"location": { "id": "b1b1b1b1-0000-4000-8000-000000000002", "name": "Sucursal Maipú" },
"resource": null,
"service": null,
"patient": {
"id": "12121212-0000-4000-8000-000000000001",
"name": "María José Fuentes Lagos",
"external_id": null
},
"contact": { "id": "22222222-0000-4000-8000-000000000001", "name": "María José Fuentes Lagos" },
"customer_name": "María José Fuentes Lagos",
"notes": "Avisar al +56987654321 si se atrasa",
"money": null,
"flags": [
{ "id": "13131313-0000-4000-8000-000000000001", "kind": "alergia", "label": "Alergia a penicilina", "severity": "severa" }
],
"vendor": { "professional_id": null, "professional_name": null, "sucursal_id": null, "agenda_id": null, "sillon": null, "status_code": null }
}
]
}What's worth knowing about each field:
enginesays which system owns the appointment.nativeis Vitrina's agenda;healthatomorreservo, an appointment that lives in the clinic's system and that Vitrina mirrors. A mirrored appointment reads the same, but it's moved in its own system.statusis the bucket:confirmed,cancelled,completed,no_show.status_labelis the clinic's own word, "Confirmado" or "Llegó", the one the people running the diary recognise.arrival_stateis where the patient is right now:scheduled,waiting,in_room,to_pay,done.patientis the clinic's record of the person andcontactis the person the clinic talks to. Usually the same person, and sometimes not: a guardian books for a child.flagsare the alerts the clinic chose to show on the appointment: allergies, risk, "don't book without a deposit". They're clinical-record content, which is why a connected app never receives them (below).vendorrepeats what the clinic's system called the same facts, as provenance. An appointment withvendor.professional_idset andprofessional: nullmarks a professional missing from Vitrina's roster; somebody is still attending it. On a Vitrina appointment, all ofvendorisnull.
Free slots
GET /clinic/agenda/availability answers with free slots from whoever runs the diary. On Medilink it's their live answer. On Reservo, the local computation over what Vitrina mirrors. On Vitrina's agenda, each professional's working hours minus exceptions, existing appointments and occupied rooms. It filters by professional_id, location_id, service_id or especialidad, starting at from:
{
"data": {
"engine": "native-clinic",
"timezone": "America/Santiago",
"slots": [
{
"starts_at": "2026-09-24T12:00:00.000Z",
"ends_at": "2026-09-24T12:30:00.000Z",
"label": "jueves, 24 de septiembre, 09:00",
"slot_ref": "ncl1_eyJwIjoiZmZmZmZmZmYtMDAwMC00MDAw…",
"professional_id": "ffffffff-0000-4000-8000-000000000001",
"professional_name": "Ana Rojas"
}
],
"searched_through": "2026-09-24",
"truncated": false
}
}slot_ref is opaque. It pins that slot's professional, room and branch. Don't build it or parse it; send it back as it is when you book.
allow_overbook is off: overbooking slots aren't offered by default.
Book and move
Both writes require clinic:write.
POST /clinic/agenda/appointments books an appointment. Send the slot_ref of a free slot, or a professional_id with starts_at and ends_at:
curl -X POST https://api.vitrinadev.com/api/v1/clinic/agenda/appointments \
-H "Authorization: Bearer $VITRINA_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: booking-2026-09-24-mfuentes" \
-d '{
"professional_id": "ffffffff-0000-4000-8000-000000000001",
"location_id": "b1b1b1b1-0000-4000-8000-000000000002",
"patient_id": "12121212-0000-4000-8000-000000000001",
"starts_at": "2026-09-24T14:00:00.000Z",
"ends_at": "2026-09-24T14:30:00.000Z",
"notes": "Control mensual"
}'The booking goes through the system that runs the diary. On a Medilink clinic, Medilink decides, and if it refuses you get its answer. On Vitrina's agenda you get the appointment and its service line, with the duration and price already frozen. When a deposit policy applies, you also get what the patient owes. If the slot is already taken, the answer is slot_taken with alternatives.
Always send an Idempotency-Key. If the network drops the response and you retry with the same one, you get the same appointment back instead of two (Errors).
PATCH /clinic/agenda/appointments/{id} moves an appointment with new starts_at and ends_at, and optionally another professional_id or resource_id. The deposit deadline moves with it.
Every booking, every time change and every cancellation also leaves an event: appointment.booked, appointment.rescheduled and appointment.cancelled.
What a connected app sees
A connected app (Claude, ChatGPT or any client the clinic authorised through OAuth) reads the same agenda, with three differences. This is what one received, for the same appointment, at a clinic that has not allowed patient names:
{
"data": [
{
"id": "eeeeeeee-0000-4000-8000-000000000001",
"starts_at": "2026-09-23T13:30:00.000Z",
"professional": { "id": "ffffffff-0000-4000-8000-000000000001", "name": "Ana" },
"location": { "id": "b1b1b1b1-0000-4000-8000-000000000002", "name": "Sucursal Maipú" },
"patient": { "id": "12121212-0000-4000-8000-000000000001", "name": "M.F. · #1001", "external_id": null },
"contact": { "id": "22222222-0000-4000-8000-000000000001", "name": "M.F. · #1001" },
"customer_name": "M.F. · s/n",
"notes": "Avisar al [teléfono oculto] si se atrasa"
}
],
"meta": {
"patient_privacy": {
"mode": "pseudonymized",
"subjects": 3,
"notice": "Patients in this result appear as pseudonyms: initials plus a stable number…"
}
}
}- Every patient is a pseudonym: their initials and a stable number,
M.F. · #1001. The same number is the same person in every call and every tool. That way a patient can be counted, grouped and followed without knowing who they are. RUT, phone and email are masked inside free text too.meta.patient_privacysays the response came this way. - Clinical alerts don't arrive. There's no
flags, neither empty nor full. It's clinical-record content, and reading those same alerts on their own is a sensitive operation. - The clinical record is out of reach. The patient register, progress notes, consents and documents answer
403 CONNECTED_APP_SENSITIVE_DATAto a connected app. The code is its own so your integration won't mistake it for a missing scope. Asking for more scopes doesn't fix it; using another kind of credential does.
{
"error": {
"code": "CONNECTED_APP_SENSITIVE_DATA",
"message": "Esta operación entrega datos personales sensibles (ficha clínica, registro de pacientes, consentimientos o documentos clínicos). Una aplicación conectada no puede leerlos…"
}
}A clinic can allow names under Configuración › Conexiones › MCP, and the decision belongs to the owner or an admin. After that the connected app receives names just like an API key does. Every read that shows a patient is written to the clinical record's access log. The line names the person behind the credential and the app: "[email protected] vía Claude". What was allowed is names: clinical alerts still don't arrive and the record stays closed.
Whatever isn't JSON can't be delivered with pseudonyms. A clinic's connected app gets 403 CONNECTED_APP_SENSITIVE_DATA for the contacts CSV export, for a conversation as markdown and for a stream, names allowed or not.
A workspace API key or a personal token goes through none of this. They act inside the clinic and receive the full appointment, as in the first example. Why the line runs there is explained in Personal and health data.
What is not published
- The clinical record, the patient register, consents and documents each have a chapter of their own: patients, record, consents and documents. All four are marked
x-vitrina-sensitive: they ask for a scope of their own, they log every read, and no connected app reaches them. - The clinic system's branch map (
/clinic/sucursales). It's the Connection panel's picker: it speaks that system's ids and configures the mirror.
The subscription to the agenda's events is built end to end in Receive appointments in your system.