Calendar API
Read a user’s calendars and what is on them, and create, change and delete events. Recurring series come back already expanded: ask for a week and a weekly meeting is one item in it, at the time it actually happens, not a rule you have to interpret. All endpoints are under https://api.cirrux.co/public_api/v1/calendars.
Every endpoint acts on one person’s calendars, so it needs a user-scoped OAuth token. A workspace API key is refused with 403 forbidden. Reading needs calendar.read, and creating, changing or deleting events needs calendar.write. A token is scoped to one workspace, and sees the calendars of the user’s mailboxes in that workspace, including calendars shared into those mailboxes from elsewhere.
Changes made through the API behave exactly like changes made in Cirrux Calendar: guests are emailed invitations, updates and cancellations, and every connected app and CalDAV client picks the change up.
The calendar object
| Field | Type | Required | Description |
|---|---|---|---|
| object | string | no | Always calendar. |
| uuid | string | no | This calendar’s entry in one mailbox. Not the id the event endpoints take: use calendar_uuid for those. |
| calendar_uuid | string | no | The calendar itself. This is the {calendar_uuid} in every event endpoint. |
| mailbox_uuid | string | no | The mailbox this entry belongs to. |
| mailbox_address | string | null | no | That mailbox’s email address. A user with several mailboxes often has several calendars named "Calendar", and this tells them apart. |
| name | string | no | The calendar’s name in this mailbox. |
| description | string | null | no | The calendar’s description, when set. |
| color | string | null | no | A #RRGGBB hex color. |
| role | string | no | Your access: owner, editor or viewer. |
| can_write | boolean | no | Whether you may create, change and delete events here (owner or editor). |
| source | string | no | owned for your own calendar, shared for one someone shared with you, and subscribed for a subscription to an external calendar feed, whose events cannot be changed here. |
| position | number | null | no | Sort position in the sidebar. |
| is_default | boolean | no | Whether this is the mailbox’s default calendar, where it files new events. |
Name, color and position belong to a mailbox, not to the calendar, so a calendar that is linked to two of your mailboxes (say you shared your own calendar with your second address) appears twice, with the same calendar_uuid and a different uuid.
{
"object": "calendar",
"uuid": "6f1d2c3b-1111-4a2b-8c3d-000000000001",
"calendar_uuid": "9a8b7c6d-2222-4e5f-9a1b-000000000002",
"mailbox_uuid": "1c2d3e4f-3333-4a5b-8c6d-000000000003",
"mailbox_address": "jane@example.com",
"name": "Work",
"description": null,
"color": "#00CCAA",
"role": "owner",
"can_write": true,
"source": "owned",
"position": 0,
"is_default": true
}The event object
An event object is one occurrence: something that happens at one time. A one-off event is one occurrence; a recurring series is one occurrence per repetition. Create and update answer with this same object, so you can read an event, change the JSON and post it back.
| Field | Type | Required | Description |
|---|---|---|---|
| object | string | no | Always calendar_event. |
| id | string | no | Identifies this occurrence, and is what the update and delete endpoints take. <uuid> for a one-off, <series_uuid>_<recurrence_id> for an occurrence of a series. Pass it back whole rather than taking it apart. See Recurring events. |
| event_uuid | string | no | The stored event this occurrence comes from: the one-off itself, the series, or the individually edited occurrence. |
| series_uuid | string | null | no | The series this occurrence belongs to. null for a one-off. |
| series_group_uuid | string | null | no | Shared by the parts of a series that was split with "this and following events" in Cirrux Calendar, so you can tell they belong together. |
| calendar_uuid | string | no | The calendar the event is on. |
| recurrence_id | string | null | no | The slot in the series this occurrence fills, in iCalendar basic form: YYYYMMDD for an all-day series, YYYYMMDDTHHMMSSZ (UTC) for a timed one. It stays the original slot when the occurrence has been moved. null for a one-off. |
| is_exception | boolean | no | Whether this occurrence was edited on its own, apart from the rest of its series. |
| status | string | no | confirmed or tentative. Cancelled events and occurrences are left out of the list entirely. |
| title | string | null | no | The event title. |
| description | string | null | no | Free text notes. |
| location | string | null | no | Where it happens. |
| url | string | null | no | A link for the event. |
| transparency | string | null | no | opaque (busy) or transparent (free). |
| all_day | boolean | no | Whether start and end are dates rather than timestamps. |
| start | object | no | date (YYYY-MM-DD) on an all-day event, or date_time (a UTC timestamp) and time_zone (the event’s IANA time zone) on a timed one. Only the keys that apply are present. |
| end | object | no | Same shape as start. On an all-day event the end date is exclusive: a one-day event on 2 September ends on 2026-09-03. |
| recurrence | string | null | no | The series’ RRULE, repeated on every occurrence of it, such as FREQ=WEEKLY;BYDAY=WE. null for a one-off. |
| sequence | number | no | The iCalendar SEQUENCE: goes up when the organizer makes a change guests need to see. |
| organizer | object | null | no | email, name and calendar_address (a mailto: URI) of whoever organizes the event. An event created through the API is always organized by you. null when the event has no organizer, as with some imported events. |
| attendees | array | no | The guests, each with email, name, calendar_address, status (needs_action, accepted, declined or tentative), role (chair, req_participant, opt_participant or non_participant), cutype (individual, group, resource, room or unknown) and response_comment. Empty when there are none. |
| created_at | string | no | ISO-8601 timestamp. |
| updated_at | string | no | ISO-8601 timestamp. |
{
"object": "calendar_event",
"id": "3b0c9e2a-4444-4b6c-8d7e-000000000004_20260909T070000Z",
"event_uuid": "3b0c9e2a-4444-4b6c-8d7e-000000000004",
"series_uuid": "3b0c9e2a-4444-4b6c-8d7e-000000000004",
"series_group_uuid": null,
"calendar_uuid": "9a8b7c6d-2222-4e5f-9a1b-000000000002",
"recurrence_id": "20260909T070000Z",
"is_exception": false,
"status": "confirmed",
"title": "Standup",
"description": null,
"location": "Room 2",
"url": null,
"transparency": "opaque",
"all_day": false,
"start": {
"date_time": "2026-09-09T07:00:00Z",
"time_zone": "Europe/Amsterdam"
},
"end": {
"date_time": "2026-09-09T07:15:00Z",
"time_zone": "Europe/Amsterdam"
},
"recurrence": "FREQ=WEEKLY;BYDAY=WE",
"sequence": 0,
"organizer": {
"email": "jane@example.com",
"name": "Jane Doe",
"calendar_address": "mailto:jane@example.com"
},
"attendees": [
{
"email": "sam@example.com",
"name": "Sam",
"calendar_address": "mailto:sam@example.com",
"status": "accepted",
"role": "req_participant",
"cutype": "individual",
"response_comment": null
}
],
"created_at": "2026-08-20T10:12:03.000Z",
"updated_at": "2026-08-20T10:12:03.000Z"
}Times and time zones
A timed event comes back as an absolute UTC instant in date_time, with the event’s own time zone in time_zone so you can show “09:00 Amsterdam” rather than an offset. A recurring event repeats at the same local time in that zone, so its UTC times shift by an hour when daylight saving time starts or ends.
When you send a timed bound, date_time may be an absolute instant (2026-09-02T07:00:00Z or 2026-09-02T09:00:00+02:00) or a local time without an offset (2026-09-02T09:00), which is read in time_zone. time_zone defaults to the user’s own time zone in Cirrux, then UTC, and is stored as the event’s zone either way.
An all-day event has dates only, and its end date is exclusive, as in iCalendar: a three-day offsite from 2 to 4 September is "start": { "date": "2026-09-02" }, "end": { "date": "2026-09-05" }.
Calendars
List calendars
/v1/calendarsReturns every calendar you can see in the token’s workspace, one entry per mailbox it is linked to, ordered by position and then name. Not paginated. Scope calendar.read.
curl https://api.cirrux.co/public_api/v1/calendars \ -H "Authorization: Bearer $CIRRUX_TOKEN"
{
"object": "list",
"url": "/v1/calendars",
"has_more": false,
"data": [
{ "object": "calendar", "name": "Work", "calendar_uuid": "9a8b7c6d-…", … }
]
}Events
List events
/v1/calendars/{calendar_uuid}/eventsReturns everything on a calendar between time_min and time_max, with recurring series expanded into their occurrences, ordered by start time. All-day and timed events are ordered together. A calendar you cannot see is 404 not_found. Scope calendar.read.
| Field | Type | Required | Description |
|---|---|---|---|
| time_min | string | no | Start of the window: an ISO-8601 timestamp, or a YYYY-MM-DD date meaning midnight in timezone. Defaults to now. |
| time_max | string | no | End of the window, in the same forms. Defaults to 31 days after time_min. Must be after time_min, and the window may be at most 366 days. |
| timezone | string | no | IANA time zone, such as Europe/Amsterdam. Used for YYYY-MM-DD bounds and to place all-day events, which run from midnight to midnight in this zone. Defaults to the user’s own time zone, then UTC. |
| limit | number | no | Occurrences per page, 1 to 2500. Defaults to 250. |
| cursor | string | no | The next_cursor of the previous page. |
An event is in the window when it overlaps it, not only when it starts in it: it is included when it starts before time_max and ends after time_min. For a window from 09:00 to 17:00:
- an event from 08:00 to 10:00 is included, because it is still running at 09:00;
- an event from 16:00 to 18:00 is included, because it has started before 17:00;
- an event from 08:00 to 09:00 is not, because it ends exactly as the window opens;
- an event starting at 17:00 is not, because the window has closed.
The same holds for each occurrence of a series and for all-day events, so a three-day conference that began yesterday shows up in today’s window. If you only want events that start inside the window, filter on start yourself. The window the server used comes back in the response as time_min, time_max (in UTC) and timezone.
While has_more is true, ask for the next page with cursor set to next_cursor, and the same time_min, time_max and timezone as the first request. An unreadable cursor starts again from the first page.
curl -G https://api.cirrux.co/public_api/v1/calendars/9a8b7c6d-…/events \ -d time_min=2026-09-07 \ -d time_max=2026-09-14 \ -d timezone=Europe/Amsterdam \ -H "Authorization: Bearer $CIRRUX_TOKEN"
{
"object": "list",
"url": "/v1/calendars/9a8b7c6d-2222-4e5f-9a1b-000000000002/events",
"time_min": "2026-09-06T22:00:00Z",
"time_max": "2026-09-13T22:00:00Z",
"timezone": "Europe/Amsterdam",
"has_more": false,
"data": [
{ "id": "3b0c9e2a-…_20260909T070000Z", "title": "Standup", … },
{ "id": "5d4e3f2a-…", "title": "Offsite", "all_day": true, … }
]
}Create an event
/v1/calendars/{calendar_uuid}/eventsCreates an event, or a recurring series when you send recurrence. You are the organizer, through the mailbox the calendar belongs to; the organizer cannot be set. Guests in attendees are emailed an invitation. Returns 201 with the event object. For a series that is the series itself: its id is the series uuid, and start and end are those of its first occurrence. Unknown fields are refused with 422 invalid_value rather than ignored, so a typo such as summary for title cannot silently lose data. Scope calendar.write.
| Field | Type | Required | Description |
|---|---|---|---|
| title | string | null | no | The event title. |
| description | string | null | no | Free text notes. |
| location | string | null | no | Where it happens. |
| url | string | null | no | A link for the event, such as a video call. |
| transparency | string | null | no | opaque (shows as busy) or transparent (shows as free). |
| start | object | yes | An object with date for an all-day event, or date_time and optionally time_zone for a timed one. See Times and time zones. |
| end | object | yes | Same shape as start, and always sent together with it. Both are dates or both are timestamps. An all-day end date is exclusive. |
| all_day | boolean | no | Optional, and only alongside start and end. Their shape already says whether the event is all-day; this is accepted so you can post back an object you read, and must agree with it. |
| recurrence | string | null | no | An iCalendar RRULE without the RRULE: prefix, such as FREQ=WEEKLY;BYDAY=WE. Makes the event a recurring series. |
| attendees | array | no | The guest list: objects with email (required), name, role, cutype, status and response_comment, with the values listed under the event object. Sending it replaces the whole list. |
curl https://api.cirrux.co/public_api/v1/calendars/9a8b7c6d-…/events \
-H "Authorization: Bearer $CIRRUX_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"title": "Standup",
"location": "Room 2",
"start": {
"date_time": "2026-09-02T09:00",
"time_zone": "Europe/Amsterdam"
},
"end": {
"date_time": "2026-09-02T09:15",
"time_zone": "Europe/Amsterdam"
},
"recurrence": "FREQ=WEEKLY;BYDAY=WE",
"attendees": [{ "email": "sam@example.com", "name": "Sam" }]
}'Update an event
/v1/calendars/{calendar_uuid}/events/{id}Takes the same fields as creating an event, none of them required: only the fields in the body change, and anything you leave out stays as it is. start and end still go together. Sending attendees replaces the guest list, so include the guests you want to keep. The id decides what you change: an event’s uuid changes the event, which for a recurring event is the whole series, and an occurrence id changes only that occurrence (see Recurring events). Guests are emailed the update. Returns 200 with the event object. Scope calendar.write.
curl https://api.cirrux.co/public_api/v1/calendars/9a8b7c6d-…/events/3b0c9e2a-… \
-H "Authorization: Bearer $CIRRUX_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "title": "Daily standup" }'Responding to an invitation. When you are a guest rather than the organizer, the only thing you can change is your own response. Update the event by its uuid with attendees holding an entry for your own address and the new status; the rest of the body is ignored, and the organizer is emailed your reply. On a series, that answers for every occurrence. Responding to a single occurrence is not supported yet: an occurrence id on an event you do not organize is 403 not_organizer.
{ "attendees": [{ "email": "jane@example.com", "status": "accepted" }] }Delete an event
/v1/calendars/{calendar_uuid}/events/{id}An event’s uuid deletes the event, which for a recurring event is the whole series, including occurrences that were edited on their own and every part of a series that was split. An occurrence id cancels only that occurrence and leaves the rest of the series alone. Guests are emailed a cancellation either way. Only the organizer can delete. Returns 204 with no body. Scope calendar.write.
curl -X DELETE "https://api.cirrux.co/public_api/v1/calendars/9a8b7c6d-…/events/3b0c9e2a-…_20260909T070000Z" \ -H "Authorization: Bearer $CIRRUX_TOKEN"
Recurring events
Series and occurrences
A recurring event is stored once, as a series with an RRULE. The list endpoint expands it, so every repetition in the window is its own item, with an id of the form <series_uuid>_<recurrence_id>: for example 3b0c9e2a-…_20260909T070000Z for the 9 September standup. The recurrence_id names the slot the rule produced, and stays the same when that occurrence is moved to another time, so the id keeps working.
Which id you send to update or delete decides what changes:
<series_uuid>is the whole series. Update changes every occurrence; delete removes the series, with every occurrence that was edited on its own.<series_uuid>_<recurrence_id>is one occurrence. Update changes just that occurrence: the first time, it is split out of the series, comes back withis_exception: true, and keeps its own start and end unless you send new ones. Delete cancels just that occurrence.
An occurrence that was edited on its own has an event_uuid of its own. Always address it by its occurrence id, never by that event_uuid, which is refused with 422 use_occurrence_id. An occurrence id the series never produces, or that was already cancelled, is 404 not_found. Changing one occurrence or the shape of a series is up to the organizer: a guest gets 403 not_organizer.
Errors
Calendar error codes
On top of the shared errors, calendar endpoints can return:
| Status | error | When |
|---|---|---|
| 404 | not_found | The calendar or event does not exist or is not visible to you. A malformed occurrence id is also a 404. |
| 403 | not_authorized | You can see the calendar but only as a viewer (can_write is false). |
| 403 | calendar_read_only | The calendar is a subscription to an external feed, so its events cannot be changed. |
| 403 | not_organizer | Only the organizer can delete an event, or change or cancel a single occurrence of a series. |
| 402 | subscription_required | The workspace needs an active subscription to create events. |
| 422 | invalid_range | time_min or time_max is unreadable, time_max is not after time_min, the window is over 366 days, or the timezone is unknown. |
| 422 | invalid_value | The body has an unknown field, a bad start or end, an unreadable RRULE, or an attendee value outside its list. error_description says which. |
| 422 | invalid_event | The end is before the start. |
| 422 | invalid_attendee | An attendee has no email address. |
| 422 | no_changes | The update changes nothing. As a guest, the body has no attendees. |
| 422 | use_occurrence_id | An occurrence that was edited on its own was addressed by its event_uuid. Use its occurrence id. |
| 422 | no_owner_mailbox | None of your mailboxes on this calendar can act for you. |
| 422 | invalid_uuid | calendar_uuid is not a valid UUID. |
| 504 | expansion_timeout | Expanding the window took too long. Ask for a shorter one. |