VitrinaAPI

Agendar una hora

Reservar, mover y cancelar horas en la agenda del workspace.

POST /appointments reserva una hora en la agenda del workspace. Vale igual para una prueba de manejo en una automotora, una consulta en una clínica o una visita técnica. Las tres son lo mismo: un bloque de tiempo con una persona, alguien que la atiende y un lugar.

Un tipo de cita es lo que se reserva. Cuando lleva precio, es el servicio del workspace: «Consulta de evaluación», «Mantenimiento 10.000 km». No hay un recurso /services aparte.

OperaciónPermiso
Leer la agendaappointments:read
Reservar y moverappointments:write
Cancelarappointments:delete más messages:send
Leer y escribir el catálogoappointment_types:read / appointment_types:write
Cambiar la política horariaschedule_config:write

schedule_config:write va aparte porque mueve el horario de todo el mundo a la vez.

El catálogo primero

Una cita se reserva contra un tipo. El listado es el catálogo completo, y ?kind=service es la pestaña «Servicios»:

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 }
}
CampoQué decide
duration_minutesEl largo del bloque cuando se reserva ese tipo. La grilla de la agenda es slot_minutes, que dice cada cuánto puede empezar una hora.
price_amountnull significa «a consultar» y 0 significa gratis. Uno es un precio, el otro es una invitación a preguntar, y conviene renderizarlos distinto.
price_is_fromtrue marca el precio como piso («desde $35.000»).
eligible_staff_idsQuién puede tomarlo. Vacío quiere decir «el equipo completo del workspace»; con gente adentro, la asignación automática sale de esa lista.

Crear uno es el mismo body, sin 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
  }'

Desactivar vs. eliminar

PATCH { "is_active": false } desactiva: el tipo se va de la pestaña activa, queda en «Inactivos» y vuelve cuando quieras.

DELETE /appointment-types/{id} elimina: marca deleted_at y el tipo desaparece del catálogo para siempre, en todas las pestañas, incluida active_only=false. Después de eso GET y PATCH responden 404, y una reserva que lo nombre se rechaza igual que una que nombra un id inexistente.

Nada se arrastra. Las citas ya reservadas conservan su appointment_type_id y se siguen leyendo enteras: el recordatorio y el espejo de calendario todavía resuelven el nombre. El nombre queda libre, así que volver a crear un servicio eliminado da un id nuevo.

La política horaria

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

La respuesta nunca es null: un workspace que no configuró nada recibe los valores por defecto sobre su propio horario de atención. Lo que cambia es configured, que es una etiqueta y no un 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 es lo guardado y effective_business_hours es lo que la reserva aplica. Se separan porque un workspace puede heredar el horario general en vez de declarar uno propio.

PUT /appointments/config es un upsert parcial: manda un campo y se guarda ese campo. business_hours es la excepción, porque se reemplaza entero. Es un mapa de día a lista de rangos, así que una pausa para almorzar son dos rangos en un día:

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
  }'

Cambiar estos valores afecta la disponibilidad futura. Las citas ya reservadas no se revalidan ni se desalojan.

Las horas libres

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

La ventana se recorta por los dos lados pase lo que pase: nunca empieza antes de ahora más min_lead_minutes ni pasa de booking_horizon_days. Pedir el año que viene devuelve el horizonte.

holidays es el calendario en el que el workspace no agenda, para todo el horizonte; aquí es de solo lectura.

Cada hora viene como { startsAt, endsAt, label, labelLong }. Esas cuatro claves son camelCase, mientras el resto de la cita es snake_case.

Las horas se memorizan unos 30 segundos, así que una hora que aparece aquí puede estar tomada cuando reserves. La reserva revalida la capacidad dentro de su propia transacción, así que una disponibilidad vieja solo produce un rechazo limpio.

Reservar una hora

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."
  }'

Un 201 es una cita confirmada: no hay un paso de confirmación aparte en esta superficie. Además queda programado el recordatorio, se espeja el evento al calendario conectado, se invita a quien atiende y se emite appointment.booked.

