VitrinaAPI

Appointments

The hours that are booked and the catalogue of what gets booked: availability, book, move, close and cancel. A test drive and a consultation are the same resource.

Beta
This may change at any time, with a changelog entry and a notice to recent callers — see versioning.

Download the full API pública projection: openapi.json.

Schedule an appointment explains this resource in prose, with runnable examples.

MethodPathWhat it does
GET/appointment-typesList appointment types
POST/appointment-typesCreate an appointment type
DELETE/appointment-types/{id}Delete an appointment type
GET/appointment-types/{id}Get an appointment type
PATCH/appointment-types/{id}Edit an appointment type
GET/appointmentsList appointments
POST/appointmentsBook an appointment
GET/appointments/{id}Fetch one appointment
PATCH/appointments/{id}Reschedule, reassign or close an appointment
POST/appointments/{id}/cancelCancel an appointment
GET/appointments/availabilityOpen slots
GET/appointments/calendarAppointments in a calendar window
GET/appointments/configThe scheduling configuration
PUT/appointments/configCreate or update the scheduling configuration

GET /appointment-types

List appointment types

The per-tenant bookable catalogue, which is ALSO the «Servicios» catalogue: a servicio is an appointment type with a price (there is no /services resource). kind and active_only are the screen’s tabs and q is its search box — free text over name AND description, case-insensitive, with % and _ escaped so "50% off" is a literal rather than a wildcard. All three COMPOSE: ?kind=service&active_only=true&q=limpieza is "the active services whose name or description mentions limpieza". An empty or omitted q is simply no text filter.

ELIMINATED types (deleted_at non-null, see DELETE /appointment-types/\{id\}) are excluded from EVERY combination of these filters — including active_only=false, so they never come back as "inactivos". Every row returned here therefore carries deleted_at: null; the field is on the wire as an audit fact, not as a state to branch on.

price_amount is null for «a consultar» and 0 for free of charge — never interchange them, because a client renders one as a price and the other as an invitation to ask. price_is_from marks it as a floor («desde $35.000»). price_currency is a 3-letter ISO-4217 code, upper-cased on the way in, and price_clp is the same amount in pesos for a workspace that prices in another currency.

ParameterInTypeRequiredConstraints
kindquerytest_drive \external \service[]
active_onlyqueryboolean | nullno
qquerystringnomáx. 200
curl https://api.vitrinadev.com/api/v1/appointment-types \
  -H "Authorization: Bearer $VITRINA_KEY"

Example response (200)

{
  "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"
    },
    {
      "id": "eeee0000-0000-4000-8000-000000000002",
      "tenant_id": "a1a1a1a1-0000-4000-8000-000000000001",
      "name": "Test drive",
      "description": "Prueba de manejo de 30 minutos, con ejecutivo.",
      "kind": "test_drive",
      "engine": "native",
      "external_ref": null,
      "buffer_minutes": null,
      "duration_minutes": 30,
      "price_amount": null,
      "price_clp": null,
      "price_currency": "CLP",
      "price_is_from": false,
      "eligible_staff_ids": [],
      "is_active": true,
      "is_default": true,
      "deleted_at": null,
      "created_at": "2026-06-02T11:02:10.004Z",
      "updated_at": "2026-06-02T11:02:10.004Z"
    }
  ],
  "meta": {
    "total": 2
  }
}

Answers: 200 · 400 · 401 · 403 · 404 · 409 · 429

POST /appointment-types

Create an appointment type

Adds a bookable type to the catalogue — a servicio once it carries a price. duration_minutes is what sets the slot LENGTH when this type is booked, while the agenda grid stays slot_minutes (GET /appointments/config).

eligible_staff_ids narrows who may take it: booking assigns round-robin from that pool, and an empty list means the workspace’s whole sales/professional team. kind defaults to external.

The NAME is unique among the live rows, so re-creating a type you eliminated is allowed and yields a NEW id.

price_amount is null for «a consultar» and 0 for free of charge — never interchange them, because a client renders one as a price and the other as an invitation to ask. price_is_from marks it as a floor («desde $35.000»). price_currency is a 3-letter ISO-4217 code, upper-cased on the way in, and price_clp is the same amount in pesos for a workspace that prices in another currency.

Depósito / abono. The deposit rule for this service is NOT on this resource: it is a deposit_policy row scoped to appointment_type, read and written through GET|PUT /deposit-policies. Each booking still takes a SNAPSHOT — the resolved amount and deadline land on the cita’s own customer_obligation — so editing the policy never changes what an already-booked customer was told to transfer.

Body

FieldTypeRequiredConstraints
namestringyesmín. 1, máx. 120
descriptionstring | null—máx. 1000
kindtest_drive \external \service
duration_minutesintegeryes≥ 1, ≤ 1440
price_amountnumber | null—≥ 0, ≤ 999999999
price_currencystring—patrón ^[A-Za-z]{3}$
price_is_fromboolean—
eligible_staff_idsuuid[]—
is_activeboolean—
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",
    "description": "Primera visita: revisión y presupuesto.",
    "kind": "service",
    "duration_minutes": 45,
    "price_amount": 35000,
    "price_currency": "CLP",
    "price_is_from": true
  }'

