VitrinaAPI

Schedule an appointment

Book, move and cancel hours in the workspace's diary.

POST /appointments books an hour in the workspace's diary. It works the same for a test drive at a car dealership, a consultation at a clinic or an on-site visit. All three are the same thing: a block of time with a person, somebody attending and a place.

An appointment type is what gets booked. When it carries a price, it is the workspace's service: "Consulta de evaluación", "10,000 km service". There's no separate /services resource.

OperationScope
Read the diaryappointments:read
Book and moveappointments:write
Cancelappointments:delete plus messages:send
Read and write the catalogueappointment_types:read / appointment_types:write
Change the scheduling policyschedule_config:write

schedule_config:write sits apart because it moves everybody's opening hours at once.

The catalogue first

An appointment is booked against a type. The list is the whole catalogue, and ?kind=service is the "Servicios" tab:

curl "https://api.vitrinadev.com/api/v1/appointment-types?kind=service&active_only=true" \
  -H "Authorization: Bearer $VITRINA_KEY"
{
  "data": [
    {
      "id": "eeee0000-0000-4000-8000-000000000001",
      "tenant_id": "a1a1a1a1-0000-4000-8000-000000000001",
      "name": "Consulta de evaluación",
      "description": "Primera visita: revisión y presupuesto.",
      "kind": "service",
      "engine": "native",
      "external_ref": null,
      "buffer_minutes": null,
      "duration_minutes": 45,
      "price_amount": 35000,
      "price_clp": 35000,
      "price_currency": "CLP",
      "price_is_from": true,
      "eligible_staff_ids": [],
      "is_active": true,
      "is_default": false,
      "deleted_at": null,
      "created_at": "2026-09-22T12:50:27.458Z",
      "updated_at": "2026-09-22T12:50:27.458Z"
    }
  ],
  "meta": { "total": 1 }
}
FieldWhat it decides
duration_minutesHow long the block is when that type is booked. The diary's grid is slot_minutes, which says how often an hour can start.
price_amountnull means "ask us" and 0 means free. One is a price, the other is an invitation to ask, and they're worth rendering differently.
price_is_fromtrue marks the price as a floor ("from $35,000").
eligible_staff_idsWho may take it. Empty means "the workspace's whole team"; with people in it, the automatic assignment comes out of that list.

Creating one is the same body without id:

curl -X POST https://api.vitrinadev.com/api/v1/appointment-types \
  -H "Authorization: Bearer $VITRINA_KEY" \
  -H 'Content-Type: application/json' \
  -d '{
    "name": "Consulta de evaluación",
    "kind": "service",
    "duration_minutes": 45,
    "price_amount": 35000,
    "price_currency": "CLP",
    "price_is_from": true
  }'

Deactivating vs. deleting

PATCH { "is_active": false } deactivates: the type leaves the active tab, stays under "Inactivos" and comes back whenever you want.

DELETE /appointment-types/{id} deletes: it stamps deleted_at and the type is gone from the catalogue for good, on every tab, active_only=false included. After that GET and PATCH answer 404, and a booking that names it is refused exactly as one naming an unknown id is.

Nothing cascades. Appointments already booked keep their appointment_type_id and stay fully readable: the reminder and the calendar mirror still resolve the name. The name is free again, so re-creating a deleted service yields a new id.

The scheduling policy

curl https://api.vitrinadev.com/api/v1/appointments/config \
  -H "Authorization: Bearer $VITRINA_KEY"

The answer is never null: a workspace that configured nothing gets the defaults on its own opening hours. What changes is configured, which is a label and not an error:

{
  "data": {
    "configured": false,
    "timezone": "America/Santiago",
    "business_hours": {},
    "effective_business_hours": {},
    "business_hours_source": "inherited",
    "holidays": ["2026-09-18", "2026-09-19", "2026-10-12", "…"],
    "slot_minutes": 60,
    "buffer_minutes": 0,
    "min_lead_minutes": 120,
    "booking_horizon_days": 14,
    "staff_capacity": 1,
    "vehicle_exclusive": false,
    "owner_capacity": 1,
    "hold_ttl_minutes": 15,
    "reminder_lead_minutes": 120,
    "sales_team_id": null,
    "arrival_board_enabled": false
  }
}

business_hours is what was saved and effective_business_hours is what booking enforces. They're separate because a workspace may inherit the general opening hours instead of declaring its own.

PUT /appointments/config is a partial upsert: send one field and that field is saved. business_hours is the exception, because it's replaced wholesale. It maps a day to a list of ranges, so a lunch break is two ranges on one day:

