Scheduling and bookings
Look up a service's free slots, book appointments for your contacts, move or cancel them, and hear about every change by webhook.
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.
import { Met } from '@meteor.ia/sdk';
const met = new Met(process.env.METEOR_API_KEY!, { workspaceId: 42 });import os
from meteor_ia import Met
met = Met(os.environ["METEOR_API_KEY"], workspace_id=42)1. What can be booked
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' }]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:
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.' }]slots = met.agenda.slots(event_type_id=3, from_="2026-11-03", to="2026-11-07")["data"]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:
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, … }appointment = met.agenda.bookings.create(
event_type_id=3,
contact_id=4821,
starts_at=slots[0]["starts_at"],
resource_id=slots[0]["resource_id"],
)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_idis a contact in this workspace's CRM (see CRM and contacts). One that does not exist or belongs to another workspace responds404contact_not_found.- Without
resource_id, the appointment goes to the first resource that is free at that time. modalityis only needed if the service isboth; if the service is only virtual or only in person, sending the other one responds400validation_error.- Before writing, Met recalculates that day's slots. If someone took the time in the meantime, it responds
409slot_unavailableand creates nothing: ask for the slots again. - If the contact already has a confirmed appointment for that service at that time, it responds
409booking_existswith thebooking_idof the existing one. - The SDK sends
Idempotency-Keyon 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
// 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);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
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' });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
409booking_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):
| 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. |
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.
{
"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. |