# Scheduling and bookings

Met has a single schedule: the one the business sees in the panel, the one the Met uses when it books over WhatsApp, and the one you read and write through the API. An appointment you create here shows up on the Schedule screen, is carried over to the connected calendar (Google or Outlook) and, if the contact's record has an email, the contact gets the invitation.

The path is always the same: **what can be booked** (services) → **when there is room** (free slots) → **the appointment**.

## Before you start

| Scope | What for | With a test key |
|---|---|---|
| `agenda:read` | Services, resources, free slots and appointments (list and detail). Also the `booking.*` events. | Yes |
| `agenda:write` | Create, reschedule and cancel appointments. | No: it responds `403` `test_mode_restricted`, because it writes to the connected calendar and sends the invitation to the contact. |

Routes carry the workspace: `/workspaces/{workspaceId}/agenda/…`. With the SDK you pass it once when creating the client. Services, resources and business hours are configured in the panel (Schedule); through the API they are read.

```ts
import { Met } from '@meteor.ia/sdk';

const met = new Met(process.env.METEOR_API_KEY!, { workspaceId: 42 });
```
```python
import os
from meteor_ia import Met

met = Met(os.environ["METEOR_API_KEY"], workspace_id=42)
```

## 1. What can be booked

```ts
const { data: services } = await met.agenda.eventTypes();
// [{ id: 3, object: 'event_type', name: 'Consulta inicial', duration_min: 30, modality: 'both', price: 80000, resource_ids: [5] }]

const { data: resources } = await met.agenda.resources();
// [{ id: 5, object: 'agenda_resource', name: 'Dra. Ruiz', kind: 'staff', timezone: 'America/Bogota' }]
```
```python
services = met.agenda.event_types()["data"]
resources = met.agenda.resources()["data"]
```

A **service** (`event_type`) is what gets booked: it lasts `duration_min` minutes, is delivered `in_person`, `virtual` or `both`, and is handled by the resources in `resource_ids`. A **resource** is who or where the service is delivered: a person (`staff`), a location (`location`) or a piece of equipment (`asset`). Only active ones are returned, up to 200 of each.

## 2. Free slots

Ask for a service's slots between two **local dates of the business** (`YYYY-MM-DD`), up to 31 days per call:

```ts
const { data: slots, has_more } = await met.agenda.slots({
  eventTypeId: 3,
  from: '2026-11-03',
  to: '2026-11-07',
});
// [{ object: 'slot', resource_id: 5, resource_name: 'Dra. Ruiz', starts_at: '2026-11-03T14:00:00.000Z',
//    ends_at: '2026-11-03T14:30:00.000Z', label: 'martes 3 de noviembre de 2026, 09:00 a. m.' }]
```
```python
slots = met.agenda.slots(event_type_id=3, from_="2026-11-03", to="2026-11-07")["data"]
```
```bash
curl "https://api.met.meteor.com.co/api/v1/workspaces/42/agenda/slots?event_type_id=3&from=2026-11-03&to=2026-11-07" \
  -H "Authorization: Bearer $METEOR_API_KEY"
```

A slot already accounts for business hours, exceptions, blocks, booked appointments and what is busy on the connected calendar. `label` is the date written out to show to a person. Up to 200 arrive: if `has_more` is `true`, ask for a shorter range. With `resourceId` (`resource_id`) you only see one resource's slots.

In Python the parameter is called `from_` because `from` is a reserved word; on the wire it travels as `from`.

## 3. Booking the appointment

Pass a slot's `starts_at` and `resource_id` **exactly** as they arrived:

```ts
const appointment = await met.agenda.bookings.create({
  eventTypeId: 3,
  contactId: 4821,
  startsAt: slots[0].starts_at,
  resourceId: slots[0].resource_id,
  modality: 'virtual',
  notes: 'Primera consulta, viene por recomendación.',
});
// { id: 91, object: 'booking', status: 'confirmed', video_call_url: 'https://meet.google.com/…', calendar_synced: true, … }
```
```python
appointment = met.agenda.bookings.create(
    event_type_id=3,
    contact_id=4821,
    starts_at=slots[0]["starts_at"],
    resource_id=slots[0]["resource_id"],
)
```
```bash
curl -X POST https://api.met.meteor.com.co/api/v1/workspaces/42/agenda/bookings \
  -H "Authorization: Bearer $METEOR_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{"event_type_id":3,"contact_id":4821,"starts_at":"2026-11-03T14:00:00.000Z","resource_id":5}'
```

- `contact_id` is a contact in this workspace's CRM (see [CRM and contacts](contacts.html)). One that does not exist or belongs to another workspace responds `404` `contact_not_found`.
- Without `resource_id`, the appointment goes to the first resource that is free at that time.
- `modality` is only needed if the service is `both`; if the service is only virtual or only in person, sending the other one responds `400` `validation_error`.
- Before writing, Met recalculates that day's slots. If someone took the time in the meantime, it responds `409` `slot_unavailable` and **creates nothing**: ask for the slots again.
- If the contact already has a confirmed appointment for that service at that time, it responds `409` `booking_exists` with the `booking_id` of the existing one.
- The SDK sends `Idempotency-Key` on its own: a network retry does not duplicate the appointment. With cURL, send it yourself.

`calendar_synced` says whether the appointment made it to the connected calendar. `video_call_url` carries the video call link when the calendar generates one.

