---
title: Create a recurring booking series
description: "Creates a recurring series: the recurrence (freq/interval, count XOR until, max 24 occurrences) validates against the service's recurring_event..."
api_method: POST
api_path: "/v1/bookings/series"
canonical_url: https://wiblo.app/docs/developers/api/series/create-booking-series
last_updated: 2026-07-28T18:08:25+02:00
md_url: https://wiblo.app/docs/developers/api/series/create-booking-series.md
---

# Create a recurring booking series

`POST /v1/bookings/series`

Creates a recurring series: the recurrence (`freq`/`interval`, `count` XOR `until`, max 24 occurrences) validates against the service's `recurring_event` config, expands with DST offset correction (local wall-clock time holds across transitions), and every occurrence shares one `recurring_event_id` and ONE host — round-robin services pre-select the fairest candidate for the WHOLE series, or `member_id` pins one. Each occurrence is its own atomic write: per-occurrence conflicts, idempotency dedupes, and limit rejections land in `conflicts` while the rest of the series books (partial success answers 201). Owners and admins only. Per-user rate-limited at 30/60s.

## Headers

| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `X-Wiblo-Workspace` | `string` | Yes | Workspace id the call is scoped to. The actor must hold an active membership in it; a foreign or unknown id collapses onto 404 WORKSPACE_NOT_FOUND. |

## Request body

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `service_id` | `string` | Yes | Id of the service being booked; it must allow recurring bookings (`recurring_event` configured). |
| `start_time` | `string` | Yes | Start of the first occurrence as an RFC 3339 datetime; UTC and offset forms accepted. |
| `end_time` | `string` | Yes | End of the first occurrence; must be after `start_time`. |
| `recurrence` | `object` | Yes | The recurrence pattern to expand. |
| `recurrence.freq` | `"daily" \| "weekly" \| "monthly" \| "yearly"` | Yes | Recurrence frequency — `daily`, `weekly`, `monthly`, or `yearly`. |
| `recurrence.interval` | `integer` | Yes | Repeat every N `freq` periods (1–12). |
| `recurrence.count` | `integer` | No | Total occurrences to create (2–24). Exactly one of `count` or `until` is required. |
| `recurrence.until` | `string` | No | Expand occurrences up to this UTC datetime; the expansion still caps at 24 occurrences. Exactly one of `count` or `until` is required. |
| `timezone` | `string` | Yes | IANA time zone the series expands in — it drives the DST offset correction, so local wall-clock time holds across transitions. |
| `attendee` | `object` | Yes | The person the series is booked for. |
| `attendee.name` | `string` | Yes | Attendee's display name (2–255 characters). |
| `attendee.email` | `string` | Yes | Attendee's email address. Also the attendee's idempotency identity — the same email retrying the same slot or seat dedupes. |
| `attendee.timezone` | `string` | Yes | Attendee's IANA time zone, e.g. `Europe/London`; unknown zones are rejected. |
| `attendee.phone_number` | `string` | No | Attendee's phone number (max 50 characters). |
| `location` | `string` | No | Where the bookings happen, as free text (max 500 characters). |
| `member_id` | `string \| null` | No | Pins ONE host for the whole series; `null` means the same as omitting it (round-robin services then pre-select the fairest candidate for the whole series). |

## Request

**curl**

```bash
curl https://api.wiblo.app/v1/bookings/series \
  -X POST \
  -H "Authorization: Bearer $WIBLO_TOKEN" \
  -H "X-Wiblo-Workspace: $WIBLO_WORKSPACE_ID" \
  -H "Content-Type: application/json" \
  -d '{
  "service_id": "a9d4b7e2-5c8f-4b3a-9e6d-1f7c4a2b8e5d",
  "start_time": "2026-08-11T09:00:00.000Z",
  "end_time": "2026-08-11T10:00:00.000Z",
  "recurrence": {
    "freq": "weekly",
    "interval": 1,
    "count": 6
  },
  "timezone": "Europe/London",
  "attendee": {
    "name": "Tom Whitfield",
    "email": "tom@example.com",
    "timezone": "Europe/London"
  },
  "location": "https://meet.example.com/studio",
  "member_id": "5f2d8c1b-7e4a-4b9d-a6c3-1d8e5f2a7b4c"
}'
```

**TypeScript**

```ts
import { createSdk, createBookingSeries } from "@workspace/sdk"

const sdk = createSdk({ baseUrl: "https://api.wiblo.app" })

const { data, error } = await createBookingSeries({
  client: sdk,
  headers: { "X-Wiblo-Workspace": "..." },
  body: {
    "service_id": "a9d4b7e2-5c8f-4b3a-9e6d-1f7c4a2b8e5d",
    "start_time": "2026-08-11T09:00:00.000Z",
    "end_time": "2026-08-11T10:00:00.000Z",
    "recurrence": {
      "freq": "weekly",
      "interval": 1,
      "count": 6
    },
    "timezone": "Europe/London",
    "attendee": {
      "name": "Tom Whitfield",
      "email": "tom@example.com",
      "timezone": "Europe/London"
    },
    "location": "https://meet.example.com/studio",
    "member_id": "5f2d8c1b-7e4a-4b9d-a6c3-1d8e5f2a7b4c"
  },
})
```

