# Events and recurrence

An event is a single event or a whole series. You address it by your own **`external_id`**, for example the post ID from WordPress. You don't need to store any id from us.

## Create or update an event

```
PUT /v1/publisher/calendars/{calendar_id}/events/{external_id}
```

```bash
curl -X PUT https://api.kalender-sync.de/v1/publisher/calendars/$KS_CAL/events/wp-1482 \
  -H "Authorization: Bearer $KS_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "title": "Sunset Yoga in the Park",
    "description": "<p>Bring your own <strong>mat</strong>. Free for members.</p>",
    "location": "Riverside Park, Pier 2, New York",
    "url": "https://example.org/classes/sunset-yoga/",
    "start": "2026-11-12T18:00:00",
    "end": "2026-11-12T19:30:00",
    "timezone": "America/New_York",
    "status": "confirmed",
    "categories": ["Outdoor"],
    "metadata": { "post_id": 1482 }
  }'
```

`external_id` may be 1 to 128 characters: letters, digits and `.` `_` `:` `-`. A prefix such as `wp-1482` works well.

**Response:** `201 Created` the first time, `200 OK` on a change.

```json
{
  "external_id": "wp-1482",
  "calendar_id": "cal_XXXXXXXXXXXX",
  "sequence": 0,
  "created_at": "2026-11-03T14:05:40.000Z",
  "updated_at": "2026-11-03T14:05:40.000Z",
  "event": {
    "title": "Sunset Yoga in the Park",
    "all_day": false,
    "start": "2026-11-12T18:00:00",
    "end": "2026-11-12T19:30:00",
    "timezone": "America/New_York",
    "status": "confirmed",
    "show_as": "free",
    "categories": ["Outdoor"],
    "description": "Bring your own mat. Free for members.",
    "location": "Riverside Park, Pier 2, New York",
    "url": "https://example.org/classes/sunset-yoga/",
    "metadata": { "post_id": 1482 }
  },
  "changed": true
}
```

- `event` is the event as we stored it: times as local time in `timezone`, HTML as text.
- `sequence` counts up with every real change.
- Send the same event again and the API answers `200` with **`"changed": false`**. Nothing is written, the feed stays unchanged. So you can simply call `PUT` on every save in your system.
- `PUT` replaces the whole event. Fields you leave out are empty or back to their default afterwards.

### Optimistic locking with `If-Match`

Optionally send the last `sequence` you saw:

```
If-Match: "3"
```

