# `/v1/series`

A recurring series books one repeating pattern, weekly or monthly, as a run of
ordinary meetings. This page covers reading, creating, and managing the
series itself: pausing it, resuming it, ending it, editing its pattern, and
moving it to a new host.

The occurrences a series creates are ordinary bookings, not a separate
resource. List them with [`GET /v1/bookings`](/help/api/bookings) and a
`series_id` filter, and read or act on any one of them with the regular
booking endpoints, `uid` for `uid`.

A series' UID is its UUID, and every endpoint that takes `:uid` rejects a
malformed or unknown one with the same `404 series_not_found`, so the
response never confirms which case it was.

---

## GET `/v1/series`

List recurring series, narrowed to what you may see.

| | |
|---|---|
| **Method** | `GET` |
| **URL** | `https://api.42min.us/v1/series` |
| **Scope** | `series:read` |
| **Auth** | Required |

### Query parameters

| Param | Type | Notes |
|---|---|---|
| `limit` | integer | Default `20`, max `100`. |
| `cursor` | string | Opaque cursor from a prior `meta.next_cursor`. |
| `status` | string | Comma-separated. Allowed values: `active`, `paused`, `ended`. |
| `host_user_id` | UUID | Filter to one host. |
| `event_type_id` | UUID | Filter to one event type. |

What you see depends on your role: your own series as a user, the series of
the people you manage as a manager, and the whole organization as an admin
or owner. A `host_user_id` outside that set comes back as an empty page,
not an error, so it never confirms whether the host exists.

### Response

```json
{
  "data": [
    {
      "uid": "01H…",
      "version": 1,
      "event_type": { "id": "01HXXX…", "slug": "weekly-lesson", "name": "Weekly lesson" },
      "host": { "user_id": "01H…", "username": "ada", "email": "ada@example.com" },
      "attendee": { "name": "Bob Builder", "email": "bob@example.com", "phone": null },
      "frequency": "weekly",
      "interval_weeks": 1,
      "weekdays": ["mon", "wed"],
      "time": "18:30",
      "timezone": "Europe/Lisbon",
      "starts_on": "2026-09-14",
      "count": 24,
      "status": "active",
      "location_url": null,
      "notify_invitee": true,
      "source": "api",
      "created_at": "2026-09-10T09:00:00.000Z",
      "updated_at": "2026-09-10T09:00:00.000Z",
      "ended_at": null
    }
  ],
  "meta": { "request_id": "req_…", "next_cursor": null, "has_more": false }
}
```

Field notes:

- `version` is the series' revision, bumped by every action on it. It's what
  you send back as `If-Match` on `PATCH`.
- `source` says where the series came from: `public` (an invitee, from the
  booking page), `internal` (a host or admin, from the dashboard), `api`, or
  `mcp`.
- Occurrences aren't included here; see `GET /v1/bookings?series_id={uid}`.

### curl

```bash
curl -H "Authorization: Bearer $TOKEN" \
  "https://api.42min.us/v1/series?status=active&limit=50"
```

---

## GET `/v1/series/:uid`

Return one series. Sets `ETag` for the revision.

| | |
|---|---|
| **Method** | `GET` |
| **URL** | `https://api.42min.us/v1/series/{uid}` |
| **Scope** | `series:read` |
| **Auth** | Required |

### Headers (response)

```
ETag: "3"
```

### Response

Same shape as a list item.

### curl

```bash
curl -i -H "Authorization: Bearer $TOKEN" \
  https://api.42min.us/v1/series/01H…
```

### Common errors

- `404 series_not_found`: unknown UID, or the UID is not a UUID (collapsed
  into the same 404 to avoid leaking format details).

---

## POST `/v1/series`

Create a recurring series: books up to `count` occurrences of one repeating
pattern in a single call.

| | |
|---|---|
| **Method** | `POST` |
| **URL** | `https://api.42min.us/v1/series` |
| **Scope** | `series:write` |
| **Auth** | Required |

**Requires an [`Idempotency-Key`](/help/api/idempotency).** This sends real
invitee email and writes to a real calendar, so retry a failed attempt with
the same key rather than reissuing the call.

### Headers

```
Authorization: Bearer <token>
Content-Type: application/json
Idempotency-Key: <unique-per-operation>
```

### Body

Either `event_type_id` **or** (`username` + `event_slug`) is required.