Quien llama por API se trata como alguien del equipo. Por eso el horario de atención y el tiempo mínimo de anticipación no se aplican: puedes reservar a las 3 de la mañana, dentro de diez minutos o en el pasado. Esas dos reglas existen para el agente de IA.

Lo que sí se aplica siempre es el choque físico. Se revisa dentro de una transacción con candado por workspace. Dos pedidos simultáneos por la última hora no pueden ganar los dos.

Cuando no se puede

Cada rechazo trae un details.reason legible por máquina, y el estado depende de qué describe ese motivo. Un choque con el mundo es 409, y se recupera ofreciendo otra hora:

reasonQué pasó
slot_takenLa capacidad del equipo está llena en esa ventana
professional_takenQuien atiende ya tiene una cita ahí
vehicle_takenEl recurso exclusivo de la cita ya está tomado
blockedUn bloqueo administrativo cubre esa hora

El recurso exclusivo es el vehículo, y el motivo es vehicle_taken: ese auto ya está fuera en otra prueba de manejo.

Un problema del pedido es 400. invalid cubre tres casos: horas que no se entienden, un término anterior al inicio, y un tipo de cita inexistente o inactivo.

Ramifica siempre por details.reason, nunca por el texto del mensaje.

kind, y el bloqueo

kind es test_drive por defecto. block marca una ventana como no reservable: no consume capacidad, no lleva a nadie asignado y nace confirmado. Es la forma de tapar una tarde de inventario o un feriado del equipo sin inventar una cita falsa.

Leer la agenda

Hay dos lecturas y no hacen lo mismo.

GET /appointments es la lista, paginada por cursor (pagination.nextCursor, null en la última página). Filtra por status, kind, vehicle_id, appointment_type_id, owner_user_id, lead_id, contact_id, from y to. status y kind aceptan listas separadas por coma. No excluye nada por defecto: las canceladas y los bloqueos vienen junto a las visitas reales. Una vista de calendario casi siempre quiere ?status=pending_hold,confirmed.

GET /appointments/calendar es la agenda de una ventana: devuelve todo lo que se superpone con [from, to). Una cita que empieza antes de from y termina después está en pantalla, y viene en la respuesta. La lista filtrada por starts_at no hace eso. Trae ya resueltos los nombres de contacto, tipo, responsable y sucursal, más el recurso exclusivo de cada cita. Un mes es un solo pedido, y la ventana está limitada a 62 días.

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 acota a la agenda de quien llama, lo suyo y lo que no tiene responsable. Con una API key, que no es nadie en particular, no hace nada.

id es un uuid; A-3 es una etiqueta

{id} acepta las dos formas, así que un id copiado de la pantalla funciona directo. Lo que se guarda y lo que viaja entre recursos siempre es el uuid. display_id es el ID visible, una etiqueta que lee una persona, y nunca un valor que un registro guarde sobre otro.

Mover, reasignar, cerrar

PATCH /appointments/{id} hace cuatro cosas distintas y las aplica en un orden fijo.

PasoQué pide, y qué revisa
Reprogramarstarts_at y ends_at juntos; uno sin el otro es un 400. Revalida capacidad, colchón, bloqueos y el recurso exclusivo contra la ventana nueva, ignorando la cita que se está moviendo, y al lograrlo libera la hora vieja. Los mismos 409 / 400 de la reserva.
Estadocompleted y no_show, y nada más. cancelled no se alcanza por aquí.
Responsableowner_user_id mueve la cita a otra persona, o a nadie con null.
Sucursallocation_id, y null la limpia. Una sucursal que no es de este workspace es un 404.

Se pueden mandar varias a la vez, y no son atómicas: si un paso posterior falla, los anteriores quedan aplicados. Solo el paso de reprogramar reporta fallo. Un estado o un responsable que no cambió responde 200 con la cita como estaba. Compara la respuesta en vez de suponer.

La respuesta es la forma unida, la misma que devuelve /appointments/calendar, para que una ficha se pueda redibujar directo desde ahí.

