> ## Documentation Index
> Fetch the complete documentation index at: https://docs.ruber.me/llms.txt
> Use this file to discover all available pages before exploring further.

# Calendar

> Calendars, events and recurrence — including the one thing about time zones that decides whether a repeating meeting is right.

**Required permission:** Calendar → Read to list, Calendar → Write to create,
change or delete. One permission covers calendars and their events: a calendar
you may list but whose events you may not read describes no integration
anybody wants.

<Warning>
  **These are organization calendars. A key never sees anybody's personal one.**

  Calendars a person makes in the web app belong to that person and are invisible
  to every API key, as are the events on them. Calendars a key creates **are**
  visible to your team in the app.
</Warning>

## The one thing to read first

A repeating event does not happen at an instant. **"Every weekday at 09:00"
means nine o'clock on the clock on the wall** — and the instant that names moves
by an hour when the clocks change. A series stored as a UTC instant plus a rule
is therefore wrong for half the year.

So Ruber stores a **local time, an IANA zone and a duration**, and this API is
explicit about which of the two you are looking at:

|                                                                 | You get                                                  | Because                                                      |
| --------------------------------------------------------------- | -------------------------------------------------------- | ------------------------------------------------------------ |
| [`GET /v1/events`](#what-is-on)                                 | **Occurrences** — absolute `starts_at` / `ends_at`       | "What is on next Tuesday" is a question about moments        |
| [`GET /v1/events/{id}`](#one-series)                            | **The series** — `starts_at_local`, `time_zone`, `rrule` | That is what the rule is actually defined in                 |
| [`GET /v1/events/{id}/occurrences`](#when-does-this-one-repeat) | When **one** series happens                              | Walking a series forward without fetching the whole calendar |
| [`GET /v1/availability`](#when-is-anybody-free)                 | **Busy and free blocks**                                 | "When could we meet" is not a question about titles          |

## What is on

```
GET https://app.ruber.me/api/v1/events?from=…&to=…
```

Returns one entry per time something happens. A weekly stand-up is one row in
the database and four entries in a month.

| Parameter    |                                                                         |
| ------------ | ----------------------------------------------------------------------- |
| `from`, `to` | ISO 8601 instants. Default: now to 30 days out. At most 366 days apart. |
| `date`       | A single local day, like `2026-10-25`. Use instead of `from`/`to`.      |
| `time_zone`  | Whose day `date` means. Defaults to UTC.                                |
| `calendar`   | Only this calendar's events.                                            |
| `status`     | `confirmed`, `tentative` or `cancelled`.                                |
| `limit`      | 1–1000. Defaults to 250.                                                |

### One day at a time

```bash theme={null}
curl "https://app.ruber.me/api/v1/events?date=2026-10-25&time_zone=Europe/London"   -H "Authorization: Bearer $RUBER_API_KEY"
```

<Warning>
  **A local day is not 24 hours, and `date` is not `from` plus a day.**

  The 25th of October 2026 is **25 hours long** in London; the 29th of March is
  23\. `?date=` converts both edges from wall clock in `time_zone`, so the window
  is the day people actually lived — a fixed 86,400,000 milliseconds would close
  an hour early in October and drop everything after 23:00.

  Consecutive days tile exactly: one day's end is the next day's start, so
  nothing at midnight is counted twice or missed.
</Warning>

Passing `date` together with `from` or `to` is refused rather than merged —
they would have to agree, and there is no useful answer when they do not.

```bash theme={null}
curl "https://app.ruber.me/api/v1/events?from=2026-10-18T00:00:00Z&to=2026-11-16T00:00:00Z" \
  -H "Authorization: Bearer $RUBER_API_KEY"
```

```json theme={null}
{
  "data": [
    {
      "event_id": "7474309c-53f5-40c3-b734-19f7c6482d38",
      "starts_at": "2026-10-19T08:00:00.000Z",
      "ends_at": "2026-10-19T08:15:00.000Z",
      "event": {
        "id": "7474309c-53f5-40c3-b734-19f7c6482d38",
        "title": "Stand-up",
        "starts_at_local": "2026-10-19T09:00:00",
        "time_zone": "Europe/London",
        "duration_minutes": 15,
        "rrule": "FREQ=WEEKLY;BYDAY=MO",
        "all_day": false,
        "status": "confirmed",
        "is_override": false
      }
    },
    {
      "event_id": "7474309c-53f5-40c3-b734-19f7c6482d38",
      "starts_at": "2026-10-26T09:00:00.000Z",
      "ends_at": "2026-10-26T09:15:00.000Z",
      "event": { "…": "…" }
    }
  ],
  "window": { "from": "2026-10-18T00:00:00.000Z", "to": "2026-11-16T00:00:00.000Z" }
}
```

**Look at those two instants.** `08:00Z` and then `09:00Z` — Britain left summer
time on the 25th. The meeting did not move; 09:00 stayed 09:00, which is the
whole point of storing a local time.

<Note>
  **Overlap, not containment.** An event that starts before your window and ends
  inside it is returned. Asking only for events *starting* in a range is how a
  "what is on today" call loses the overnight shift.
</Note>

Occurrences are expanded per request rather than stored, so an event edited in
the app is correct here on the next call — there is no index to fall behind.

## One series

```
GET https://app.ruber.me/api/v1/events/{id}
```

The event in its own terms: `starts_at_local` and `time_zone` (the rule's
definition), `starts_at` (the instant the first occurrence begins),
`duration_minutes`, `rrule`, `exdates` and `series_ends_at` — `null` for a rule
with no end.

## Create an event

```
POST https://app.ruber.me/api/v1/events
```

```bash theme={null}
curl -X POST https://app.ruber.me/api/v1/events \
  -H "Authorization: Bearer $RUBER_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "title": "Stand-up",
    "starts_at": "2026-10-19T09:00:00",
    "time_zone": "Europe/London",
    "duration_minutes": 15,
    "rrule": "FREQ=WEEKLY;BYDAY=MO"
  }'
```

| Field                     |              |                                                         |
| ------------------------- | ------------ | ------------------------------------------------------- |
| `title`                   | **required** | Up to 500 characters.                                   |
| `starts_at`               | **required** | See the three forms below.                              |
| `time_zone`               |              | IANA, like `Europe/London`. Defaults to the calendar's. |
| `duration_minutes`        |              | Preferred for repeating events.                         |
| `ends_at`                 |              | The alternative. Must be after `starts_at`.             |
| `all_day`                 |              | Then `starts_at` is a date.                             |
| `rrule`                   |              | An RFC 5545 rule, with or without the `RRULE:` prefix.  |
| `calendar_id`             |              | Defaults to the organization's oldest calendar.         |
| `description`, `location` |              |                                                         |
| `status`                  |              | `confirmed` (default), `tentative`, `cancelled`.        |
| `colour`                  |              | A named colour, or `colour_custom` as `#rrggbb`.        |
| `reminders`               |              | Up to 5. Minutes before the start.                      |

### The three forms of `starts_at`

| You write              | It means                                                          |
| ---------------------- | ----------------------------------------------------------------- |
| `2026-10-19`           | An all-day date. Zoneless by definition — the 19th everywhere.    |
| `2026-10-19T09:00:00`  | **09:00 in `time_zone`**, whatever that is in UTC that week.      |
| `2026-10-19T08:00:00Z` | An instant, converted to whatever the clock in `time_zone` reads. |

Both timed forms are unambiguous and both are honoured exactly as written. The
middle one is what a repeating event wants.

<Note>
  **Prefer `duration_minutes` over `ends_at` for a repeating event.** An end
  instant describes the first occurrence only, so a series expressed that way
  changes length halfway through the year, when the clocks go back.
</Note>

A date that does not exist is refused, not rounded. `2026-02-30` is a `400` —
not the 2nd of March, which is what naive date parsing produces and what would
otherwise silently anchor a whole series two days out.

## Change or delete a series

```
PATCH  /v1/events/{id}
DELETE /v1/events/{id}
```

`PATCH` changes only the fields you name — but **timing is one field**. Naming
`time_zone`, `starts_at`, `duration_minutes`, `ends_at`, `all_day` or `rrule`
recomputes the whole timing together, because writing a new zone against an
unchanged local time does not translate an event, it reinterprets it.

```bash theme={null}
# the same 09:00 meeting, now held in Berlin
curl -X PATCH https://app.ruber.me/api/v1/events/7474309c-… \
  -H "Authorization: Bearer $RUBER_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"time_zone": "Europe/Berlin"}'
```

`starts_at_local` stays `09:00`; `starts_at` moves from `08:00Z` to `07:00Z`.

<Warning>
  **`DELETE` removes the whole series, not one occurrence.** The answer says
  which it was:

  ```json theme={null}
  { "deleted": true, "id": "7474309c-…", "series": true }
  ```
</Warning>

## When does this one repeat

```
GET https://app.ruber.me/api/v1/events/{id}/occurrences
```

`GET /v1/events` answers "what is on" across the whole calendar. This answers
"when does **this** happen", which is the other question — and getting it from
the list endpoint means asking for every event in a window and discarding all
but one, over and over, to walk a series forward.

Takes `from`/`to` (default: now to 90 days out), or `date` and `time_zone`, and
`limit`.

```json theme={null}
{
  "data": [
    { "starts_at": "2026-10-19T08:00:00.000Z", "ends_at": "2026-10-19T08:30:00.000Z" },
    { "starts_at": "2026-10-26T09:00:00.000Z", "ends_at": "2026-10-26T09:30:00.000Z" },
    { "starts_at": "2026-11-02T09:00:00.000Z", "ends_at": "2026-11-02T09:30:00.000Z" }
  ],
  "event": { "…": "…" },
  "has_more": false,
  "window": { "from": "…", "to": "…" }
}
```

There is the clock change again: `08:00Z`, then `09:00Z`. The meeting stayed at
09:00 local throughout.

`has_more` is true when the limit was reached, so a caller walking a long series
knows to ask again from the last occurrence rather than concluding it ended.

## When is anybody free

```
GET https://app.ruber.me/api/v1/availability
```

The same data as the event list, and a different question. "What is on
Thursday" wants titles; "when could we meet on Thursday" wants blocks of time —
and turning one into the other means expanding recurrence, sorting, and merging
overlaps at the joins.

Takes `from`/`to` (default: now to 7 days out, at most 92 days), or `date` and
`time_zone`, and `calendar`.

```json theme={null}
{
  "busy": [
    { "start": "2026-11-02T09:00:00.000Z", "end": "2026-11-02T10:15:00.000Z" }
  ],
  "free": [
    { "start": "2026-11-02T00:00:00.000Z", "end": "2026-11-02T09:00:00.000Z" },
    { "start": "2026-11-02T10:15:00.000Z", "end": "2026-11-03T00:00:00.000Z" }
  ],
  "window": { "from": "…", "to": "…" }
}
```

**Blocks are merged.** Three overlapping meetings are one busy block, and a
meeting ending at 10:00 followed by one starting at 10:00 is one unbroken hour —
not two with a gap nobody can use. `free` is the gaps between them, clipped to
the window.

<Note>
  **No working hours are applied here.** This endpoint does not know whose day it
  is describing or when they work, and inventing 09:00–17:00 would make "free"
  mean something you did not ask for.

  Working hours live on a booking link — see
  [its slots](/api/booking#the-slots-a-link-is-offering), which applies opening
  hours, a buffer, a lead time and a horizon on top of exactly this.
</Note>

Cancelled events make nobody busy, and encrypted events are not counted — see
below.

## Calendars

```
GET    /v1/calendars
POST   /v1/calendars
GET    /v1/calendars/{id}
PATCH  /v1/calendars/{id}
DELETE /v1/calendars/{id}
```

```json theme={null}
{
  "id": "126aad38-b029-4e98-8132-74516ef7b073",
  "name": "Team",
  "colour": "violet",
  "time_zone": "Europe/London",
  "event_count": 12,
  "created_at": "2026-09-09T16:12:09.356Z",
  "updated_at": "2026-09-09T16:12:09.356Z"
}
```

`name` is required and unique to nothing — two calendars may share one.
`time_zone` is the default every new event on the calendar inherits; changing it
does **not** move events that already exist.

<Warning>
  **Deleting a calendar deletes its events.** The answer says how many:
  `{"deleted": true, "events_deleted": 12}`. Read `event_count` first if that
  matters.

  A calendar with a booking link pointing at it cannot be deleted at all — a live
  booking page whose calendar vanished would take reservations into nothing.
  Remove the booking link first.
</Warning>

## Not available yet

**Editing one occurrence of a series.** "Move just next Tuesday's" splits the
rule and writes a separate override row. The app does it; the API does not yet,
because a half-right implementation leaves a series with a hole or a duplicate.
Overrides made in the app **are** readable here, flagged `is_override`.

**Encrypted events are never returned.** A Private event's title, location and
description are ciphertext, and the key that reads them is in one person's
browser. Such events are omitted rather than returned nameless — see
[Private mailboxes](/api/introduction#private-mailboxes-are-not-readable) for
the same rule applied to mail.

**Attendees.** Invitations and RSVPs are in the app and are not exposed yet.
Booking links **are** — see [Booking](/api/booking).

## Errors

| Code              | HTTP  | Cause                                                                                                                                                                                             |
| ----------------- | ----- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `invalid_request` | `400` | A window over 366 days, a `to` before `from`, a zone that is not IANA, a rule that does not parse, an `ends_at` before its start, a date that does not exist, or no calendar to put the event on. |
| `unauthorized`    | `401` | Missing, malformed, unknown or revoked key.                                                                                                                                                       |
| `forbidden`       | `403` | The key lacks `calendar:read` or `calendar:write`.                                                                                                                                                |
| `not_found`       | `404` | No organization calendar or event with that id. A personal one answers the same way.                                                                                                              |
| `rate_limited`    | `429` | Over 600 requests in a minute.                                                                                                                                                                    |

See [Errors](/api/introduction#errors) for the full response shape.
