---
title: "Calendars"
description: "Store events with recurrence and time zones, import and export iCalendar, and show free/busy without showing details."
section: "interfaces"
status: "Building for 1.2 · ships as a experimental API"
spec: "calendar"
canonical: "https://mainsdk.dev/docs/interfaces/calendar"
updated: "2026-10-28"
---

# Calendars

> Store events with recurrence and time zones, import and export iCalendar, and show free/busy without showing details.

Status: Building for 1.2 · ships as a experimental API · Canonical: https://mainsdk.dev/docs/interfaces/calendar · Updated: 2026-10-28

`calendar` stores events with recurrence and time zones, imports and exports iCalendar, and shows free/busy without details. Reach for it for bookings, team schedules or a mail client's calendar.

Events keep the wall-clock time a person chose in their zone, so "every Monday at 09:00 in New York" stays at 09:00 through daylight-saving changes. Recurring series can be edited one occurrence at a time or from a date onward.

> **Note:** Experimental. Import it from `mainsdk/experimental/calendar` and build a `Calendar` with your `db` stores, tenant and subject. It is not on the `app` object yet.

## At a glance

- Status: Building for 1.2 · ships as a experimental API
- Phase: 3 (Mobile and regulated)
- Milestone: 1.2 Regulated industries
- Plan: free in your app (Apache-2.0)
- Spec: https://github.com/hubxero/mainsdk/blob/develop/docs/specs/calendar.md
- Category: Data

Calls:

- `calendar.createCalendar({ id?, name, owner? })`
- `calendar.calendars()`
- `calendar.create(cal, event)`
- `calendar.get(id)`
- `calendar.update(id, patch, { scope, occurrence?, version })`
- `calendar.delete(id, { scope, occurrence? })`
- `calendar.list({ calendars, from, to, cursor?, limit? })`
- `calendar.freeBusy({ calendars?, members?, from, to })`
- `calendar.import(cal, source)`
- `calendar.export(cal, { from?, to? })`
- `calendar.subscribe(cal)`

In main.yaml:

```yaml
calendar:
  store: db
  max-occurrences: 5000
  max-window: 366d
```

Guarantees, whatever the driver:

- Wall-clock times stay put across daylight-saving changes
- Recurrence expansion is bounded
- Free/busy never shows titles, places or attendees to someone who can't read the calendar


## Drivers

Pick one in main.yaml with `calendar.driver`.