```json
{
  "host_user_id": "8b7c1e6f-1111-4222-8333-444455556666",
  "event_type_id": "1c2d3e4f-5555-4666-8777-888899990000",
  "attendee": {
    "email": "ada@example.com",
    "name": "Ada Lovelace"
  },
  "frequency": "weekly",
  "interval_weeks": 1,
  "weekdays": ["mon", "wed"],
  "time": "18:30",
  "timezone": "Europe/Lisbon",
  "starts_on": "2026-09-14",
  "count": 24
}
```

Field reference:

| Field | Type | Notes |
|---|---|---|
| `host_user_id` | UUID | Required. Whose calendar the series is booked on; must be someone you may act for. |
| `event_type_id` | UUID | One of {`event_type_id`} or {`username`+`event_slug`} required. |
| `username` / `event_slug` | string | Alternative to `event_type_id`. |
| `attendee.email` | string | Required. |
| `attendee.name` | string | If omitted, built from `first_name` + `last_name`, else falls back to email. |
| `attendee.first_name` / `last_name` | string | Optional. |
| `attendee.phone` | string | Optional. |
| `frequency` | string | Required. `weekly` or `monthly_same_day`. |
| `interval_weeks` | integer | 1 to 8, default `1`. Weekly patterns only. |
| `weekdays` | array | Required for a weekly pattern: up to 7 of `sun`…`sat`. Ignored for `monthly_same_day`. |
| `time` | string | Required. `HH:MM`, 24-hour, the wall clock in `timezone`. |
| `timezone` | IANA tz | Optional. Defaults to the host's own zone. |
| `starts_on` | date | Required. `YYYY-MM-DD`, the first civil day the pattern is walked from. Past instants are never booked. |
| `count` | integer | Required, 1 to 42. Dates the host isn't free on are skipped, not shifted, so a series can come back with fewer occurrences than requested. |
| `location_url` | string | Optional. A fixed http(s) link every occurrence carries, instead of a generated conference link. |
| `notify_invitee` | boolean | Optional, default `true`. `false` means no mail reaches the attendee for this series. |

### Response

`201 Created`:

```json
{
  "data": {
    "series": { "uid": "01H…", "version": 1, "…": "…" },
    "occurrences": [
      { "uid": "01H…", "start_at": "2026-09-14T17:30:00.000Z", "end_at": "2026-09-14T18:00:00.000Z", "status": "confirmed" }
    ],
    "skipped": []
  },
  "meta": { "request_id": "req_…" }
}
```

Field notes:

- `occurrences` lists what was actually booked, in order. Each `uid` is an
  ordinary booking UID, usable on every `/v1/bookings` route.
- `skipped` lists pattern dates that weren't free (`reason: "conflict"`); the
  series is still created with the rest. Compare its length against `count`
  to know whether every occurrence was booked.

### curl

```bash
curl -X POST https://api.42min.us/v1/series \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{
    "host_user_id": "8b7c1e6f-1111-4222-8333-444455556666",
    "event_type_id": "1c2d3e4f-5555-4666-8777-888899990000",
    "attendee": {"email": "ada@example.com", "name": "Ada Lovelace"},
    "frequency": "weekly",
    "weekdays": ["mon", "wed"],
    "time": "18:30",
    "starts_on": "2026-09-14",
    "count": 24
  }'
```

### Common errors

- `400 validation_error`: required field missing or malformed.
- `400 attendee_email_invalid`: bad email format.
- `400 missing_idempotency_key`: header missing.
- `403 host_not_allowed`: `host_user_id` is outside who you may act for.
- `404 event_type_not_found`: bad id/slug.
- `409 recurring_disabled`: the event type's recurring mode is `off`.
- `409 no_dates_free`: no date in the pattern was free, so nothing was
  created.
- `422 count_out_of_range`: `count` is outside the allowed range.
- `503 availability_unavailable`: the host's conflict calendar couldn't be
  read, so nothing was booked on a guess. Retry with the SAME
  `Idempotency-Key`, honoring `Retry-After`.

---

## POST `/v1/series/:uid/pause`

Cancel every future occurrence and record each as owed, so a later `resume`
books a replacement for it. Occurrences the invitee already moved on their
own are left alone. Nothing is emailed to the attendee.

