# Termine und Wiederholungen

Ein Termin ist ein einzelner Termin oder eine ganze Serie. Du adressierst ihn mit deiner eigenen **`external_id`**, zum Beispiel der Post-ID aus WordPress. Eine ID von uns musst du nicht speichern.

## Termin anlegen oder ändern

```
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 im Park",
    "description": "<p>Bring eine <strong>Matte</strong> mit. Für Mitglieder kostenlos.</p>",
    "location": "Riverside Park, Steg 2, Berlin",
    "url": "https://example.org/kurse/sunset-yoga/",
    "start": "2026-11-12T18:00:00",
    "end": "2026-11-12T19:30:00",
    "timezone": "Europe/Berlin",
    "status": "confirmed",
    "categories": ["Outdoor"],
    "metadata": { "post_id": 1482 }
  }'
```

`external_id` darf 1 bis 128 Zeichen lang sein: Buchstaben, Ziffern und `.` `_` `:` `-`. Bewährt hat sich ein Präfix, etwa `wp-1482`.

**Antwort:** `201 Created` beim ersten Schreiben, `200 OK` bei einer Änderung.

```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 im Park",
    "all_day": false,
    "start": "2026-11-12T18:00:00",
    "end": "2026-11-12T19:30:00",
    "timezone": "Europe/Berlin",
    "status": "confirmed",
    "show_as": "free",
    "categories": ["Outdoor"],
    "description": "Bring eine Matte mit. Für Mitglieder kostenlos.",
    "location": "Riverside Park, Steg 2, Berlin",
    "url": "https://example.org/kurse/sunset-yoga/",
    "metadata": { "post_id": 1482 }
  },
  "changed": true
}
```

- `event` ist der Termin so, wie wir ihn gespeichert haben: Zeiten als lokale Zeit in `timezone`, HTML als Text.
- `sequence` zählt jede echte Änderung hoch.
- Schickst du denselben Termin noch einmal, antwortet die API mit `200` und **`"changed": false`**. Es wird nichts geschrieben, der Feed bleibt unverändert. Du kannst also bei jedem Speichern in deinem System einfach `PUT` aufrufen.
- `PUT` ersetzt den ganzen Termin. Felder, die du weglässt, sind danach leer bzw. auf dem Standardwert.

### Optimistisches Sperren mit `If-Match`

Optional schickst du die zuletzt gesehene `sequence` mit:

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