curl -X PUT https://api.vitrinadev.com/api/v1/appointments/config \
  -H "Authorization: Bearer $VITRINA_KEY" \
  -H 'Content-Type: application/json' \
  -d '{
    "timezone": "America/Santiago",
    "business_hours": {
      "mon": [["09:00", "13:00"], ["14:30", "18:00"]],
      "fri": [["09:00", "13:00"], ["14:30", "17:00"]]
    },
    "slot_minutes": 30,
    "buffer_minutes": 10,
    "staff_capacity": 2
  }'

Changing these affects future availability. Appointments already booked are never re-validated or evicted.

Open hours

curl "https://api.vitrinadev.com/api/v1/appointments/availability?appointment_type_id=eeee0000-0000-4000-8000-000000000001" \
  -H "Authorization: Bearer $VITRINA_KEY"

The window is clamped on both sides whatever you ask for: it never starts before now plus min_lead_minutes and never runs past booking_horizon_days. Ask for next year and you get the horizon.

holidays is the calendar the workspace doesn't book on, for the whole horizon; it's read-only here.

A slot reads { startsAt, endsAt, label, labelLong }. Those four keys are camelCase, while every other appointment field is snake_case.

Slots are memoised for about 30 seconds, so one offered here can be gone by the time you book. Booking re-validates capacity inside its own transaction, so stale availability only ever produces a clean refusal.

Booking an hour

curl -X POST https://api.vitrinadev.com/api/v1/appointments \
  -H "Authorization: Bearer $VITRINA_KEY" \
  -H 'Content-Type: application/json' \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{
    "starts_at": "2026-10-01T14:00:00.000Z",
    "ends_at": "2026-10-01T14:45:00.000Z",
    "appointment_type_id": "eeee0000-0000-4000-8000-000000000001",
    "contact_id": "22222222-0000-4000-8000-000000000001",
    "customer_name": "Camila R."
  }'

A 201 is a confirmed appointment: there's no separate confirm step on this surface. It also schedules the reminder, mirrors the event to the connected calendar, invites whoever is attending, and fires appointment.booked.

An API caller is treated as somebody on the team. That's why the opening-hours window and the minimum lead time don't apply: you can book at 3am, ten minutes from now, or in the past. Those two rules exist for the AI agent.

What is always enforced is physical conflict. It's checked inside a transaction under a per-workspace lock. Two simultaneous requests for the last hour can't both win.

When it cannot be done

Every refusal carries a machine-readable details.reason, and the status depends on what that reason describes. A clash with the world is a 409, and the recovery is to offer another hour:

reasonWhat happened
slot_takenStaff capacity is full for that window
professional_takenWhoever attends already has an appointment then
vehicle_takenThe appointment's exclusive resource is already taken
blockedAn admin block covers that hour

The exclusive resource is the car, and the reason is vehicle_taken: that vehicle is already out on another test drive.

A problem with the request is a 400. invalid covers three cases: times that don't parse, an end before the start, and an appointment type that's unknown or inactive.

Always branch on details.reason, never on the message text.

kind, and the block

kind defaults to test_drive. block marks a window unbookable: it takes no capacity, carries nobody assigned and is created already confirmed. It's how you close off a stocktaking afternoon or a team holiday without inventing a fake appointment.

Reading the diary

There are two reads and they don't do the same thing.

GET /appointments is the list, cursor-paginated (pagination.nextCursor, null on the last page). It filters by status, kind, vehicle_id, appointment_type_id, owner_user_id, lead_id, contact_id, from and to. status and kind accept comma-separated lists. Nothing is excluded by default: cancelled appointments and blocks come back alongside real visits. A calendar view almost always wants ?status=pending_hold,confirmed.

GET /appointments/calendar is one window of the diary: it returns everything that overlaps [from, to). An appointment that starts before from and ends after it is on screen, and it's in the response. The starts_at-filtered list doesn't do that. It brings the contact, type, owner and branch names already resolved, plus each appointment's exclusive resource. A month is one request, and the window is capped at 62 days.

curl "https://api.vitrinadev.com/api/v1/appointments/calendar?from=2026-10-01T00:00:00Z&to=2026-10-31T00:00:00Z" \
  -H "Authorization: Bearer $VITRINA_KEY"

?mine=true narrows to the caller's own diary, theirs plus whatever has no owner. With an API key, which is nobody in particular, it does nothing.

id is a uuid; A-3 is a label

