# Create a block booking schedule with recurring reservations

`POST /api/bookings/block-bookings`

- **Success:** `200 OK`
- **Products:** Operator (`FOH`)
- **Features:** Front of House Generic Access (`FOHGENERIC`)

HTML version: [Create a block booking schedule with recurring reservations](https://support.gladstonesoftware.cloud/reference/bookings/create-a-block-booking-schedule-with-recurring-reservations)

## Overview

Books the same slot repeatedly for one member in a single call. You describe the series —
a start and end date-time, a frequency, and optionally which days of the week — and the
endpoint expands it into individual bookings, creates an activity to hold them, and raises
a sales invoice per booking.

The expansion works like this: `dateTimeFrom` and `dateTimeTo` supply both the range of
the series and the time-of-day of every occurrence, so a slot cannot cross midnight.
`frequency` then decides how the range is walked, and `days`, `recursEvery`,
`recurranceType` and `recursOn` refine it. Dates listed in `exclusionDates` are dropped.

You must price the series yourself. `products` carries one entry per product per
occurrence, and every date the expansion produces must be covered by at least one entry,
or the call fails. Send `checkOnly=true` first to expand and clash-check the series
without writing anything.

Three failure shapes are worth designing for, and all of them return the full response
body rather than a message:

- occurrences that overlap **each other** — `400`, with `overlappingDates` populated
- clashes with **existing** bookings or schedule exceptions on the same resources —
  `409`, with `existingBookings` and `existingExceptions` populated
- nothing created and `errors` non-empty — `400`

On success the call returns `201 Created` (or `200 OK` for a `checkOnly` run) with the
`scheduleId` that identifies the series for later updates and deletes.

Block bookings are invoiced, not paid at the till: the payment method must collect through
an invoicing account and the member must have one configured, otherwise the request is
rejected.

**Use this endpoint to:**

- Reserve a recurring slot — a weekly coaching session, a monthly club night
- Dry-run a proposed series against existing bookings before committing it
- Create a whole term's bookings, and their invoices, in one call

## Endpoint

`POST {base_url}/api/bookings/block-bookings`

`{base_url}` is the environment URL issued to your integration. See [Authenticating with Gladstone APIs](../../guides/authenticating-with-gladstone-apis.md).

## Query parameters

| Name | Type | Required | Description |
|------|------|----------|-------------|
| `checkOnly` | boolean | No | When true, performs validation and conflict checking only without creating bookings. Returns 200 OK if no conflicts are found. |

## Request body

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `siteId` | string | Yes | The site the series belongs to. Must be non-empty (`Site ID must be specified`) and must exist (`Invalid Site ID '{siteId}'`). It also determines the default payment method used when `paymentMethodId` is omitted, and the counters that allocate booking ids and booking references. |
| `memberId` | integer (int32) | Yes | The member every booking in the series, and every invoice raised for it, belongs to. Must be 1 or greater (`Member ID must be specified`) and must exist (`Invalid Member ID '{memberId}'`). Their name is used as each booking's display name and their surname supplies the default `description`. Where the payment method collects through an invoicing account, that account must exist on this member. |
| `products` | array of object | No | The priced lines that become each occurrence's sales invoice, one entry per product per date. Required whenever bookings are actually created (`One or more Products must be specified`); the per-product checks are skipped when `checkOnly=true`, but the time bands in it are still validated, so send it either way. Every date the series generates must be covered by at least one entry, otherwise the call fails with `One or more booking dates have no matching products: {dates}`. Entries whose `date` matches no generated occurrence are ignored. |
| `products[].date` | string (date-time) | No | Which occurrence this line belongs to. Sent as a real UTC date-time, but only the calendar date is matched against the occurrence's start date. A missing value is rejected with `One or more products are missing a Date`. It is re-based using `dateTimeFrom`'s UTC offset, so a series crossing a daylight-saving change still lines up with the occurrences it generates. |
| `products[].productId` | string | No | The product invoiced on that date. Must be an existing standard product (`One or more Product IDs are invalid '{ids}'`). The same product may repeat across different dates, but not twice on the same date — that is rejected with `Duplicate product entries for the same date: {product} on {date}`. |
| `products[].quantity` | integer (int32) | No | How many units of the product to invoice for that occurrence. Must be greater than zero (`One or more products have an invalid quantity`). The invoice line total is `price` multiplied by this. |
| `products[].price` | number (double) | No | The unit amount invoiced for the line. Must be zero or greater (`One or more products have an invalid price`). The series is not priced for you — the value you send is used on the invoice line as-is, and the invoice total is the sum of price × quantity across the lines for that occurrence. |
| `products[].taxCodeId` | string | No | The tax code the line is charged under. Required on every entry (`One or more products are missing a Tax Code ID`); its rate is applied and both are recorded on the invoice line. |
| `products[].timeBandId` | string | No | The time band the line is charged under, recorded on the invoice line. Required on every entry (`One or more products are missing a Time Band ID`) and must be an existing time band (`One or more Time Band IDs are invalid '{ids}'`). |
| `activityGroupId` | string | No | The activity group the activity created for this series is filed under. Required whenever bookings are actually created (`Activity Group ID must be specified`) and must exist (`Invalid Activity Group ID '{id}'`); both checks are skipped when `checkOnly=true`. |
| `frequency` | string | Yes | How the series repeats: `Daily`, `Weekly` or `Monthly`, matched case-insensitively. Anything else is rejected with `Invalid Frequency '{value}', must be one of 'Daily', 'Weekly', 'Monthly'`. This choice decides which of `recursEvery`, `recurranceType` and `recursOn` are permitted — sending one that does not apply is an error rather than being ignored. |
| `recursEvery` | integer (int32) | No | The gap between repeats: every N weeks for `Weekly`, every N months for `Monthly`. Defaults to `1` when omitted and must be greater than zero. Must **not** be sent for `Daily`, which always steps a day at a time (`RecursEvery cannot be specified for Daily frequency`). |
| `recurranceType` | string | No | Only for `frequency: Monthly`, where it is mandatory (`RecurranceType must be specified for Monthly frequency`). `ByDate` repeats on the same day of the month as `dateTimeFrom` and forbids `recursOn` and `days`; `ByDay` repeats on the nth named weekday of the month and requires both. Any other value is rejected with `Invalid RecurranceType '{value}', must be one of 'ByDate', 'ByDay'`. Must not be sent for `Daily` or `Weekly`. |
| `recursOn` | integer (int32) | No | Which occurrence of the weekday within the month, `1` to `5`. Required, and only allowed, when `frequency` is `Monthly` and `recurranceType` is `ByDay`; a value outside 1–5 is rejected with `RecursOn must be between 1 and 5`. Must not be sent for `Daily`, `Weekly` or `ByDate`. |
| `paymentMethodId` | string | No | How the invoices raised for the series are collected. Omit it and the site's default host payment method is used. A supplied value must exist (`Invalid Payment Method ID '{id}'`). Whether supplied or defaulted, it must be a method that collects through an invoicing account and the member must have that account configured — otherwise the request fails with `Payment method '{id}' is not an invoicing account. Block bookings must be paid via an invoicing account.`, returned as a `400`. |
| `resourceProductIds` | array of string | Yes | The resource products reserved on every occurrence in the series — the things being booked out, as opposed to the `products` being charged for. At least one is required (`One or more Resource Products must be specified`), duplicates are rejected (`Duplicate Resource Product IDs found in request`) and each must exist (`One or more Resource Product IDs are invalid '{ids}'`). These are also what clash detection runs against: an existing booking or schedule exception on any of them, overlapping any generated occurrence, blocks the request. |
| `days` | array of string | No | Restricts the series to particular days of the week. Use full English day names, matched case-insensitively (`monday`, `tuesday`, …); an unrecognised name is rejected with `Invalid day in request` and repeats with `Duplicate Days found in request`. Omit it, or send an empty array, and every date in range that the frequency produces is used. For `frequency: Monthly` it is required with `recurranceType: ByDay` and forbidden with `ByDate`. |
| `dateTimeFrom` | string (date-time) | Yes | Start of the series, as a real UTC date-time. Its **date** is the first date an occurrence can fall on; its **time of day** is the start time of every occurrence. It must be supplied (`Invalid DateTimeFrom or DateTimeTo`) and must be earlier than `dateTimeTo`. Its UTC offset is also the reference used to interpret `dateTimeTo`, `exclusionDates` and `products[].date`, which keeps every occurrence at the same wall-clock time across a daylight-saving change. |
| `dateTimeTo` | string (date-time) | Yes | End of the series, as a real UTC date-time. Its **date** is the last date an occurrence can fall on; its **time of day** is the end time of every occurrence. It must be later than `dateTimeFrom` both overall and by time of day, so a single occurrence cannot run past midnight (`DateTimeTo time ({time}) must be after DateTimeFrom time ({time})`); model an overnight slot as separate series. |
| `exclusionDates` | array of string | No | Dates to skip — a closure day or a holiday, for instance. Only the calendar date is compared, so the time component is irrelevant. Every entry must fall between `dateTimeFrom` and `dateTimeTo` (`Exclusion dates must fall within the booking date range…`) and none may repeat (`Duplicate exclusion dates found in request`). Excluded dates are echoed back in the response's `excludedDates`, and no product entry is needed for them. |
| `notes` | string | No | Free-text comments copied onto every booking in the series. Omit it and each booking's comments are an empty string. |
| `reference` | string | No | Your own reference, stored on every booking the series creates. Maximum 255 characters. |
| `activityId` | string | No | An identifier of your own for the activity that holds the series. Omit it and a unique one is generated. If supplied it must be 15 characters or fewer (`ActivityId cannot exceed 15 characters`) and must not already be in use (`ActivityId '{id}' already exists`); both checks also run under `checkOnly=true`, so you can validate a candidate id before committing. If every booking then fails to create, the activity is removed so the same id can be retried. |
| `description` | string | No | The booking name, shown against every occurrence in the series. Maximum 25 characters: a longer value is rejected with `400` and the message `Booking name cannot be greater than 25 characters`, and nothing is created, even when `checkOnly=true`. Leave it blank and each booking is described as `Hosted {member surname}`. To rename the series later, send a new value to [Update an existing block booking schedule](update-an-existing-block-booking-schedule.md) with `editType: All`; a rename is not possible from a given date onwards. |
| `invoiceGroupingPeriod` | integer (int32) | Yes | Required — an absent value is rejected with `Invoice Grouping Period must be specified`, and it has no default. An integer from 0 to 255, set as the activity's invoice grouping period indicator. |
| `invoiceItemisation` | boolean | Yes | Required — an absent value is rejected with `Invoice Itemisation must be specified`, and it has no default. Sent as a boolean; the activity records it as `1` for itemised or `0` for not. |
| `participantNumber` | integer (int32) | No | A theoretical participant capacity for the activity, held for reporting only — it is **not** enforced against the number of people actually booked. Set as both the minimum and maximum bookee count on the activity. Must be between 0 and 32767; omitted on create, it defaults to `0`. |

## Sample request

```bash title="cURL"
curl -X POST '{base_url}/api/bookings/block-bookings' \
  -H 'Authorization: Bearer {accessToken}' \
  -H 'X-Use-SSO: true' \
  -H 'Content-Type: application/json' \
  -d '{
  "siteId": "string",
  "memberId": 12345,
  "products": [
    {
      "date": "2026-08-14T19:00:00Z",
      "productId": "string",
      "quantity": 1,
      "price": 10.5,
      "taxCodeId": "string",
      "timeBandId": "string"
    }
  ],
  "activityGroupId": "string",
  "frequency": "string",
  "recursEvery": 1,
  "recurranceType": "string",
  "recursOn": 1,
  "paymentMethodId": "string",
  "resourceProductIds": [
    "string"
  ],
  "days": [
    "string"
  ],
  "dateTimeFrom": "2026-08-14T19:00:00Z",
  "dateTimeTo": "2026-08-14T19:00:00Z",
  "exclusionDates": [
    "2026-08-14T19:00:00Z"
  ],
  "notes": "string",
  "reference": "string",
  "activityId": "string",
  "description": "string",
  "invoiceGroupingPeriod": 1,
  "invoiceItemisation": true,
  "participantNumber": 1
}'
```

## Response fields

| Field | Type | Description |
|-------|------|-------------|
| `bookingIds` | array of integer | The ids of the bookings that were created, one per occurrence, in the order they were created. Empty for a `checkOnly` run, and empty if every booking failed. |
| `dates` | array of object | The occurrences that were created, with their booking and invoice ids. On a `checkOnly` run the same list is returned to show what *would* be created, but with `bookingId` and `salesInvoiceId` of `0`. Occurrences that failed appear in `failedBookings` instead. |
| `dates[].bookingId` | integer (int32) | The id of the booking created for this occurrence; use it against the single-booking endpoints. `0` on a `checkOnly` run. |
| `dates[].salesInvoiceId` | integer (int32) | The id of the sales invoice raised for this occurrence from its matching `products` entries. `0` on a `checkOnly` run. |
| `dates[].startDate` | string (date-time) | When the occurrence starts, in real UTC — the occurrence's date at `dateTimeFrom`'s time of day. |
| `dates[].endDate` | string (date-time) | When the occurrence ends, in real UTC — the same date at `dateTimeTo`'s time of day. |
| `excludedDates` | array of string | The `exclusionDates` you sent, echoed back as plain dates. Empty when you sent none. |
| `scheduleId` | string (uuid) | Identifies the series as a whole; keep it, as the update and delete block-booking endpoints work on this rather than on individual bookings. It is generated only when bookings are actually created, so a `checkOnly` run and any failed request return the empty GUID `00000000-0000-0000-0000-000000000000`. |
| `selectedDates` | array of object | Every occurrence the recurrence rule produced, after `days` filtering and `exclusionDates` removal, whether or not it was created. It is returned even when the request fails, so it is the field to read when checking that your rule expands to the dates you expected. |
| `selectedDates[].startDate` | string (date-time) | Start of the generated occurrence, in real UTC. |
| `selectedDates[].endDate` | string (date-time) | End of the generated occurrence, in real UTC. |
| `errors` | array of string | Why the series was refused, as sentences — for example `No valid dates found for the specified days`, `One or more bookings overlap with each other`, `One or more bookings overlap with existing bookings for the same product(s)`, `One or more bookings overlap with existing exceptions for the same product(s)` or `All bookings failed to create`. Present and non-empty with no `bookingIds` means nothing was written, and the call returns `400`. |
| `overlappingDates` | array of object | Occurrences in **this** request that overlap one another — usually a recurrence rule that produces two slots on the same day. Any entry here makes the response a `400` and nothing is created. |
| `overlappingDates[].dateTimeFrom` | string (date-time) | Start of the occurrence that clashes with a later one, in real UTC. |
| `overlappingDates[].dateTimeTo` | string (date-time) | End of that occurrence, in real UTC. |
| `overlappingDates[].productId` | string | Never included for this list, because a self-overlap is between two occurrences rather than against a particular product. Do not depend on it. |
| `existingBookings` | array of object | Bookings that already exist on one of your `resourceProductIds` and overlap a generated occurrence. Any entry here makes the response a `409` and nothing is created; free the resource, change the times, or exclude the date, then retry. |
| `existingBookings[].bookingId` | integer (int32) | The id of the clashing booking. |
| `existingBookings[].recurringScheduleId` | string (uuid) | The schedule the clashing booking belongs to, when it is itself part of a block booking. Null for an ad-hoc booking. |
| `existingBookings[].dateTimeFrom` | string (date-time) | Start of the clashing booking, in real UTC. |
| `existingBookings[].dateTimeTo` | string (date-time) | End of the clashing booking, in real UTC. |
| `existingBookings[].productId` | string | Which of your `resourceProductIds` the clash is on. A single booking appears once per resource it clashes on. |
| `existingExceptions` | array of object | Schedule exceptions — periods where a resource is unavailable — that overlap a generated occurrence. Treated like `existingBookings`: any entry makes the response a `409` and nothing is created. |
| `existingExceptions[].dateTimeFrom` | string (date-time) | Start of the blocking exception, in real UTC. |
| `existingExceptions[].dateTimeTo` | string (date-time) | End of the blocking exception, in real UTC. |
| `existingExceptions[].productId` | string | Which of your `resourceProductIds` the exception applies to. |
| `failedBookings` | array of object | Occurrences that passed validation but could not be written. Creation is per-occurrence, not all-or-nothing: a partly successful call returns `201` with entries in both `dates` and `failedBookings`, so check this list even on success. If **every** occurrence failed, `errors` carries `All bookings failed to create` and the activity created for the series is removed. |
| `failedBookings[].startDate` | string (date-time) | Start of the occurrence that could not be created, in real UTC. |
| `failedBookings[].endDate` | string (date-time) | End of that occurrence, in real UTC. |
| `failedBookings[].error` | string | The failure message explaining why that occurrence could not be created. |

## Sample response

```json title="200 OK"
{
  "bookingIds": [
    12345
  ],
  "dates": [
    {
      "bookingId": 12345,
      "salesInvoiceId": 12345,
      "startDate": "2026-08-14T19:00:00Z",
      "endDate": "2026-08-14T19:00:00Z"
    }
  ],
  "excludedDates": [
    "string"
  ],
  "scheduleId": "b7f3c1e2-9a44-4f2b-8c17-0d5e9a1b6c33",
  "selectedDates": [
    {
      "startDate": "2026-08-14T19:00:00Z",
      "endDate": "2026-08-14T19:00:00Z"
    }
  ],
  "errors": [
    "string"
  ],
  "overlappingDates": [
    {
      "dateTimeFrom": "2026-08-14T19:00:00Z",
      "dateTimeTo": "2026-08-14T19:00:00Z",
      "productId": "string"
    }
  ],
  "existingBookings": [
    {
      "bookingId": 12345,
      "recurringScheduleId": "b7f3c1e2-9a44-4f2b-8c17-0d5e9a1b6c33",
      "dateTimeFrom": "2026-08-14T19:00:00Z",
      "dateTimeTo": "2026-08-14T19:00:00Z",
      "productId": "string"
    }
  ],
  "existingExceptions": [
    {
      "dateTimeFrom": "2026-08-14T19:00:00Z",
      "dateTimeTo": "2026-08-14T19:00:00Z",
      "productId": "string"
    }
  ],
  "failedBookings": [
    {
      "startDate": "2026-08-14T19:00:00Z",
      "endDate": "2026-08-14T19:00:00Z",
      "error": "string"
    }
  ]
}
```

## Errors

| Status | Meaning | When |
|--------|---------|------|
| `400` | Bad request | Validation failed: a missing or malformed field, an unknown site, member, activity group, product, resource product, time band or payment method, a payment method that does not collect through an invoicing account, or a generated booking date with no matching product entry. Also returned when the generated occurrences overlap each other (body carries `overlappingDates`), when nothing was created and `errors` is non-empty, and when saving fails — `A database error occurred. Please ensure that the ID supplied is unique`. |
| `400` | Bad request | `description` is longer than 25 characters (`Booking name cannot be greater than 25 characters`). Nothing is created. |
| `404` | Not found | Not returned for unknown references — an unknown site, member, activity group, product or resource product returns `400`. |
| `409` | Conflict | Existing bookings, or schedule exceptions, on the requested `resourceProductIds` overlap one or more generated occurrences. The body is the full response with `existingBookings` and `existingExceptions` populated, so you can resolve the clashes and retry. Nothing is created. |
| `422` | Unprocessable entity | Not returned — a payment method that is not an invoicing account returns `400`. |
| `500` | Internal server error | An unexpected internal server error occurred. |
