List services and their prices
What the clinic offers, who performs it and what it costs each patient.
ClinicsOnly in clinic workspaces.
GET /clinic/services lists what the clinic offers and GET /clinic/professionals who performs it. The agenda rests on that catalogue: an appointment is a service with a professional at a time. Budgets come from the same place.
Four resources, in the order a clinic builds them:
- Professionals: who sees patients, in which speciality.
- Services: what is performed, how long it takes, what it lists at.
- Prices: price lists, rules and agreements. The price of a service for a patient comes from the quote.
- Packs: sessions sold up front and used later.
All of this lives in Vitrina when the clinic runs its own catalogue. If the clinic works with Medilink, Dentalink or Reservo, the rows arrive mirrored from that system and writes answer 409 not_native_source. They're edited where they're owned. Every row says where it comes from in source.
Professionals
curl https://api.vitrinadev.com/api/v1/clinic/professionals \
-H "Authorization: Bearer $VITRINA_KEY"{
"data": {
"data": [
{
"id": "18342d1b-0000-4000-8000-000000000001",
"source": "native",
"nombre": "Ana",
"apellidos": "Rojas Vidal",
"rut": "12.345.678-5",
"email": "[email protected]",
"especialidad": "Ortodoncia",
"registro": "SIS 123456",
"agenda_online": true,
"intervalo_minutes": 30,
"active": true
}
],
"total": 1,
"page": 1,
"limit": 50
}
}The id is the one the agenda uses: professional_id in the feed, in free slots and when booking. agenda_online says whether they appear in online booking. intervalo_minutes is the default length of their block.
The professional's own personal data
A professional's row holds data about them: RUT, date of birth, address. A
workspace credential receives it; a connected app doesn't. rut,
birthdate and direccion never travel to a third party, because booking
doesn't need them. The name, the speciality, the registration number and the
work contact details do, which is what the roster is for.
Create, edit and delete need clinic:write. Deleting a professional is final, and their past appointments still name them by id.
Services
curl -X POST https://api.vitrinadev.com/api/v1/clinic/services \
-H "Authorization: Bearer $VITRINA_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: svc-ort-ctrl-2026" \
-d '{
"nombre": "Control de ortodoncia",
"codigo": "ORT-CTRL",
"categoria": "Ortodoncia",
"precio": 25000,
"duration_minutes": 30,
"online_bookable": true
}'precio is the service's list price, the starting point. duration_minutes is what it blocks on the agenda. online_bookable offers it in online booking. requires_consent_template_id demands a signed consent before it can be performed, and eligibility_* limits who may be booked for it by age, sex or insurer.
Categories (/clinic/service-categories) and specialities (/clinic/specialties) are the short lists that order the catalogue. Both are created with a nombre, and if one already exists under that name they return the existing row instead of failing.
PUT /clinic/services/{id}/professionals sets who may perform a service, and with which duration and price of their own. An empty list means "anyone", not "nobody".
Prices: the list, the rules and the agreement
A price list (/clinic/pricing/lists) is a page of prices: one price per service. Its entries are saved in one go:
curl -X PUT https://api.vitrinadev.com/api/v1/clinic/pricing/lists/$LIST/entries \
-H "Authorization: Bearer $VITRINA_KEY" \
-H "Content-Type: application/json" \
-d '{
"entries": [
{ "clinic_service_id": "4cf5bc59-0000-4000-8000-000000000001", "precio_clp": 28000 },
{ "clinic_service_id": "4cf5bc59-0000-4000-8000-000000000002", "precio_clp": 35000 }
]
}'precio_clp: null removes the entry. "This list doesn't price it" and "it costs zero" are different claims.
A rule (/clinic/pricing/rules) moves that price for a case: an agreement, an insurer, a campaign, a professional, how long the patient has been coming. It carries modifier (percent_off, amount_off, override), value and priority. Priority decides which one wins when two apply.
An agreement (/clinic/pricing/agreements) is the deal with an organisation, and its members are the patients who carry it. Adding a member names a patient, so a connected app reads that list as pseudonyms.
The question that matters is what this person pays, and the quote answers it. It also explains where the number came from:
curl -X POST https://api.vitrinadev.com/api/v1/clinic/pricing/quote \
-H "Authorization: Bearer $VITRINA_KEY" \
-H "Content-Type: application/json" \
-d '{
"service_id": "4cf5bc59-0000-4000-8000-000000000001",
"clinic_patient_id": "b1b0b8de-0000-4000-8000-000000000001",
"professional_id": "18342d1b-0000-4000-8000-000000000001"
}'The answer carries the final price, the list it used and the rules that applied, in order. It's a read (clinic:read): it books nothing and charges nothing. Call it before showing a price in your system, rather than doing the arithmetic yourself.
Packs
A pack is the definition: how many sessions, of which services, at what price and for how long. A purchase (/clinic/packs/purchases) is that pack sold to a patient, and it's what carries the balance:
{
"data": {
"purchase": {
"id": "d376ca29-0000-4000-8000-000000000001",
"clinic_patient_id": "b1b0b8de-0000-4000-8000-000000000001",
"name_snapshot": "Pack 4 controles de ortodoncia",
"session_count": 4,
"precio_clp": 96000,
"expires_at": "2027-03-21",
"status": "active"
},
"balance": { "session_count": 4, "consumed": 0, "remaining": 4 }
}
}name_snapshot and precio_clp are frozen into the purchase. If the pack changes its price or its name tomorrow, what was bought doesn't move.
The balance is derived from sessions consumed minus sessions reversed, never a stored counter. That's why POST .../consume and POST .../sessions/{sessionId}/reverse are the only two ways to move it, and why a reversal is a new row that never erases the old one.
Freezing (/freeze) stops the expiry clock while the patient can't come. Cancelling (/cancel) ends the purchase with its reason, with or without a refund, and takes the remaining sessions with it.
Selling a purchase needs clinic_money:write, because it's money. Using a session needs clinic:write, because it's the day's work.
Events
Every catalogue change has its event, so nothing has to re-read the whole catalogue every night:
| Event | When |
|---|---|
clinic_professional.created · .updated · .deleted | The roster changed |
clinic_service.created · .updated · .deleted | A service changed (.updated carries the previous price) |
clinic_price_list.updated | A price list was edited, or the prices inside it |
clinic_pack_purchase.created · .session_consumed · .cancelled | A patient bought a pack, used a session, or the purchase was cancelled |
The pack events name the patient by id and never by name. To learn who that is, read the resource with a credential that may, and subscribing is covered in Webhooks.