| | |
|---|---|
| **Method** | `POST` |
| **URL** | `https://api.42min.us/v1/series/{uid}/pause` |
| **Scope** | `series:write` |
| **Auth** | Required |

No body. Safe to repeat: a re-run holds whatever the first run didn't reach.

### Response

`200 OK`:

```json
{
  "data": { "held": 6, "superseded": false },
  "meta": { "request_id": "req_…" }
}
```

`held` is how many occurrences this call canceled and recorded as owed. See
[Superseded and busy responses](#superseded-and-busy-responses) below for
what `superseded: true` means.

### curl

```bash
curl -X POST https://api.42min.us/v1/series/01H…/pause \
  -H "Authorization: Bearer $TOKEN"
```

### Common errors

- `404 series_not_found`
- `409 series_busy`: another action holds the series right now (for example,
  its confirmation email is still sending). Read it again and retry.
- `409 series_ended`: terminal, nothing can change it.

---

## POST `/v1/series/:uid/resume`

Book one replacement for each occurrence a pause is holding, and reactivate
the series.

| | |
|---|---|
| **Method** | `POST` |
| **URL** | `https://api.42min.us/v1/series/{uid}/resume` |
| **Scope** | `series:write` |
| **Auth** | Required |

**Requires an [`Idempotency-Key`](/help/api/idempotency).** `If-Match` is
optional here: send the `ETag` from a prior `GET` to refuse a resume that
another change overtook, or omit it to resume whatever the series is now. A
value that doesn't parse as a valid revision answers `400 invalid_if_match`.

### Response

`200 OK`:

```json
{
  "data": { "created": 4, "skipped": [], "owed": 0, "superseded": false },
  "meta": { "request_id": "req_…" }
}
```

Field notes:

- `created` is how many replacement occurrences this call booked.
- `skipped` lists dates that weren't free (`reason: "conflict"`); those stay
  owed.
- `owed` is how many occurrences are still waiting for a replacement. Above
  zero means the series is still `paused`, and calling `resume` again with
  the current revision converges on the rest.

### curl

```bash
curl -X POST https://api.42min.us/v1/series/01H…/resume \
  -H "Authorization: Bearer $TOKEN" \
  -H "Idempotency-Key: $(uuidgen)"
```

### Common errors

- `400 invalid_if_match`: `If-Match` was sent but isn't a valid revision.
- `400 missing_idempotency_key`
- `404 series_not_found`
- `409 series_busy` / `409 series_ended`
- `503 availability_unavailable`: retry with the SAME `Idempotency-Key`,
  honoring `Retry-After`.

---

## POST `/v1/series/:uid/end`

Cancel every future occurrence for good and send the attendee one notice
listing them. Nothing is owed afterward, and the series can never be
resumed. Past occurrences are untouched.

| | |
|---|---|
| **Method** | `POST` |
| **URL** | `https://api.42min.us/v1/series/{uid}/end` |
| **Scope** | `series:write` |
| **Auth** | Required |

**Requires an [`Idempotency-Key`](/help/api/idempotency).** Safe to repeat: a
re-run cancels whatever the first run didn't reach.

### Response

`200 OK`:

```json
{
  "data": { "canceled": 5, "superseded": false },
  "meta": { "request_id": "req_…" }
}
```

### curl

```bash
curl -X POST https://api.42min.us/v1/series/01H…/end \
  -H "Authorization: Bearer $TOKEN" \
  -H "Idempotency-Key: $(uuidgen)"
```

### Common errors

- `400 missing_idempotency_key`
- `404 series_not_found`
- `409 series_busy` / `409 series_ended`

---

## POST `/v1/series/:uid/host`

Move every future occurrence, then the series itself, to another host.

| | |
|---|---|
| **Method** | `POST` |
| **URL** | `https://api.42min.us/v1/series/{uid}/host` |
| **Scope** | `series:write` |
| **Auth** | Required |

### Body

```json
{ "host_user_id": "9c8d2e7a-2222-4333-9444-555566667777" }
```

| Field | Type | Notes |
|---|---|---|
| `host_user_id` | UUID | Required. The new host; must be someone you may act for. |

### Response

`200 OK`:

```json
{
  "data": { "moved": 5, "unchanged": 0, "stopped_by": null, "superseded": false },
  "meta": { "request_id": "req_…" }
}
```

A transfer that can't complete (no writable calendar on the new host, or
another action taking the series over) stops the run: `moved` says how many
occurrences did move, and `stopped_by` names the reason. `stopped_by: null`
means every occurrence moved and the series' own host changed too; anything
else means it didn't, and the call can be repeated once the cause is fixed.