Example response (201)

{
  "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"
}

Answers: 201 · 400 · 401 · 403 · 404 · 409 · 429

DELETE /appointment-types/{id}

Delete an appointment type

ELIMINAR — a TOMBSTONE, and NOT the same act as deactivating. It stamps deleted_at (and is_active = false), after which the type is gone from the catalogue for good: absent from GET /appointment-types on every tab, 404 on GET and PATCH /appointment-types/\{id\}, and unbookable — a new booking that names it is refused exactly as one naming an unknown id is.

DEACTIVATING is the other verb and is unchanged: PATCH \{ "is_active": false \}. That one is reversible and keeps the type in the catalogue under the "Inactivos" tab.

NOTHING CASCADES. Appointments already booked against this type keep their appointment_type_id and stay fully readable — their calendar mirror and reminders still resolve the type by id so they can say what they were for. A second DELETE of the same id is a 404, so the recorded moment of removal is the first one.

The seeded default type (is_default) refuses deletion with a 400. An eliminated NAME is reusable — the uniqueness index is partial on the live rows — so re-creating a servicio you removed yields a NEW id, never the old row back.

ParameterInTypeRequiredConstraints
idpathuuidyes
curl -X DELETE https://api.vitrinadev.com/api/v1/appointment-types/<id> \
  -H "Authorization: Bearer $VITRINA_KEY"

Answers: 204 · 400 · 401 · 403 · 404 · 409 · 429

GET /appointment-types/{id}

Get an appointment type

By uuid. An ELIMINATED type is a 404 here even though the appointments booked against it still resolve its name — the catalogue and the history answer different questions.

ParameterInTypeRequiredConstraints
idpathuuidyes
curl https://api.vitrinadev.com/api/v1/appointment-types/<id> \
  -H "Authorization: Bearer $VITRINA_KEY"

Example response (200)

{
  "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"
}

Answers: 200 · 400 · 401 · 403 · 404 · 409 · 429

PATCH /appointment-types/{id}

Edit an appointment type

A true partial: only the keys present are written, so a body with one field never nulls the rest. eligible_staff_ids is REPLACED wholesale when present — send the full list, not a delta.

\{ "is_active": false \} is DEACTIVATING: reversible, and the type stays in the catalogue under the "Inactivos" tab. Removing it for good is the other verb, DELETE /appointment-types/\{id\}.

Editing a type never re-writes the appointments already booked against it: they keep the duration and the price they were booked with.

price_amount is null for «a consultar» and 0 for free of charge — never interchange them, because a client renders one as a price and the other as an invitation to ask. price_is_from marks it as a floor («desde $35.000»). price_currency is a 3-letter ISO-4217 code, upper-cased on the way in, and price_clp is the same amount in pesos for a workspace that prices in another currency.

Depósito / abono. The deposit rule for this service is NOT on this resource: it is a deposit_policy row scoped to appointment_type, read and written through GET|PUT /deposit-policies. Each booking still takes a SNAPSHOT — the resolved amount and deadline land on the cita’s own customer_obligation — so editing the policy never changes what an already-booked customer was told to transfer.

ParameterInTypeRequiredConstraints
idpathuuidyes

Body

FieldTypeRequiredConstraints
namestring—mín. 1, máx. 120
descriptionstring | null—máx. 1000
kindtest_drive \external \service
duration_minutesinteger—≥ 1, ≤ 1440
price_amountnumber | null—≥ 0, ≤ 999999999
price_currencystring—patrón ^[A-Za-z]{3}$
price_is_fromboolean—
eligible_staff_idsuuid[]—
is_activeboolean—
curl -X PATCH https://api.vitrinadev.com/api/v1/appointment-types/<id> \
  -H "Authorization: Bearer $VITRINA_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "price_amount": 39000,
    "eligible_staff_ids": [
      "11111111-0000-4000-8000-000000000001"
    ]
  }'

Example response (200)

{
  "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"
}

Answers: 200 · 400 · 401 · 403 · 404 · 409 · 429

GET /appointments

List appointments

Cursor-paginated: read pagination.nextCursor from the envelope and send it back as cursor. A null nextCursor means the end. There is no total and no offset.

status and kind accept comma-separated lists, so ?status=confirmed,pending_hold is one filter rather than two calls. Everything else (vehicle_id, appointment_type_id, owner_user_id, lead_id, contact_id, from, to) is a single value and the filters AND together.

Nothing is excluded by default — cancelled appointments and block rows come back alongside real visits. A calendar view almost always wants status=pending_hold,confirmed and to treat kind=block as shading rather than as a booking.

Row visibility is forced from the principal, so a restricted role sees only the appointments it is allowed to see. owner_user_id is a UI filter ANDed with that ceiling, never a way around it — asking for a colleague's diary narrows the caller's own visible rows rather than widening them.

No abono_* columns (T12). This flat list carries appointment facts only: a cita’s deposit is a customer_obligation and the badge rides the joined /appointments/calendar shape under abono. The whole money picture for a person is GET /contacts/\{id\}/ledger.

