# How-to: schedule recurring events

**Goal:** set up a weekly lesson once, let the platform create every
occurrence, move or cancel single lessons, change the schedule from a date
on, and bill the lessons that happened.

Assumes the [Quickstart](/docs/quickstart.html) (`$LYDIAN`, `$TENANT`, `$KEY`
exported) plus a teacher `$TEACHER` (a person id) and, for the fee, a Lesson
event type `$LESSON_TYPE` with a number attribute `$FEE` and a string
attribute `$ATTENDANCE` — see [Model your domain](/docs/model-your-domain.html).

A **series** is a repeating event your tenant owns. Its **occurrences** are
ordinary events: they list, search, invoice, notify and reference exactly
like any other event. The series just keeps them coming.

---

## 1. Create a series

```bash
curl -s -X POST "$LYDIAN/tenants/$TENANT/event-series" \
  -H "X-API-Key: $KEY" -H "Content-Type: application/json" \
  -d "{
        \"timeZone\": \"America/New_York\",
        \"startDate\": \"2026-10-06\",
        \"rule\": {\"freq\": \"WEEKLY\", \"byDay\": [\"TU\"], \"until\": \"2026-12-15\"},
        \"template\": {
          \"eventTypeId\": \"$LESSON_TYPE\",
          \"name\": \"Piano — Sam\",
          \"startTime\": \"16:00\",
          \"durationMinutes\": 30,
          \"attributeValues\": {\"$FEE\": 40},
          \"participants\": [{\"personId\": \"$TEACHER\", \"role\": \"ORGANIZER\"}]
        }
      }" | jq '{id, status, materialisation, materialisedThrough}'
```

When the response arrives the occurrences already exist — up to the
**horizon**, 90 days ahead by default. Set `horizonDays` (at most 366) to see
a whole term. The platform tops the horizon up in the background as time
passes. The template is validated like an event, so a missing participant or
a bad attribute value is refused here, not discovered later.

**Rules** are a small, predictable subset of calendar recurrence:

| `freq` | Picks days with | Default |
|---|---|---|
| `DAILY` | — | every day |
| `WEEKLY` | `byDay`: `MO` … `SU`, one or more | the start date's weekday |
| `MONTHLY` | `byMonthDay` (`15`; `-1` is the last day) **or** `byDay` with an ordinal (`2TU` second Tuesday, `-1FR` last Friday) | the start date's day of month |

Add `interval` for every *n*th period, and end with `count` (occurrences in
the whole series) **or** `until` (a date, inclusive) — or neither for an
open-ended series. A day a month lacks (the 31st, a fifth Thursday) is
skipped. Times are local to `timeZone`, so 4 pm stays 4 pm across daylight
saving.

Each person on the template gets **one** notification for the series
("Added to *Piano — Sam*, weekly on TU at 16:00 …"), not one per lesson.

## 2. Read the schedule

```bash
curl -s "$LYDIAN/tenants/$TENANT/event-series/$SERIES/occurrences?from=2026-10-01T00:00:00Z&to=2026-11-01T00:00:00Z" \
  -H "X-API-Key: $KEY" | jq '{complete, occurrences: [.occurrences[] | {id, startAt, occurrenceAt, overrides}]}'
```

Occurrences are ordered by **`occurrenceAt`** — where the rule put them — so a
moved lesson still sorts in its slot. `GET /events?seriesId=$SERIES` gives the
same events in the plain events list. If your window reaches past what has
been created, `complete` is `false`: the platform never invents rows. To show
"repeats weekly until December", read the series' last segment rule.

## 3. Change one lesson

Use the ordinary event update. Move a lesson to Thursday:

```bash
curl -s -X PUT "$LYDIAN/tenants/$TENANT/events/$OCCURRENCE" \
  -H "X-API-Key: $KEY" -H "Content-Type: application/json" \
  -d "$(curl -s "$LYDIAN/tenants/$TENANT/events/$OCCURRENCE" -H "X-API-Key: $KEY" \
        | jq '.startAt = "2026-10-22T20:00:00Z" | .endAt = "2026-10-22T20:30:00Z"')" \
  | jq '{startAt, occurrenceAt, overrides}'
```

```json
{"startAt": "2026-10-22T20:00:00Z", "occurrenceAt": "2026-10-20T20:00:00Z", "overrides": ["startAt"]}
```

