A subscribing workspace's own calendar - the same one their team sees under /calendar, and the same one a voice agent books into.

```ts
const { eventTypes } = await connect.scheduling.listEventTypes({ workspaceId });
const { slots, timezone } = await connect.scheduling.findSlots({
  workspaceId,
  eventTypeId: eventTypes[0].id,
  from,
  to,
});
const booking = await connect.scheduling.createBooking({
  workspaceId,
  eventTypeId: eventTypes[0].id,
  startAt: slots[0].startAt,
  attendee: { name: "Priya", phone: "+919000000001" },
});
```

**There is one calendar, not one per application.** A booking your integration makes is visible on the workspace's own calendar screen immediately, and a slot their receptionist just took is gone from your next `findSlots`. That is the point of reading it through Connect rather than keeping your own copy - two calendars double-book, and nothing can reconcile them after the fact.

`findSlots` is advisory by construction. Between reading it and booking, another caller can take any of those times, so `createBooking` re-checks inside a transaction and answers `409` if it has gone. Handle that as an ordinary outcome and offer the next slot; it is not an error condition.

Times are instants. `startAt` goes back exactly as `findSlots` gave it, and `timezone` is the *business's* zone - what to render the wall time in, which is rarely the viewer's.

## Endpoints

### GET `/api/v1/application/scheduling/schedules`

List a workspace's availability schedules

**Scope** `calendar:read` &nbsp;·&nbsp; **Rate limit** 60 requests / min per key

The working-hours templates an appointment type points at. Read this before offering to create one, so a workspace that already has hours set does not get a second "Working hours" from your integration.

#### Implementation
```bash
curl "https://connect.cosmoops.com/api/v1/application/scheduling/schedules?workspaceId=WORKSPACE_ID" \
  -H "Authorization: Bearer YOUR_API_KEY_PLAINTEXT"
```

#### Errors
`forbidden` (403), `rate_limited` (429).

**Parameters**

| Name | In | Required | Notes |
| --- | --- | --- | --- |
| `workspaceId` | query | yes |  |

**Responses**

| Status | Meaning |
| --- | --- |
| `200` | Availability schedules |
| `403` | forbidden |
| `429` | rate_limited |

**Response body**

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `schedules` | `array` | yes |  |

### POST `/api/v1/application/scheduling/schedules`

Create an availability schedule

**Scope** `calendar:write` &nbsp;·&nbsp; **Rate limit** 20 requests / min per key

A working-hours template, for a workspace that has none yet. Create at most one for a first-run setup flow - check `GET /scheduling/schedules` first.

#### Implementation
```bash
curl -X POST "https://connect.cosmoops.com/api/v1/application/scheduling/schedules" \
  -H "Authorization: Bearer YOUR_API_KEY_PLAINTEXT" \
  -H "Content-Type: application/json" \
  -d '{"workspaceId":"WORKSPACE_ID","name":"Working hours","timezone":"Asia/Kolkata","weeklyHours":{"0":[],"1":[{"startMinute":540,"endMinute":1020}],"2":[{"startMinute":540,"endMinute":1020}],"3":[{"startMinute":540,"endMinute":1020}],"4":[{"startMinute":540,"endMinute":1020}],"5":[{"startMinute":540,"endMinute":1020}],"6":[]}}'
```

This is deliberately narrower than what the workspace's own Calendar settings can do - no date overrides, and it is never owned by one person (`ownerMemberId` stays `null`). Anything beyond that is a Console edit, not a second write path for your integration to maintain.

#### Errors
`validation_error` (400 - an interval does not end after it starts, or overlaps another in the same day), `forbidden` (403), `rate_limited` (429).

**Request body**

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `workspaceId` | `string` | yes |  |
| `name` | `string` | yes |  |
| `timezone` | `string` | yes |  |
| `weeklyHours` | `object` | yes |  |

**Responses**

| Status | Meaning |
| --- | --- |
| `201` | The created schedule |
| `400` | validation_error |
| `403` | forbidden |
| `429` | rate_limited |

**Response body**

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `id` | `string` | yes |  |
| `name` | `string` | yes |  |
| `timezone` | `string` | yes |  |
| `weeklyHours` | `object` | yes |  |

### GET `/api/v1/application/scheduling/event-types`

List what a workspace offers as bookable

**Scope** `calendar:read` &nbsp;·&nbsp; **Rate limit** 60 requests / min per key