### curl

```bash
curl -X POST https://api.42min.us/v1/series/01H…/host \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"host_user_id":"9c8d2e7a-2222-4333-9444-555566667777"}'
```

### Common errors

- `400 validation_error`
- `403 host_not_allowed`: the new host is outside who you may act for.
- `404 series_not_found`
- `409 series_busy` / `409 series_ended`

---

## PATCH `/v1/series/:uid`

Edit a series' pattern, its fixed link, or whether the attendee is notified.
Requires [optimistic locking](/help/api/optimistic-locking).

| | |
|---|---|
| **Method** | `PATCH` |
| **URL** | `https://api.42min.us/v1/series/{uid}` |
| **Scope** | `series:write` |
| **Auth** | Required |

**`If-Match` is required**: the `ETag` from a prior `GET` of this series.
Omitting it returns `428 missing_if_match` and nothing is changed. Unlike
`PATCH /v1/bookings/:uid`, this route does not also require an
`Idempotency-Key`.

### Headers

```
Authorization: Bearer <token>
Content-Type: application/json
If-Match: "3"
```

### Body

Send only what changes.

```json
{ "weekdays": ["tue", "thu"], "time": "09:00" }
```

| Field | Type | Notes |
|---|---|---|
| `frequency` | string | `weekly` or `monthly_same_day`. |
| `interval_weeks` | integer | 1 to 8. |
| `weekdays` | array | Up to 7 of `sun`…`sat`. |
| `time` | string | `HH:MM`, 24-hour. |
| `location_url` | string | Replaces the series' existing fixed link. A series created without one can't gain one here, and sending `null` to clear it is refused as a validation error rather than treated as "remove it". |
| `notify_invitee` | boolean | Changes whether future sends reach the attendee. |

`starts_on`, `count`, the attendee, and the event type are fixed for the
life of a series and can't be patched.

Changing `frequency`, `interval_weeks`, `weekdays`, or `time` holds every
future occurrence the new pattern no longer generates and books a
replacement for each, reported under `resumed`. Changing only
`location_url` or `notify_invitee` restamps the existing occurrences in
place and leaves every date alone, so `resumed` is `null`.

### Response

`200 OK`, with headers including `ETag: "<new-version>"`:

```json
{
  "data": {
    "series": { "uid": "01H…", "version": 2, "…": "…" },
    "resumed": { "created": 3, "skipped": [], "owed": 0, "superseded": false }
  },
  "meta": { "request_id": "req_…" }
}
```

### curl

```bash
curl -X PATCH https://api.42min.us/v1/series/01H… \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -H "If-Match: \"3\"" \
  -d '{"weekdays":["tue","thu"],"time":"09:00"}'
```

### Common errors

- `400 validation_error`: malformed field, such as a bad `time` format.
- `404 series_not_found`
- `409 series_busy` / `409 series_ended`
- `409 version_conflict`: `If-Match` doesn't match the current `version`.
  Re-`GET` and retry.
- `422`: a well-formed but semantically rejected change, such as sending
  `location_url: null` to clear an existing link.
- `428 missing_if_match`: header missing.

---

## Superseded and busy responses

Every action above can be overtaken by another one running on the same
series at the same time: two requests pausing a series at once, or an
`end` landing while a `resume` is still booking replacements. Two different
things can happen:

- **`409 series_busy`** means the call couldn't get started at all, because
  something else currently holds the series (mid-send confirmation email,
  or another action's very first write). Nothing changed. Read the series
  again and retry.
- **`superseded: true`** in a `200` response means the call *did* start, and
  its own work up to that point is real and already committed, but another
  action took the series over partway through, so it stopped early. The
  counts in the response (`held`, `created`, `canceled`, `moved`) are what
  this call actually finished, not what it was asked to do. Read the series
  again and repeat the same call; it converges on whatever is left.

`change_host` reports the same situation through `stopped_by` instead of
`superseded`, since it already has a field for "the run didn't finish."

This matters most for `resume` and a pattern-changing `PATCH`, both of which
can need several occurrence inserts to finish one call.
