# Product Groups

Product groups let you manage a set of related products as one catalog unit. You can use them to control contract windows, activation state, publishing windows, and booking expiry settings across multiple products.

In practice, a product group helps you organise products that should follow the same commercial rules. This is useful when you want consistent configuration for a cohort of products instead of updating each product one by one.

## Key Concepts

### Contract windows

Each product group has contract date boundaries:

- `contractEarliestStartDate`
- `contractLatestStartDate`
- `contractEarliestEndDate`
- `contractLatestEndDate`

`contractEarliestStartDate` and `contractLatestEndDate` are the outer bounds that control the overall date range a booking can be made over for products in this group. No booking can start before `contractEarliestStartDate` or end after `contractLatestEndDate`.

`contractLatestStartDate` and `contractEarliestEndDate` define the inner flexibility within those outer bounds:

- A booking can start anywhere between `contractEarliestStartDate` and `contractLatestStartDate` (e.g. a student arriving late).
- A booking can end anywhere between `contractEarliestEndDate` and `contractLatestEndDate` (e.g. a student leaving early).

In other words, the full window a booking may occupy is `[contractEarliestStartDate, contractLatestEndDate]`, while the start date must fall in `[contractEarliestStartDate, contractLatestStartDate]` and the end date must fall in `[contractEarliestEndDate, contractLatestEndDate]`.

### State and publishing

A product group has two related controls:

- **Activation state** (`ACTIVE`, `INACTIVE`)
- **Publishing window** (`startDate`, `endDate`) with a publishing state

You can update activation state for one or more product groups in a single mutation.

### Product segments

Product groups can be classified by segment to indicate the type of student accommodation they represent. The `productSegment` field is optional and applies to student product groups only.

| Value | Description |
| --- | --- |
| `FULL_YEAR` | Accommodation spanning the full calendar year including summer |
| `ACADEMIC_YEAR` | Accommodation covering the full academic year, excluding summer |
| `SEMESTER_1` | Accommodation for the first semester of the academic year only |
| `SEMESTER_2` | Accommodation for the second semester of the academic year only |

Omit `productSegment` for non-student product groups.

### Product membership

A product group contains a connection of products. This lets you inspect the products that belong to the group and paginate through them.

## GraphQL API

### Type

#### `ProductGroup`

| Field | Type | Description |
| --- | --- | --- |
| `id` | `ID!` | Product group identifier |
| `internalReference` | `String!` | Internal reference string for the group |
| `workspaceId` | `Integer!` | Workspace identifier |
| `contractEarliestStartDate` | `String!` | Earliest contract start date |
| `contractLatestStartDate` | `String!` | Latest contract start date |
| `contractEarliestEndDate` | `String!` | Earliest contract end date |
| `contractLatestEndDate` | `String!` | Latest contract end date |
| `defaultExpiryDurationInSeconds` | `Integer` | Default booking expiry duration |
| `allowBookingExpiryTimer` | `Boolean` | Whether booking expiry timer is enabled |
| `academicYear` | `String` | Academic year in `YYYY/YYYY` format |
| `productSegment` | `CatalogProductSegmentEnum` | Segment classification (student products only) |
| `state` | `CatalogProductGroupStateEnum!` | Activation state |
| `publishing` | `Publishing!` | Publishing window and lifecycle state |
| `requiresCancelationPolicy` | `Boolean!` | Whether a cancellation policy is required for this product group |
| `contractTemplateExternalId` | `String` | External contract template identifier |
| `createdAt` | `ISO8601DateTime!` | Creation timestamp |
| `updatedAt` | `ISO8601DateTime!` | Last update timestamp |

#### `CatalogProductGroupStateEnum`

| Value | Description |
| --- | --- |
| `ACTIVE` | Product group is active |
| `INACTIVE` | Product group is inactive |

#### `CatalogProductSegmentEnum`

| Value | Description |
| --- | --- |
| `FULL_YEAR` | Accommodation spanning the full calendar year including summer |
| `ACADEMIC_YEAR` | Accommodation covering the full academic year, excluding summer |
| `SEMESTER_1` | Accommodation for the first semester of the academic year only |
| `SEMESTER_2` | Accommodation for the second semester of the academic year only |

#### `CatalogPublishStateEnum`

| Value | Description |
| --- | --- |
| `SCHEDULED` | Publishing has not started yet |
| `ONGOING` | Publishing is currently active |
| `FINISHED` | Publishing window has ended |

### Queries

#### Get a product group

```graphql
query ProductGroup($id: ID!) {
  catalog {
    productGroup(id: $id) {
      id
      internalReference
      state
      productSegment
      publishing {
        startDate
        endDate
        state
      }
      products {
        totalCount
      }
    }
  }
}
```

```json
{
  "id": "product-group-id"
}
```

#### List product groups