If the event changed in the meantime (or does not exist yet), you get `409` [`sequence_mismatch`](https://docs.kalender-sync.de/en/api/errors/sequence_mismatch/) instead of an overwrite.

## Fields

| Field | Rules | Default |
|---|---|---|
| `title` | Required, 1 to 255 characters | |
| `start` | Required. Date or date-time, see [times](#times-and-time-zones) | |
| `end` | Optional. For timed events not before `start`; for all-day events **exclusive** | `start` + the calendar's `default_duration`, or one day |
| `timezone` | IANA name, e.g. `America/New_York` | The calendar's time zone |
| `all_day` | `true` or `false` | `true` if `start` is a plain date |
| `description` | Text or HTML, up to 65,536 characters. HTML becomes text; links are kept as `text (URL)`. At most 8,000 characters are stored. | |
| `location` | up to 500 characters | |
| `url` | `https://` URL, up to 2,000 characters. Shown as a link in apps and also appended to the description. | |
| `status` | `confirmed`, `tentative` or `cancelled` | `confirmed` |
| `show_as` | `free` or `busy`: whether the event counts as busy for subscribers | `free` |
| `categories` | up to 10 entries of 1 to 50 characters | `[]` |
| `recurrence` | Series, see [recurrence](#recurrence) | |
| `metadata` | Any JSON object up to 2 KB. Returned in API responses, but never shown in the feed or on the subscribe page. | |

`show_as` defaults to `free`: public events should not mark your subscribers' calendars as busy.

A cancelled event (`"status": "cancelled"`) stays in the feed and is marked as cancelled there; how that looks depends on the app. To remove it completely, delete it.

## Times and time zones

| `start` / `end` | Meaning |
|---|---|
| `"2026-11-12T18:00:00"` | Local time in `timezone` (or the calendar's time zone). Recommended. |
| `"2026-11-12T23:00:00Z"` or `"2026-11-12T18:00:00-05:00"` | Instant with offset. Converted to `timezone` and stored that way. |
| `"2026-11-12"` | Plain date: all-day event. |

Local time plus an IANA time zone is the safest choice: daylight saving time stays right, even for series across the clock change.

**All-day events:** `end` is exclusive. An event on 12 November only has `"start": "2026-11-12"` and `"end": "2026-11-13"`, or no `end` at all. A three-day festival from 12 to 14 November has `"end": "2026-11-15"`.

## Recurrence

```json
{
  "title": "Morning Flow",
  "start": "2026-11-03T07:30:00",
  "end": "2026-11-03T08:30:00",
  "timezone": "America/New_York",
  "recurrence": {
    "rules": ["FREQ=WEEKLY;BYDAY=TU;UNTIL=20270330"],
    "except_dates": ["2026-12-22", "2026-12-29"],
    "overrides": [
      {
        "original_start": "2026-11-17T07:30:00",
        "start": "2026-11-17T08:00:00",
        "end": "2026-11-17T09:00:00",
        "location": "Studio 2"
      },
      { "original_start": "2026-12-01T07:30:00", "status": "cancelled" }
    ]
  }
}
```

| Field | Rules |
|---|---|
| `rules` | 1 to 5 RRULE strings per RFC 5545, up to 500 characters each. The `RRULE:` prefix is optional. Several rules become separate series in the feed. |
| `except_dates` | up to 1,000 occurrences that are skipped without a trace. A date is enough; for timed series it means the occurrence on that day. |
| `overrides` | up to 500 changed occurrences, see below. |

**Allowed RRULE parts:** `FREQ` (`DAILY`, `WEEKLY`, `MONTHLY`, `YEARLY`), `INTERVAL` (1 to 1,000), `COUNT` (1 to 1,000), `UNTIL`, `BYDAY`, `BYMONTHDAY`, `BYMONTH`, `BYSETPOS`, `WKST`. `COUNT` and `UNTIL` exclude each other. Other parts such as `BYHOUR` or `FREQ=HOURLY` are refused with `unsupported_rrule`.

**`UNTIL`** may be `YYYYMMDD`, `YYYYMMDDTHHMMSS` (local time in the event's time zone) or `YYYYMMDDTHHMMSSZ` (UTC). We convert it the way RFC 5545 requires. A plain date on a timed event means "through the end of that day".

### Change single occurrences: `overrides`

- `original_start` names the occurrence as the rule generates it, in the event's time zone (a date for all-day series).
- You can change `start`, `end`, `title`, `description`, `location`, `url` and `status`. Fields you leave out keep the series value.
- `"status": "cancelled"` cancels just that occurrence. To remove it without a trace, use `except_dates` instead.
- Overrides are part of the event: every `PUT` sends the **full** list. An override you leave out reverts to the series.
- Each occurrence may be overridden only once (otherwise `duplicate_override`).

## Mapping typical plugin fields

How common fields of an event plugin (WordPress and similar) map:

| In the plugin | In the API |
|---|---|
| Post ID | `external_id`, e.g. `"wp-1482"` |
| Title | `title` |
| Content / description (HTML) | `description`. HTML works as is; a short excerpt plus `url` works better. |
| Event permalink | `url` |
| Venue, address | `location`, e.g. `"Studio by the River, 5 Main Street, New York"` |
| Start date + start time | `start`: `"2026-11-12T18:00:00"` |
| End date + end time | `end`: `"2026-11-12T19:30:00"` |
| Site time zone (`timezone_string`) | `timezone` on the calendar, on the event only if different |
| "All day" checked | `start`/`end` as plain dates, `end` + 1 day |
| Categories, tags | `categories` |
| Status "cancelled" | `"status": "cancelled"` |
| Status "tentative" | `"status": "tentative"` |
| Post trashed, draft, private | `DELETE` on the event |
| Custom fields (price, ticket id …) | `metadata` (only for you) or as text in `description` (for subscribers) |

Recurrence settings:

| Setting in the plugin | RRULE |
|---|---|
| Daily / weekly / monthly / yearly | `FREQ=DAILY` / `WEEKLY` / `MONTHLY` / `YEARLY` |
| Every 2 weeks | `FREQ=WEEKLY;INTERVAL=2` |
| Tuesdays and Thursdays | `FREQ=WEEKLY;BYDAY=TU,TH` |
| Second Monday of the month | `FREQ=MONTHLY;BYDAY=2MO` |
| Last Friday of the month | `FREQ=MONTHLY;BYDAY=-1FR` |
| On the 15th of every month | `FREQ=MONTHLY;BYMONTHDAY=15` |
| Ends after 10 occurrences | `;COUNT=10` |
| Ends on 25 March 2027 | `;UNTIL=20270325` |
| Blackout dates (holidays) | `recurrence.except_dates` |
| Single occurrence moved | `recurrence.overrides` |

For PHP, the MIT-licensed library [rlanvin/php-rrule](https://github.com/rlanvin/php-rrule) builds and validates these rules.

## Get an event

```bash
curl https://api.kalender-sync.de/v1/publisher/calendars/$KS_CAL/events/wp-1482 -H "Authorization: Bearer $KS_KEY"
```

If the event does not exist: `404` [`event_not_found`](https://docs.kalender-sync.de/en/api/errors/event_not_found/).

## List events

```bash
curl "https://api.kalender-sync.de/v1/publisher/calendars/$KS_CAL/events?limit=100" -H "Authorization: Bearer $KS_KEY"
```

| Parameter | Meaning |
|---|---|
| `limit` | 1 to 100, default 100 |
| `cursor` | `next_cursor` of the previous page |
| `updated_since` | ISO instant with offset, e.g. `2026-11-01T00:00:00Z`: only events changed after it |

The response contains `data` (events as above) and `next_cursor` (`null` on the last page). Events are sorted by last change, so `updated_since` plus the cursor returns every change exactly once. An invalid cursor answers `400` [`invalid_cursor`](https://docs.kalender-sync.de/en/api/errors/invalid_cursor/).

## Delete an event

```bash
curl -X DELETE https://api.kalender-sync.de/v1/publisher/calendars/$KS_CAL/events/wp-1482 -H "Authorization: Bearer $KS_KEY"
```

Always `204 No Content`, also when the event was already gone. Retries are safe.

## Validation errors

Invalid events are refused as a whole, with `400` [`invalid_request`](https://docs.kalender-sync.de/en/api/errors/invalid_request/) and **every** problem at once in `errors`:

```json
{
  "type": "https://docs.kalender-sync.de/en/api/errors/invalid_request",
  "title": "The request is invalid",
  "status": 400,
  "code": "invalid_request",
  "request_id": "req_…",
  "errors": [
    { "field": "end", "code": "end_before_start", "message": "end must not be before start." },
    { "field": "recurrence.rules[0]", "code": "unsupported_rrule", "message": "BYHOUR is not supported." }
  ]
}
```

| `errors[].code` | Meaning |
|---|---|
| `invalid_timezone` | Unknown time zone |
| `invalid_date` | Unreadable date (all-day events, `except_dates`) |
| `invalid_datetime` | Unreadable date-time, or an override that does not fit the series |
| `end_before_start` | `end` is before `start` (all-day: not after it) |
| `invalid_rrule` | Rule is malformed, e.g. `COUNT` and `UNTIL` together |
| `unsupported_rrule` | Rule part or frequency is not supported |
| `duplicate_override` | An occurrence was overridden twice |
| `too_large` | `metadata` larger than 2 KB |

Schema violations (missing required field, unknown field, text too long) carry the validator's code, e.g. `invalid_type`, `too_big` or `unrecognized_keys`.

Other possible errors when writing: [`event_limit_reached`](https://docs.kalender-sync.de/en/api/errors/event_limit_reached/), [`calendar_not_found`](https://docs.kalender-sync.de/en/api/errors/calendar_not_found/), [`plan_required`](https://docs.kalender-sync.de/en/api/errors/plan_required/), [`calendar_blocked`](https://docs.kalender-sync.de/en/api/errors/calendar_blocked/).