## Example response

```json
{
  "series": {
    "recurring_event_id": "e7b3f9d1-2a6c-4d8b-9f4e-5c1a8d3b7e2f",
    "created": [
      {
        "booking_id": "1a5c8e2b-9d4f-4c6a-8b3e-7f2d5a9c1e4b",
        "booking_uid": "2b6d9f3c-0e5a-4d7b-9c4f-8a3e6b0d2f5c",
        "start_time": "2026-08-11T09:00:00.000Z",
        "end_time": "2026-08-11T10:00:00.000Z",
        "status": "accepted"
      },
      {
        "booking_id": "3c7e0a4d-1f6b-4e8c-ad5a-9b4f7c1e3a6d",
        "booking_uid": "4d8f1b5e-2a7c-4f9d-8e6b-0c5a8d2f4b7e",
        "start_time": "2026-08-18T09:00:00.000Z",
        "end_time": "2026-08-18T10:00:00.000Z",
        "status": "accepted"
      },
      {
        "booking_id": "5e9a2c6f-3b8d-4a0e-9f7c-1d6b9e3a5c8f",
        "booking_uid": "6f0b3d7a-4c9e-4b1f-a08d-2e7c0f4b6d9a",
        "start_time": "2026-08-25T09:00:00.000Z",
        "end_time": "2026-08-25T10:00:00.000Z",
        "status": "accepted"
      },
      {
        "booking_id": "7a1c4e8b-5d0f-4c2a-b19e-3f8d1a5c7e0b",
        "booking_uid": "8b2d5f9c-6e1a-4d3b-82af-4a9e2b6d8f1c",
        "start_time": "2026-09-08T09:00:00.000Z",
        "end_time": "2026-09-08T10:00:00.000Z",
        "status": "accepted"
      },
      {
        "booking_id": "9c3e6a0d-7f2b-4e4c-93ba-5b0f3c7e9a2d",
        "booking_uid": "0d4f7b1e-8a3c-4f5d-a4cb-6c1a4d8f0b3e",
        "start_time": "2026-09-15T09:00:00.000Z",
        "end_time": "2026-09-15T10:00:00.000Z",
        "status": "accepted"
      }
    ],
    "conflicts": [
      {
        "start_time": "2026-09-01T09:00:00.000Z",
        "end_time": "2026-09-01T10:00:00.000Z",
        "reason": "Host has a conflicting booking"
      }
    ],
    "total_requested": 6,
    "total_created": 5,
    "total_conflicts": 1
  }
}
```

## Responses

**`201`** — At least one occurrence was created. `conflicts` lists the ones that were not, with reasons. Returns `CreateBookingSeriesResponse`.

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `series` | `object` | Yes | The series-creation outcome. |
| `series.recurring_event_id` | `string` | Yes | The shared id every occurrence row of the series carries. |
| `series.created` | `array<BookingSeriesOccurrence>` | Yes | The occurrences that booked. |
| `series.conflicts` | `array<BookingSeriesConflict>` | Yes | The occurrences that could not book, with reasons; the rest of the series still books (partial success). |
| `series.total_requested` | `integer` | Yes | How many occurrences the recurrence pattern expanded to. |
| `series.total_created` | `integer` | Yes | How many occurrences booked. |
| `series.total_conflicts` | `integer` | Yes | How many occurrences conflicted. |

**`400`** — Body failed Zod validation, the service does not support recurring bookings, the count exceeds the service's configured maximum, or the pattern expands to more than 24 occurrences (`VALIDATION_FAILED` / `BAD_REQUEST`). Returns `ApiErrorEnvelope`.

**`401`** — No valid session cookie or `wbl_*` bearer was present. Returns `ApiErrorEnvelope`.

**`403`** — The actor is a plain member (`ADMIN_REQUIRED` — owners and admins only). Returns `ApiErrorEnvelope`.

**`404`** — The workspace or service collapsed (`WORKSPACE_NOT_FOUND` / `SERVICE_NOT_FOUND`) or `member_id` is not an assigned host (`HOST_NOT_FOUND`). Returns `ApiErrorEnvelope`.

**`409`** — Every occurrence conflicted — nothing was created (`BOOKING_CONFLICT`). Returns `ApiErrorEnvelope`.

**`422`** — The service has no assigned hosts (`SERVICE_HAS_NO_HOSTS`). Returns `ApiErrorEnvelope`.

**`429`** — Rate limit exceeded. The body's `error.code` is `RATE_LIMITED` and `error.details.retry_after` is the same number of seconds as the `Retry-After` header. Returns `ApiErrorEnvelope`.