Cancelar

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." }'

Cancelar deja la cita en cancelled, suelta la hora, borra el recordatorio pendiente, quita el evento espejado del calendario y emite appointment.cancelled. Es idempotente: cancelar algo ya cancelado responde 200 con la fila y no vuelve a hacer nada.

Avisa a la persona. Como el workspace es el que cancela, el aviso sale por la conversación de donde salió la cita. Va en el idioma y la zona horaria del workspace. Ese envío es best-effort y está marcado para no repetirse, así que nunca hace fallar la llamada ni sale dos veces. Se omite entero cuando la cita no tiene conversación detrás.

Por eso esta operación pide messages:send además de appointments:delete: sale un mensaje de Vitrina. Una credencial que puede desagendar pero no tiene autoridad para escribirle a nadie recibe un 403 aquí.

reason no se guarda, pero sí se muestra. El texto se agrega a ese aviso al cliente, y a ningún otro lado. Escríbelo como algo que la persona pueda leer. Si necesitas dejar constancia interna, ponla en una nota del lead o de la conversación.

Una cita importada desde un calendario externo no se borra allí arriba. El calendario es el dueño de esas, así que la cancelación es local. El evento sigue en la agenda de quien lo creó.

Los eventos

Cada cambio de estado se avisa por webhook. El sobre lleva changes, indexado por qué cambió y no por la columna que lo guarda.

EventoCuándochanges
appointment.bookedSe reservó y quedó confirmada
appointment.rescheduledSe movió a otra horastarts_at, ends_at, y owner / location si cambiaron en el mismo pedido
appointment.cancelledSe dejó sin efectostatus
appointment.completedAlguien la marcó atendidastatus
appointment.no_showNo llegó nadiestatus
appointment.remindedSalió el recordatorio
appointment.importedSe importó un evento del calendario externo

Una acción, un evento. Un PATCH que mueve la hora y reasigna es un solo appointment.rescheduled que lleva los dos cambios adentro. Nunca hay que reconstruir una decisión juntando eventos por marca de tiempo.

completed y no_show son eventos distintos. Una cancelación libera la hora; un no-show la quemó. El negocio los trata distinto, así que se avisan distinto. Una cita atendida se avisa una vez, sin importar por dónde se marcó.

El data de estos eventos lleva identificadores, horas y estados, y nada más. Nunca un nombre, un teléfono ni una nota. Si tu receptor necesita a la persona, lee resource.url con tu propia credencial. Ahí se aplican tus scopes y tu visibilidad.

El autor

Cada evento trae el author de quien hizo el cambio. Quien actúa a través de una aplicación conectada o de un token personal sigue siendo esa persona (kind: "member"), con la credencial en via. Una API key es autora de lo suyo, con el nombre que tenía entonces.

Qué ve cada credencial

La visibilidad por fila se toma del principal y no de la query. Un rol acotado ve solo las citas que le corresponden. ?owner_user_id= es un filtro que se suma a ese techo: pedir la agenda de un colega acota la propia en vez de ensancharla. Una cita que no se puede ver responde 404, no 403, para que no se pueda distinguir de una que no existe.

En un workspace de salud una cita es dato de un paciente. En uno de automotoras lo reservado es un vehículo, y no hay ficha clínica detrás. Una aplicación conectada que lee la agenda recibe a cada paciente como un seudónimo: iniciales más un número estable, "J.P. · #4821". El RUT, el teléfono, el correo, la dirección y la fecha de nacimiento quedan fuera de la respuesta. El número es el mismo paciente en todas las llamadas, así que se puede contar, agrupar y seguir a alguien sin saber quién es. El personal (quien atiende, el responsable, el equipo) pasa tal cual.

Una API key del propio workspace no es una aplicación conectada y no pasa por ese filtro. El dueño o un administrador puede permitir los nombres en Configuración › Conexiones › MCP; mientras no lo haga, el seudónimo es lo que sale.

En esta página