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