ParameterInTypeRequiredConstraints
statusquerypending_hold \confirmed \cancelled \
kindquerytest_drive \external \block \
fromquerystringnodate-time
toquerystringnodate-time
vehicle_idqueryuuidno
appointment_type_idqueryuuidno
owner_user_idqueryuuidno
lead_idqueryuuidno
contact_idqueryuuidno
cursorquerystringno
limitqueryintegerno≥ 1, ≤ 100, por defecto 25
curl https://api.vitrinadev.com/api/v1/appointments \
  -H "Authorization: Bearer $VITRINA_KEY"

Example response (200)

{
  "data": [
    {
      "id": "dddddddd-0000-4000-8000-000000000001",
      "tenant_id": "a1a1a1a1-0000-4000-8000-000000000001",
      "lead_id": null,
      "contact_id": "22222222-0000-4000-8000-000000000001",
      "conversation_id": null,
      "vehicle_id": null,
      "appointment_type_id": "eeee0000-0000-4000-8000-000000000001",
      "owner_user_id": "11111111-0000-4000-8000-000000000001",
      "location_id": "b1b1b1b1-0000-4000-8000-000000000001",
      "professional_id": null,
      "resource_id": null,
      "kind": "external",
      "source": "human",
      "engine": "native",
      "external_id": null,
      "status": "confirmed",
      "status_epoch": 1,
      "clinic_status_id": null,
      "arrival_state": "scheduled",
      "arrived_at": null,
      "in_room_at": null,
      "done_at": null,
      "series_id": null,
      "series_index": null,
      "booking_landing_id": null,
      "starts_at": "2026-10-01T14:00:00.000Z",
      "ends_at": "2026-10-01T14:45:00.000Z",
      "hold_expires_at": null,
      "google_event_id": null,
      "google_ical_uid": null,
      "google_updated_at": null,
      "google_etag": null,
      "reminder_at": "2026-10-01T12:00:00.000Z",
      "reminder_sent_at": null,
      "reminder_job_id": "appt-reminder-dddddddd-0000-4000-8000-000000000001",
      "customer_name": "Camila R.",
      "notes": "Primera visita.",
      "metadata": {},
      "display_seq": 3,
      "display_id": "A-3",
      "created_at": "2026-09-22T12:50:37.046Z",
      "updated_at": "2026-09-22T12:50:37.080Z"
    },
    {
      "id": "dddddddd-0000-4000-8000-000000000002",
      "tenant_id": "a1a1a1a1-0000-4000-8000-000000000001",
      "lead_id": null,
      "contact_id": "22222222-0000-4000-8000-000000000001",
      "conversation_id": null,
      "vehicle_id": "e1e1e1e1-0000-4000-8000-000000000001",
      "appointment_type_id": "eeee0000-0000-4000-8000-000000000002",
      "owner_user_id": "11111111-0000-4000-8000-000000000001",
      "location_id": "b1b1b1b1-0000-4000-8000-000000000001",
      "professional_id": null,
      "resource_id": null,
      "kind": "test_drive",
      "source": "human",
      "engine": "native",
      "external_id": null,
      "status": "confirmed",
      "status_epoch": 1,
      "clinic_status_id": null,
      "arrival_state": "scheduled",
      "arrived_at": null,
      "in_room_at": null,
      "done_at": null,
      "series_id": null,
      "series_index": null,
      "booking_landing_id": null,
      "starts_at": "2026-10-02T13:00:00.000Z",
      "ends_at": "2026-10-02T13:30:00.000Z",
      "hold_expires_at": null,
      "google_event_id": null,
      "google_ical_uid": null,
      "google_updated_at": null,
      "google_etag": null,
      "reminder_at": "2026-10-02T11:00:00.000Z",
      "reminder_sent_at": null,
      "reminder_job_id": "appt-reminder-dddddddd-0000-4000-8000-000000000002",
      "customer_name": "Diego M.",
      "notes": null,
      "metadata": {},
      "display_seq": 4,
      "display_id": "A-4",
      "created_at": "2026-09-22T12:50:37.046Z",
      "updated_at": "2026-09-22T12:50:37.080Z"
    }
  ],
  "meta": {
    "pagination": {
      "nextCursor": "eyJ0cyI6IjIwMjYtMTAtMDJUMTM6MDA6MDAuMDAwWiIsImlkIjoiZGRkZGRkZGQtMDAwMC00MDAwLTgwMDAtMDAwMDAwMDAwMDAyIn0",
      "limit": 25
    }
  }
}

Answers: 200 · 400 · 401 · 403 · 404 · 409 · 429

POST /appointments

Book an appointment

Books and confirms in one step — there is no separate confirm call on this surface, so a 201 is a confirmed appointment, not a hold. It also schedules the customer reminder, mirrors the event to Google and invites the executive (both best-effort), and fires appointment.booked.

Booked as an operator, so the opening-hours and lead-time gates do not apply. A REST caller may book at 3am, ten minutes from now, or in the past; only the agent is held to the business-hours window. Physical conflicts are still enforced under a per-tenant lock, so two simultaneous requests for the last slot cannot both win.

