VitrinaAPI

Publish your open hours and receive the booking

Check availability and book from your own server, no widget

Your own site can show the workspace's open hours and let someone book, without opening the app. This recipe covers both halves: which hours are open, and how the booking gets confirmed.

Trap

There is no browser-safe version of this recipe: availability and booking get called from your server, always

The publishable key (pk_), the same one the site's embedded chat uses, does not work here: it does not include appointments:read or appointments:write. Use an sk_ or a personal token, always from your server and never in the browser.

Before you start

  • appointment_types:read and appointment_types:write to define what can be booked.
  • appointments:read to check schedule and availability.
  • appointments:write to confirm a booking; appointments:delete plus messages:send to cancel one.
  • Credential families live in Authentication.

1. Define what can be booked

curl -X POST https://api.vitrinadev.com/api/v1/appointment-types \
  -H "Authorization: Bearer $VITRINA_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Reunión inicial",
    "description": "Primera conversación para entender lo que necesitas.",
    "kind": "external",
    "duration_minutes": 30
  }'
{
  "data": {
    "id": "eed63545-0f09-47a3-9d14-d844ba1b0cd1",
    "name": "Reunion inicial",
    "kind": "external",
    "duration_minutes": 30,
    "price_amount": null,
    "eligible_staff_ids": [],
    "is_active": true
  }
}

There is no color field and no location field here: location gets sent per booking, not per type. duration_minutes sets the block's length. An empty eligible_staff_ids means anyone on the team can take it; with the list filled in, the booking rotates only among those names. price_amount at null reads as «ask us»; at 0, as free. They are not the same thing.

2. Check the schedule policy before showing anything

curl https://api.vitrinadev.com/api/v1/appointments/config \
  -H "Authorization: Bearer $VITRINA_KEY"
{ "data": { "configured": true, "timezone": "America/Santiago" } }

configured says whether the workspace saved a schedule policy. While it is false, the calendar still works with the workspace's general hours. Saving hours, buffer, capacity and the rest of the detail lives in Scheduling and appointments; this call only reads it.

3. Ask for open hours

curl "https://api.vitrinadev.com/api/v1/appointments/availability?appointment_type_id=eed63545-0f09-47a3-9d14-d844ba1b0cd1" \
  -H "Authorization: Bearer $VITRINA_KEY"
{
  "data": {
    "configured": true,
    "timezone": "America/Santiago",
    "slots": [
      { "startsAt": "2026-09-23T14:00:00.000Z", "endsAt": "2026-09-23T14:30:00.000Z", "label": "2026-09-23 11:00" },
      { "startsAt": "2026-09-23T14:30:00.000Z", "endsAt": "2026-09-23T15:00:00.000Z", "label": "2026-09-23 11:30" }
    ]
  }
}

appointment_type_id sets each block's length from the type's duration. The window carries a fixed floor and ceiling, whatever from/to you send. It never starts before the minimum lead time. It never runs past the workspace's booking horizon.

The response is cached for about thirty seconds. A block shown here can be taken by the time you reach step 4. That does not produce a double booking: step 4 re-validates the time against the real calendar. At most, it produces a clean refusal.

4. Receive the booking

curl -X POST https://api.vitrinadev.com/api/v1/appointments \
  -H "Authorization: Bearer $VITRINA_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "starts_at": "2026-09-24T14:00:00.000Z",
    "ends_at": "2026-09-24T14:30:00.000Z",
    "kind": "external",
    "appointment_type_id": "eed63545-0f09-47a3-9d14-d844ba1b0cd1",
    "owner_user_id": "3197957f-5fb6-4c7a-837d-1fe296bc548d",
    "customer_name": "Camila Rios"
  }'
{
  "data": {
    "id": "a06600c8-7577-42dd-81c0-24392ed77976",
    "status": "confirmed",
    "starts_at": "2026-09-24T14:00:00.000Z",
    "ends_at": "2026-09-24T14:30:00.000Z",
    "customer_name": "Camila Rios",
    "display_id": "A-14"
  }
}

A 201 here is a confirmed booking, with no separate confirmation step. It also schedules the reminder and mirrors the event to the shared calendar when one is connected, without blocking the response. Booked as an operator, business hours and the minimum lead time do not apply here. Only the AI is held to those limits when it books on its own. Leave owner_user_id out and the booking rotates across the type's eligible staff.

In the app: the same calendar, with drag and drop, lives under Schedule. The catalog of appointment types is under Settings → Business → Appointment types. Booking inside the site's chat widget, with its own pk_ key, gets turned on separately. That lives under Settings → Channels → Web chat, and it does not go through this recipe.

5. Cancel without leaving loose ends

curl -X POST https://api.vitrinadev.com/api/v1/appointments/c6f1bf05-8d32-4ff9-a01e-6616995652a1/cancel \
  -H "Authorization: Bearer $VITRINA_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "reason": "El cliente reagendó por teléfono." }'
{ "data": { "id": "c6f1bf05-8d32-4ff9-a01e-6616995652a1", "status": "cancelled" } }

Canceling needs appointments:delete and messages:send together, a scope of its own rather than a broader permission. It frees the block, drops the event from the shared calendar and messages the customer. That notice cannot be unsent, so a role can hold permission to book and reschedule without holding permission to cancel.

When it fails

Two bookings on the same block, with the same owner_user_id, answer a 409 with the reason in details.reason:

{ "error": { "code": "CONFLICT", "message": "Could not book appointment: professional_taken", "details": { "reason": "professional_taken" } } }

professional_taken is not the same as slot_taken. professional_taken is the named person who has run out of room in that block; slot_taken is the block's overall capacity running out, with nobody named. Both are losable races: the recovery is offering another block, never retrying the same one. A problem with the request itself, instead, answers 400 with reason invalid: times that do not line up, or an appointment_type_id that is unknown or inactive.

The appointment.booked, .rescheduled, .cancelled and .no_show events surface the same cycle from your own system, without polling the API; the full catalog lives in Webhooks.

On this page