# Create price level

`POST /api/configuration/price-levels`

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

HTML version: [Create price level](https://support.gladstonesoftware.cloud/reference/configuration/create-price-level)

## Overview

Creates a price level. A price level is a named pricing category — "Adult", "Junior", "Corporate" and so
on — that members and casual customers are assigned to, and that activity and membership prices are then
quoted against.

A price level is identified by a short string id. You choose that id yourself by sending `id`, or you omit
`id` and one is allocated for you: the endpoint takes the highest existing id beginning `PLVL`, adds one, and
zero-pads the number to a total length of 15 characters, giving ids of the form `PLVL00000000001`.

A successful call returns `201 Created`. The body is a small JSON object whose only property is the id that
was actually used — the one you sent, upper-cased and stripped of spaces, or the one allocated for you:

```json title="201 Created"
{ "id": "PLVL00000000001" }
```

Read that property case-insensitively, and take the id from it rather than assuming the value you sent was
stored verbatim. The endpoint also sets a `Location` header.

Retrying is only safe when you choose the id yourself. If you send an `id` that already exists the request
is rejected with `409 Conflict` and nothing is created, so a retry after a timeout is harmless. If you omit
`id`, every retry allocates a fresh id and creates another price level — there is no idempotency key and no
duplicate detection on `description`.

Uniqueness is enforced on the id only: `id` must be unique across all price levels, but two price levels may
carry the same `description`. Within a single request, each `alternativeDescriptions[].alternativeDescription`
must be distinct, and no site id or activity id may appear more than once across the whole
`alternativeDescriptions` array.

Two of the fields on this request are routinely confused, and they do different things:
`infringementPriceLevel` is the price level a member is moved to when they breach the terms of their
agreement — falling into debt, missing payments — whereas `nextPriceLevel` is the price level a member
progresses to automatically, typically when they age out of one band into another (junior to adult). Setting
one does not affect the other.

Creation is not all-or-nothing. The price level is saved first, and any
`alternativeDescriptions` are saved afterwards, one entry at a time. All validation runs before anything is
written, so this rarely bites, but if saving the alternative descriptions fails mid-way the price level itself
still exists and the call returns `500`. Retrieve the price level before retrying rather than assuming
nothing was created.

## Endpoint

`POST {base_url}/api/configuration/price-levels`

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

## Request body

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `id` | string | No | The identifier you want the price level to have, up to 15 characters. It is upper-cased and has spaces stripped before it is stored, so `gym adult` is saved and must later be referenced as `GYMADULT`. It must not already be in use — a clash is rejected with `409`. Omit the field and the endpoint allocates the next id in the `PLVL…` sequence instead, and returns it in the response body. |
| `description` | string | Yes | The name of the price level as staff and customers see it, 1–255 characters. Descriptions are not required to be unique, so nothing stops you creating two price levels called "Adult". Use `alternativeDescriptions` if a particular site or activity needs to show a different name for this same price level. |
| `ageRangeStart` | integer (int32) | No | Lower bound of the age band this price level covers, in whole years. Despite the error message ("AgeRangeStart has to be greater than 0"), the accepted range is 0 to 32767 inclusive, and 0 is valid. Defaults to `0` when omitted. Must not be greater than `ageRangeEnd`, or the request is rejected with `400`. |
| `ageRangeEnd` | integer (int32) | No | Upper bound of the age band, in whole years, on the same 0–32767 scale as `ageRangeStart`. Defaults to `0` when omitted — so a request that sets only `ageRangeStart` to a non-zero value is rejected, because the start would then exceed the end. |
| `backColourHex` | string | No | Background colour used when the price level is shown in Gladstone's own front-of-house screens. Send a hex string such as `#E0E0E0`; a value that cannot be parsed as a colour fails the request with `500` rather than `400`. Send `null` or `""` to get the default `#E0E0E0`. The default is applied only when the field is actually present in the JSON: send it explicitly as `null` rather than leaving the key out. |
| `textColourHex` | string | No | Foreground (text) colour used alongside `backColourHex` on the same screens, with identical parsing and identical handling of null and empty values. Its default is `#000000`. |
| `membersAllowed` | boolean | No | Marks the price level as applying to members. Defaults to `false` when omitted. Price levels with this set are the ones returned by `GET /api/configuration/price-levels?filter=Member`. |
| `nonMembersAllowed` | boolean | No | Marks the price level as applying to non-members — casual customers with no membership. Defaults to `false`, and is independent of `membersAllowed`; a price level may have both, one, or neither. Price levels with this set are returned by `GET /api/configuration/price-levels?filter=Non-Member`. |
| `webNonMembersAllowed` | boolean | No | Marks the price level as available to non-members booking through the consumer web channel, as opposed to at the centre. Defaults to `false`. It is a separate flag from `nonMembersAllowed`, and is what the `webNonMembers=true` query parameter on the list endpoint filters on. |
| `infringementPriceLevel` | string | No | The id of the price level a member on this price level is moved to when they breach the terms of their agreement — for example falling into debt or missing payments. Must be the `id` of a price level that already exists, matched exactly as stored (upper case), or the request fails with `400`. Defaults to empty, meaning no infringement level is configured. Do not confuse this with `nextPriceLevel`: this one is punitive, that one is progression. |
| `nextPriceLevel` | string | No | The id of the price level a member progresses to automatically as part of normal lifecycle, most commonly an age-based transition such as junior to adult. Must be the `id` of an existing price level, matched exactly as stored (upper case), or the request fails with `400`. Defaults to empty, meaning no onward level is configured. There is no cycle detection, and you cannot point it at the price level you are creating in this request, because the target must already exist. |
| `translations` | array of object | No | Translations of this price level's `description` into other cultures, for bilingual sites. Omit or send an empty array if you only need the `description`. The entries are stored as supplied and are not validated, so mistakes here surface as a `500` rather than a `400`. |
| `translations[].type` | string | No | The name of the field being translated. Use `description` — that is the only property the price level retrieval and list endpoints read back, so an entry with any other value is stored but never returned. |
| `translations[].culture` | string | No | The culture code the translation is for, for example `cy-GB`. It must be a culture code the platform already knows about; an unrecognised code fails the request with `500`. |
| `translations[].text` | string | No | The translated text itself — what `description` should read as in that culture. |
| `alternativeDescriptions` | array of object | No | Per-site and per-activity overrides of the price level's name, for cases where the same price level is called something different at a particular centre or for a particular activity. Omit or send an empty array if no overrides are needed. All entries are validated before the price level is written, so an invalid entry means nothing at all is created. |
| `alternativeDescriptions[].alternativeDescription` | string | No | The name to display instead of `description` for the sites and activities listed in this entry, up to 25 characters — a much tighter limit than `description`'s 255. It must not be blank, and comparing case-insensitively and ignoring surrounding whitespace, it must differ from every other `alternativeDescription` in the same request. |
| `alternativeDescriptions[].activityIds` | array of string | No | The activities this override applies to. Each id must be an activity that already exists, and no activity id may be repeated anywhere in the `alternativeDescriptions` array — including across two different entries. Ids are upper-cased before use. May be empty, but only if `siteIds` is populated: every entry needs at least one site or activity. |
| `alternativeDescriptions[].siteIds` | array of string | No | The sites (centres) this override applies to. Each id must be a site that already exists, and no site id may be repeated anywhere in the `alternativeDescriptions` array. Ids are upper-cased before use. May be empty, but only if `activityIds` is populated. |

## Sample request

```bash title="cURL"
curl -X POST '{base_url}/api/configuration/price-levels' \
  -H 'Authorization: Bearer {accessToken}' \
  -H 'X-Use-SSO: true' \
  -H 'Content-Type: application/json' \
  -d '{
  "id": "string",
  "description": "string",
  "ageRangeStart": 1,
  "ageRangeEnd": 1,
  "backColourHex": "string",
  "textColourHex": "string",
  "membersAllowed": true,
  "nonMembersAllowed": true,
  "webNonMembersAllowed": true,
  "infringementPriceLevel": "string",
  "nextPriceLevel": "string",
  "translations": [
    {
      "type": "string",
      "culture": "string",
      "text": "string"
    }
  ],
  "alternativeDescriptions": [
    {
      "alternativeDescription": "string",
      "activityIds": [
        "string"
      ],
      "siteIds": [
        "string"
      ]
    }
  ]
}'
```

## Errors

Error responses carry the reason as the response body. Request validation failures return a validation
problem document (`application/problem+json`) with an `errors` map keyed by field name; every other failure
returns the message below as a plain string.

| Status | Meaning | When |
|--------|---------|------|
| `400` | Bad request | Request validation failed before any other check: `id` longer than 15 characters, `description` missing, empty or longer than 255 characters, `ageRangeStart` or `ageRangeEnd` outside 0–32767, or an `alternativeDescription` longer than 25 characters. The body is a validation document listing the offending fields. |
| `400` | Bad request | Malformed JSON, or a value of the wrong type for its field. |
| `400` | Bad request | `ageRangeStart` is greater than `ageRangeEnd`. Body: `The starting age range is greater than the ending age range`. |
| `400` | Bad request | `nextPriceLevel` or `infringementPriceLevel` names a price level that does not exist. Body: `PriceLevel {id} does not exist`. Remember ids are stored upper-cased. |
| `400` | Bad request | An `alternativeDescriptions` entry has a blank or missing `alternativeDescription`. Body: `One or more alternative descriptions does not have a description`. |
| `400` | Bad request | An `alternativeDescriptions` entry supplies neither `siteIds` nor `activityIds`. Body: `One or more alternative descriptions does not have a activity ID or site ID defined`. |
| `400` | Bad request | Two entries in `alternativeDescriptions` use the same text, compared case-insensitively and trimmed. Body: `Duplicate alternateDescriptions within the same PriceLevel is not allowed. Please ensure each alternateDescription is unique`. |
| `400` | Bad request | The same site id appears more than once across `alternativeDescriptions`, whether inside one entry or spread across several. Body: `Duplicate Site IDs are not allowed across alternative descriptions`. |
| `400` | Bad request | The same activity id appears more than once across `alternativeDescriptions`. Body: `Duplicate Activity IDs are not allowed across alternative descriptions`. |
| `400` | Bad request | One or more ids in `siteIds` do not match an existing site. Body: `Some or all of the sites supplied are not valid`. The message does not say which id was wrong. |
| `400` | Bad request | One or more ids in `activityIds` do not match an existing activity. Body: `Some or all of the activities supplied are not valid`. |
| `409` | Conflict | The `id` you supplied is already in use by another price level. Body: `The PriceLevel : {id} already exists`. Nothing is created, so this is safe to treat as "already done". |
| `409` | Conflict | A concurrent request created a price level with the same id at the same moment. Body: `This entry already exists in the database`. |
| `500` | Internal server error | `backColourHex` or `textColourHex` could not be parsed as a colour. This is a bad request in everything but status code, so validate the hex yourself before sending. |
| `500` | Internal server error | A `translations[].culture` is not a culture code the platform recognises, so the translation cannot be saved. |
| `500` | Internal server error | Any other unexpected server-side failure, including one while saving the alternative descriptions — in which case the price level itself may already exist. |