Every refusal carries a machine-readable details.reason, and the STATUS splits on what that reason describes. A clash with the world is a 409: slot_taken (the WINDOW's overall staff capacity is full, checked when the booking names no owner_user_id), professional_taken (the ONE named owner_user_id is already at their own per-slot capacity — distinct from slot_taken because a workspace can staff the window while that one person cannot take another), vehicle_taken (that car is already out on another visit) or blocked (an admin block covers it). All four are losable races, so the recovery is to offer another slot. A problem with the request is a 400: invalid (the times do not parse, the end is not after the start, or the appointment type is unknown or inactive). Branch on details.reason, never on the message text.

A workspace with no schedule config is no longer refused: it books on the table’s default knobs and the workspace’s own opening hours, the same policy GET /appointments/config reports with configured: false. The not_configured reason is therefore no longer emitted by this surface.

kind defaults to test_drive. Use block to mark a window unbookable: it skips capacity entirely, takes no executive, and is created already confirmed.

owner_user_id pins a specific member; leave it out and one is assigned round-robin from the appointment type’s eligible staff. Omitting appointment_type_id on a test_drive resolves the workspace default type.

Body

FieldTypeRequiredConstraints
starts_atstringyesdate-time
ends_atstringyesdate-time
kindtest_drive \external \block \
vehicle_idstring | null—
appointment_type_idstring | null—
lead_idstring | null—
contact_idstring | null—
conversation_idstring | null—
owner_user_idstring | null—
location_idstring | null—
customer_namestring | null—máx. 200
notesstring | null—máx. 2000
curl -X POST https://api.vitrinadev.com/api/v1/appointments \
  -H "Authorization: Bearer $VITRINA_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "starts_at": "2026-10-01T14:00:00.000Z",
    "ends_at": "2026-10-01T14:45:00.000Z",
    "kind": "external",
    "appointment_type_id": "eeee0000-0000-4000-8000-000000000001",
    "contact_id": "22222222-0000-4000-8000-000000000001",
    "location_id": "b1b1b1b1-0000-4000-8000-000000000001",
    "customer_name": "Camila R.",
    "notes": "Primera visita."
  }'

Example response (201)

{
  "data": {
    "id": "dddddddd-0000-4000-8000-000000000001",
    "tenant_id": "a1a1a1a1-0000-4000-8000-000000000001",
    "lead_id": null,
    "contact_id": "22222222-0000-4000-8000-000000000001",
    "conversation_id": null,
    "vehicle_id": null,
    "appointment_type_id": "eeee0000-0000-4000-8000-000000000001",
    "owner_user_id": "11111111-0000-4000-8000-000000000001",
    "location_id": "b1b1b1b1-0000-4000-8000-000000000001",
    "professional_id": null,
    "resource_id": null,
    "kind": "external",
    "source": "human",
    "engine": "native",
    "external_id": null,
    "status": "confirmed",
    "status_epoch": 1,
    "clinic_status_id": null,
    "arrival_state": "scheduled",
    "arrived_at": null,
    "in_room_at": null,
    "done_at": null,
    "series_id": null,
    "series_index": null,
    "booking_landing_id": null,
    "starts_at": "2026-10-01T14:00:00.000Z",
    "ends_at": "2026-10-01T14:45:00.000Z",
    "hold_expires_at": null,
    "google_event_id": null,
    "google_ical_uid": null,
    "google_updated_at": null,
    "google_etag": null,
    "reminder_at": "2026-10-01T12:00:00.000Z",
    "reminder_sent_at": null,
    "reminder_job_id": "appt-reminder-dddddddd-0000-4000-8000-000000000001",
    "customer_name": "Camila R.",
    "notes": "Primera visita.",
    "metadata": {},
    "display_seq": 3,
    "display_id": "A-3",
    "created_at": "2026-09-22T12:50:37.046Z",
    "updated_at": "2026-09-22T12:50:37.080Z"
  }
}

Answers: 201 · 400 · 401 · 403 · 404 · 409 · 429

GET /appointments/{id}

Fetch one appointment

By UUID or by A- display id — both resolve here, so an id copied from the operator UI works directly.

Appointment facts only — no abono_* columns (T12). The deposit badge rides /appointments/calendar; the money picture is GET /contacts/\{id\}/ledger.

ParameterInTypeRequiredConstraints
idpathstringyesmín. 1
curl https://api.vitrinadev.com/api/v1/appointments/<id> \
  -H "Authorization: Bearer $VITRINA_KEY"

Example response (200)

{
  "data": {
    "id": "dddddddd-0000-4000-8000-000000000001",
    "tenant_id": "a1a1a1a1-0000-4000-8000-000000000001",
    "lead_id": null,
    "contact_id": "22222222-0000-4000-8000-000000000001",
    "conversation_id": null,
    "vehicle_id": null,
    "appointment_type_id": "eeee0000-0000-4000-8000-000000000001",
    "owner_user_id": "11111111-0000-4000-8000-000000000001",
    "location_id": "b1b1b1b1-0000-4000-8000-000000000001",
    "professional_id": null,
    "resource_id": null,
    "kind": "external",
    "source": "human",
    "engine": "native",
    "external_id": null,
    "status": "confirmed",
    "status_epoch": 1,
    "clinic_status_id": null,
    "arrival_state": "scheduled",
    "arrived_at": null,
    "in_room_at": null,
    "done_at": null,
    "series_id": null,
    "series_index": null,
    "booking_landing_id": null,
    "starts_at": "2026-10-01T14:00:00.000Z",
    "ends_at": "2026-10-01T14:45:00.000Z",
    "hold_expires_at": null,
    "google_event_id": null,
    "google_ical_uid": null,
    "google_updated_at": null,
    "google_etag": null,
    "reminder_at": "2026-10-01T12:00:00.000Z",
    "reminder_sent_at": null,
    "reminder_job_id": "appt-reminder-dddddddd-0000-4000-8000-000000000001",
    "customer_name": "Camila R.",
    "notes": "Primera visita.",
    "metadata": {},
    "display_seq": 3,
    "display_id": "A-3",
    "created_at": "2026-09-22T12:50:37.046Z",
    "updated_at": "2026-09-22T12:50:37.080Z"
  }
}