{id} accepts both forms, so an id copied from the screen works directly. What's stored, and what travels between resources, is always the uuid. display_id is the ID visible, a label a person reads, and never a value one record stores about another.

Move, reassign, close

PATCH /appointments/{id} does four distinct things, applied in a fixed order.

StepWhat it takes, and what it re-checks
Reschedulestarts_at and ends_at together; one without the other is a 400. It re-checks capacity, buffer, blocks and the exclusive resource against the new window, ignoring the appointment being moved, and frees the old hour on success. The same 409 / 400 as booking.
Statuscompleted and no_show, and nothing else. cancelled is not reachable here.
Ownerowner_user_id moves the appointment to another person, or to nobody with null.
Branchlocation_id, and null clears it. A branch that isn't this workspace's is a 404.

You can send several at once, and they're not atomic: a later step failing leaves the earlier ones applied. Only the reschedule step reports failure. A status or owner write that changed nothing answers 200 with the appointment as it was. Compare the response rather than assuming.

The response is the joined shape, the same one /appointments/calendar returns, so a detail view can re-render straight from it.

Cancelling

curl -X POST https://api.vitrinadev.com/api/v1/appointments/dddddddd-0000-4000-8000-000000000001/cancel \
  -H "Authorization: Bearer $VITRINA_KEY" \
  -H 'Content-Type: application/json' \
  -d '{ "reason": "Reprogramamos por disponibilidad del equipo." }'

Cancelling leaves the appointment cancelled, frees the hour, drops the pending reminder, removes the mirrored calendar event and fires appointment.cancelled. It's idempotent: cancelling something already cancelled answers 200 with the row and does none of it again.

It tells the person. Since the workspace is the one calling it off, the notice goes out on the conversation the appointment came from. It's in the workspace's language and timezone. That send is best-effort and stamped so it can't repeat, so it never fails the call and never goes out twice. It's skipped entirely when the appointment has no conversation behind it.

That's why this operation needs messages:send on top of appointments:delete: a message leaves Vitrina. A credential that may un-book but holds no authority to write to anybody gets a 403 here.

reason is not stored, but it IS shown. The text is appended to that customer notice and nowhere else. Write it as something the person may read. If you need an internal record, put it in a note on the lead or the conversation.

An appointment imported from an external calendar is not deleted upstream. The calendar owns those, so the cancellation is local. The event stays in the diary of whoever created it.

The events

Every state change is announced by webhook. The envelope carries changes, keyed by what changed and not by the column that holds it.

EventWhenchanges
appointment.bookedBooked and confirmed
appointment.rescheduledMoved to another hourstarts_at, ends_at, plus owner / location if they changed in the same request
appointment.cancelledCalled offstatus
appointment.completedSomebody marked it attendedstatus
appointment.no_showNobody turned upstatus
appointment.remindedThe reminder went out
appointment.importedAn external-calendar event was imported

One action, one event. A PATCH that moves the hour and reassigns is a single appointment.rescheduled carrying both changes inside it. You never have to reconstruct one decision by stitching events together by timestamp.

completed and no_show are separate events. A cancellation releases the hour; a no-show burned it. The business treats them differently, so they're announced differently. An attended appointment is announced once, whichever door marked it.

The data on these events carries identifiers, times and states, and nothing else. Never a name, a phone number or a note. If your receiver needs the person, read resource.url with your own credential. That's where your scopes and your visibility apply.

The author

Every event carries the author of whoever made the change. Somebody acting through a connected app or a personal token is still that person (kind: "member"), with the credential in via. An API key authors its own actions, under the name it had at the time.

What each credential sees

Row visibility is taken from the principal and not from the query. A narrowed role sees only the appointments it is allowed to see. ?owner_user_id= is a filter added to that ceiling: asking for a colleague's diary narrows your own rather than widening it. An appointment you may not see answers 404, not 403, so it can't be told apart from one that doesn't exist.

On a healthcare workspace an appointment is patient data. On a car-dealership workspace what gets booked is a vehicle, and there's no clinical record behind it. A connected app reading the diary receives every patient as a pseudonym: initials plus a stable number, "J.P. · #4821". The RUT, phone, email, address and birth date are withheld. The number is the same patient in every call, so you can still count, group and follow somebody without knowing who they are. Staff (whoever attends, the owner, the team) pass through untouched.

An API key belonging to the workspace itself is not a connected app and doesn't go through that filter. An owner or admin can allow names under Configuración › Conexiones › MCP; until they do, the pseudonym is what you get.

On this page