The workspace's own appointment types - the same ones their team configures under Calendar, and the same ones their voice agent books into. Start here: every other endpoint in this module takes an `eventTypeId` from this list.

#### Implementation

**1. Prerequisites** - an API key scoped to `calendar:read`, and **a live subscription to your application in that workspace**. The key proves which application is calling; the subscription is what makes you welcome in a given tenant. A workspace that has not installed your application answers `403`, with the same message as an unknown workspace id.

**2. Send the request:**
```bash
curl "https://connect.cosmoops.com/api/v1/application/scheduling/event-types?workspaceId=WORKSPACE_ID" \
  -H "Authorization: Bearer YOUR_API_KEY_PLAINTEXT"
```

**3. Active only.** An inactive appointment type is one the workspace has taken out of service; rendering it would offer something that cannot be booked.

`locationKind` is the kind and never the value - the address or the meeting link belongs to whoever booked, not to a catalogue.

#### Errors
`forbidden` (403 - key missing `calendar:read`, or your application is not installed in that workspace), `rate_limited` (429).

**Parameters**

| Name | In | Required | Notes |
| --- | --- | --- | --- |
| `workspaceId` | query | yes |  |

**Responses**

| Status | Meaning |
| --- | --- |
| `200` | Bookable appointment types |
| `403` | forbidden |
| `429` | rate_limited |

**Response body**

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `eventTypes` | `array` | yes |  |

### POST `/api/v1/application/scheduling/event-types`

Create an appointment type

**Scope** `calendar:write` &nbsp;·&nbsp; **Rate limit** 20 requests / min per key

The first thing a workspace can be booked for, for a workspace that has none yet. Needs a schedule - create or pick one with the `/scheduling/schedules` endpoints first.

#### Implementation
```bash
curl -X POST "https://connect.cosmoops.com/api/v1/application/scheduling/event-types" \
  -H "Authorization: Bearer YOUR_API_KEY_PLAINTEXT" \
  -H "Content-Type: application/json" \
  -d '{"workspaceId":"WORKSPACE_ID","name":"Consultation","durationMinutes":30,"hostMemberId":"MEMBER_ID","scheduleId":"SCHEDULE_ID","location":{"kind":"phone","value":null}}'
```

**Host is a specific member, never a team.** There is no groups read in this API, so `hostMemberId` must name one `GET /workspaces/members` row; a round-robin team host is a Console-only capability. The slug for a future public booking link is derived from `name` server-side - you cannot choose one.

Buffers default to 5 minutes either side; notice and booking-horizon rules default to the same 60/60 the Console form starts a new appointment type with, and are a Console edit from there.

#### Errors
`validation_error` (400), `forbidden` (403), `not_found` (404 - unknown `scheduleId` or `hostMemberId`), `rate_limited` (429).

**Request body**

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `workspaceId` | `string` | yes |  |
| `name` | `string` | yes |  |
| `description` | `any` | no |  |
| `durationMinutes` | `integer` | no |  |
| `bufferBeforeMinutes` | `integer` | no |  |
| `bufferAfterMinutes` | `integer` | no |  |
| `hostMemberId` | `string` | yes |  |
| `scheduleId` | `string` | yes |  |
| `location` | `object` | yes |  |
| `publicBookingEnabled` | `boolean` | no |  |

**Responses**

| Status | Meaning |
| --- | --- |
| `201` | The created appointment type |
| `400` | validation_error |
| `403` | forbidden |
| `404` | not_found |
| `429` | rate_limited |

**Response body**

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `id` | `string` | yes |  |
| `slug` | `string` | yes |  |
| `name` | `string` | yes |  |
| `description` | `any` | yes |  |
| `durationMinutes` | `integer` | yes |  |
| `locationKind` | `phone` \| `address` \| `link` | yes |  |

### GET `/api/v1/application/scheduling/slots`

Find free times for an appointment type

**Scope** `calendar:read` &nbsp;·&nbsp; **Rate limit** 60 requests / min per key

Free slots for one appointment type over one window, computed server-side from the workspace's working hours, its existing bookings and the appointment type's own buffers and notice rules.

#### Implementation

**1. Send the request** with an ISO-8601 `from` and `to`:
```bash
curl "https://connect.cosmoops.com/api/v1/application/scheduling/slots?workspaceId=WORKSPACE_ID&eventTypeId=EVENT_TYPE_ID&from=2026-09-08T00:00:00Z&to=2026-09-15T00:00:00Z" \
  -H "Authorization: Bearer YOUR_API_KEY_PLAINTEXT"
```