Answers: 200 · 400 · 401 · 403 · 404 · 409 · 429

PATCH /appointments/{id}

Reschedule, reassign or close an appointment

Four distinct operations behind one verb, applied in a fixed order: reschedule, then status, then reassign, then move sucursal. Sending several at once is allowed and they are NOT atomic — a later step failing leaves the earlier ones applied.

Reschedule needs starts_at and ends_at together; one without the other is a 400. It re-checks capacity, buffer, blocks and the vehicle against the NEW window, ignoring the appointment being moved, and frees the old slot on success. Every refusal carries a machine-readable details.reason, and the STATUS splits on what that reason describes. A clash with the world is a 409: slot_taken (the WINDOW's overall staff capacity is full, checked when the booking names no owner_user_id), professional_taken (the ONE named owner_user_id is already at their own per-slot capacity — distinct from slot_taken because a workspace can staff the window while that one person cannot take another), vehicle_taken (that car is already out on another visit) or blocked (an admin block covers it). All four are losable races, so the recovery is to offer another slot. A problem with the request is a 400: invalid (the times do not parse, the end is not after the start, or the appointment type is unknown or inactive). Branch on details.reason, never on the message text.

A workspace with no schedule config is no longer refused: it books on the table’s default knobs and the workspace’s own opening hours, the same policy GET /appointments/config reports with configured: false. The not_configured reason is therefore no longer emitted by this surface.

Status accepts completed and no_show, and nothing else. Each fires its own Evento (appointment.completed / appointment.no_show); neither changes the calendar and neither frees the slot, because the time was used. cancelled is deliberately not reachable here — the Zod enum rejects it with a 400. Cancelling frees the slot, drops the calendar event and messages the customer, so it has one door of its own: POST /appointments/\{id\}/cancel, under appointments:delete plus messages:send. That split lets a role be granted "may book and reschedule" without also being granted "may un-book".

Reassign via owner_user_id moves the appointment to another executive, or to nobody with null. It changes the row only — the Google invite is not re-sent, so the new owner does not receive a calendar invitation from this call.

location_id moves the visit between sucursales, and null clears it. A location that is not this workspace’s is a 404.

Only the reschedule step reports failure. A status or reassign write that does not apply answers 200 with the appointment unchanged, so compare the response rather than assuming.

The response is the JOINED row — the same shape /appointments/calendar returns, with the contact / vehicle / type / owner / location names resolved — not the bare row, so a detail view can re-render straight from it.

ParameterInTypeRequiredConstraints
idpathstringyesmín. 1

Body

FieldTypeRequiredConstraints
starts_atstring—date-time
ends_atstring—date-time
owner_user_idstring | null—
location_idstring | null—
statuscompleted \no_show—
curl -X PATCH https://api.vitrinadev.com/api/v1/appointments/<id> \
  -H "Authorization: Bearer $VITRINA_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "starts_at": "2026-10-02T16:00:00.000Z",
    "ends_at": "2026-10-02T16:45:00.000Z"
  }'

Example response (200)

{
  "data": {
    "id": "dddddddd-0000-4000-8000-000000000001",
    "display_id": "A-3",
    "kind": "external",
    "status": "confirmed",
    "engine": "native",
    "title": null,
    "source": "human",
    "starts_at": "2026-10-02T16:00:00.000Z",
    "ends_at": "2026-10-02T16:45:00.000Z",
    "hold_expires_at": null,
    "contact": {
      "id": "22222222-0000-4000-8000-000000000001",
      "name": "Camila R.",
      "phone": "+56900000001",
      "email": null
    },
    "customer_name": "Camila R.",
    "vehicle": null,
    "appointment_type": {
      "id": "eeee0000-0000-4000-8000-000000000001",
      "name": "Consulta de evaluación"
    },
    "owner": {
      "id": "11111111-0000-4000-8000-000000000001",
      "name": "Valentina S."
    },
    "location": {
      "id": "b1b1b1b1-0000-4000-8000-000000000001",
      "name": "Sucursal Centro"
    },
    "conversation_id": null,
    "lead_id": null,
    "notes": "Primera visita.",
    "metadata": {},
    "abono": null
  }
}

Answers: 200 · 400 · 401 · 403 · 404 · 409 · 429

POST /appointments/{id}/cancel

Cancel an appointment