The platform compares the lesson with its template and records in
**`overrides`** exactly the fields that now differ: `startAt` here, an
attribute id when you mark attendance. Series changes never touch an
overridden field, so marking attendance does not stop a later fee change from
reaching the lesson. Sending the template's own values back overrides
nothing. An occurrence's event type follows its series.

To undo, re-attach it — all overrides, or just some:

```bash
curl -s -X POST "$LYDIAN/tenants/$TENANT/events/$OCCURRENCE/reattach" \
  -H "X-API-Key: $KEY" -H "Content-Type: application/json" -d '{"fields": ["startAt"]}'
```

## 4. Cancel one lesson

Two different things, and the platform prefers neither:

- **The lesson should not exist** (a holiday): `DELETE /events/$OCCURRENCE`.
  That is a **skip**, permanent for that slot. A billed or referenced lesson
  cannot be deleted (`409 REFERENCE_BLOCKED`).
- **It was cancelled but still matters** (a make-up, a late-cancellation fee):
  keep it and set your own status attribute, e.g. Attendance = `cancelled`.

## 5. Change the schedule from a date on

```bash
curl -s -X PUT "$LYDIAN/tenants/$TENANT/event-series/$SERIES" \
  -H "X-API-Key: $KEY" -H "Content-Type: application/json" \
  -d "{\"from\": \"2027-01-05\",
       \"template\": {\"eventTypeId\": \"$LESSON_TYPE\", \"name\": \"Piano — Sam\",
                      \"startTime\": \"16:00\", \"durationMinutes\": 30,
                      \"attributeValues\": {\"$FEE\": 45}}}" | jq '.segments | length'
```

A `PUT` is **this and following**: lessons before `from` keep their history,
the series keeps its id, and the change starts a new **segment**. Give a new
`rule`, a new `template`, or both (the other stays as it was). Lessons from
`from` on are rewritten in the fields they do not override. Lessons the new
rule no longer schedules are removed unless they carry overrides; a moved
lesson survives. New slots are created. If a lesson the change would remove
is already invoiced, the whole edit is refused with `409 REFERENCE_BLOCKED`
and nothing is written. Use `from` = the series' start to change everything.

## 6. End it

```bash
curl -s -X POST "$LYDIAN/tenants/$TENANT/event-series/$SERIES/end" \
  -H "X-API-Key: $KEY" -H "Content-Type: application/json" -d '{"until": "2027-03-30"}'
```

Lessons after `until` are removed (overridden ones stay). Once its last
lesson has passed, a series is **`ENDED`**: read-only, and no longer holding
on to the people its template names. While a series is `ACTIVE`, those people
— and its event type and attributes — cannot be deleted. Delete a series once
it has no occurrences left.

## 7. Bill the month with a workflow

Occurrences are events with a fee, so [from-events invoicing](/docs/invoice-from-events.html)
bills them. A scheduled [workflow](/docs/automate-with-workflows.html) does it
with no app code. It creates a draft for the payer, then adds only the
attended lessons:

```json
{
  "name": "Monthly tuition — Sam",
  "description": "Drafts last month's lessons for review",
  "trigger": {"type": "SCHEDULE", "cron": "0 9 1 * *"},
  "steps": [
    {"kind": "create_invoice",
     "config": {"recipientPersonId": "<payer id>", "dueInDays": 14, "memo": "Tuition"}},
    {"kind": "add_invoice_lines_from_events",
     "config": {"invoiceId": "{{invoiceId}}", "attributeId": "<fee id>",
                "personId": "<student id>", "from": "<month start>", "to": "<month end>",
                "where": {"attributeId": "<attendance id>", "value": "attended"}}}
  ]
}
```

The draft waits for a human to review and issue. To react to each lesson as
it is created, subscribe a workflow to `event.occurrence_created`.

## The guards you'll meet

- **Admin plane.** `event-series` routes need the API key. A signed-in user's
  bearer token can edit one occurrence but not skip it or re-attach it
  (`403`).
- **Health.** Every series carries `materialisedThrough`, `lastError` and
  `materialisation` — `OK`, `STALE` (the horizon is lagging) or `FAILING`.
  `GET /event-series?materialisation=FAILING` lists the ones that need a look.
- **Bounds.** A series may start at most a year in the past, and a template
  may not run past midnight.
- **Calendar-invite series are different.** A recurring meeting mirrored from
  Google stays a set of independent events grouped by the calendar link's
  `seriesId`; only series you create here behave as described above.