**2. The window is clamped**, silently and on both ends - to now, and to the appointment type's own booking horizon. Asking for a year of a sixty-day appointment type returns sixty days, not an error.

**3. `timezone` is the *business's* zone**, not yours and not your user's. It is what to render the wall time in; a slot shown in the viewer's zone is an hour the business may not be open.

**4. This answer is advisory.** Between reading it and booking, anyone else on that calendar - a member, a voice agent mid-call, someone on the workspace's public booking link - can take any of these. `POST /scheduling/bookings` re-checks inside a transaction, so treat its `409` as an ordinary outcome and offer the next slot.

#### Errors
`validation_error` (400 - missing `eventTypeId`, or `from` not earlier than `to`), `forbidden` (403), `not_found` (404 - no such appointment type in that workspace), `rate_limited` (429).

**Parameters**

| Name | In | Required | Notes |
| --- | --- | --- | --- |
| `workspaceId` | query | yes |  |
| `eventTypeId` | query | yes |  |
| `from` | query | yes |  |
| `to` | query | yes |  |

**Responses**

| Status | Meaning |
| --- | --- |
| `200` | Free slots |
| `400` | validation_error |
| `403` | forbidden |
| `404` | not_found |
| `429` | rate_limited |

**Response body**

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `timezone` | `string` | yes |  |
| `durationMinutes` | `integer` | yes |  |
| `slots` | `array` | yes |  |

### GET `/api/v1/application/scheduling/bookings`

List what is already booked

**Scope** `calendar:read` &nbsp;·&nbsp; **Rate limit** 60 requests / min per key

The workspace's diary, with the same filters their own team has: one host, a period, a status.

#### Implementation

```bash
curl "https://connect.cosmoops.com/api/v1/application/scheduling/bookings?workspaceId=WORKSPACE_ID&from=2026-09-08T00:00:00Z&order=asc&limit=5" \
  -H "Authorization: Bearer YOUR_API_KEY_PLAINTEXT"
```

**`order` follows what you are asking for.** `asc` with a `from` is an upcoming diary; the default `desc` is a history. An upcoming list read newest-first shows next year before tomorrow.

**`source` is the field worth rendering.** It says whether a booking came from your integration (`api`), from the workspace's own screen or public link (`portal`), or from a voice agent during a call (`agent-call`) - which is usually the first question anyone asks about an appointment they did not expect.

Page with `cursor` from `nextCursor`. A cursor belongs to one particular set of filters; changing a filter means starting again.

#### Errors
`validation_error` (400 - `limit` outside 1..100), `forbidden` (403), `rate_limited` (429).

**Parameters**

| Name | In | Required | Notes |
| --- | --- | --- | --- |
| `workspaceId` | query | yes |  |
| `status` | query | no |  |
| `hostMemberId` | query | no |  |
| `from` | query | no |  |
| `to` | query | no |  |
| `order` | query | no |  |
| `cursor` | query | no |  |
| `limit` | query | no |  |

**Responses**

| Status | Meaning |
| --- | --- |
| `200` | One page of bookings |
| `400` | validation_error |
| `403` | forbidden |
| `429` | rate_limited |

**Response body**

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `bookings` | `array` | yes |  |
| `nextCursor` | `any` | yes |  |
| `hasMore` | `boolean` | yes |  |
| `totalCount` | `integer` | yes |  |

### GET `/api/v1/application/scheduling/bookings/{bookingId}`

Read one booking you stored the id of

**Scope** `calendar:read` &nbsp;·&nbsp; **Rate limit** 60 requests / min per key

One appointment, by id. This is the read to make when you kept a booking id on a record of your own - a meeting on a deal, an appointment on a case.

#### Implementation

```bash
curl "https://connect.cosmoops.com/api/v1/application/scheduling/bookings/BOOKING_ID?workspaceId=WORKSPACE_ID" \
  -H "Authorization: Bearer YOUR_API_KEY_PLAINTEXT"
```

**`GET /scheduling/bookings` cannot answer this.** That is a window over `startAt`, and the `startAt` you cached is precisely what is wrong once someone has moved the appointment on the workspace's own calendar - so the window you build from it comes back without the row, in the one case worth re-reading for.