Sets cancelled, drops the pending reminder, removes the mirrored calendar event, fires appointment.cancelled and frees the slot for the follow-up trigger. Idempotent — cancelling an already-cancelled appointment answers 200 with the row and does none of it again.

Two scopes: appointments:delete AND messages:send. The first is the split that lets a workspace grant "may book and reschedule" without granting "may un-book" (appointments:write reaches neither). The second is because a MESSAGE LEAVES VITRINA here, which is what every sending operation costs (ADR 0106 §3.2) — a credential that may cancel but holds no authority to write to anybody gets a 403.

It messages the customer. The workspace is the one calling this off, so the cancellation notice goes out on the appointment’s originating conversation, in the workspace language and timezone. That send is best-effort and stamped for idempotency — it never fails the call and never goes out twice — and it is skipped entirely when the appointment has no conversation behind it. It is a transactional confirmation Vitrina composes about a booking the person made, not a send this caller authors, so it does not go through the política de envíos verdicts: a Bloqueo there would leave somebody turning up to a cancelled appointment.

reason is not stored, but it IS shown: the text is appended to that customer notice, and nowhere else. Nothing persists it on the appointment, so put the internal explanation in a note on the lead or conversation if it needs to survive — and write reason as something the customer may read.

An appointment imported FROM Google is not deleted upstream — the calendar owns those, so the cancellation is local and the event stays on the customer’s calendar.

ParameterInTypeRequiredConstraints
idpathstringyesmín. 1

Body

FieldTypeRequiredConstraints
reasonstring—máx. 500
curl -X POST https://api.vitrinadev.com/api/v1/appointments/<id>/cancel \
  -H "Authorization: Bearer $VITRINA_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "reason": "Reprogramamos por disponibilidad del equipo."
  }'

Example response (200)

{
  "data": {
    "id": "dddddddd-0000-4000-8000-000000000001",
    "tenant_id": "a1a1a1a1-0000-4000-8000-000000000001",
    "lead_id": null,
    "contact_id": "22222222-0000-4000-8000-000000000001",
    "conversation_id": null,
    "vehicle_id": null,
    "appointment_type_id": "eeee0000-0000-4000-8000-000000000001",
    "owner_user_id": "11111111-0000-4000-8000-000000000001",
    "location_id": "b1b1b1b1-0000-4000-8000-000000000001",
    "professional_id": null,
    "resource_id": null,
    "kind": "external",
    "source": "human",
    "engine": "native",
    "external_id": null,
    "status": "cancelled",
    "status_epoch": 2,
    "clinic_status_id": null,
    "arrival_state": "scheduled",
    "arrived_at": null,
    "in_room_at": null,
    "done_at": null,
    "series_id": null,
    "series_index": null,
    "booking_landing_id": null,
    "starts_at": "2026-10-01T14:00:00.000Z",
    "ends_at": "2026-10-01T14:45:00.000Z",
    "hold_expires_at": null,
    "google_event_id": null,
    "google_ical_uid": null,
    "google_updated_at": null,
    "google_etag": null,
    "reminder_at": "2026-10-01T12:00:00.000Z",
    "reminder_sent_at": null,
    "reminder_job_id": "appt-reminder-dddddddd-0000-4000-8000-000000000001",
    "customer_name": "Camila R.",
    "notes": "Primera visita.",
    "metadata": {},
    "display_seq": 3,
    "display_id": "A-3",
    "created_at": "2026-09-22T12:50:37.046Z",
    "updated_at": "2026-09-22T12:50:37.080Z"
  }
}

Answers: 200 · 400 · 401 · 403 · 404 · 409 · 429

GET /appointments/availability

Open slots

Answers \{ configured, timezone, slots \}. configured: false means the workspace has never saved a schedule config, so these slots were generated from the table’s default knobs on the workspace’s own opening hours. It is a LABEL, not an error — the slots are real and bookable, and the right treatment is to render them while offering PUT /appointments/config, never to hide them. An empty slots list means what it says at any value of configured: nothing is open in the window you asked for.

The window is clamped on both sides regardless of from/to: 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, not an error.

appointment_type_id sets the slot LENGTH from that type’s duration while the grid step stays slot_minutes. An unknown or inactive type is tolerated here and falls back to the grid size — but booking validates it strictly, so a slot offered with a bad type id will be refused at POST /appointments. vehicle_id additionally excludes windows where that specific car is already out.

Results are memoised in-process for about 30 seconds, so a slot can be offered here and gone by the time you book. That is bounded and safe rather than a race: the booking transaction re-validates capacity, so stale availability can only produce a clean refusal, never a double booking. Registered before /\{id\}.

ParameterInTypeRequiredConstraints
vehicle_idqueryuuidno
owner_user_idqueryuuidno
appointment_type_idqueryuuidno
fromquerystringnodate-time
toquerystringnodate-time
curl https://api.vitrinadev.com/api/v1/appointments/availability \
  -H "Authorization: Bearer $VITRINA_KEY"

Example response (200)

