# CalMonkey documentation

---

> CalMonkey is one API for your users' Google Calendar, Microsoft 365, Outlook.com and Apple iCloud calendars: hosted connect pages, calendars, free/busy, events with guests, repeating events and meeting links, and signed webhooks. Field names and shapes follow Cronofy's calendar API.

---

The API description is at https://calmonkey.com/openapi.json. A list of these pages, one file each, is at https://calmonkey.com/llms.txt.

---

# Documentation

CalMonkey gives your product one API for your users’ Google Calendar, Microsoft 365, Outlook.com and Apple iCloud calendars. Your users connect on a hosted page, and your server reads free/busy and events, writes events (with guests, repeats and meeting links) and gets told about changes. New here? Go to the [quickstart](https://calmonkey.com/docs/quickstart.md).

<a id="pages"></a>

## Start here

- [Quickstart](https://calmonkey.com/docs/quickstart.md): Connect a calendar, exchange the code, list calendars, read free/busy, write an event and receive webhooks.
- [API reference](https://calmonkey.com/docs/api.md): Every endpoint with its parameters, request and response, including guests, repeating events and meeting links.
- [Errors and limits](https://calmonkey.com/docs/errors.md): Error shapes, rate limits, request ids and the webhook retry schedule.
- [Providers](https://calmonkey.com/docs/providers.md): Google, Microsoft 365, Outlook.com and Apple iCloud: what each needs, what each does with guests and meeting links, and how fresh the data is.
- [Migrating from Cronofy](https://calmonkey.com/docs/cronofy.md): Compatibility mode, what differs, and the steps to switch.

<a id="hosts"></a>

## Hostnames

CalMonkey answers on two hostnames. Every example in these pages uses them as they are.

| Host | What is there |
| --- | --- |
| `https://api.calmonkey.com` | The API: `/oauth/token`, `/oauth/token/revoke` and everything under `/v1`. Your server calls it. |
| `https://app.calmonkey.com` | What people see in a browser: `/oauth/authorize` and the hosted connect pages your users go through, and the dashboard at `/dashboard` where you manage your applications. |

If you are moving from Cronofy, the API host takes the place of `api.cronofy.com` and the app host the place of `app.cronofy.com`. See [Migrating from Cronofy](https://calmonkey.com/docs/cronofy.md).

<a id="concepts"></a>

## How the pieces fit

- **Application**: Your product, as CalMonkey knows it. It has a client id, a client secret and the redirect URIs your users may be sent back to. You create applications in the [dashboard](https://app.calmonkey.com/dashboard) (sign in at [app.calmonkey.com/sign-in](https://app.calmonkey.com/sign-in)). The client secret is shown once, when it is created or rotated: store it in your secret manager.
- **Account**: One of your users (`acc_…`). Your application gets an account, with an access token and a refresh token, when a user connects a calendar through the hosted connect page. Its id comes back as `sub`.
- **Profile**: One calendar account the user connected (`pro_…`): a Google account, a Microsoft 365 or Outlook.com mailbox, or an Apple ID. An account can have several.
- **Calendar**: A calendar of a profile (`cal_…`). You read free/busy and events from calendars and write events to the ones that are not read-only.
- **Event**: Something in a calendar. Your application writes events under its own `event_id`: a single event or a [repeating](https://calmonkey.com/docs/api.md#recurrence) one, with [guests](https://calmonkey.com/docs/api.md#guests) the calendar invites for you and a [meeting link](https://calmonkey.com/docs/api.md#conferencing) the calendar adds. Reads return every event in the calendar, with its guests and their replies.
- **Application calendar**: A calendar CalMonkey hosts itself (`apc_…` accounts), opened with your client credentials alone. Use them for tests and demos, or for people who have no calendar to connect.
- **Channel**: A webhook subscription (`chn_…`): CalMonkey sends a signed notification to your callback URL when something changes in an account’s calendars.

<a id="conventions"></a>

## Conventions

- Requests and responses are JSON. Field names are snake_case, and the same as Cronofy’s for the endpoints both have.
- `/v1` endpoints take `Authorization: Bearer <access_token>`. Access tokens last 3 hours; refresh them with the refresh token, which does not change.
- Times are ISO 8601 with an offset, returned in UTC unless you ask for local times. All-day events are dates, and their end date is exclusive.
- Every response carries a `Calmonkey-Request-Id` header. Quote it when you write to [support@calmonkey.com](mailto:support@calmonkey.com).
- Event writes answer `202 Accepted` with an empty body. The event shows in free/busy and event reads straight away; the write to Google, Microsoft or iCloud follows in the background.

Source: https://calmonkey.com/docs

---

# Quickstart

From nothing to a connected calendar, free/busy, a written event and verified webhooks. The examples use curl; any HTTP client works. Replace `$CLIENT_SECRET` with your client secret.

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

## 1. Create an application

Sign in at [app.calmonkey.com/sign-in](https://app.calmonkey.com/sign-in) and create an application in the [dashboard](https://app.calmonkey.com/dashboard). You get:

- a **client id**, which is public: it goes in the connect link;
- a **client secret** (`cmsec_…`), shown once, when it is created or rotated. Keep it on your server only. It authenticates your token requests and is also the key of the webhook signature. After a rotation the previous secret keeps working for 24 hours, so you can deploy the new one without downtime;
- the **redirect URIs** your users may be sent back to. They must match exactly (no wildcards, no prefix matching, no forgiving trailing slash): https addresses, http only on `localhost`, `127.0.0.1` or `[::1]` (any http host while the application is in test mode), or an app link such as `yourapp://calendar`. No fragments, no credentials.

New applications start in **test mode**: the connect page then shows a “Test mode” label, so a trial run is never mistaken for the real thing.

<a id="connect"></a>

## 2. Send your user to connect a calendar

Redirect the user’s browser to `/oauth/authorize` on the app host. They pick Google, Microsoft 365, Outlook.com or Apple iCloud, sign in with the provider and come back to your redirect URI with a code.

```sh
https://app.calmonkey.com/oauth/authorize
  ?response_type=code
  &client_id=V2msxgA-kN7GMgxrsWLaCXL_NhKGLLGd
  &redirect_uri=https%3A%2F%2Fyourapp.example%2Fcalendar%2Fcallback
  &scope=read_write
  &state=4f1c2a9e7b
```

| Name | Type | Description |
| --- | --- | --- |
| `response_type` (required) | `string` | Always `code`. |
| `client_id` (required) | `string` | Your application's client id. |
| `redirect_uri` (required) | `string` | One of the application's redirect URIs, exactly as registered. |
| `scope` | `string` | Defaults to `read_write`. |
| `state` | `string` | Any value; it comes back unchanged. Use it to tie the callback to the user's session and to stop cross-site request forgery. |
| `provider_name` | `string` | Skip the chooser: `google`, `office365` (Microsoft 365 work or school), `live_connect` (Outlook.com, Hotmail, Live) or `apple` (iCloud). |
| `link_token` | `string` | From [POST /v1/link_tokens](https://calmonkey.com/docs/api.md#link-tokens): the calendar account connected in this run joins an existing account instead of starting a new one. |

When the user has connected, the browser comes back to your redirect URI:

```
https://yourapp.example/calendar/callback?code=cmac_syNmBk3GTXqpLQVNfyoZ0iofd3uXDmunIBaksd9i1u0&state=4f1c2a9e7b
```

If they cancel or decline, it comes back with `error=access_denied` (and your `state`), sometimes with an `error_description`. An unknown client id or a redirect URI that is not registered never redirects: the user sees an error page instead, so the endpoint cannot be used to send people to other sites.

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

## 3. Exchange the code for tokens

From your server, within 10 minutes. A code works once, and only with the `redirect_uri` it was issued for.

```sh
curl https://api.calmonkey.com/oauth/token \
  -H "Content-Type: application/json" \
  -d '{
    "client_id": "V2msxgA-kN7GMgxrsWLaCXL_NhKGLLGd",
    "client_secret": "'"$CLIENT_SECRET"'",
    "grant_type": "authorization_code",
    "code": "cmac_syNmBk3GTXqpLQVNfyoZ0iofd3uXDmunIBaksd9i1u0",
    "redirect_uri": "https://yourapp.example/calendar/callback"
  }'
```

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"
  }
}
```

Store `access_token`, `refresh_token` and `sub` (the account id) against your user. `linking_profile` says which calendar account was just connected. The body may also be sent as `application/x-www-form-urlencoded`.

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

### Refreshing

Access tokens last 3 hours (`expires_in` is in seconds). A `401` from `/v1` means the token expired or was revoked: refresh it and retry once. The refresh token does not change.

```sh
curl https://api.calmonkey.com/oauth/token \
  -H "Content-Type: application/json" \
  -d '{
    "client_id": "V2msxgA-kN7GMgxrsWLaCXL_NhKGLLGd",
    "client_secret": "'"$CLIENT_SECRET"'",
    "grant_type": "refresh_token",
    "refresh_token": "cmrt_D0L78QVguUuBnvOcex1VPdSP0uPRYOCtQMGeFi8Qku8"
  }'
```

200 OK

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

A refresh token that has been revoked answers `400 {"error":"invalid_grant"}`: the user has to connect again.

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

## 4. List the calendars

```sh
curl https://api.calmonkey.com/v1/calendars \
  -H "Authorization: Bearer cmat_Tz5vTHRrZ5QIIK0Ng0zRJCkt9ZqmjBrNn1mv7x4DbGg"
```

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"
}
```

The shape is the same for every provider. Write to calendars whose `calendar_readonly` is false; most products use the one with `calendar_primary: true`. The list is read from the provider during the connect flow, so it is normally there as soon as you have the token.

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

## 5. Read free/busy

```sh
curl -G https://api.calmonkey.com/v1/free_busy \
  -H "Authorization: Bearer cmat_Tz5vTHRrZ5QIIK0Ng0zRJCkt9ZqmjBrNn1mv7x4DbGg" \
  --data-urlencode "tzid=Australia/Sydney" \
  --data-urlencode "from=2026-11-03" \
  --data-urlencode "to=2026-11-04" \
  --data-urlencode "calendar_ids[]=cal_89645320138ae1ac547189a1" \
  --data-urlencode "include_managed=true" \
  --data-urlencode "include_ids=true"
```

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"
    }
  ]
}
```

- `from` and `to` are days in `tzid` (or times with an offset); `to` is exclusive.
- One period per event. Events marked “show as free” are `free`, tentative ones `tentative`, everything else `busy`; cancelled events are left out.
- Events your application wrote are left out unless you pass `include_managed=true`; with `include_ids=true` they carry your own `event_id`, so you can tell your bookings from the person’s other commitments.
- More than 1,000 periods come in pages: follow `pages.next_page`, an absolute URL, as it is.

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

## 6. Write an event

Events are created and updated by **your** id, `event_id`. Sending the same body twice leaves one event; sending a changed one updates it.

```sh
curl https://api.calmonkey.com/v1/calendars/cal_89645320138ae1ac547189a1/events \
  -H "Authorization: Bearer cmat_Tz5vTHRrZ5QIIK0Ng0zRJCkt9ZqmjBrNn1mv7x4DbGg" \
  -H "Content-Type: application/json" \
  -d '{
  "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"
}'
```

```http
HTTP/1.1 202 Accepted
```

The event is in free/busy and in `GET /v1/events` as soon as the 202 is sent; the write to the provider follows within moments. For an all-day event send two dates, the end exclusive: `"start": "2026-11-03", "end": "2026-11-04"`. To remove it:

```sh
curl -X DELETE https://api.calmonkey.com/v1/calendars/cal_89645320138ae1ac547189a1/events \
  -H "Authorization: Bearer cmat_Tz5vTHRrZ5QIIK0Ng0zRJCkt9ZqmjBrNn1mv7x4DbGg" \
  -H "Content-Type: application/json" \
  -d '{ "event_id": "booking-1042" }'
```

Also `202`, including when there was no such event. A read-only calendar answers `403`, an unknown one `404`.

The same call does more when you need it: `attendees` invites guests and reads their replies back, `recurrence` makes the event repeat, and `conferencing` adds a Google Meet or Microsoft Teams link. See [guests](https://calmonkey.com/docs/api.md#guests), [repeating events](https://calmonkey.com/docs/api.md#recurrence) and [meeting links](https://calmonkey.com/docs/api.md#conferencing) in the API reference.

<a id="webhooks"></a>

## 7. Get told about changes

Create a channel per account. CalMonkey sends a notification to your callback URL whenever the person’s calendars change.

```sh
curl https://api.calmonkey.com/v1/channels \
  -H "Authorization: Bearer cmat_Tz5vTHRrZ5QIIK0Ng0zRJCkt9ZqmjBrNn1mv7x4DbGg" \
  -H "Content-Type: application/json" \
  -d '{
  "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"
  }
}
```

- The callback URL must be https on port 443 and on a public host. A `verification` notification is sent to it straight away.
- `signing_secret` is returned once, here. Store it with the channel: it keys the `Calmonkey-Signature` header. (Applications in [Cronofy compatibility mode](https://calmonkey.com/docs/cronofy.md) get Cronofy’s response without it, and their notifications carry no `Calmonkey-Signature`.)
- Changes your application made through the API are never notified back to it. With `only_managed: true` the channel only hears about events your application created.

What your callback URL receives

```json
{
  "notification": {
    "type": "change",
    "changes_since": "2026-10-20T01:12:09Z"
  },
  "channel": {
    "channel_id": "chn_633d9797c6e79a681d8ba688",
    "callback_url": "https://yourapp.example/calmonkey/notifications",
    "filters": { "calendar_ids": ["cal_89645320138ae1ac547189a1"] }
  }
}
```

Types: `verification`, `change` (read events changed since `changes_since`, for example with `GET /v1/events?last_modified=…`, or read free/busy again), `profile_disconnected` (the person must connect again; see `GET /v1/profiles`) and `profile_initial_sync_completed`.

<a id="verify"></a>

### Verify every notification

Each request carries these headers:

| Name | Type | Description |
| --- | --- | --- |
| `Calmonkey-HMAC-SHA256` | `base64` | HMAC-SHA256 of the raw body, keyed with your client secret. While a rotated secret is still valid, one value per active secret, comma-separated: accept the request when any value matches. |
| `Calmonkey-Signature` | `t=…,v1=…` | `t` is the time in Unix seconds, `v1` the hex HMAC-SHA256 of `<t>.<raw body>` keyed with the channel’s `whsec_` secret. Checking `t` stops replays. |
| `Cronofy-HMAC-SHA256` | `base64` | The same value as Calmonkey-HMAC-SHA256, sent only to applications in Cronofy compatibility mode. |
| `Calmonkey-Delivery-Id` | `whd_…` | The same on every retry of one notification: use it to ignore repeats. |
| `Calmonkey-Delivery-Attempt` | `integer` | 1 for the first attempt. |

**Node**

```js
import crypto from "node:crypto";
import express from "express";

const app = express();
const CLIENT_SECRET = process.env.CALMONKEY_CLIENT_SECRET;
const SIGNING_SECRET = process.env.CALMONKEY_CHANNEL_SIGNING_SECRET; // whsec_…

// Verify the raw bytes: JSON parsed and serialised again will not match.
app.post("/calmonkey/notifications", express.raw({ type: "application/json" }), (req, res) => {
  const raw = req.body; // a Buffer
  const ok =
    verifyHmac(raw, req.get("Calmonkey-HMAC-SHA256"), CLIENT_SECRET) &&
    verifySignature(raw, req.get("Calmonkey-Signature"), SIGNING_SECRET);
  if (!ok) return res.sendStatus(401);

  const { notification, channel } = JSON.parse(raw.toString("utf8"));
  // Answer fast; do the work in your own queue.
  queueCalendarRefresh(channel.channel_id, notification.type, notification.changes_since);
  res.sendStatus(204);
});

const same = (a, b) => {
  const x = Buffer.from(a);
  const y = Buffer.from(b);
  return x.length === y.length && crypto.timingSafeEqual(x, y);
};

// Base64 HMAC-SHA256 of the body, keyed with your client secret. During a secret
// rotation the header holds one value per active secret, separated by commas.
function verifyHmac(raw, header, clientSecret) {
  if (!header) return false;
  const expected = crypto.createHmac("sha256", clientSecret).update(raw).digest("base64");
  return header.split(",").some((value) => same(value.trim(), expected));
}

// t=<unix seconds>,v1=<hex HMAC-SHA256 of "<t>.<body>"> keyed with the channel's
// whsec_ secret. Refuse old timestamps so a recorded request cannot be replayed.
function verifySignature(raw, header, signingSecret, toleranceSeconds = 300) {
  if (!header) return false;
  const parts = Object.fromEntries(header.split(",").map((p) => p.trim().split("=", 2)));
  const t = Number(parts.t);
  if (!Number.isInteger(t) || !parts.v1) return false;
  if (Math.abs(Date.now() / 1000 - t) > toleranceSeconds) return false;
  const expected = crypto.createHmac("sha256", signingSecret).update(`${t}.`).update(raw).digest("hex");
  return same(parts.v1, expected);
}
```

**Python**

```python
import base64
import hashlib
import hmac
import os
import time

from flask import Flask, abort, request

app = Flask(__name__)
CLIENT_SECRET = os.environ["CALMONKEY_CLIENT_SECRET"].encode()
SIGNING_SECRET = os.environ["CALMONKEY_CHANNEL_SIGNING_SECRET"].encode()  # whsec_…


@app.post("/calmonkey/notifications")
def notifications():
    raw = request.get_data()  # the exact bytes that were signed
    if not (
        valid_hmac(raw, request.headers.get("Calmonkey-HMAC-SHA256", ""))
        and valid_signature(raw, request.headers.get("Calmonkey-Signature", ""))
    ):
        abort(401)
    body = request.get_json()
    # Answer fast; do the work in your own queue.
    queue_calendar_refresh(body["channel"]["channel_id"], body["notification"]["type"])
    return "", 204


def valid_hmac(raw: bytes, header: str) -> bool:
    # Base64 HMAC-SHA256 of the body, keyed with your client secret. During a secret
    # rotation the header holds one value per active secret, separated by commas.
    expected = base64.b64encode(hmac.new(CLIENT_SECRET, raw, hashlib.sha256).digest()).decode()
    return any(hmac.compare_digest(v.strip(), expected) for v in header.split(",") if v.strip())


def valid_signature(raw: bytes, header: str, tolerance: int = 300) -> bool:
    # t=<unix seconds>,v1=<hex HMAC-SHA256 of "<t>.<body>">, keyed with the channel's whsec_ secret.
    parts = dict(p.strip().split("=", 1) for p in header.split(",") if "=" in p)
    try:
        t = int(parts["t"])
    except (KeyError, ValueError):
        return False
    if abs(time.time() - t) > tolerance:
        return False
    expected = hmac.new(SIGNING_SECRET, f"{t}.".encode() + raw, hashlib.sha256).hexdigest()
    return hmac.compare_digest(parts.get("v1", ""), expected)
```

**curl**

```sh
# Sign a test notification the way CalMonkey does and send it to your
# receiver, to check its verification before real notifications arrive.
BODY='{"notification":{"type":"verification"},"channel":{"channel_id":"chn_633d9797c6e79a681d8ba688","callback_url":"https://yourapp.example/calmonkey/notifications","filters":{}}}'
T=$(date +%s)

# Calmonkey-HMAC-SHA256: base64 HMAC-SHA256 of the body with your client secret
HMAC=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$CLIENT_SECRET" -binary | base64)

# Calmonkey-Signature: hex HMAC-SHA256 of "<t>.<body>" with the channel's whsec_ secret
SIG=$(printf '%s.%s' "$T" "$BODY" | openssl dgst -sha256 -hmac "$SIGNING_SECRET" | sed 's/^.*= //')

curl -i http://localhost:8080/calmonkey/notifications \
  -H "Content-Type: application/json; charset=utf-8" \
  -H "Calmonkey-HMAC-SHA256: $HMAC" \
  -H "Calmonkey-Signature: t=$T,v1=$SIG" \
  -H "Calmonkey-Delivery-Id: whd_0c7e5b3a1d9f8e6c4b2a0d1e" \
  -H "Calmonkey-Delivery-Attempt: 1" \
  --data-raw "$BODY"
```

Answer with any 2xx within 5 seconds. Anything else, including a redirect, is retried for about 23 hours; `410 Gone` closes the channel. The schedule is on [Errors and limits](https://calmonkey.com/docs/errors.md#webhook-retries).

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

## 8. Test without a real calendar

Application calendars are hosted by CalMonkey and opened with your client credentials alone, no browser involved. The same `application_calendar_id` always opens the same calendar. Everything above works on them, which makes them the quickest way to try the API and to run integration tests.

```sh
curl https://api.calmonkey.com/v1/application_calendars \
  -H "Content-Type: application/json" \
  -d '{
    "client_id": "V2msxgA-kN7GMgxrsWLaCXL_NhKGLLGd",
    "client_secret": "'"$CLIENT_SECRET"'",
    "application_calendar_id": "test-calendar-1"
  }'
```

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"
}
```

<a id="disconnect"></a>

## 9. Disconnect

When a user disconnects their calendar in your product, revoke the grant. Send either token:

```sh
curl https://api.calmonkey.com/oauth/token/revoke \
  -H "Content-Type: application/json" \
  -d '{
    "client_id": "V2msxgA-kN7GMgxrsWLaCXL_NhKGLLGd",
    "client_secret": "'"$CLIENT_SECRET"'",
    "token": "cmrt_D0L78QVguUuBnvOcex1VPdSP0uPRYOCtQMGeFi8Qku8"
  }'
```

`200` with an empty body, whether or not the token was known. Every token of the account stops working at once, its channels close, and its stored credentials and cached events are deleted within 24 hours. Events your application wrote are not removed from the person’s calendar: delete them first if you want them gone. An application calendar is deleted altogether.

> Next: the full [API reference](https://calmonkey.com/docs/api.md), [errors and limits](https://calmonkey.com/docs/errors.md), and what to expect from each [provider](https://calmonkey.com/docs/providers.md).

Source: https://calmonkey.com/docs/quickstart

---

# 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.

Source: https://calmonkey.com/docs/api

---

# Errors and limits

What a failed request looks like, how fast you may call, how to trace a request, and what happens when your webhook endpoint is down.

<a id="shapes"></a>

## Error shapes

The status code says what went wrong. Bodies follow Cronofy’s conventions, so existing Cronofy clients read them unchanged.

| Status | Body | When |
| --- | --- | --- |
| 400 | `{"error": "…"}` | OAuth endpoints only (`/oauth/token`, `/oauth/token/revoke`), including wrong client credentials. |
| 401 | empty, with `WWW-Authenticate` | The access token is missing, expired or revoked; or wrong client credentials on `/v1/application_calendars`. |
| 403 | empty | Writing to a read-only calendar. |
| 404 | empty | A calendar or channel the account does not have. Another customer’s id is “not found” too. (`DELETE /v1/channels/…` sends a small JSON body, as Cronofy does.) |
| 422 | `{"errors": {…}}` | The request is invalid; the body says which fields and why. |
| 429 | empty, with `Retry-After` | Rate limited ([below](#rate-limits)). |
| 5xx | empty | Our fault. Retry with backoff; if it persists, write to [support@calmonkey.com](mailto:support@calmonkey.com) with the request id. |

<a id="unauthorized"></a>

## 401: refresh and retry once

```http
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer realm="calmonkey", error="invalid_token", error_description="The access token is missing, invalid, expired or revoked."
Calmonkey-Request-Id: req_4b0d7c2e9f1a46d38b5e0c7a2f9d1e63
```

A request without a token says `error="invalid_request"`. On a 401, get a new access token with the refresh token and repeat the request once. If the refresh answers `invalid_grant`, the user revoked access or was disconnected: ask them to connect again.

<a id="validation"></a>

## 422: field errors

GET /v1/free_busy without tzid, and with a bad from

```json
{
  "errors": {
    "tzid": [
      { "key": "errors.required", "description": "required" }
    ],
    "from": [
      {
        "key": "errors.invalid",
        "description": "must be a date (YYYY-MM-DD) or an ISO 8601 time with an offset, e.g. 2026-10-10T01:00:00Z"
      }
    ]
  }
}
```

Each field maps to a list of `{ key, description }`. Match on `key`; `description` is for people and may be reworded. Nested fields are written `location.description`, array items `calendar_ids[0]`, problems with the whole body `base`.

| Name | Type | Description |
| --- | --- | --- |
| `errors.required` | `key` | The field is missing. |
| `errors.invalid` | `key` | The value is not acceptable (a time, a time zone, end before start…). |
| `errors.invalid_type` | `key` | Wrong type, such as a number where a string belongs. |
| `errors.invalid_format` | `key` | Wrong format. |
| `errors.invalid_value` | `key` | Not one of the allowed values (e.g. `transparency`). |
| `errors.too_small / errors.too_big` | `key` | Too short or too long, or too many items. |
| `errors.invalid_json` | `key` | The body is not a JSON object (`base`). |
| `errors.invalid_callback_url` | `key` | The callback URL is not an https URL. |
| `errors.invalid_callback_url_blocked` | `key` | CalMonkey will not call that host: local, private or example addresses, or a port other than 443. |
| `errors.too_many_channels` | `key` | The account already has 100 open channels. |

Event writes have keys of their own for guests, repeating events and meeting links, such as `errors.invalid_email`, `errors.attendees_unsupported`, `errors.not_an_occurrence` and `errors.conferencing_unavailable`. They are listed in the [API reference](https://calmonkey.com/docs/api.md#event-errors).

Applications in [Cronofy compatibility mode](https://calmonkey.com/docs/cronofy.md) get Cronofy’s wording instead: `"tzid must be specified"`-style descriptions, `errors.tzid_unrecognized` for a missing or unknown `tzid`, and `errors.invalid_time_pair` when an event ends before it starts.

<a id="oauth"></a>

## OAuth errors

400 Bad Request

```json
{ "error": "invalid_grant" }
```

| Name | Type | Description |
| --- | --- | --- |
| `invalid_client` | `error` | Unknown client id or wrong client secret (the two are not told apart). |
| `invalid_grant` | `error` | The code is used, expired or was issued for another redirect URI; or the refresh token is revoked or belongs to another application. |
| `invalid_request` | `error` | A required field is missing or the body cannot be read; `error_description` says which. |
| `unsupported_grant_type` | `error` | Only `authorization_code` and `refresh_token` are supported. |

On `/oauth/authorize` errors go back to your redirect URI as `error=` (`access_denied`, `unsupported_response_type`, `invalid_scope`), except an unknown client id or redirect URI, which shows an error page.

<a id="rate-limits"></a>

## Rate limits

Two token buckets apply to every authenticated request. A request needs room in both.

| Bucket | Burst | Sustained |
| --- | --- | --- |
| Per application | 600 requests | 100 a second |
| Per authorised account (access token) | 150 requests | 25 a second |

Responses carry the state of the tighter bucket:

```http
RateLimit-Limit: 150
RateLimit-Remaining: 149
RateLimit-Reset: 1
```

`RateLimit-Reset` is the number of seconds until the bucket is full again. Over the limit the answer is `429` with an empty body and `Retry-After` (seconds): wait that long, then retry.

<a id="request-ids"></a>

## Request ids and the request log

Every response has a `Calmonkey-Request-Id` header. Send your own in the request’s `Calmonkey-Request-Id` header (8 to 64 letters, digits, dots, dashes or underscores) and it comes back unchanged, which ties our logs to yours.

Authenticated API requests are kept in your request log in the dashboard for 30 days (7 days on the Developer plan): method, path, status, duration and the bodies, with tokens, credentials and event text replaced by `[redacted]`.

<a id="webhook-retries"></a>

## Webhook delivery and retries

A notification counts as delivered when your callback URL answers with a 2xx status within 5 seconds. Redirects are not followed and count as failures. Anything else is retried:

| Attempt | When |
| --- | --- |
| 1 | Straight away (a change waits about a second, so changes close together share one notification) |
| 2 | 15 seconds after the first failure |
| 3 | 1 minute later |
| 4 | 5 minutes later |
| 5 | 15 minutes later |
| 6 | 1 hour later |
| 7 | 2 hours later |
| 8 | 4 hours later |
| 9 | 6 hours later |
| 10 | 10 hours later, the last attempt (about 23 hours after the first) |

- Each wait has up to 10% added at random, so a recovering endpoint is not hit by every waiting notification in the same second.
- After the tenth failed attempt, or 48 hours after the notification was created, the delivery is marked failed.
- `410 Gone` closes the channel: no more notifications are sent to it.
- Every attempt of one notification has the same `Calmonkey-Delivery-Id`, and `Calmonkey-Delivery-Attempt` counts up. Delivery is at least once: ignore an id you have already handled.
- Deliveries and their attempts are listed in the dashboard for 30 days.

Source: https://calmonkey.com/docs/errors

---

# Providers

What each calendar provider asks of your users, what each calendar does with guests, repeating events and meeting links, and how quickly changes arrive.

<a id="overview"></a>

## At a glance

| Provider | `provider_name` | How the user connects | How changes reach CalMonkey |
| --- | --- | --- | --- |
| Google Calendar | `google` | Google sign-in and consent | Push from Google |
| Microsoft 365 (work or school) | `office365` | Microsoft sign-in and consent | Push from Microsoft Graph |
| Outlook.com, Hotmail, Live | `live_connect` | Microsoft sign-in and consent | Push from Microsoft Graph |
| Apple iCloud | `apple` | Apple ID and an app-specific password, on a CalMonkey form | Polling every 5 minutes |

Your code is the same for all of them: the same endpoints, fields and notifications. Pass `provider_name` to `/oauth/authorize` to skip the chooser.

<a id="features"></a>

## Guests, repeating events and meeting links

The fields are the same for every calendar ([guests](https://calmonkey.com/docs/api.md#guests), [repeating events](https://calmonkey.com/docs/api.md#recurrence), [meeting links](https://calmonkey.com/docs/api.md#conferencing)). Invitations and meeting links are made by the calendar’s own service, so what happens depends on the calendar:

| Calendar | Writing `attendees` | With `notify_attendees: false` | Read back | Repeating events | Meeting link (`conferencing`) |
| --- | --- | --- | --- | --- | --- |
| Google Calendar | Google sends the invitation, updates and the cancellation | Guests are put on the event and nobody is emailed | Guests, organiser and replies | Yes | Google Meet |
| Microsoft 365 | Microsoft Exchange sends the invitation, updates and the cancellation | 422 errors.notify_attendees_unsupported: Exchange always tells guests | Guests, organiser and replies | Yes | Microsoft Teams, on calendars that allow online meetings |
| Outlook.com | Outlook.com sends the invitation, updates and the cancellation | 422 errors.notify_attendees_unsupported: Outlook.com always tells guests | Guests, organiser and replies | Yes | Microsoft Teams |
| Apple iCloud | 422 errors.attendees_unsupported | 422 errors.attendees_unsupported | Guests, organiser and replies | Yes | None: default writes the event without a link, integrated answers 422 |
| Application calendar | 422 errors.attendees_unsupported: an application calendar has no mail service | Guests are stored and returned, and nobody is emailed | The guests you stored, as needs_action | Yes | None: default writes the event without a link, integrated answers 422 |

- **Invitations** come from the person’s own calendar account, in Google’s or Microsoft’s own format, with their name as the organiser. CalMonkey sends no email itself. Guests answer in their own calendar or mail, and the answer shows as the attendee’s `status` in your next read.
- **Repeating events** work the same everywhere: a whole series, one skipped occurrence, one changed occurrence. A series repeats in local time in its time zone, across changes of the clocks. Your application can change the series it wrote; a series the person made in their own calendar is read as its occurrences.
- **Meeting links** are the calendar’s own: a Google Meet link belongs to the Google event, a Teams link to the Microsoft one. Events the person made with a link of their own are read with it too.
- Whatever a calendar answers with `422` is refused before anything is written, so an event is never half made.

<a id="freshness"></a>

## How fresh the data is

- CalMonkey keeps a copy of each calendar’s events from **42 days back to 201 days ahead**. Free/busy and event reads are answered from it, so they are fast and look the same for every provider. Events outside that window are not kept.
- Calendars your application uses are kept current: Google and Microsoft tell CalMonkey about changes as they happen (with a full check every hour as a safety net), and iCloud is asked every 5 minutes. A change in the person’s calendar reaches you as a `change` notification.
- A calendar nobody has read or written for 14 days is left idle. Reading it again brings it up to date: if its copy is older than 2 minutes (30 minutes while push is on), CalMonkey checks the provider during your request, for up to 4 seconds, then answers.
- The list of calendars is refreshed daily, and read straight away when someone connects.
- Your writes are visible in your reads at once; the write to the provider follows within moments and is retried if the provider is unavailable. Changes your application makes are never notified back to it.

<a id="google"></a>

## Google Calendar

- Permissions asked: see and edit events (`calendar.events`) and see the list of calendars (`calendar.calendarlist.readonly`), plus the person’s email address to name the profile. Nothing else in their Google account.
- Google lets people untick permissions on the consent screen. If either calendar permission is missing, nothing is connected and the hosted page asks them to try again with both ticked.
- The consent screen names CalMonkey, because the connection goes through CalMonkey’s Google OAuth app.
- Cancelled events and working-location entries are left out. Invitations the person declined are returned by `GET /v1/events` with `participation_status: "declined"` and never count as busy.
- Guests: Google emails the invitation, every update and the cancellation. Google keeps each guest’s answer through your later writes, and asks guests to answer again when the event’s time changes.
- Meeting links are Google Meet links, made by Google for the event and returned as `provider_name: "google_meet"`.
- `provider_service` is `gmail` for gmail.com and googlemail.com addresses and `gsuite` for every other domain.
- When the account is disconnected, CalMonkey revokes its access at Google too, unless the same person is still connected through another application that uses the same Google app.

<a id="microsoft"></a>

## Microsoft 365 and Outlook.com

- One sign-in for both. `office365` sends people to the work or school sign-in, `live_connect` to the personal one; from the chooser they pick. The profile’s `provider_name` follows the account they actually signed in with.
- Permissions asked: read and write calendars (`Calendars.ReadWrite`), keep access (`offline_access`), and the sign-in basics (`openid email profile`).
- **Admin consent.** Some organisations do not let their people approve applications themselves. Then the hosted page explains that their Microsoft 365 administrator (usually their IT team) has to approve the application for calendar access, and offers to try again once they have. Nothing is connected until then. If your customers are such organisations, tell their IT team in advance.
- Events shown as free or “working elsewhere” are `transparent`, tentative ones `tentative`; busy and out of office block time. Cancelled events are left out. Invitations the person declined are returned by `GET /v1/events` with `participation_status: "declined"` and never count as busy.
- Guests: Microsoft sends the invitation, every update and the cancellation, and always does: an event with guests cannot be written or deleted with `notify_attendees: false` (`422`, `errors.notify_attendees_unsupported`).
- Meeting links are Microsoft Teams links (`provider_name: "ms_teams"`), on calendars that allow online meetings. Microsoft also writes the join details into the event’s description in the person’s calendar; your reads return the description you wrote.
- A repeating event with a time is written in its own time zone, so it shows at the same local time in Outlook all year.
- On-premises Exchange servers are not supported, only Microsoft 365 and Outlook.com.
- Microsoft offers applications no way to withdraw their own access. On disconnect CalMonkey deletes its copy of the tokens; the app stays listed among the apps with access in the person’s Microsoft account until they remove it.

<a id="apple"></a>

## Apple iCloud

Apple has no OAuth for iCloud calendars. People connect with their Apple ID and an **app-specific password** on a CalMonkey form, which walks them through creating one at [account.apple.com](https://account.apple.com) (formerly appleid.apple.com): two-factor authentication must be on, then Sign-In and Security, App-Specific Passwords.

> <a id="apple-disclosure"></a>
>
> ### What your users must know
>
> - **An app-specific password is not limited to calendars.** Apple gives it the same reach as any app the person signs in to with their Apple ID, which includes iCloud Mail and Contacts. Apple offers nothing narrower.
> - CalMonkey uses it only to read and write their iCloud calendars, and only sends it to Apple’s iCloud servers. It is stored encrypted (AES-256 with a key held in AWS Key Management Service), never logged, and never shown to you, the application.
> - **They can end it at any time:** at account.apple.com, Sign-In and Security, App-Specific Passwords, remove it. The connection stops at the next check (within 5 minutes for a calendar in use): the profile shows `profile_connected: false` and you get `profile_disconnected`. Changing the Apple ID password removes every app-specific password too.
> - Disconnecting in your product deletes CalMonkey’s copy of the password, but Apple has no way for us to remove it from their Apple account: it stays listed there until they remove it. Say so in your disconnect screen.
>
> The CalMonkey form says all of this before the person types anything. Do not soften it in your own help pages.

- The form refuses anything that is not 16 letters without sending it anywhere, which catches someone typing their normal Apple ID password. It says plainly when Apple refuses the password, when the Apple ID has no iCloud calendar yet, and when iCloud is unreachable.
- Attempts are limited per connect run, per Apple ID and per network address, so the form cannot be used to guess passwords or get an Apple ID locked.
- Managed Apple IDs from schools and businesses usually cannot create app-specific passwords.
- Changes are found by polling: every 5 minutes for accounts with a calendar in use, every 30 minutes otherwise. A change made in Apple’s Calendar can take a few minutes longer to reach you than with Google or Microsoft.
- Reminder lists and subscribed calendars are not listed. Calendars shared to the person read-only are read-only here too.
- Repeating events are written as one iCloud event with its rule, its skipped days and its changed occurrences, so your write of a series replaces the whole series.
- Guests, organisers and replies on iCloud events are read. A write with `attendees` answers `422` with `errors.attendees_unsupported`. iCloud has no meeting links of its own.

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

## Application calendars

Calendars hosted by CalMonkey itself, with `provider_name` `calmonkey` (`cronofy` in Cronofy compatibility mode). They need no user and no provider, and are always up to date. See the [quickstart](https://calmonkey.com/docs/quickstart.md#application-calendars). Repeating events work as on any calendar. An application calendar has no mail service behind it, so guests are stored and returned when you write them with `notify_attendees: false`, and nobody is emailed.

Source: https://calmonkey.com/docs/providers

---

# Migrating from Cronofy

CalMonkey’s v1 API follows Cronofy’s calendar API: the same paths, parameter names, response shapes, status codes and error shapes for connecting calendars, free/busy, events with attendees, recurring events and conferencing, and push notifications. An existing Cronofy client usually switches with a change of hostnames and credentials, and its users reconnecting.

<a id="compatible"></a>

## What is compatible

These endpoints have Cronofy’s paths, parameters, response fields, status codes and error shapes. A Cronofy client that uses only them switches by changing hostnames and credentials.

- `GET /oauth/authorize`
- `POST /oauth/token`
- `POST /oauth/token/revoke`
- `POST /v1/application_calendars`
- `GET /v1/account`
- `GET /v1/userinfo`
- `GET /v1/profiles`
- `GET /v1/calendars`
- `GET /v1/free_busy`
- `GET /v1/events`
- `POST /v1/calendars/{id}/events`
- `DELETE /v1/calendars/{id}/events`
- `POST /v1/calendars/{id}/events/occurrences`
- `POST /v1/channels`
- `GET /v1/channels`
- `DELETE /v1/channels/{id}`
- `POST /v1/link_tokens`

Provider names are Cronofy’s: `google`, `office365`, `live_connect`, `apple`. Every response also carries a `Calmonkey-Request-Id` header, and authenticated responses the `RateLimit-*` headers.

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

### Attendees, recurring events and conferencing

Event writes and reads take Cronofy’s fields and return Cronofy’s shapes:

- `attendees: { invite: [{ email, display_name }], remove: [{ email }] }` on writes; `attendees[]` with `email`, `display_name` and `status`, `organizer` and `participation_status` on reads. A wrong address is `errors.invalid_email` on `attendees.invite[0].email`, as Cronofy.
- `recurrence: { rules: [{ frequency, interval, count | until, by_day: [{ day, nth_of_period }] }] }`, with one rule, a date-only `until` and Cronofy’s keys for what a rule cannot say (`errors.not_supported`, `errors.not_recognized`, `errors.must_be_date`, `errors.must_be_integer_greater_than_zero`, `errors.not_supported_count_until_combination`, `errors.max_recurrence_rules_exceeded`, `errors.invalid_format`). Leaving `recurrence` out keeps the series and `null` removes it. `exceptions.add` skips dates.
- `conferencing: { profile_id }` with `default`, `integrated` and `none`; reads show `{ pending: true }`, then the `join_url`.

<a id="other-products"></a>

### Outside the compatible set

Cronofy’s other products and endpoints have no counterpart on this API: the Availability and Scheduler APIs, Smart Invites, `conferencing.profile_id: "explicit"` with a join URL of your own, changing a participation status through the API, on-premises Exchange, and per-profile revoke (`POST /v1/profiles/{id}/revoke`). Revoke the whole grant with `/oauth/token/revoke` instead.

<a id="mode"></a>

## Cronofy compatibility mode

Where CalMonkey’s own behaviour differs from Cronofy’s, an application can ask for Cronofy’s. Compatibility mode is a setting of each application, off by default. Turn it on for the application an existing Cronofy client will use; leave it off for new integrations. You switch it in the application’s page in the dashboard.

| Behaviour | Compatibility on (as Cronofy) | Off (CalMonkey) |
| --- | --- | --- |
| All-day event written without transparency | transparent (free), as Cronofy | opaque (busy) |
| free_busy periods | event_uid only with include_ids=true | always event_uid |
| 422 wording | “\<field> must be specified”; errors.tzid_unrecognized “TZID not recognized”; errors.invalid_time_pair “end must be after start” | errors.required “required”; errors.invalid |
| POST /v1/application_calendars | no account_id; linking_profile.profile_name null; the same token pair again while it is valid | account_id; the calendar’s name; a new pair on every call |
| Refreshing a token | the same access token while it has at least 10 minutes left (expires_in = what is left) | a new access token |
| GET /v1/account for an application calendar | 403, plain text | 200 |
| GET /v1/userinfo | cronofy.type / cronofy.data; an application calendar has application_calendar_id and no name or zoneinfo | calmonkey.type / calmonkey.data |
| Creating a channel | no signing_secret in the response | signing_secret, once |
| Webhook headers | Cronofy-HMAC-SHA256 as well | Calmonkey-\* headers only |
| Application calendars’ provider_name / provider_service | cronofy | calmonkey |
| Reading a recurring event your application wrote | the first occurrence carries event_id and recurrence; later ones carry series_identifier and series_master, no event_id, and options.update false | every occurrence carries event_id, series_identifier and series_master; the first also recurrence |
| A timed recurring event written without tzid | accepted; it repeats at the same UTC time | 422 on tzid: a series repeats in local time and needs its zone |
| recurrence with an empty rules list | accepted: the event stops repeating | 422; send recurrence: null |
| attendees on a calendar that sends no invitations | errors.event.cannot_set_attendees | errors.attendees_unsupported |
| conferencing.profile_id integrated on a calendar without a link of its own | errors.integrated_conferencing_not_available | errors.conferencing_unavailable |
| An unknown conferencing.profile_id | errors.not_recognized | errors.invalid_value |

Webhook signatures: in compatibility mode `Cronofy-HMAC-SHA256` is computed exactly as Cronofy computes it (base64 HMAC-SHA256 of the body, keyed with your client secret), so your existing verification keeps working once it uses the new client secret.

<a id="differences"></a>

## Differences in every mode

- **Callback URLs** must be https on port 443 and on a public host. Cronofy also accepts http and other ports.
- **Channels** have no `scheduling_conversations` key (that belongs to Cronofy’s Scheduler).
- **Profiles** carry two added keys, which Cronofy clients ignore: `profile_calendars` (each profile’s calendars, so one call is enough) and `profile_relink_url` (always null: a profile is reconnected through `/oauth/authorize`).
- **Events** always carry `event_private` (false), `extended_transparency` (the same as `transparency`), `organizer`, and `url` when one was written. **Calendars** carry `calendar_attachments_available` (false). **Profiles** carry `provider_service`.
- **Attendees.** `notify_attendees` (on writes and deletes) is CalMonkey’s own field. With `notify_attendees: false` an application calendar stores guests, which Cronofy’s application calendars refuse; taking guests off with `remove` alone is always accepted. A body with a mistake in it answers with the mistake first, and with the calendar’s refusal once the body is valid.
- **Recurring events.** The event’s start must itself be an occurrence of the rule (a rule for Tuesdays needs a start on a Tuesday, in the event’s time zone); Cronofy accepts a start that is not. A monthly rule without `by_day` starts on the 1st to the 28th, a yearly one on any date but 29 February. `count` goes up to 730 and `interval` to 99. `start` and `end` are also accepted as `{ time, tzid }` objects.
- **Single occurrences.** `recurrence.exceptions.change`, `DELETE` with `occurrence_date` and `POST /v1/calendars/{id}/events/occurrences` are CalMonkey’s own, and every occurrence carries an added `occurrence_date`. Other keys under `exceptions` (Cronofy’s `exclude` among them) are `errors.not_supported`, as on Cronofy.
- **Conferencing.** A made link reads `{ provider_name, join_url }`: `provider_name` is added. `profile_id: "default"` on a calendar without a link of its own (iCloud, application calendars) writes the event without one and reads show no `conferencing` key; Cronofy shows `pending` there. `profile_id: "explicit"` is refused with `422` on `conferencing.profile_id`.
- **Wrong client credentials** are `400 {"error":"invalid_client"}` on `/oauth/*` and `401` with an empty body on `POST /v1/application_calendars`, as Cronofy.
- **Webhooks** are retried for about 23 hours ([schedule](https://calmonkey.com/docs/errors.md#webhook-retries)); `410 Gone` closes the channel.

<a id="steps"></a>

## Switching, step by step

1. **Create an application** in the [dashboard](https://app.calmonkey.com/dashboard). Copy the client id and the client secret (shown once).
2. **Register your redirect URIs**, exactly as your code sends them, and turn on Cronofy compatibility mode.
3. **Point your client at CalMonkey.** If it reads the hosts from settings, that is four values:

   ```sh
   CRONOFY_CLIENT_ID=<your CalMonkey client id>
   CRONOFY_CLIENT_SECRET=<your CalMonkey client secret>
   CRONOFY_API_HOST=https://api.calmonkey.com
   CRONOFY_SITE_HOST=https://app.calmonkey.com
   ```

   The API host replaces `api.cronofy.com` (or your data centre’s API host); the app host replaces `app.cronofy.com` for `/oauth/authorize`. If your code checks that callback or API URLs end in `cronofy.com`, change that check to compare with the CalMonkey hosts exactly.
4. **Check webhook verification** with the new client secret, and that your callback URL is https on port 443.
5. **Move your users.** Tokens cannot be carried over from Cronofy: each user connects their calendar again through CalMonkey. Before switching, for each connection: delete the events you wrote through Cronofy, close its channels and revoke its Cronofy token; then mark the connection as needing to reconnect, and show the user a short message (“Calendar sync was upgraded. Connect your calendar again.”). When they reconnect, write their upcoming events again: writes are idempotent by `event_id`.
6. **Test first** with an [application calendar](https://calmonkey.com/docs/quickstart.md#application-calendars), which needs no browser, then with one real account per provider you offer.

> CalMonkey is hosted in Sydney, Australia. There is no choice of data centre, so there is no data centre in the hostnames either.

Source: https://calmonkey.com/docs/cronofy