**A retired booking still resolves, deliberately.** `status: "rescheduled"` answers with `rescheduledToId`, so an id you stored months ago leads to the appointment that exists today - follow the pointer. `cancelled` answers with `cancelledReason`. A `404` means this workspace never had that booking, which is a different fact from "it moved".

**`hostMemberIds`** - and the `hostMemberId` filter - are `memberId` values from `GET /workspaces/members`, **not `userId`**. So showing who an appointment belongs to, or narrowing the diary to one person, is a lookup against a roster you already hold; matching on `userId` finds nothing and fails silently, because a filter nobody matches reads as a free calendar rather than an error.

#### Errors
`validation_error` (400), `forbidden` (403), `not_found` (404 - no such booking in that workspace), `rate_limited` (429).

**Parameters**

| Name | In | Required | Notes |
| --- | --- | --- | --- |
| `bookingId` | path | yes |  |
| `workspaceId` | query | yes |  |

**Responses**

| Status | Meaning |
| --- | --- |
| `200` | The booking |
| `400` | validation_error |
| `403` | forbidden |
| `404` | not_found |
| `429` | rate_limited |

**Response body**

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `id` | `string` | yes |  |
| `eventTypeId` | `string` | yes |  |
| `hostMemberIds` | `array` | yes |  |
| `status` | `hold` \| `confirmed` \| `cancelled` \| `rescheduled` \| `no_show` | yes |  |
| `startAt` | `string` | yes |  |
| `endAt` | `string` | yes |  |
| `timezone` | `string` | yes |  |
| `attendee` | `object` | yes |  |
| `notes` | `any` | yes |  |
| `source` | `agent-call` \| `portal` \| `api` | yes |  |
| `sourceCallId` | `any` | yes |  |
| `holdExpiresAt` | `any` | yes |  |
| `cancelledReason` | `any` | yes |  |
| `rescheduledToId` | `any` | yes |  |
| `createdAt` | `string` | yes |  |

### POST `/api/v1/application/scheduling/bookings`

Book a slot

**Scope** `calendar:write` &nbsp;·&nbsp; **Rate limit** 20 requests / min per key

Takes one of the times `GET /scheduling/slots` offered. Holds and confirms in a single call - you already have the attendee, so there is no half-finished state for your integration to remember.

#### Implementation

**1. Prerequisite** - an API key scoped to `calendar:write`. Split from `calendar:read` the same way `drive:write` is from `drive:read`: a key that renders "your next appointment" beside a contact has no business taking slots.

**2. Pass `startAt` back exactly as `/slots` gave it.** It is an instant, and re-deriving it from a local wall time is how an integration books an hour that was never offered.

```bash
curl -X POST "https://connect.cosmoops.com/api/v1/application/scheduling/bookings" \
  -H "Authorization: Bearer YOUR_API_KEY_PLAINTEXT" \
  -H "Content-Type: application/json" \
  -d '{"workspaceId":"WORKSPACE_ID","eventTypeId":"EVENT_TYPE_ID","startAt":"2026-09-08T04:30:00.000Z","attendee":{"name":"Priya","phone":"+919000000001"}}'
```

**3. Handle `409` as a normal outcome.** The slot is re-checked inside a transaction, so a time taken since you read it is refused rather than double-booked. Read the slots again and offer the next one; the message is written to be shown to whoever is booking.

The booking appears on the workspace's own calendar immediately, tagged `source: "api"` - or `"agent-call"` with your own `sourceCallId`, when an AI agent you run made it on a call. No contact is linked - naming one would mean naming a row in Connect's contact graph you cannot see.

