VitrinaAPI

Receive appointments in your system

Subscribe to an appointment's three events and handle every delivery.

ClinicsOnly in clinic workspaces.

The clinic books in Vitrina and needs that to show up in the billing system, in the reminders one or on the site manager's dashboard. This recipe does it with one subscription to the calendar's events, with no polling every five minutes and nothing to export.

The three events are an appointment's whole cycle: it's booked, it's moved, it's cancelled.

1. A key for the integration

A subscription has an owner, and the owner decides what each delivery carries. An event arrives with the whole resource only if its owner may read it. For the calendar, appointments:read is enough:

curl -X POST https://api.vitrinadev.com/api/v1/api-keys \
  -H "Authorization: Bearer $VITRINA_ROOT_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "clínica — agenda hacia mi sistema",
    "scopes": ["webhooks:read", "webhooks:write", "appointments:read"]
  }'

The name here, and the description further down, are in Spanish like the rest of the examples on this site. Both are free-form labels for you, and the API stores whatever you send.

Notice what it does not ask for: nothing from the clinical record. This integration moves appointment times, not clinical histories, and the credential says as much (Scopes).

2. The subscription

curl -X POST https://api.vitrinadev.com/api/v1/webhooks \
  -H "Authorization: Bearer $VITRINA_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://sistema.tu-clinica.cl/vitrina",
    "events": ["appointment.booked", "appointment.rescheduled", "appointment.cancelled"],
    "description": "agenda → sistema de la clínica",
    "include_data": true
  }'

The response carries a secret beginning with whsec_, once and only once. Save it before you close the terminal.

Verify the signature on every delivery before you write anything. The full verifier, in JavaScript and Python, with a worked example, is in Quickstart.

3. It is booked

This is what reached the receiver when somebody booked a slot from the clinic's calendar:

{
  "id": "70373954-361e-466e-85b3-355ba695d11b",
  "type": "appointment.booked",
  "version": 1,
  "created_at": "2026-09-22T03:29:47.254Z",
  "tenant_id": "23970000-0000-4000-8000-000000000001",
  "resource": {
    "type": "appointment",
    "id": "6e3f2df4-ca99-47c4-879a-d69093051961",
    "url": "https://api.vitrinadev.com/api/v1/appointments/6e3f2df4-ca99-47c4-879a-d69093051961"
  },
  "author": {
    "kind": "api_key",
    "id": "24e30945-ac7c-4da7-a3aa-984abd7811bc",
    "name": "agenda de la clínica"
  },
  "data": {
    "id": "6e3f2df4-ca99-47c4-879a-d69093051961",
    "display_id": "A-4",
    "status": "confirmed",
    "kind": "clinic",
    "starts_at": "2026-09-26T13:30:00.000Z",
    "ends_at": "2026-09-26T14:00:00.000Z",
    "contact_id": null,
    "lead_id": null,
    "owner_user_id": null,
    "vehicle_id": null,
    "appointment_type_id": null
  }
}

kind: "clinic" tells a clinic appointment apart from any other kind of visit. display_id, here A-4, is the identifier people see in the application: use it when somebody has to find the same appointment in both systems.

starts_at and ends_at are UTC, always. The time a person sees is their location's: timezone, in Locations.

The event doesn't carry who the person is. contact_id comes through when the appointment is linked to a workspace contact, and it's an id, not a name. The rest you read off the resource, with your key.

4. It moves

{
  "id": "fee58690-29d5-419c-9f3a-334e3c5ab1f6",
  "type": "appointment.rescheduled",
  "version": 1,
  "created_at": "2026-09-22T03:29:51.979Z",
  "tenant_id": "23970000-0000-4000-8000-000000000001",
  "resource": {
    "type": "appointment",
    "id": "6e3f2df4-ca99-47c4-879a-d69093051961",
    "url": "https://api.vitrinadev.com/api/v1/appointments/6e3f2df4-ca99-47c4-879a-d69093051961"
  },
  "author": {
    "kind": "api_key",
    "id": "24e30945-ac7c-4da7-a3aa-984abd7811bc",
    "name": "agenda de la clínica"
  },
  "data": {
    "id": "6e3f2df4-ca99-47c4-879a-d69093051961",
    "display_id": "A-4",
    "status": "confirmed",
    "kind": "clinic",
    "starts_at": "2026-09-26T15:00:00.000Z",
    "ends_at": "2026-09-26T15:30:00.000Z",
    "contact_id": null,
    "lead_id": null,
    "owner_user_id": null,
    "vehicle_id": null,
    "appointment_type_id": null
  }
}

Same appointment id, new times. It's a move of the same appointment: if your system creates one row per appointment.booked and another per appointment.rescheduled, your calendar ends up duplicated. Key on data.id.

5. It is cancelled

{
  "id": "f332a4e8-6d8a-424d-a904-24a82d2cbbe8",
  "type": "appointment.cancelled",
  "version": 1,
  "created_at": "2026-09-22T03:29:56.330Z",
  "tenant_id": "23970000-0000-4000-8000-000000000001",
  "resource": {
    "type": "appointment",
    "id": "6e3f2df4-ca99-47c4-879a-d69093051961",
    "url": "https://api.vitrinadev.com/api/v1/appointments/6e3f2df4-ca99-47c4-879a-d69093051961"
  },
  "author": {
    "kind": "api_key",
    "id": "24e30945-ac7c-4da7-a3aa-984abd7811bc",
    "name": "agenda de la clínica"
  },
  "data": {
    "id": "6e3f2df4-ca99-47c4-879a-d69093051961",
    "display_id": "A-4",
    "status": "cancelled",
    "kind": "clinic",
    "starts_at": "2026-09-26T15:00:00.000Z",
    "ends_at": "2026-09-26T15:30:00.000Z",
    "contact_id": null,
    "lead_id": null,
    "owner_user_id": null,
    "vehicle_id": null,
    "appointment_type_id": null
  }
}

status: "cancelled", and starts_at is still the time that was freed, which is what your system needs in order to offer it again.

Trap

A cancelled appointment does not vanish: it changes status

The row is still there and the id is still the same. If your system deletes on appointment.cancelled, you lose the history somebody is going to ask for later: how many slots were cancelled this month, which ones were cancelled the same day. Mark the status instead of deleting.

6. Treat every delivery as repeatable

We retry up to five times with a growing wait, and a manual redelivery sends the same event again. Your endpoint is going to see it more than once: id is unique per event, so store it and discard the repeat. Answer 200 quickly and process afterwards; if you take too long, it counts as a failure and comes back.

When something doesn't arrive, the delivery log is the first place to look:

curl https://api.vitrinadev.com/api/v1/webhooks/625c97b6-1856-41e8-828b-081965b2c067/deliveries \
  -H "Authorization: Bearer $VITRINA_KEY"

Every attempt leaves a row, with the code your endpoint answered and the exact envelope we sent.

The other half of this story is asking the calendar instead of copying it, in Connect Claude to your calendar.

On this page