{
  "data": {
    "configured": true,
    "timezone": "America/Santiago",
    "slots": [
      {
        "startsAt": "2026-10-01T13:30:00.000Z",
        "endsAt": "2026-10-01T14:15:00.000Z",
        "label": "2026-10-01 10:30",
        "labelLong": "jueves, 1 de octubre, 10:30"
      },
      {
        "startsAt": "2026-10-01T14:00:00.000Z",
        "endsAt": "2026-10-01T14:45:00.000Z",
        "label": "2026-10-01 11:00",
        "labelLong": "jueves, 1 de octubre, 11:00"
      }
    ],
    "horizon_end": "2026-10-15T13:43:45.308Z",
    "booking_horizon_days": 14
  }
}

Answers: 200 · 400 · 401 · 403 · 404 · 409 · 429

GET /appointments/calendar

Appointments in a calendar window

Everything OVERLAPPING [from, to) — an appointment that starts before from and ends after it IS on screen and IS returned, which is what separates this from the starts_at-filtered list. Ascending, with contact / vehicle / type / owner / location display names joined so a month view is one request. Range capped at 62 days. mine=true narrows to the caller's own diary (owned by me OR unassigned).

Abono / depósito. A booking that opened a deposit expectation carries an abono object; one that did not carries abono: null. It is derived from the cita’s customer_obligation of kind appointment_deposit and its allocations, so it holds amount_clp (what is owed), paid_clp (what has landed), outstanding_clp, deadline (due_on, a calendar day), the derived state (pending | partial | paid | overpaid | overdue | overdue_partial), the badge’s effective_status and provenance (verified = a bank said so, manual = a human did).

overdue is derived, never stored: it is an open obligation whose due_on has passed with something still outstanding. There is no cron and no transition behind it — a late transfer still settles the obligation. The full picture for a person, including their credit, is GET /contacts/\{id\}/ledger.

ParameterInTypeRequiredConstraints
fromquerystringyesdate-time
toquerystringyesdate-time
kindquerytest_drive \external \block \
statusquerypending_hold \confirmed \cancelled \
location_idqueryuuidno
owner_user_idqueryuuidno
minequerybooleanno
curl https://api.vitrinadev.com/api/v1/appointments/calendar \
  -H "Authorization: Bearer $VITRINA_KEY"

Example response (200)

{
  "data": [
    {
      "id": "dddddddd-0000-4000-8000-000000000001",
      "display_id": "A-3",
      "kind": "external",
      "status": "confirmed",
      "engine": "native",
      "title": null,
      "source": "human",
      "starts_at": "2026-10-01T14:00:00.000Z",
      "ends_at": "2026-10-01T14:45:00.000Z",
      "hold_expires_at": null,
      "contact": {
        "id": "22222222-0000-4000-8000-000000000001",
        "name": "Camila R.",
        "phone": "+56900000001",
        "email": null
      },
      "customer_name": "Camila R.",
      "vehicle": null,
      "appointment_type": {
        "id": "eeee0000-0000-4000-8000-000000000001",
        "name": "Consulta de evaluación"
      },
      "owner": {
        "id": "11111111-0000-4000-8000-000000000001",
        "name": "Valentina S."
      },
      "location": {
        "id": "b1b1b1b1-0000-4000-8000-000000000001",
        "name": "Sucursal Centro"
      },
      "conversation_id": null,
      "lead_id": null,
      "notes": "Primera visita.",
      "metadata": {},
      "abono": null
    }
  ]
}

Answers: 200 · 400 · 401 · 403 · 404 · 409 · 429

GET /appointments/config

The scheduling configuration

The workspace’s EFFECTIVE booking rules: timezone, weekly business_hours (raw) and effective_business_hours (what booking enforces), slot grid, buffer, lead time, horizon, staff capacity, hold TTL, reminder lead, and the arrival_board_enabled reception switch. Never null: a workspace that configured nothing gets the table defaults on the workspace clock.