#### Errors
`validation_error` (400), `forbidden` (403), `not_found` (404 - unknown appointment type), `conflict` (409 - the slot was taken, or is outside the appointment type's rules), `rate_limited` (429).

**Request body**

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `workspaceId` | `string` | yes |  |
| `eventTypeId` | `string` | yes |  |
| `startAt` | `string` | yes |  |
| `attendee` | `object` | yes |  |
| `notes` | `any` | no |  |
| `source` | `api` \| `agent-call` | no |  |
| `sourceCallId` | `any` | no |  |

**Responses**

| Status | Meaning |
| --- | --- |
| `201` | The confirmed booking |
| `400` | validation_error |
| `403` | forbidden |
| `404` | not_found |
| `409` | conflict - the slot was taken |
| `429` | rate_limited |

**Response body**

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `id` | `string` | yes |  |
| `eventTypeId` | `string` | yes |  |
| `hostMemberIds` | `array` | yes |  |
| `status` | `hold` \| `confirmed` \| `cancelled` \| `rescheduled` \| `no_show` | yes |  |
| `startAt` | `string` | yes |  |
| `endAt` | `string` | yes |  |
| `timezone` | `string` | yes |  |
| `attendee` | `object` | yes |  |
| `notes` | `any` | yes |  |
| `source` | `agent-call` \| `portal` \| `api` | yes |  |
| `sourceCallId` | `any` | yes |  |
| `holdExpiresAt` | `any` | yes |  |
| `cancelledReason` | `any` | yes |  |
| `rescheduledToId` | `any` | yes |  |
| `createdAt` | `string` | yes |  |

### POST `/api/v1/application/scheduling/holds`

Hold a slot before you know who it is for

**Scope** `calendar:write` &nbsp;·&nbsp; **Rate limit** 20 requests / min per key

Phase one of the two-phase booking, for a caller that learns the attendee only after the time is picked - a voice agent reading three options aloud, then asking for a name. If you already have the attendee, use `POST /scheduling/bookings` instead.

#### Implementation
```bash
curl -X POST "https://connect.cosmoops.com/api/v1/application/scheduling/holds" \
  -H "Authorization: Bearer YOUR_API_KEY_PLAINTEXT" \
  -H "Content-Type: application/json" \
  -d '{"workspaceId":"WORKSPACE_ID","eventTypeId":"EVENT_TYPE_ID","startAt":"2026-09-08T04:30:00.000Z","source":"agent-call","sourceCallId":"CALL_ID"}'
```

The hold is a real `hold` booking on the workspace's calendar, written in the same transaction that re-checks the slot - so from this moment nobody else can take it, including the workspace's own public booking page. That is the race a one-shot booking cannot close while a caller is still spelling their name.

It lapses at `holdExpiresAt` (3 minutes by default, `holdTtlMinutes` up to 30) and frees the slot the instant it does. Confirm it with `POST /scheduling/bookings/{bookingId}/confirm`; only your application can.

`source` is `agent-call` by default here and `sourceCallId` is your own call id, returned on every read of the booking.

#### Errors
`validation_error` (400), `forbidden` (403), `not_found` (404 - unknown appointment type), `conflict` (409 - the slot was taken, or is not one `/slots` offers), `rate_limited` (429).

**Request body**

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `workspaceId` | `string` | yes |  |
| `eventTypeId` | `string` | yes |  |
| `startAt` | `string` | yes |  |
| `holdTtlMinutes` | `integer` | no |  |
| `source` | `api` \| `agent-call` | no |  |
| `sourceCallId` | `any` | no |  |

**Responses**

| Status | Meaning |
| --- | --- |
| `201` | The hold - a booking with status hold |
| `400` | validation_error |
| `403` | forbidden |
| `404` | not_found |
| `409` | conflict - the slot was taken |
| `429` | rate_limited |

**Response body**

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `id` | `string` | yes |  |
| `eventTypeId` | `string` | yes |  |
| `hostMemberIds` | `array` | yes |  |
| `status` | `hold` \| `confirmed` \| `cancelled` \| `rescheduled` \| `no_show` | yes |  |
| `startAt` | `string` | yes |  |
| `endAt` | `string` | yes |  |
| `timezone` | `string` | yes |  |
| `attendee` | `object` | yes |  |
| `notes` | `any` | yes |  |
| `source` | `agent-call` \| `portal` \| `api` | yes |  |
| `sourceCallId` | `any` | yes |  |
| `holdExpiresAt` | `any` | yes |  |
| `cancelledReason` | `any` | yes |  |
| `rescheduledToId` | `any` | yes |  |
| `createdAt` | `string` | yes |  |

### POST `/api/v1/application/scheduling/bookings/{bookingId}/confirm`

Confirm a hold

**Scope** `calendar:write` &nbsp;·&nbsp; **Rate limit** 20 requests / min per key

Phase two: a hold from `POST /scheduling/holds` becomes a booking, and the attendee is told they are booked - the same confirmation, WhatsApp consent and `booking.confirmed` webhook as a booking made inside Connect.

#### Implementation
```bash
curl -X POST "https://connect.cosmoops.com/api/v1/application/scheduling/bookings/BOOKING_ID/confirm" \
  -H "Authorization: Bearer YOUR_API_KEY_PLAINTEXT" \
  -H "Content-Type: application/json" \
  -d '{"workspaceId":"WORKSPACE_ID","attendee":{"name":"Priya","phone":"+919000000001"}}'
```

**Idempotent.** Confirming a booking that is already confirmed answers it unchanged and notifies nobody, so a retry after a dropped response is safe. Do not tell the caller they are booked until this has returned.

Only a hold your application took resolves; any other id answers `404`.

#### Errors
`validation_error` (400), `forbidden` (403), `not_found` (404 - no such hold for your application), `conflict` (409 - the hold lapsed or was cancelled; hold a slot again), `rate_limited` (429).

**Parameters**

| Name | In | Required | Notes |
| --- | --- | --- | --- |
| `bookingId` | path | yes |  |

**Request body**

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `workspaceId` | `string` | yes |  |
| `attendee` | `object` | yes |  |
| `notes` | `any` | no |  |

**Responses**

| Status | Meaning |
| --- | --- |
| `200` | The confirmed booking |
| `400` | validation_error |
| `403` | forbidden |
| `404` | not_found |
| `409` | conflict - the hold lapsed |
| `429` | rate_limited |

**Response body**

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `id` | `string` | yes |  |
| `eventTypeId` | `string` | yes |  |
| `hostMemberIds` | `array` | yes |  |
| `status` | `hold` \| `confirmed` \| `cancelled` \| `rescheduled` \| `no_show` | yes |  |
| `startAt` | `string` | yes |  |
| `endAt` | `string` | yes |  |
| `timezone` | `string` | yes |  |
| `attendee` | `object` | yes |  |
| `notes` | `any` | yes |  |
| `source` | `agent-call` \| `portal` \| `api` | yes |  |
| `sourceCallId` | `any` | yes |  |
| `holdExpiresAt` | `any` | yes |  |
| `cancelledReason` | `any` | yes |  |
| `rescheduledToId` | `any` | yes |  |
| `createdAt` | `string` | yes |  |

### POST `/api/v1/application/scheduling/bookings/{bookingId}/reschedule`

Move a booking to a different time

**Scope** `calendar:write` &nbsp;·&nbsp; **Rate limit** 20 requests / min per key

Moves an appointment, keeping the same appointment type and the same attendee.

**The original row is not edited - it is retired.** It stays on the workspace's diary as `rescheduled`, with `rescheduledToId` pointing at the booking that replaced it, and the response hands you both. So a booking id your integration cached still resolves, and still tells you where the appointment went; "we moved you twice" is a chain anyone can follow.

#### Implementation

```bash
curl -X POST "https://connect.cosmoops.com/api/v1/application/scheduling/bookings/BOOKING_ID/reschedule" \
  -H "Authorization: Bearer YOUR_API_KEY_PLAINTEXT" \
  -H "Content-Type: application/json" \
  -d '{"workspaceId":"WORKSPACE_ID","startAt":"2026-09-09T05:00:00.000Z"}'
```

**Read the new times with `excludeBookingId` set to this booking.** `GET /scheduling/slots` otherwise counts the appointment as blocking its own replacement, and the adjustment people actually make - same day, half an hour later - comes back with nothing available.

**A refused move changes nothing.** The new slot is claimed before the old one is retired, so a `409` leaves the customer holding the appointment they already had. Re-read the slots and offer another.

The attendee carries over unchanged; correcting a name is not what this endpoint is for. `notes` replaces the original note when given.

#### Errors
`validation_error` (400), `forbidden` (403), `not_found` (404 - no such booking in that workspace), `conflict` (409 - the new time was taken, or the booking is cancelled or already moved), `rate_limited` (429).

**Parameters**

| Name | In | Required | Notes |
| --- | --- | --- | --- |
| `bookingId` | path | yes |  |

**Request body**

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `workspaceId` | `string` | yes |  |
| `startAt` | `string` | yes |  |
| `notes` | `any` | no |  |

**Responses**

| Status | Meaning |
| --- | --- |
| `200` | The new booking, and the retired original |
| `400` | validation_error |
| `403` | forbidden |
| `404` | not_found |
| `409` | conflict - the new time was taken |
| `429` | rate_limited |

**Response body**

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `booking` | `object` | yes |  |
| `previous` | `object` | yes |  |

### POST `/api/v1/application/scheduling/bookings/{bookingId}/cancel`

Cancel a booking

**Scope** `calendar:write` &nbsp;·&nbsp; **Rate limit** 20 requests / min per key

Frees the slot and records why.

A `POST` rather than a `DELETE`, because cancelling is not deleting: the booking stays on the workspace's diary as `cancelled` with your `reason`, which is what makes a late cancellation or a no-show something anyone can look up afterwards.

```bash
curl -X POST "https://connect.cosmoops.com/api/v1/application/scheduling/bookings/BOOKING_ID/cancel" \
  -H "Authorization: Bearer YOUR_API_KEY_PLAINTEXT" \
  -H "Content-Type: application/json" \
  -d '{"workspaceId":"WORKSPACE_ID","reason":"Customer rescheduled by phone"}'
```

The time becomes bookable again on the next `/scheduling/slots` read.

#### Errors
`validation_error` (400), `forbidden` (403), `not_found` (404 - no such booking in that workspace), `rate_limited` (429).

**Parameters**

| Name | In | Required | Notes |
| --- | --- | --- | --- |
| `bookingId` | path | yes |  |

**Request body**

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `workspaceId` | `string` | yes |  |
| `reason` | `any` | no |  |

**Responses**

| Status | Meaning |
| --- | --- |
| `200` | The cancelled booking |
| `400` | validation_error |
| `403` | forbidden |
| `404` | not_found |
| `429` | rate_limited |

**Response body**

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `id` | `string` | yes |  |
| `eventTypeId` | `string` | yes |  |
| `hostMemberIds` | `array` | yes |  |
| `status` | `hold` \| `confirmed` \| `cancelled` \| `rescheduled` \| `no_show` | yes |  |
| `startAt` | `string` | yes |  |
| `endAt` | `string` | yes |  |
| `timezone` | `string` | yes |  |
| `attendee` | `object` | yes |  |
| `notes` | `any` | yes |  |
| `source` | `agent-call` \| `portal` \| `api` | yes |  |
| `sourceCallId` | `any` | yes |  |
| `holdExpiresAt` | `any` | yes |  |
| `cancelledReason` | `any` | yes |  |
| `rescheduledToId` | `any` | yes |  |
| `createdAt` | `string` | yes |  |

### POST `/api/v1/application/scheduling/bookings/{bookingId}/attendance`

Record whether the attendee turned up

**Scope** `calendar:write` &nbsp;·&nbsp; **Rate limit** 20 requests / min per key

`attended: false` marks the booking `no_show`; `true` takes a no-show back to `confirmed`, for the one marked by mistake.

There is no "held" status to set: a `confirmed` booking whose time has passed already is a held one. The slot is not released either way - the time was reserved and consumed - and the attendee is not notified.

```bash
curl -X POST "https://connect.cosmoops.com/api/v1/application/scheduling/bookings/BOOKING_ID/attendance" \
  -H "Authorization: Bearer YOUR_API_KEY_PLAINTEXT" \
  -H "Content-Type: application/json" \
  -d '{"workspaceId":"WORKSPACE_ID","attended":false}'
```

#### Errors
`validation_error` (400), `forbidden` (403), `not_found` (404 - no such booking in that workspace), `conflict` (409 - the booking is not `confirmed` or `no_show`, or has not started yet), `rate_limited` (429).

**Parameters**

| Name | In | Required | Notes |
| --- | --- | --- | --- |
| `bookingId` | path | yes |  |

**Request body**

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `workspaceId` | `string` | yes |  |
| `attended` | `boolean` | yes |  |

**Responses**

| Status | Meaning |
| --- | --- |
| `200` | The booking, with its new status |
| `400` | validation_error |
| `403` | forbidden |
| `404` | not_found |
| `409` | conflict - not attendable, or not started |
| `429` | rate_limited |

**Response body**

| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `id` | `string` | yes |  |
| `eventTypeId` | `string` | yes |  |
| `hostMemberIds` | `array` | yes |  |
| `status` | `hold` \| `confirmed` \| `cancelled` \| `rescheduled` \| `no_show` | yes |  |
| `startAt` | `string` | yes |  |
| `endAt` | `string` | yes |  |
| `timezone` | `string` | yes |  |
| `attendee` | `object` | yes |  |
| `notes` | `any` | yes |  |
| `source` | `agent-call` \| `portal` \| `api` | yes |  |
| `sourceCallId` | `any` | yes |  |
| `holdExpiresAt` | `any` | yes |  |
| `cancelledReason` | `any` | yes |  |
| `rescheduledToId` | `any` | yes |  |
| `createdAt` | `string` | yes |  |