| Driver | Id in main.yaml | Default | Note |
| --- | --- | --- | --- |
| Your database (the tenant's region store) | `db` | yes |  |
| Local (tests and mainsdk dev) | `local` |  |  |
| CalDAV | `caldav` |  |  |

## Codes

| Code | Where | Meaning |
| --- | --- | --- |
| `MAIN-CAL-0001` | check | An event is invalid: end before start, an unknown IANA zone, a date-time mixed with a date, or no title (400) |
| `MAIN-CAL-0002` | check | A recurrence rule is invalid or uses an unsupported part (400) |
| `MAIN-CAL-0003` | runtime | A window passes `max-window`, or the expansion passes `max-occurrences` (400) |
| `MAIN-CAL-0004` | runtime | iCalendar text can't be parsed at a line; that event is skipped |
| `MAIN-CAL-0005` | runtime | An import is larger than `calendar.max-import` (413) |
| `MAIN-CAL-0006` | runtime | No calendar with that id in the tenant, or access denies it (404 or 403) |
| `MAIN-CAL-0007` | runtime | An edit scope that doesn't fit the event or occurrence (400) |
| `MAIN-CAL-0008` | runtime | The event changed since `version` was read (409) |

## Features

| Feature | Phase | Milestone | Spec ticket |
| --- | --- | --- | --- |
| calendar@1 contract | 3 | 1.2 | `calendar#contract` |
| Events in IANA time zones | 3 | 1.2 | `calendar#events` |
| RFC 5545 recurrence | 3 | 1.2 | `calendar#rrule` |
| Edit one, following or all occurrences | 3 | 1.2 | `calendar#edit-scope` |
| iCalendar import and export | 3 | 1.2 | `calendar#ical` |
| Free/busy | 3 | 1.2 | `calendar#freebusy` |

## MainSDK Cloud

Everything on this page is free in your app.


## Quick start

```yaml title="main.yaml"
calendar:
  store: db
  max-occurrences: 5000
  max-window: 366d
```

```ts title="app/calendar.ts"
import { Calendar } from "mainsdk/experimental/calendar";

type CalendarDeps = ConstructorParameters<typeof Calendar>[0];

declare const config: Record<string, unknown>;      // the calendar section of main.yaml
declare const stores: CalendarDeps["stores"];         // the region SQL stores your db uses
declare const tenant: CalendarDeps["tenant"];
declare const currentUser: { id: string };

const calendar = new Calendar({
  config,
  stores,
  tenant,
  subject: () => ({ id: currentUser.id }),
});

await calendar.createCalendar({ id: "team", name: "Team" });
await calendar.create("team", {
  title: "Stand-up", start: "2026-11-02T09:00", end: "2026-11-02T09:15",
  timeZone: "America/New_York", rrule: "FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR",
});
```

## Configuration

| Key | Default | Meaning |
| --- | --- | --- |
| `store` | `db` | Where events live: `db` or `local`. CalDAV, Google and Microsoft come later through connections |
| `max-occurrences` | `5000` | The most occurrences one list call expands |
| `max-window` | `366d` | The widest window a list or free/busy call may ask for |
| `max-import` | `5MB` | The largest iCalendar text an import accepts |

## What you get

Every call runs in the current tenant and checks `access` on the calendar.

| Call | What it does |
| --- | --- |
| `calendar.createCalendar({ id?, name, owner? })` | Make a calendar; the owner is the caller unless one is named |
| `calendar.calendars()` | List the calendars the caller may read |
| `calendar.create(cal, event)` | Add an event. `start` and `end` are local date-times, or dates for an all-day event; `timeZone` is an IANA name, and without one the event floats. Takes `rrule`, `exdates` and `rdates` for recurrence |
| `calendar.get(id)` | Read one event with its `version` |
| `calendar.update(id, patch, { scope, occurrence?, version })` | Change an event. `scope` is `this`, `following` or `all`; a stale `version` fails with `MAIN-CAL-0008` |
| `calendar.delete(id, { scope, occurrence? })` | Delete one occurrence, this and the following, or the whole series |
| `calendar.list({ calendars, from, to, cursor?, limit? })` | Return expanded occurrences in a window, as UTC instants plus local times |
| `calendar.freeBusy({ calendars?, members?, from, to })` | Return merged busy intervals, with tentative ones marked. It never returns titles, places or attendees to someone who cannot read the calendar |
| `calendar.import(cal, source)` | Read iCalendar text or a file. Events with the same `UID` update instead of duplicating; a bad event is skipped and listed with its line |
| `calendar.export(cal, { from?, to? })` | Write valid RFC 5545 text that imports back unchanged |
| `calendar.subscribe(cal)` | Return a getqr.id share link to the calendar feed, never a raw URL |

Always:

- Wall-clock times stay put across daylight-saving changes. A time in the spring-forward gap moves forward by the gap, and a time that happens twice uses the first.
- Recurrence expansion is bounded by `max-window` and `max-occurrences`.
- Free/busy never shows titles, places or attendees to someone who can't read the calendar.
- Events stay in the tenant's region and are labelled `personal`.

## Examples

### Move one Monday

```ts
import type { Calendar } from "mainsdk/experimental/calendar";

declare const calendar: Calendar;
declare const eventId: string;

const week = await calendar.list({ calendars: ["team"], from: "2026-11-01", to: "2026-11-30" });
await calendar.update(eventId, { start: "2026-11-17T11:00", end: "2026-11-17T11:15" }, {
  scope: "this", occurrence: week.items[2].occurrence, version: 1,
});
```

### Show when people are busy

```ts
import type { Calendar } from "mainsdk/experimental/calendar";

declare const calendar: Calendar;

const { busy } = await calendar.freeBusy({ calendars: ["a", "b"], from: "2026-11-02T00:00", to: "2026-11-03T00:00" });
```

## Errors and checks

| Code | When | Fix |
| --- | --- | --- |
| `MAIN-CAL-0001` | An event is invalid: end before start, an unknown zone, mixed date and date-time, or no title (400) | Use an IANA zone and a start before the end |
| `MAIN-CAL-0002` | A recurrence rule is invalid or uses an unsupported part (400) | Drop the named part, or use a supported one |
| `MAIN-CAL-0003` | A window passes `max-window`, or expansion passes `max-occurrences` (400) | Ask for a narrower window |
| `MAIN-CAL-0004` | iCalendar text can't be parsed at a line; that event is skipped | Fix the line the result names |
| `MAIN-CAL-0005` | An import is larger than `max-import` (413) | Split the file or raise `max-import` |
| `MAIN-CAL-0006` | No such calendar in the tenant, or access denies it (404 or 403) | Use a calendar you may read |
| `MAIN-CAL-0007` | An edit scope that doesn't fit the event (400) | Use `all` on a single event |
| `MAIN-CAL-0008` | The event changed since `version` was read (409) | Read the event again and retry |

`mainsdk check` has a `calendar` gate: `MAIN-CAL-0001` and `MAIN-CAL-0002` in seed data and `main.yaml`, each with its file and line. Each code's cause and fix is in [check codes](https://mainsdk.dev/docs/reference/check-codes).

## Swapping drivers

`store: db` keeps events in the tenant's region store; `store: local` keeps them in process for tests and `mainsdk dev`. The calls do not change. A CalDAV driver comes later, with a connection.

## Testing

`store: local` runs offline on the fake clock, so recurrence and daylight-saving cases need no real time. See [Test your app](https://mainsdk.dev/docs/guides/testing).

## Security and privacy

- Every call checks `access` on the calendar, and another tenant's calendar reads as missing.
- `privacy.export` includes the calendars a person owns and the events they created or attend; `privacy.erase` deletes owned calendars and removes the person from other events' attendees.
- Subscriptions are getqr.id links, so the feed address is never handed out raw.

## Next

- [Use calendars in your app](https://mainsdk.dev/docs/guides/use-calendar): the steps, end to end.
- [Inbound mail](https://mainsdk.dev/docs/interfaces/mail)
- [Access](https://mainsdk.dev/docs/interfaces/access)