## Reading appointments

```ts
// A contact's upcoming ones
const { data, has_more } = await met.agenda.bookings.list({
  from: new Date().toISOString(),
  contactId: 4821,
  status: 'confirmed',
});

// All of November's, without handling cursors
for await (const c of met.agenda.bookings.iterate({ from: '2026-11-01T00:00:00-05:00', to: '2026-11-30T23:59:59-05:00' })) {
  console.log(c.id, c.starts_at, c.status);
}

const one = await met.agenda.bookings.retrieve(91);
```
```python
from datetime import datetime, timezone

upcoming = met.agenda.bookings.list(from_=datetime.now(timezone.utc).isoformat(), contact_id=4821)
for c in met.agenda.bookings.iterate(from_="2026-11-01T00:00:00-05:00", to="2026-11-30T23:59:59-05:00"):
    print(c["id"], c["starts_at"], c["status"])
```

The list goes **from oldest to newest** by start time. Without `from` it starts with the ones already past: to see only upcoming ones, pass `from` with the current time. `from` and `to` are ISO 8601 instants with a zone (unlike slots, which use local dates). You can also filter by `resourceId` and by `status`: `confirmed`, `cancelled`, `rescheduled`, `no_show` or `completed`. Paginate with `limit` (1–100, 20 by default) and `startingAfter` = the `id` of the last appointment on the page.

## Rescheduling and cancelling

```ts
await met.agenda.bookings.reschedule(91, { startsAt: '2026-11-04T15:00:00.000Z' });
await met.agenda.bookings.cancel(91, { reason: 'El cliente viaja esa semana' });
```
```python
met.agenda.bookings.reschedule(91, starts_at="2026-11-04T15:00:00.000Z")
met.agenda.bookings.cancel(91, reason="El cliente viaja esa semana")
```

- **Rescheduling** moves a confirmed appointment to another time (and, if you pass `resourceId`, to another resource that handles the service), within business hours and without overlapping another appointment. The event on the connected calendar moves with it.
- **Cancelling** cancels the appointment and its calendar event. The reason is kept in the notes. An appointment that is not confirmed (already cancelled, attended or a no-show) responds `409` `booking_not_confirmed`.

## Hearing about every change

Appointments change from four places: the Met in a conversation, a person in the panel, the business's operations app and your integration. All four emit the same events (see [Webhooks](webhooks.html)):

| Event | When |
|---|---|
| `booking.created` | An appointment was booked. |
| `booking.updated` | It was rescheduled or its details were edited (modality, duration, location, notes). |
| `booking.cancelled` | It was cancelled. |

```ts
await met.webhooks.subscriptions.create({
  url: 'https://your-system.com/met/events',
  enabled_events: ['booking.created', 'booking.updated', 'booking.cancelled'],
});
```

The `data` of all three is the appointment, with the same shape as `bookings.retrieve()`, plus the `app_url` that opens the contact's conversation in Met. `booking.updated` also carries `previous_attributes`: **only** the fields that changed, with their previous value.

```json
{
  "type": "booking.updated",
  "data": {
    "id": 91, "object": "booking", "status": "confirmed", "contact_id": 4821,
    "starts_at": "2026-11-04T15:00:00.000Z", "ends_at": "2026-11-04T15:30:00.000Z",
    "previous_attributes": { "starts_at": "2026-11-03T14:00:00.000Z", "ends_at": "2026-11-03T14:30:00.000Z" },
    "app_url": "https://met.meteor.com.co/abrir/42/contacto/4821"
  }
}
```

Saving an appointment without changing anything emits no event, and neither does cancelling one that was already cancelled. The event is sent with `livemode: false` only when the appointment was booked by a Met inside a Run of a test key.

## From an assistant (MCP)

Meteor's MCP server has `met_list_event_types`, `met_find_slots` and `met_list_bookings`, which require `agenda:read`. The ones that write —`met_create_booking`, `met_reschedule_booking` and `met_cancel_booking`— require `agenda:write`, which an MCP client does not get by default: it has to ask for it on purpose.

## Common errors

| Code | What happened |
|---|---|
| `400` `validation_error` | A malformed date, `to` before `from`, more than 31 days of slots or a modality the service does not have. When rescheduling: a time that already passed, outside business hours, or an appointment that is not confirmed. |
| `403` `missing_scope` | The key does not carry `agenda:read` or `agenda:write`. Keys issued before the scope existed do not carry it. |
| `403` `test_mode_restricted` | A `met_test_` key tried to write to the schedule. |
| `403` `workspace_mismatch` | The route's `workspaceId` is not the key's. |
| `404` `event_type_not_found` | The service does not exist, belongs to another workspace or is inactive. |
| `404` `contact_not_found` | The contact does not belong to this workspace. |
| `404` `booking_not_found` | The appointment does not exist in this workspace. When rescheduling it arrives as `not_found`. |
| `404` `resource_not_found` | When rescheduling, the resource does not handle that service in this workspace. |
| `409` `slot_unavailable` | When booking, the time is no longer free (or already passed, or does not match a slot). Ask for the slots again. |
| `409` `booking_exists` | The contact already has that appointment; the error carries its `booking_id`. |
| `409` `booking_not_confirmed` | Tried to cancel an appointment that is not confirmed. |
| `409` `conflict` | When rescheduling, the new time overlaps another appointment. |