configured is true only once a scheduling POLICY has been saved — a PUT carrying any field other than arrival_board_enabled. It is NOT "a stored row exists": saving arrival_board_enabled alone creates the row and leaves configured: false (default knobs, the agent's native appointments tools stay gated). It is the same flag availability reports.

arrival_board_enabled is the stored «Sala de espera» switch (false when never saved).

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

Example response (200)

{
  "data": {
    "configured": true,
    "timezone": "America/Santiago",
    "business_hours": {
      "mon": [
        [
          "09:00",
          "13:00"
        ],
        [
          "14:30",
          "18:00"
        ]
      ],
      "tue": [
        [
          "09:00",
          "13:00"
        ],
        [
          "14:30",
          "18:00"
        ]
      ],
      "wed": [
        [
          "09:00",
          "13:00"
        ],
        [
          "14:30",
          "18:00"
        ]
      ],
      "thu": [
        [
          "09:00",
          "13:00"
        ],
        [
          "14:30",
          "18:00"
        ]
      ],
      "fri": [
        [
          "09:00",
          "13:00"
        ],
        [
          "14:30",
          "17:00"
        ]
      ]
    },
    "effective_business_hours": {
      "mon": [
        [
          "09:00",
          "13:00"
        ],
        [
          "14:30",
          "18:00"
        ]
      ],
      "tue": [
        [
          "09:00",
          "13:00"
        ],
        [
          "14:30",
          "18:00"
        ]
      ],
      "wed": [
        [
          "09:00",
          "13:00"
        ],
        [
          "14:30",
          "18:00"
        ]
      ],
      "thu": [
        [
          "09:00",
          "13:00"
        ],
        [
          "14:30",
          "18:00"
        ]
      ],
      "fri": [
        [
          "09:00",
          "13:00"
        ],
        [
          "14:30",
          "17:00"
        ]
      ]
    },
    "business_hours_source": "config",
    "holidays": [
      "2026-09-18",
      "2026-09-19",
      "2026-10-12",
      "2026-10-31"
    ],
    "slot_minutes": 30,
    "buffer_minutes": 10,
    "min_lead_minutes": 120,
    "booking_horizon_days": 14,
    "staff_capacity": 2,
    "vehicle_exclusive": false,
    "owner_capacity": 1,
    "hold_ttl_minutes": 15,
    "reminder_lead_minutes": 120,
    "sales_team_id": "cccccccc-0000-4000-8000-000000000001",
    "arrival_board_enabled": false
  }
}

Answers: 200 · 400 · 401 · 403 · 404 · 409 · 429

PUT /appointments/config

Create or update the scheduling configuration

An upsert: the first call creates the config, later ones patch the fields present. Everything is optional, so this is safe to call with one field.

This doubles as the connect flow for scheduling. Saving a POLICY field (anything but arrival_board_enabled) marks the agenda configured and seeds the per-tenant scheduling tool rows, so the booking tools become wireable to an agent the moment scheduling exists. That seeding is best-effort and its failure does not fail the call. arrival_board_enabled alone is a reception setting: it creates or patches the row but never marks the agenda configured and seeds nothing.

business_hours is a weekday map to a list of ["HH:MM", "HH:MM"] ranges — a list, so a lunch break is two ranges on one day. It is REPLACED wholesale, and it is a different shape from the workspace business_hours used for SLA coverage; these are opening hours for booking, not the support clock.

slot_minutes is the grid step, buffer_minutes the gap kept between visits, staff_capacity how many can overlap at once, vehicle_exclusive whether one car can be on only one active visit at a time (default false: several customers may book the same car at the same hour), owner_capacity how many appointments one professional / executive may carry at the same time (default 1). Changing them affects future availability only — appointments already booked are never re-validated or evicted.

Body

FieldTypeRequiredConstraints
timezonestring—mín. 1
business_hoursobject—
slot_minutesinteger—> 0
buffer_minutesinteger—≥ 0
min_lead_minutesinteger—≥ 0
booking_horizon_daysinteger—> 0
staff_capacityinteger—≥ 1
vehicle_exclusiveboolean—
owner_capacityinteger—≥ 1
default_overbook_limitinteger—≥ 0, ≤ 50
arrival_board_enabledboolean—
late_cancel_hoursinteger | null—≥ 1, ≤ 720
no_show_fee_clpinteger | null—≥ 0, ≤ 100000000
hold_ttl_minutesinteger—> 0
reminder_lead_minutesinteger—≥ 0
sales_team_idstring | null—
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,
    "reminder_lead_minutes": 120
  }'

Example response (200)

{
  "data": {
    "configured": true,
    "timezone": "America/Santiago",
    "business_hours": {
      "mon": [
        [
          "09:00",
          "13:00"
        ],
        [
          "14:30",
          "18:00"
        ]
      ],
      "tue": [
        [
          "09:00",
          "13:00"
        ],
        [
          "14:30",
          "18:00"
        ]
      ],
      "wed": [
        [
          "09:00",
          "13:00"
        ],
        [
          "14:30",
          "18:00"
        ]
      ],
      "thu": [
        [
          "09:00",
          "13:00"
        ],
        [
          "14:30",
          "18:00"
        ]
      ],
      "fri": [
        [
          "09:00",
          "13:00"
        ],
        [
          "14:30",
          "17:00"
        ]
      ]
    },
    "effective_business_hours": {
      "mon": [
        [
          "09:00",
          "13:00"
        ],
        [
          "14:30",
          "18:00"
        ]
      ],
      "tue": [
        [
          "09:00",
          "13:00"
        ],
        [
          "14:30",
          "18:00"
        ]
      ],
      "wed": [
        [
          "09:00",
          "13:00"
        ],
        [
          "14:30",
          "18:00"
        ]
      ],
      "thu": [
        [
          "09:00",
          "13:00"
        ],
        [
          "14:30",
          "18:00"
        ]
      ],
      "fri": [
        [
          "09:00",
          "13:00"
        ],
        [
          "14:30",
          "17:00"
        ]
      ]
    },
    "business_hours_source": "config",
    "holidays": [
      "2026-09-18",
      "2026-09-19",
      "2026-10-12",
      "2026-10-31"
    ],
    "slot_minutes": 30,
    "buffer_minutes": 10,
    "min_lead_minutes": 120,
    "booking_horizon_days": 14,
    "staff_capacity": 2,
    "vehicle_exclusive": false,
    "owner_capacity": 1,
    "hold_ttl_minutes": 15,
    "reminder_lead_minutes": 120,
    "sales_team_id": "cccccccc-0000-4000-8000-000000000001",
    "arrival_board_enabled": false
  }
}

Answers: 200 · 400 · 401 · 403 · 404 · 409 · 429

On this page