```graphql
query ProductGroups(
  $filters: ProductGroupsFiltersInput
  $first: Int
  $after: String
) {
  catalog {
    productGroups(filters: $filters, first: $first, after: $after) {
      nodes {
        id
        internalReference
        state
      }
      totalCount
      pageInfo {
        hasNextPage
        endCursor
      }
    }
  }
}
```

```json
{
  "filters": {
    "state": "ACTIVE",
    "published": true
  },
  "first": 20
}
```

> **Tip:** `productGroups` follows the standard connection pattern. Use cursors for pagination when iterating through large catalogs.

### Mutations

#### Create a product group

```graphql
mutation CreateProductGroup($input: CreateProductGroupMutationInput!) {
  catalog {
    productGroup {
      create(input: $input) {
        id
      }
    }
  }
}
```

```json
{
  "input": {
    "productGroup": {
      "internalReference": "PG-2026-01",
      "contractEarliestStartDate": "2026-01-01",
      "contractLatestStartDate": "2026-09-30",
      "contractEarliestEndDate": "2026-01-15",
      "contractLatestEndDate": "2027-08-31",
      "workspaceId": "workspace-id",
      "academicYear": "2026/2027",
      "productSegment": "ACADEMIC_YEAR",
      "allowBookingExpiryTimer": true,
      "defaultExpiryDurationInSeconds": 1800,
      "publishing": {
        "startDate": "2026-02-01",
        "endDate": "2027-06-30"
      }
    }
  }
}
```

Required fields for `productGroup` in create:

| Field | Type |
| --- | --- |
| `internalReference` | `String!` |
| `contractEarliestStartDate` | `ISO8601Date!` |
| `contractLatestStartDate` | `ISO8601Date!` |
| `contractEarliestEndDate` | `ISO8601Date!` |
| `contractLatestEndDate` | `ISO8601Date!` |
| `workspaceId` | `ID!` |
| `academicYear` | `String!` |

Optional fields for `productGroup` in create:

| Field | Type | Description |
| --- | --- | --- |
| `allowBookingExpiryTimer` | `Boolean` | Whether booking expiry timer is enabled |
| `defaultExpiryDurationInSeconds` | `Integer` | Default booking expiry duration in seconds |
| `productSegment` | `CatalogProductSegmentEnum` | Segment classification (student products only) |
| `cancelationPolicyId` | `ID` | ID of the cancellation policy to associate |
| `requiresCancelationPolicy` | `Boolean` | Whether a cancellation policy is required |
| `contractTemplateExternalId` | `String` | External contract template identifier |
| `publishing` | `PublishingInput` | Publishing window configuration |

#### Update a product group

```graphql
mutation UpdateProductGroup($input: UpdateProductGroupMutationInput!) {
  catalog {
    productGroup {
      update(input: $input) {
        id
      }
    }
  }
}
```

```json
{
  "input": {
    "productGroupId": "product-group-id",
    "productGroup": {
      "internalReference": "PG-2026-01-REV1",
      "productSegment": "SEMESTER_1",
      "publishing": {
        "startDate": "2026-03-01",
        "endDate": "2027-07-31"
      }
    }
  }
}
```

Required fields for update input:

| Field | Type |
| --- | --- |
| `productGroupId` | `ID!` |
| `productGroup` | `ProductGroupUpdateInput!` |

All fields within `productGroup` are optional for update. Key settable fields include:

| Field | Type | Description |
| --- | --- | --- |
| `internalReference` | `String` | Internal reference string |
| `cancelationPolicyId` | `ID` | ID of the cancellation policy to associate |
| `requiresCancelationPolicy` | `Boolean` | Whether a cancellation policy is required |
| `allowBookingExpiryTimer` | `Boolean` | Whether booking expiry timer is enabled |
| `defaultExpiryDurationInSeconds` | `Integer` | Default booking expiry duration in seconds |
| `productSegment` | `CatalogProductSegmentEnum` | Segment classification (student products only) |
| `contractTemplateExternalId` | `String` | External contract template identifier |
| `publishing` | `PublishingInput` | Publishing window configuration |

#### Update product group state in bulk

```graphql
mutation UpdateProductGroupState($input: UpdateProductGroupsStateMutationInput!) {
  catalog {
    productGroup {
      updateState(input: $input) {
        updatedCount
        productGroupIds
      }
    }
  }
}
```

```json
{
  "input": {
    "productGroupIds": [
      "product-group-id-1",
      "product-group-id-2"
    ],
    "state": "ACTIVE"
  }
}
```

## MCP Tools

If you use MCP, these tools are relevant:

- [get_product_group](/mcp/tools/catalog/product-groups/get_product_group/)
- [update_product_groups](/mcp/tools/catalog/product-groups/update_product_groups/)
- [get_product](/mcp/tools/catalog/products/get_product/)

See [Tools](/mcp/tools/) for setup and full coverage.

## Relationships

- A product group belongs to a [Workspace](/workspaces/workspaces/)
- A product group has many [Products](/catalog/products/)
- Product groups work alongside [Pricing](/pricing/pricing/) to control what can be sold and when
