# API reference

Every endpoint of the v1 API. Field names and shapes match Cronofy’s for the endpoints both have; the few additions are listed under [Migrating from Cronofy](https://calmonkey.com/docs/cronofy.md). Examples use made-up ids in the real formats.

<a id="basics"></a>

## Basics

- Base URL of the API: `https://api.calmonkey.com`. `/oauth/authorize` is on `https://app.calmonkey.com`, because a browser opens it.
- Bodies are JSON (`Content-Type: application/json`). The OAuth endpoints and `/v1/application_calendars` also take `application/x-www-form-urlencoded`.
- Booleans in query strings are `true`, `false`, `1` or `0`. Repeated parameters use brackets: `calendar_ids[]=a&calendar_ids[]=b`.
- Ids: accounts `acc_…` (application calendars `apc_…`), profiles `pro_…`, calendars `cal_…`, channels `chn_…`, events `evt_…`. Treat them as opaque strings.
- Errors and rate limits: [Errors and limits](https://calmonkey.com/docs/errors.md).

<a id="authorize"></a>

## GET /oauth/authorize

**GET** `https://app.calmonkey.com/oauth/authorize`

Opens the hosted connect page in the user’s browser. Not called by your server. Parameters and callbacks are described in the [quickstart](https://calmonkey.com/docs/quickstart.md#connect).

| Name | Type | Description |
| --- | --- | --- |
| `response_type` (required) | `string` | `code`. Anything else returns `error=unsupported_response_type` to your redirect URI. |
| `client_id` (required) | `string` | Your client id. |
| `redirect_uri` (required) | `string` | A registered redirect URI, exactly. |
| `scope` | `string` | Default `read_write`. A malformed scope returns `error=invalid_scope`. |
| `state` | `string` | Returned unchanged. |
| `provider_name` | `string` | `google`, `office365`, `live_connect` or `apple`: go straight to that provider. |
| `link_token` | `string` | Adds the connected calendar account to an existing account ([link tokens](#link-tokens)). |

Back at your redirect URI: `?code=…&state=…`, or `?error=access_denied&state=…` when the user cancels, declines, or the calendar account is already connected to another account of your application.

<a id="token"></a>

## POST /oauth/token

**POST** `https://api.calmonkey.com/oauth/token`

**Authentication:** Your client id and client secret, in the request body.

<a id="token-code"></a>

### grant_type=authorization_code

| Name | Type | Description |
| --- | --- | --- |
| `client_id` (required) | `string` | Your client id. |
| `client_secret` (required) | `string` | Your client secret. |
| `grant_type` (required) | `string` | `authorization_code` |
| `code` (required) | `string` | The code from the callback. Valid once, for 10 minutes. |
| `redirect_uri` (required) | `string` | The same redirect URI the authorization request used. |

200 OK

```json
{
  "token_type": "bearer",
  "access_token": "cmat_Tz5vTHRrZ5QIIK0Ng0zRJCkt9ZqmjBrNn1mv7x4DbGg",
  "expires_in": 10800,
  "refresh_token": "cmrt_D0L78QVguUuBnvOcex1VPdSP0uPRYOCtQMGeFi8Qku8",
  "scope": "read_write",
  "account_id": "acc_38da51e7b1345bb5fae3656a",
  "sub": "acc_38da51e7b1345bb5fae3656a",
  "linking_profile": {
    "provider_name": "google",
    "profile_id": "pro_7782a96ff1609061631ebc5b",
    "profile_name": "dana@example.com"
  }
}
```

| Name | Type | Description |
| --- | --- | --- |
| `access_token` | `string` | For `Authorization: Bearer`. Valid for `expires_in` seconds (10,800: 3 hours). |
| `refresh_token` | `string` | Gets new access tokens. Does not rotate. |
| `scope` | `string` | `read_write` |
| `sub / account_id` | `string` | The account (acc\_…). The same value under both names. |
| `linking_profile` | `object` | The calendar account connected in this run: `provider_name`, `profile_id`, `profile_name`. |

<a id="token-refresh"></a>

### grant_type=refresh_token

| Name | Type | Description |
| --- | --- | --- |
| `client_id` (required) | `string` | Your client id. |
| `client_secret` (required) | `string` | Your client secret. |
| `grant_type` (required) | `string` | `refresh_token` |
| `refresh_token` (required) | `string` | The account's refresh token. |

200 OK

```json
{
  "token_type": "bearer",
  "access_token": "cmat_8yWq0eYb2Hn6R1sVdKp3LmXc5ZtA9uGf4JoQiNwE7Bv",
  "expires_in": 10800,
  "refresh_token": "cmrt_D0L78QVguUuBnvOcex1VPdSP0uPRYOCtQMGeFi8Qku8",
  "scope": "read_write"
}
```

Errors are `400` with `{"error": "…"}`: `invalid_client` (wrong client id or secret), `invalid_grant` (code used, expired or for another redirect URI; refresh token revoked), `invalid_request` (a field is missing), `unsupported_grant_type`.

<a id="revoke"></a>

## POST /oauth/token/revoke

**POST** `https://api.calmonkey.com/oauth/token/revoke`

**Authentication:** Your client id and client secret, in the request body.

| Name | Type | Description |
| --- | --- | --- |
| `client_id` (required) | `string` | Your client id. |
| `client_secret` (required) | `string` | Your client secret. |
| `token` (required) | `string` | An access token or a refresh token of the account. |

`200`, empty body, whether or not the token was known. Revokes the whole grant: every token of the account, its channels, its stored provider credentials and its cached events. An application calendar is deleted with it; opening the same `application_calendar_id` again gives a new, empty calendar.

<a id="application-calendars"></a>

## POST /v1/application_calendars

**POST** `https://api.calmonkey.com/v1/application_calendars`

**Authentication:** Your client id and client secret, in the request body. Wrong credentials answer 401 with an empty body.

| Name | Type | Description |
| --- | --- | --- |
| `client_id` (required) | `string` | Your client id. |
| `client_secret` (required) | `string` | Your client secret. |
| `application_calendar_id` (required) | `string` | Your name for the calendar, up to 255 characters. The same id always opens the same calendar. |

200 OK

```json
{
  "token_type": "bearer",
  "access_token": "cmat_3kVb7Qx1Lr9Zs5Hd0Wn2Pc8Fy4Mj6Tg1Ae5Ku7Oi3Ys0",
  "expires_in": 10800,
  "refresh_token": "cmrt_9Xc2Vb6Nm1Lk4Jh8Gf3Ds7Aq0Pw5Oe2Iu9Yt6Rr1Ee4",
  "scope": "read_write",
  "account_id": "apc_c4a4b70dce47c34ad4d384f8",
  "sub": "apc_c4a4b70dce47c34ad4d384f8",
  "linking_profile": {
    "provider_name": "calmonkey",
    "profile_id": "pro_1b9e64c0d2a7f3e85c6d4a21",
    "profile_name": "test-calendar-1"
  },
  "application_calendar_id": "test-calendar-1"
}
```

Creates the calendar on first use and returns tokens for it: a token response with `sub` `apc_…` and your `application_calendar_id`. Each call returns a new token pair (in Cronofy compatibility mode, the pair the calendar already has while it is valid). The account has one profile and one writable, primary calendar.

<a id="account"></a>

## GET /v1/account

**GET** `https://api.calmonkey.com/v1/account`

**Authentication:** `Authorization: Bearer <access_token>`

200 OK

```json
{
  "account": {
    "account_id": "acc_38da51e7b1345bb5fae3656a",
    "type": "account",
    "email": "dana@example.com",
    "name": "",
    "scope": "read_write",
    "default_tzid": "Etc/UTC"
  }
}
```

`type` is `account`, or `application_calendar` for an application calendar.

<a id="userinfo"></a>

## GET /v1/userinfo

**GET** `https://api.calmonkey.com/v1/userinfo`

**Authentication:** `Authorization: Bearer <access_token>`

Who the token belongs to, with every connected profile and its calendars.

200 OK

```json
{
  "sub": "acc_38da51e7b1345bb5fae3656a",
  "email": "dana@example.com",
  "zoneinfo": "Etc/UTC",
  "calmonkey.type": "account",
  "calmonkey.data": {
    "authorization": { "scope": "read_write", "status": "active" },
    "profiles": [
      {
        "provider_name": "google",
        "provider_service": "gmail",
        "profile_id": "pro_7782a96ff1609061631ebc5b",
        "profile_name": "dana@example.com",
        "profile_connected": true,
        "profile_initial_sync_required": false,
        "profile_calendars": [
          {
            "calendar_id": "cal_89645320138ae1ac547189a1",
            "calendar_name": "dana@example.com",
            "calendar_readonly": false,
            "calendar_deleted": false,
            "calendar_primary": true,
            "calendar_integrated_conferencing_available": true,
            "calendar_attachments_available": false,
            "permission_level": "unrestricted"
          }
        ]
      }
    ]
  }
}
```

For an application calendar, `application_calendar_id` takes the place of `email`, and the data includes `application_calendar`. In [Cronofy compatibility mode](https://calmonkey.com/docs/cronofy.md) the keys are `cronofy.type` and `cronofy.data`.

<a id="profiles"></a>

## GET /v1/profiles

**GET** `https://api.calmonkey.com/v1/profiles`

**Authentication:** `Authorization: Bearer <access_token>`

200 OK

```json
{
  "profiles": [
    {
      "provider_name": "office365",
      "provider_service": "office365",
      "profile_id": "pro_7782a96ff1609061631ebc5b",
      "profile_name": "dana@contoso.example",
      "profile_connected": false,
      "profile_relink_url": null,
      "profile_initial_sync_required": false,
      "profile_calendars": [
        {
          "calendar_id": "cal_89645320138ae1ac547189a1",
          "calendar_name": "Calendar",
          "calendar_readonly": false,
          "calendar_deleted": false,
          "calendar_primary": true,
          "calendar_integrated_conferencing_available": true,
          "calendar_attachments_available": false,
          "permission_level": "unrestricted"
        }
      ]
    }
  ]
}
```

- `profile_connected: false` means the person has to connect that calendar account again (the provider refused our access, or the password was revoked). Send them through `/oauth/authorize` with the same `provider_name`: the same profile, calendars and account come back.
- `provider_service`: `gmail`, `gsuite`, `office365`, `outlook_com`, `icloud`, or `calmonkey` for an application calendar.
- `profile_relink_url` is `null`: a profile is reconnected through `/oauth/authorize`, as above.
- `profile_initial_sync_required` is true until the profile’s first sync has finished.

<a id="calendars"></a>

## GET /v1/calendars

**GET** `https://api.calmonkey.com/v1/calendars`

**Authentication:** `Authorization: Bearer <access_token>`

200 OK

```json
{
  "calendars": [
    {
      "provider_name": "google",
      "profile_id": "pro_7782a96ff1609061631ebc5b",
      "profile_name": "dana@example.com",
      "calendar_id": "cal_89645320138ae1ac547189a1",
      "calendar_name": "dana@example.com",
      "calendar_readonly": false,
      "calendar_deleted": false,
      "calendar_primary": true,
      "calendar_integrated_conferencing_available": true,
      "calendar_attachments_available": false,
      "permission_level": "unrestricted"
    },
    {
      "provider_name": "google",
      "profile_id": "pro_7782a96ff1609061631ebc5b",
      "profile_name": "dana@example.com",
      "calendar_id": "cal_2f6c8e1a9b3d4f5a6b7c8d9e",
      "calendar_name": "Public holidays",
      "calendar_readonly": true,
      "calendar_deleted": false,
      "calendar_primary": false,
      "calendar_integrated_conferencing_available": false,
      "calendar_attachments_available": false,
      "permission_level": "unrestricted"
    }
  ],
  "sub": "acc_38da51e7b1345bb5fae3656a"
}
```

Calendars deleted at the provider stay in the list with `calendar_deleted: true`. `calendar_integrated_conferencing_available` is true on a calendar that adds a meeting link of its own, so [`conferencing.profile_id: "integrated"`](#conferencing) is accepted there: a Google calendar, and a Microsoft calendar that allows online meetings, when your application can write to it ([provider table](https://calmonkey.com/docs/providers.md#features)). `calendar_attachments_available` is false for every calendar.

<a id="free-busy"></a>

## GET /v1/free_busy

**GET** `https://api.calmonkey.com/v1/free_busy`

**Authentication:** `Authorization: Bearer <access_token>`

| Name | Type | Description |
| --- | --- | --- |
| `tzid` (required) | `string` | Time zone the request’s dates are read in, e.g. `Australia/Sydney`. |
| `from` | `date or time` | Start, a day (`2026-11-03`) in `tzid` or a time with an offset. Defaults to 42 days ago. |
| `to` | `date or time` | End, exclusive. Defaults to 201 days from now. |
| `calendar_ids[]` | `string, repeatable` | Only these calendars (up to 100). Default: every calendar of the account. |
| `include_managed` | `boolean` | Include events your application created. Default `false`. |
| `include_ids` | `boolean` | Add `event_id` to the periods of events your application created. |
| `localized_times` | `boolean` | Times with the offset of `tzid` instead of UTC. |

200 OK

```json
{
  "pages": { "current": 1, "total": 1 },
  "free_busy": [
    {
      "calendar_id": "cal_89645320138ae1ac547189a1",
      "event_uid": "evt_d589a60f0b07d0e721482ee2",
      "start": "2026-11-02T22:00:00Z",
      "end": "2026-11-02T23:30:00Z",
      "free_busy_status": "busy"
    },
    {
      "calendar_id": "cal_89645320138ae1ac547189a1",
      "event_uid": "evt_6a1f2c3d4e5b6a7c8d9e0f12",
      "start": "2026-11-03T03:00:00Z",
      "end": "2026-11-03T04:00:00Z",
      "free_busy_status": "busy",
      "event_id": "booking-1042"
    }
  ]
}
```

| Name | Type | Description |
| --- | --- | --- |
| `calendar_id` | `string` | The calendar of the event. |
| `event_uid` | `string` | Identifies the event (evt\_…). |
| `start / end` | `string` | Times in UTC (or local with localized_times), or dates for all-day events with an exclusive end. Not clipped to the window. |
| `free_busy_status` | `string` | `busy`, `tentative` or `free` (an event shown as free). |
| `event_id` | `string` | Only with `include_ids=true`, only on your application’s own events: the id you chose. |

A repeating event gives one period for each occurrence in the window. Invitations the person declined are never busy and are left out.

Up to 1,000 periods per page. When there are more, `pages.next_page` is an absolute URL for the next page: request it with the same token, as it is.

<a id="events-read"></a>

## GET /v1/events

**GET** `https://api.calmonkey.com/v1/events`

**Authentication:** `Authorization: Bearer <access_token>`

| Name | Type | Description |
| --- | --- | --- |
| `tzid` (required) | `string` | Time zone the request’s dates are read in, e.g. `Australia/Sydney`. |
| `from` | `date or time` | Start, a day (`2026-11-03`) in `tzid` or a time with an offset. Defaults to 42 days ago. |
| `to` | `date or time` | End, exclusive. Defaults to 201 days from now. |
| `calendar_ids[]` | `string, repeatable` | Only these calendars (up to 100). Default: every calendar of the account. |
| `include_managed` | `boolean` | Include events your application created. Default `false`. |
| `only_managed` | `boolean` | Only the events your application created. |
| `include_deleted` | `boolean` | Include events deleted since they were cached (`deleted: true`). |
| `last_modified` | `time` | Only events changed at or after this time. Use a change notification’s `changes_since`. |
| `localized_times` | `boolean` | Times with the offset of `tzid` instead of UTC. |

200 OK

```json
{
  "pages": { "current": 1, "total": 1 },
  "events": [
    {
      "calendar_id": "cal_89645320138ae1ac547189a1",
      "event_uid": "evt_6a1f2c3d4e5b6a7c8d9e0f12",
      "event_id": "booking-1042",
      "summary": "Lash lift with Grace",
      "description": "Booked through Your App",
      "start": "2026-11-03T03:00:00Z",
      "end": "2026-11-03T04:00:00Z",
      "deleted": false,
      "created": "2026-10-20T01:12:09Z",
      "updated": "2026-10-20T01:12:09Z",
      "location": { "description": "12 Harbour St, Sydney" },
      "url": "https://yourapp.example/bookings/1042",
      "participation_status": "accepted",
      "attendees": [],
      "transparency": "opaque",
      "extended_transparency": "opaque",
      "event_private": false,
      "organizer": null,
      "status": "confirmed",
      "categories": [],
      "recurring": false,
      "options": { "delete": true, "update": true, "change_participation_status": false }
    },
    {
      "calendar_id": "cal_89645320138ae1ac547189a1",
      "event_uid": "evt_0b9d4e7f2a1c3b5d6e8f9a01",
      "event_id": "consult-88",
      "summary": "Colour consultation",
      "description": "",
      "start": "2026-11-05T00:00:00Z",
      "end": "2026-11-05T00:30:00Z",
      "deleted": false,
      "created": "2026-10-20T01:15:40Z",
      "updated": "2026-10-21T22:03:11Z",
      "participation_status": "accepted",
      "attendees": [
        { "email": "grace@example.com", "display_name": "Grace Park", "status": "accepted" },
        { "email": "alex@example.com", "display_name": "Alex Rivera", "status": "needs_action" }
      ],
      "transparency": "opaque",
      "extended_transparency": "opaque",
      "event_private": false,
      "organizer": { "email": "dana@example.com", "display_name": "Dana Lee" },
      "status": "confirmed",
      "categories": [],
      "recurring": false,
      "conferencing": { "provider_name": "google_meet", "join_url": "https://meet.google.com/abc-defg-hij" },
      "options": { "delete": true, "update": true, "change_participation_status": false }
    },
    {
      "calendar_id": "cal_89645320138ae1ac547189a1",
      "event_uid": "evt_5c2e8a1f7b3d9e0a4c6f1b23",
      "event_id": "class-7",
      "summary": "Tuesday pilates",
      "description": "",
      "start": "2026-11-02T23:00:00Z",
      "end": "2026-11-03T00:00:00Z",
      "deleted": false,
      "created": "2026-10-20T01:20:02Z",
      "updated": "2026-10-20T01:20:02Z",
      "participation_status": "accepted",
      "attendees": [],
      "transparency": "opaque",
      "extended_transparency": "opaque",
      "event_private": false,
      "organizer": null,
      "status": "confirmed",
      "categories": [],
      "recurring": true,
      "recurrence": {
        "rules": [{ "frequency": "weekly", "count": 10, "by_day": [{ "day": "tuesday" }] }]
      },
      "series_identifier": "9f2c4b7e1a6d8035c2e4f6a8b0d1e3f5",
      "series_master": { "event_id": "class-7", "event_uid": "evt_5c2e8a1f7b3d9e0a4c6f1b23" },
      "occurrence_date": "2026-11-03",
      "options": { "delete": true, "update": true, "change_participation_status": false }
    }
  ]
}
```

- Up to 300 events per page; follow `pages.next_page`.
- `event_id` appears only on events your application created, and `options.update` / `options.delete` are true only for those.
- Cancelled events are not included.

| Name | Type | Description |
| --- | --- | --- |
| `attendees` | `object[]` | The guests: `email` (lower case), `display_name` (string or null) and `status`, their reply: `needs_action`, `accepted`, `declined`, `tentative` or `unknown`. Empty for an event without guests. |
| `organizer` | `object or null` | Who the event belongs to: `email` and `display_name` (string or null). Null when the calendar names nobody. |
| `participation_status` | `string` | The calendar owner’s own reply, with the same values as a guest’s `status`. An invitation they declined is returned with `declined`, and never counts as busy in `/v1/free_busy`. |
| `conferencing` | `object` | The event’s meeting link: `{ "provider_name": "…", "join_url": "…" }`, or `{ "pending": true }` from the moment you ask for one until the calendar has made it. `provider_name` is `google_meet`, `ms_teams`, `google_hangouts`, `skype_for_business`, `skype` or `other`. Left out when the event has no link. |
| `recurring` | `boolean` | True on every occurrence of a repeating event. A repeating event is returned as its occurrences, one event each. |
| `series_identifier` | `string` | On every occurrence of a repeating event: the same value for all occurrences of one series, and for nothing else. |
| `occurrence_date` | `date` | On every occurrence: its original day in the series’ time zone (`2026-11-24`). An occurrence that was moved keeps it. This is the day [DELETE](#events-delete) and [/events/occurrences](#events-occurrences) name an occurrence by. |
| `series_master` | `object` | On the occurrences of a series your application wrote: `event_id` (the id you wrote the series with) and `event_uid` of its first occurrence. |
| `recurrence` | `object` | On the first occurrence of a series your application wrote: `{ "rules": [ … ] }`, the rule as you sent it (`interval` is left out when it is 1). |

- Every occurrence of a series your application wrote carries your `event_id`, so `only_managed` and `include_managed` treat the whole series as yours. Tell the occurrences apart by `occurrence_date` or `event_uid`.
- Guests, organisers, replies and meeting links are returned for every event in the calendar, whoever created it. Events are read from 42 days back to 201 days ahead, and a series gives the occurrences inside that window.

<a id="events-upsert"></a>

## POST /v1/calendars/{calendar_id}/events

**POST** `https://api.calmonkey.com/v1/calendars/{calendar_id}/events`

**Authentication:** `Authorization: Bearer <access_token>`

Creates the event with this `event_id`, or replaces it. The same call invites [guests](#guests), makes the event [repeat](#recurrence) and adds a [meeting link](#conferencing).

| Name | Type | Description |
| --- | --- | --- |
| `event_id` (required) | `string` | Your id for the event, up to 512 characters. Unique per calendar and application. |
| `summary` (required) | `string` | Title, up to 2,000 characters. |
| `description` | `string` | Up to 50,000 characters. |
| `start` (required) | `string or object` | A time with an offset (`2026-11-03T03:00:00Z`), a date for an all-day event, or `{ "time": "…", "tzid": "…" }`. |
| `end` (required) | `string or object` | As start. A date when start is a date (exclusive), a time when start is a time, not before start. |
| `tzid` | `string` | The event's time zone, used by the provider to show it. |
| `location.description` | `string` | Up to 2,000 characters. |
| `url` | `string` | A link, returned on reads. Up to 2,048 characters. |
| `transparency` | `string` | `opaque` (busy, the default) or `transparent` (shown as free). |
| `attendees.invite` | `object[]` | Guests to add: `{ "email": "…", "display_name": "…" }`, the name optional. See [Guests](#guests). |
| `attendees.remove` | `object[]` | Guests to take off: `{ "email": "…" }`. |
| `notify_attendees` | `boolean` | Whether the calendar tells the guests about this write. Default `true`. |
| `recurrence` | `object or null` | How the event repeats: `rules` and `exceptions`. `null` stops it repeating. See [Repeating events](#recurrence). |
| `conferencing.profile_id` | `string` | `default`, `integrated` or `none`. See [Meeting links](#conferencing). |

Request body

```json
{
  "event_id": "booking-1042",
  "summary": "Lash lift with Grace",
  "description": "Booked through Your App",
  "start": "2026-11-03T03:00:00Z",
  "end": "2026-11-03T04:00:00Z",
  "tzid": "Australia/Sydney",
  "location": { "description": "12 Harbour St, Sydney" },
  "url": "https://yourapp.example/bookings/1042",
  "transparency": "opaque"
}
```

`202 Accepted`, empty body. Fields the API does not have (reminders, for example) are accepted and ignored. `404` for a calendar the account does not have, `403` for a read-only one, `422` for an invalid body or for something the calendar cannot do ([the keys](#event-errors)).

<a id="guests"></a>

## Guests

Add guests to an event with `attendees.invite`. The calendar’s own service sends each guest the invitation, sends an update when you change the event and a cancellation when you delete it, exactly as if the person had invited them from their calendar. Guests answer in their own calendar or mail, and the answers come back as each attendee’s `status` in [GET /v1/events](#events-read); a `change` notification tells you when one arrives.

Invite two guests and add a meeting link

```json
{
  "event_id": "consult-88",
  "summary": "Colour consultation",
  "start": "2026-11-05T00:00:00Z",
  "end": "2026-11-05T00:30:00Z",
  "tzid": "Australia/Sydney",
  "attendees": {
    "invite": [
      { "email": "grace@example.com", "display_name": "Grace Park" },
      { "email": "sam@example.com" }
    ]
  },
  "conferencing": { "profile_id": "default" }
}
```

A later write: one guest added, one taken off

```json
{
  "event_id": "consult-88",
  "summary": "Colour consultation",
  "start": "2026-11-05T00:00:00Z",
  "end": "2026-11-05T00:30:00Z",
  "tzid": "Australia/Sydney",
  "attendees": {
    "invite": [{ "email": "alex@example.com", "display_name": "Alex Rivera" }],
    "remove": [{ "email": "sam@example.com" }]
  }
}
```

- `attendees` describes a change, not the whole list. The event’s guests after the write are the guests it had, plus `invite`, minus `remove`. An address in both lists is removed. Inviting a guest again keeps them once; a `display_name` sent with it replaces the name.
- A write without `attendees` keeps the guests as they are, so you can move or rename an event without sending the list again. To take every guest off, name them in `remove`.
- `notify_attendees: false` makes the write without telling anybody: the guests are on the event and nobody is emailed. It also applies to [DELETE](#events-delete). Which calendars take it is in the [provider table](https://calmonkey.com/docs/providers.md#features).
- Limits: 100 guests on an event, and 100 entries in each of `invite` and `remove`; an email address up to 254 characters, a name up to 256. Addresses are trimmed and compared in lower case, and one named twice counts once.
- A guest’s `status` is the answer the calendar holds, and your later writes keep it. Moving an event to another time can ask the guests again: Google sets them back to `needs_action` when the time changes.
- A calendar can add a guest of its own: somebody who answers from another address than the one you invited, or who was forwarded the invitation. They appear in `attendees` with their answer, and your later writes keep them. To take one off, name the address in `remove`.
- The calendar’s owner is the event’s `organizer`.

<a id="recurrence"></a>

## Repeating events

`recurrence` makes the event a series. `start` and `end` are its first occurrence, and `rules` holds one rule that says how it repeats. `exceptions` skips occurrences (`add`) and gives single occurrences their own time or text (`change`).

Tuesdays at 10 am Sydney time, ten times; one skipped, one moved to the afternoon

```json
{
  "event_id": "class-7",
  "summary": "Tuesday pilates",
  "start": "2026-11-02T23:00:00Z",
  "end": "2026-11-03T00:00:00Z",
  "tzid": "Australia/Sydney",
  "recurrence": {
    "rules": [
      { "frequency": "weekly", "interval": 1, "count": 10, "by_day": [{ "day": "tuesday" }] }
    ],
    "exceptions": {
      "add": [{ "date": "2026-11-17" }],
      "change": [
        {
          "date": "2026-11-24",
          "start": "2026-11-24T03:00:00Z",
          "end": "2026-11-24T04:00:00Z",
          "summary": "Tuesday pilates (afternoon)"
        }
      ]
    }
  }
}
```

| Name | Type | Description |
| --- | --- | --- |
| `frequency` (required) | `string` | `daily`, `weekly`, `monthly` or `yearly`. |
| `interval` | `integer` | Every how many days, weeks, months or years: 1 to 99. Default 1. |
| `count` | `integer` | How many occurrences in all, the first included: 1 to 730. |
| `until` | `date` | The last day an occurrence may fall on (`2027-12-31`, inclusive, in the event’s time zone). Not together with `count`. With neither, the series has no end. |
| `by_day` | `object[]` | Weekly: the days of the week, `[{ "day": "tuesday" }, { "day": "thursday" }]`. Monthly: a day with its place in the month, `[{ "day": "monday", "nth_of_period": 1 }]` for the first Monday; `nth_of_period` is 1 to 4, or -1 for the last. |

All day on the first Monday of every month, until the end of 2027

```json
{
  "event_id": "stocktake",
  "summary": "Stocktake",
  "start": "2026-11-02",
  "end": "2026-11-03",
  "recurrence": {
    "rules": [
      { "frequency": "monthly", "until": "2027-12-31", "by_day": [{ "day": "monday", "nth_of_period": 1 }] }
    ]
  }
}
```

<a id="recurrence-rules"></a>

### How a rule is read

- The start is the first occurrence, so it has to fit the rule: a weekly rule for Tuesdays needs a start on a Tuesday. A weekly rule without `by_day` repeats on the start’s weekday.
- A monthly rule without `by_day` repeats on the start’s day of the month, and a yearly rule on the start’s date. Start a monthly series on the 1st to the 28th, and a yearly one on any date but 29 February; use `by_day` with `nth_of_period: -1` for “the last Friday of the month”.
- A series with a time needs `tzid`, and repeats at the same local time all year: 10 am stays 10 am when the clocks change. An all-day series needs none.
- `until` is not before the start. A rule has the five fields above and no others.

<a id="recurrence-exceptions"></a>

### Skipped and changed occurrences

- An occurrence is named by its **original day** in the event’s time zone, `YYYY-MM-DD`: the day the rule puts it on, also after it was moved. Reads return it as `occurrence_date`.
- `exceptions.add`: up to 64 days to skip, `{ "date": "2026-11-17" }`.
- `exceptions.change`: up to 64 occurrences with something of their own: `date`, and any of `start` + `end` (both, of the same kind as the series: times for a timed series, dates for an all-day one), `summary`, `description` and `location.description`. What is left out is the series’ own.
- A day is skipped or changed, not both, and named once.

<a id="recurrence-updates"></a>

### Writing a series again

- Send the event again with the same `event_id` to move it, rename it or change its rule: every occurrence follows.
- Leave `recurrence` out and the event keeps repeating as it does (and keeps its time zone when you send none). `"recurrence": null` makes it a single event again.
- Leave `exceptions.add` or `exceptions.change` out and that list is kept; send one and it replaces the series’ list. Exceptions on days the new rule no longer has are dropped.
- For one occurrence there are two shorter calls: [DELETE with `occurrence_date`](#events-delete) skips it, and [POST …/events/occurrences](#events-occurrences) changes it.
- Deleting the event deletes the whole series. A series can have guests and a meeting link like any other event.

The event stops repeating and becomes one event

```json
{
  "event_id": "class-7",
  "summary": "Tuesday pilates",
  "start": "2026-11-02T23:00:00Z",
  "end": "2026-11-03T00:00:00Z",
  "tzid": "Australia/Sydney",
  "recurrence": null
}
```

<a id="conferencing"></a>

## Meeting links

`conferencing.profile_id` asks the calendar to add its own meeting link to the event: Google Meet on Google calendars, Microsoft Teams on Microsoft 365 and Outlook.com.

| Name | Type | Description |
| --- | --- | --- |
| `default` | `value` | Add the calendar's own link where it has one. On a calendar without one the event is written without a link, and the write still succeeds. |
| `integrated` | `value` | Add the calendar’s own link, and answer `422` (`errors.conferencing_unavailable`) on a calendar that has none. |
| `none` | `value` | Take the link off the event. |

- Reads show `"conferencing": { "pending": true }` as soon as the write is accepted, then `{ "provider_name", "join_url" }` once the calendar has made the link, usually within seconds. You get no notification for your own write, so read the event again a moment later.
- A link stays on the event through later writes, also ones that leave `conferencing` out. The same link is kept when the event moves.
- Guests get the link with their invitation. On Microsoft calendars the join details are also added to the event’s description in the person’s calendar; reads return your description without them.

<a id="events-delete"></a>

## DELETE /v1/calendars/{calendar_id}/events

**DELETE** `https://api.calmonkey.com/v1/calendars/{calendar_id}/events`

**Authentication:** `Authorization: Bearer <access_token>`

| Name | Type | Description |
| --- | --- | --- |
| `event_id` (required) | `string` | The id the event was written with. |
| `occurrence_date` | `date` | Cancel only this occurrence of a repeating event: its original day in the event’s time zone (`2026-12-01`). The rest of the series stays. |
| `notify_attendees` | `boolean` | Whether guests are sent the cancellation. Default `true`. |

Cancel the occurrence of 1 December

```json
{
  "event_id": "class-7",
  "occurrence_date": "2026-12-01"
}
```

Delete an event and send no cancellations

```json
{
  "event_id": "consult-88",
  "notify_attendees": false
}
```

`202 Accepted`, empty body, also when there is no such event. Without `occurrence_date` a repeating event is deleted with all its occurrences. With it, `422` on `occurrence_date` when the event does not repeat (`errors.not_recurring`) or has no occurrence on that day (`errors.not_an_occurrence`); skipping a day that is already skipped is fine.

<a id="events-occurrences"></a>

## POST /v1/calendars/{calendar_id}/events/occurrences

**POST** `https://api.calmonkey.com/v1/calendars/{calendar_id}/events/occurrences`

**Authentication:** `Authorization: Bearer <access_token>`

Gives one occurrence of a repeating event its own time, text or both, without sending the series again.

| Name | Type | Description |
| --- | --- | --- |
| `event_id` (required) | `string` | The id the series was written with. |
| `occurrence_date` (required) | `date` | The occurrence’s original day in the event’s time zone. |
| `start / end` | `string or object` | A new time for this occurrence: both or neither, times for a timed series and dates for an all-day one. |
| `tzid` | `string` | The time zone of the new time. Default: the series' own. |
| `summary` | `string` | A title for this occurrence, up to 2,000 characters. |
| `description` | `string` | Up to 50,000 characters. |
| `location.description` | `string` | Up to 2,000 characters. |
| `notify_attendees` | `boolean` | Whether guests are told. Default `true`. |

The class of 8 December moves to midday and gets its own title

```json
{
  "event_id": "class-7",
  "occurrence_date": "2026-12-08",
  "start": "2026-12-08T01:00:00Z",
  "end": "2026-12-08T02:00:00Z",
  "summary": "Tuesday pilates (midday)"
}
```

- `202 Accepted`, empty body. The body is everything that differs for this occurrence: it replaces an earlier change of the same occurrence, and a field left out is the series’ own again. At least one of the new time, `summary`, `description` and `location` is needed.
- Changing an occurrence that was skipped brings it back.
- `404` for an `event_id` your application has not written to the calendar. `422` on `occurrence_date` with `errors.not_recurring` or `errors.not_an_occurrence`, as for DELETE.
- The same change can be made with the series itself, in `recurrence.exceptions.change`.

<a id="event-errors"></a>

## 422 keys of event writes

Event writes answer `422` in the usual shape ([field errors](https://calmonkey.com/docs/errors.md#validation)). These are the keys of the guest, repeating-event and meeting-link fields; the field name is the path into your body, such as `attendees.invite[0].email` or `recurrence.rules[0].until`.

| Name | Type | Description |
| --- | --- | --- |
| `errors.invalid_format` | `attendees` | `attendees` is not an object with `invite` and / or `remove`. Also on `recurrence` that is not an object or null, and on a `by_day` entry that is not an object. |
| `errors.invalid_email` | `attendees.…email` | Not an email address. |
| `errors.required` | `any` | A guest without `email`; `conferencing` without `profile_id`; a changed occurrence with only one of `start` and `end`. |
| `errors.too_big` | `any` | Over a limit: more than 100 entries in `invite` or `remove`, an address over 254 or a name over 256 characters, `interval` over 99, `count` over 730, more than 64 entries in `exceptions.add` or `exceptions.change`. |
| `errors.too_many_attendees` | `attendees` | The event would have more than 100 guests. |
| `errors.attendees_unsupported` | `attendees` | Guests were added on a calendar that sends no invitations ([provider table](https://calmonkey.com/docs/providers.md#features)). On an application calendar, send `notify_attendees: false` to store them without telling anybody. |
| `errors.notify_attendees_unsupported` | `notify_attendees` | `notify_attendees: false` on an event with guests in a calendar that always tells them (Microsoft). |
| `errors.invalid_value` | `conferencing.profile_id` | Not `default`, `integrated` or `none`. |
| `errors.conferencing_unavailable` | `conferencing.profile_id` | `integrated` on a calendar that adds no meeting link of its own. |
| `errors.max_recurrence_rules_exceeded` | `recurrence.rules` | More than one rule. |
| `errors.too_small` | `recurrence.rules` | No rule. (To stop a series send `"recurrence": null`.) |
| `errors.not_recognized` | `…frequency` | Not `daily`, `weekly`, `monthly` or `yearly`. |
| `errors.not_supported` | `recurrence.…` | A field a rule does not have (`by_month_day`, `by_month`, `by_set_pos`, `week_start`…), a field of a `by_day` entry other than `day` and `nth_of_period`, or a list under `exceptions` other than `add` and `change`. Nothing in a rule is ever dropped silently. |
| `errors.must_be_integer_greater_than_zero` | `…interval, …count` | Not a whole number above zero. |
| `errors.must_be_date` | `…until` | Not a date (`YYYY-MM-DD`). |
| `errors.not_supported_count_until_combination` | `…until` | `count` and `until` together. |
| `errors.invalid` | `recurrence.…, tzid` | The rule does not fit the event: the start is not an occurrence of it (`…by_day`), `by_day` is wrong for the frequency, a monthly series starts on the 29th to 31st or a yearly one on 29 February (`…frequency`), `until` is before the start, a timed series has no `tzid`, or an exception’s `date` is not a date or is named twice. The description says which. |
| `errors.not_recurring` | `occurrence_date` | The event does not repeat. |
| `errors.not_an_occurrence` | `occurrence_date` | The series has no occurrence on that day. |

The body is checked first and the calendar second: a body with a mistake in it answers with the mistake, and a well-formed body the calendar cannot carry out answers with the calendar’s key. Nothing is written in either case.

<a id="channels-create"></a>

## POST /v1/channels

**POST** `https://api.calmonkey.com/v1/channels`

**Authentication:** `Authorization: Bearer <access_token>`

| Name | Type | Description |
| --- | --- | --- |
| `callback_url` (required) | `string` | https, port 443, a public host. No credentials in the URL. |
| `filters.calendar_ids` | `string[]` | Only changes in these calendars (up to 200). Default: every calendar of the account. |
| `filters.only_managed` | `boolean` | Only changes to events your application created. |

Request body

```json
{
  "callback_url": "https://yourapp.example/calmonkey/notifications",
  "filters": {
    "calendar_ids": ["cal_89645320138ae1ac547189a1"],
    "only_managed": false
  }
}
```

200 OK

```json
{
  "channel": {
    "channel_id": "chn_633d9797c6e79a681d8ba688",
    "callback_url": "https://yourapp.example/calmonkey/notifications",
    "filters": { "calendar_ids": ["cal_89645320138ae1ac547189a1"] },
    "signing_secret": "whsec_jHsZ7xVMwTVOJBnkmfDpreMncVmdh-pg_mWfWApj36M"
  }
}
```

A `verification` notification goes to the callback URL at once. `signing_secret` is returned only here (not in Cronofy compatibility mode). An account has at most 100 open channels. A URL that is not https answers `422` with `errors.invalid_callback_url`; a host CalMonkey will not call (local, private or example addresses) with `errors.invalid_callback_url_blocked`.

<a id="channels-list"></a>

## GET /v1/channels

**GET** `https://api.calmonkey.com/v1/channels`

**Authentication:** `Authorization: Bearer <access_token>`

200 OK

```json
{
  "channels": [
    {
      "channel_id": "chn_633d9797c6e79a681d8ba688",
      "callback_url": "https://yourapp.example/calmonkey/notifications",
      "filters": { "calendar_ids": ["cal_89645320138ae1ac547189a1"] }
    }
  ]
}
```

The account’s open channels. Filters that were not set are left out.

<a id="channels-close"></a>

## DELETE /v1/channels/{channel_id}

**DELETE** `https://api.calmonkey.com/v1/channels/{channel_id}`

**Authentication:** `Authorization: Bearer <access_token>`

Closes the channel: `202`, empty body, also when it was already closed. `404` for an id the account never had, with the body `{"error":"not_found","description":"…"}`.

<a id="link-tokens"></a>

## POST /v1/link_tokens

**POST** `https://api.calmonkey.com/v1/link_tokens`

**Authentication:** `Authorization: Bearer <access_token>`

200 OK

```json
{ "link_token": "cmlt_Hy5_QceiTdbfCgu7Ou9ok59Aa8OC_OAZmwEbQLOo3io" }
```

Pass it to `/oauth/authorize` as `link_token` to add another calendar account to the same account, for example a work calendar next to a personal one. Single use, valid for 5 minutes. If the calendar account the person connects already belongs to a different account of your application, the flow ends with `error=access_denied`.

<a id="notifications"></a>

## Notifications

What CalMonkey POSTs to a channel’s callback URL: `{ "notification": { "type", "changes_since" }, "channel": { … } }`. Headers, signatures and verification code are in the [quickstart](https://calmonkey.com/docs/quickstart.md#verify); retries in [Errors and limits](https://calmonkey.com/docs/errors.md#webhook-retries).

| Name | Type | Description |
| --- | --- | --- |
| `verification` | `type` | Sent once when the channel is created. |
| `change` | `type` | Something changed in the account’s calendars. `changes_since` (UTC, whole seconds) is where to read from. |
| `profile_disconnected` | `type` | A connected calendar account needs to be connected again. |
| `profile_initial_sync_completed` | `type` | A newly connected calendar account has finished its first sync. |

> Your application is never notified of changes it made itself through the API. Several changes close together are sent as one notification.