Hat sich der Termin inzwischen geändert (oder gibt es ihn noch nicht), kommt `409` [`sequence_mismatch`](https://docs.kalender-sync.de/api/errors/sequence_mismatch/) statt eines Überschreibens.

## Felder

| Feld | Regeln | Standard |
|---|---|---|
| `title` | Pflicht, 1 bis 255 Zeichen | |
| `start` | Pflicht. Datum oder Datum mit Uhrzeit, siehe [Zeiten](#zeiten-und-zeitzonen) | |
| `end` | optional. Bei Terminen mit Uhrzeit nicht vor `start`; bei ganztägigen Terminen **exklusiv** | `start` + `default_duration` des Kalenders bzw. ein Tag |
| `timezone` | IANA-Name, z. B. `Europe/Berlin` | Zeitzone des Kalenders |
| `all_day` | `true` oder `false` | `true`, wenn `start` ein reines Datum ist |
| `description` | Text oder HTML, bis 65.536 Zeichen. HTML wird zu Text; Links bleiben als `Text (URL)` erhalten. Gespeichert werden höchstens 8.000 Zeichen. | |
| `location` | bis 500 Zeichen | |
| `url` | `https://`-URL, bis 2.000 Zeichen. Wird in Apps als Link gezeigt und zusätzlich an die Beschreibung angehängt. | |
| `status` | `confirmed`, `tentative` oder `cancelled` | `confirmed` |
| `show_as` | `free` oder `busy`: ob der Termin bei Abonnenten als belegt gilt | `free` |
| `categories` | bis 10 Einträge à 1 bis 50 Zeichen | `[]` |
| `recurrence` | Serie, siehe [Wiederholungen](#wiederholungen) | |
| `metadata` | Beliebiges JSON-Objekt bis 2 KB. Kommt in API-Antworten zurück, erscheint aber nie im Feed oder auf der Abo-Seite. | |

`show_as` steht standardmäßig auf `free`: Öffentliche Termine sollen den Kalender deiner Abonnenten nicht als belegt markieren.

Ein abgesagter Termin (`"status": "cancelled"`) bleibt im Feed und ist dort als abgesagt markiert; wie das aussieht, hängt von der App ab. Soll er ganz verschwinden, lösche ihn.

## Zeiten und Zeitzonen

| `start` / `end` | Bedeutung |
|---|---|
| `"2026-11-12T18:00:00"` | Lokale Zeit in `timezone` (bzw. der Zeitzone des Kalenders). Empfohlen. |
| `"2026-11-12T17:00:00Z"` oder `"2026-11-12T18:00:00+01:00"` | Zeitpunkt mit Offset. Wird in `timezone` umgerechnet und so gespeichert. |
| `"2026-11-12"` | Reines Datum: ganztägiger Termin. |

Lokale Zeit plus IANA-Zeitzone ist der sicherste Weg: Sommer- und Winterzeit stimmen dann auch bei Serien über die Zeitumstellung hinweg.

**Ganztägige Termine:** `end` ist exklusiv. Ein Termin nur am 12. November hat `"start": "2026-11-12"` und `"end": "2026-11-13"` oder gar kein `end`. Ein dreitägiges Festival vom 12. bis 14. November hat `"end": "2026-11-15"`.

## Wiederholungen

```json
{
  "title": "Morning Flow",
  "start": "2026-11-03T07:30:00",
  "end": "2026-11-03T08:30:00",
  "timezone": "Europe/Berlin",
  "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" }
    ]
  }
}
```

| Feld | Regeln |
|---|---|
| `rules` | 1 bis 5 RRULE-Strings nach RFC 5545, je bis 500 Zeichen. Das Präfix `RRULE:` ist optional. Mehrere Regeln werden im Feed zu getrennten Serien. |
| `except_dates` | bis 1.000 Termine, die ausfallen und spurlos verschwinden. Ein Datum genügt; bei Serien mit Uhrzeit ist damit der Termin an diesem Tag gemeint. |
| `overrides` | bis 500 geänderte Einzeltermine, siehe unten. |

**Erlaubte RRULE-Teile:** `FREQ` (`DAILY`, `WEEKLY`, `MONTHLY`, `YEARLY`), `INTERVAL` (1 bis 1.000), `COUNT` (1 bis 1.000), `UNTIL`, `BYDAY`, `BYMONTHDAY`, `BYMONTH`, `BYSETPOS`, `WKST`. `COUNT` und `UNTIL` schließen sich aus. Andere Teile wie `BYHOUR` oder `FREQ=HOURLY` werden mit `unsupported_rrule` abgelehnt.

**`UNTIL`** darf `YYYYMMDD`, `YYYYMMDDTHHMMSS` (lokale Zeit in der Zeitzone des Termins) oder `YYYYMMDDTHHMMSSZ` (UTC) sein. Wir rechnen es so um, wie RFC 5545 es verlangt. Ein reines Datum bei einem Termin mit Uhrzeit heißt „bis einschließlich diesen Tag“.

### Einzeltermine ändern: `overrides`

- `original_start` bezeichnet den Termin so, wie die Regel ihn erzeugt, in der Zeitzone des Termins (bei ganztägigen Serien ein Datum).
- Ändern kannst du `start`, `end`, `title`, `description`, `location`, `url` und `status`. Weggelassene Felder übernehmen den Wert der Serie.
- `"status": "cancelled"` sagt nur diesen einen Termin ab. Soll er spurlos verschwinden, nimm stattdessen `except_dates`.
- Overrides sind Teil des Termins: Jedes `PUT` schickt die **vollständige** Liste. Ein Override, den du weglässt, fällt auf die Serie zurück.
- Jeder Termin der Serie darf nur einmal überschrieben werden (sonst `duplicate_override`).

## Mapping typischer Plugin-Felder

So bildest du übliche Felder eines Event-Plugins (WordPress und ähnliche) ab:

| Im Plugin | In der API |
|---|---|
| Post-ID | `external_id`, z. B. `"wp-1482"` |
| Titel | `title` |
| Inhalt / Beschreibung (HTML) | `description`. HTML geht direkt; besser ist ein kurzer Auszug plus `url`. |
| Permalink der Veranstaltung | `url` |
| Veranstaltungsort, Adresse | `location`, z. B. `"Studio am Fluss, Hauptstraße 5, Berlin"` |
| Startdatum + Startzeit | `start`: `"2026-11-12T18:00:00"` |
| Enddatum + Endzeit | `end`: `"2026-11-12T19:30:00"` |
| Zeitzone der Website (`timezone_string`) | `timezone` am Kalender, nur bei Abweichung am Termin |
| „Ganztägig“ angehakt | `start`/`end` als reines Datum, `end` + 1 Tag |
| Kategorien, Schlagwörter | `categories` |
| Status „abgesagt“ | `"status": "cancelled"` |
| Status „vorläufig“ | `"status": "tentative"` |
| Beitrag in den Papierkorb, Entwurf, privat | `DELETE` auf den Termin |
| Eigene Felder (Preis, Ticket-ID …) | `metadata` (nur für dich) oder als Text in `description` (für Abonnenten) |

Wiederholungseinstellungen:

| Einstellung im Plugin | RRULE |
|---|---|
| Täglich / wöchentlich / monatlich / jährlich | `FREQ=DAILY` / `WEEKLY` / `MONTHLY` / `YEARLY` |
| Alle 2 Wochen | `FREQ=WEEKLY;INTERVAL=2` |
| Dienstags und donnerstags | `FREQ=WEEKLY;BYDAY=TU,TH` |
| Jeden zweiten Montag im Monat | `FREQ=MONTHLY;BYDAY=2MO` |
| Letzter Freitag im Monat | `FREQ=MONTHLY;BYDAY=-1FR` |
| Am 15. jedes Monats | `FREQ=MONTHLY;BYMONTHDAY=15` |
| Endet nach 10 Terminen | `;COUNT=10` |
| Endet am 25. März 2027 | `;UNTIL=20270325` |
| Ausgenommene Tage (Ferien, Feiertage) | `recurrence.except_dates` |
| Einzelner Termin verschoben | `recurrence.overrides` |

Für PHP baut und prüft die MIT-lizenzierte Bibliothek [rlanvin/php-rrule](https://github.com/rlanvin/php-rrule) solche Regeln.

## Termin lesen

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

Gibt es den Termin nicht: `404` [`event_not_found`](https://docs.kalender-sync.de/api/errors/event_not_found/).

## Termine auflisten

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

| Parameter | Bedeutung |
|---|---|
| `limit` | 1 bis 100, Standard 100 |
| `cursor` | `next_cursor` der vorigen Seite |
| `updated_since` | ISO-Zeitpunkt mit Offset, z. B. `2026-11-01T00:00:00Z`: nur Termine, die danach geändert wurden |

Die Antwort enthält `data` (Termine wie oben) und `next_cursor` (`null` auf der letzten Seite). Sortiert wird nach letzter Änderung, sodass `updated_since` plus Cursor jede Änderung genau einmal liefert. Ein ungültiger Cursor ergibt `400` [`invalid_cursor`](https://docs.kalender-sync.de/api/errors/invalid_cursor/).

## Termin löschen

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

Antwort immer `204 No Content`, auch wenn der Termin schon weg war. Wiederholungen sind also unbedenklich.

## Validierungsfehler

Ungültige Termine werden komplett abgelehnt, mit `400` [`invalid_request`](https://docs.kalender-sync.de/api/errors/invalid_request/) und **allen** Problemen auf einmal 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` | Bedeutung |
|---|---|
| `invalid_timezone` | Unbekannte Zeitzone |
| `invalid_date` | Datum unlesbar (ganztägige Termine, `except_dates`) |
| `invalid_datetime` | Datum mit Uhrzeit unlesbar, oder ein Override passt nicht zur Serie |
| `end_before_start` | `end` liegt vor `start` (ganztägig: nicht danach) |
| `invalid_rrule` | Regel syntaktisch falsch, z. B. `COUNT` und `UNTIL` zusammen |
| `unsupported_rrule` | Regelteil oder Frequenz wird nicht unterstützt |
| `duplicate_override` | Ein Einzeltermin wurde doppelt überschrieben |
| `too_large` | `metadata` größer als 2 KB |

Verstöße gegen das Schema (fehlendes Pflichtfeld, unbekanntes Feld, zu langer Text) tragen den Code des Validators, z. B. `invalid_type`, `too_big` oder `unrecognized_keys`.

Weitere mögliche Fehler beim Schreiben: [`event_limit_reached`](https://docs.kalender-sync.de/api/errors/event_limit_reached/), [`calendar_not_found`](https://docs.kalender-sync.de/api/errors/calendar_not_found/), [`plan_required`](https://docs.kalender-sync.de/api/errors/plan_required/), [`calendar_blocked`](https://docs.kalender-sync.de/api/errors/calendar_blocked/).