# Create promotion

`POST /api/configuration/promotions`

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

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

## Overview

Builds a complete promotional offer in one call. The endpoint performs three steps in sequence: it creates one **discount** for every membership type you list, creates one **promo code** for every code string you supply, and finally creates the **promotion** record that ties them together. A promotion is the container an operator manages; the discount holds the money, and the promo codes are what a member types in to claim it.

You do not choose the identifier. On success you get `201 Created` and a body containing the integer `id` the platform allocated for the promotion, plus the `name` you sent. Use that `id` with [Retrieve promotion by id](get-promotion-by-id.md), [Update promotion](update-promotion.md) and [Delete promotion](delete-promotion.md).

**The call is not idempotent and not all-or-nothing.** There is no idempotency key, and promotion names are not checked for uniqueness, so two identical requests that carry no `promoCodes` produce two separate promotions with different ids. Promo code strings, by contrast, are unique across the whole account: repeat a request that carried codes and it fails at the second step with `400` and a message ending `A promo-code with the code '{code}' already exists with an ID of {id}`. Because the three steps are not all-or-nothing and nothing is rolled back, that failed retry still leaves a fresh set of discounts behind. If a call fails part-way, clean up before retrying rather than sending the same body again.

Everything the request references must already exist. `discount.membershipTypeIds` are validated against [the membership types on the account](get-all-membership-types-with-optional-filtering.md), and `discount.siteId`, if you send one, against [the sites](get-all-sites-with-optional-filtering.md).

## Endpoint

`POST {base_url}/api/configuration/promotions`

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

## Request body

The body has two required top-level objects: `promotion` and `discount`. The table below describes the members of `promotion`; the members of `discount` follow underneath it. Send the wrapped form shown in the sample request, not the promotion fields unwrapped at the top level.

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `promotionName` | string | Yes | The promotion's display name, and how it is identified in [Retrieve all promotions](get-all-promotions.md). Maximum 50 characters — longer values are rejected with `The field 'PromotionName' can not exceed 50 characters`. Not checked for uniqueness, so nothing stops two promotions sharing a name. Echoed back as `name` in the response. |
| `validFrom` | string (date-time) | No | The date the promotion starts. Only the date part is kept; the time is discarded. Must be on or before `validTo`, otherwise the call fails with `The validFrom date must be before the validTo date`. Omitting the field is accepted and stores `0001-01-01`; sending an explicit `null` is rejected as malformed JSON before any validation runs. When you supply `promoCodes` this date is copied on to each new promo code, and promo codes additionally require it to be today or later — so a back-dated `validFrom` works for a promotion with no codes and fails for one with codes. |
| `validTo` | string (date-time) | Yes | The date the promotion stops being valid, and again only the date part is kept. Must be today (UTC) or later, or the call fails with `The validTo date must be from today onwards`. There is no way to leave it out: omit it and it defaults to `0001-01-01`, which fails that check; send `null` and it is rejected as malformed JSON. When you supply `promoCodes` this date is copied on to each new promo code, which require it to be strictly after today — so a `validTo` of today succeeds for a promotion with no codes and fails with `The Valid To date must be a future date` for one with codes. |
| `isActive` | boolean | No | Whether the offer is live. This one flag governs all three records: it overrides whatever you put in `discount.isActive` and applies it to every promo code it creates, so setting `discount.isActive` yourself has no effect. Defaults to `false` when omitted, which creates the promotion, discount and codes in a dormant state ready to be switched on later with [Update promotion](update-promotion.md). An explicit `null` is rejected as malformed JSON. |
| `promoCodes` | array of string | No | The codes a member enters to claim the offer. Each string is **created** as a new promo code linked to the discounts this call has just created — do not send codes that already exist, or the call fails. Codes may not contain whitespace (`Promo-code code can not have any blank spaces.`) and are unique across the account. Each code is created with unlimited usages, auto-apply off, and the promotion's own `validFrom`, `validTo` and `isActive`; if you need different settings per code, create them with [Create promo code](create-promo-code.md) instead. Omit the field or send `[]` to create a promotion with no codes, which is valid — an offer applied by the operator rather than typed in by a member. |
| `promotionType` | string | No | What the promotion applies to: `Membership` or `Booking`, case-sensitive. Defaults to `Membership` when omitted or `null`; any other value fails with `Invalid promotionType. Must be either Membership or Booking`. It is the filter used by `GET /api/configuration/promotions?promotionType=…`. |

`discount` is required — omit the object entirely and the call fails with `Discount is required`. Its members are:

- `membershipTypeIds` (array of string, required) — the membership types the discount applies to, taken from [Retrieve all membership types](get-all-membership-types-with-optional-filtering.md). One discount is created per id, all sharing the amounts below. The list may not be empty, may not repeat an id, and every id must exist.
- `discountRegularAmount` (number, required) — the percentage off the recurring subscription payments, applied for the number of months in `discountUntilUnit`. Between 0 and 100; `0` means no regular discount. Send `0` rather than omitting the field — a missing value fails validation.
- `discountIntroductoryAmount` (number, required) — the percentage off the initial fee. On a Direct Debit membership this covers only the first pro-rata portion. Same 0–100 scale and the same "send 0 for none" rule.
- `discountOneOffAmount` (number, required) — the percentage off the one-off joining fee, on memberships that charge one. Same 0–100 scale and the same "send 0 for none" rule.
- `discountUntilUnit` (integer, required) — the number of months the regular discount lasts for. `0` combined with a `discountRegularAmount` above 0 makes the discount perpetual — it never lapses. Must be 0 or greater, and may only be greater than 0 when `discountRegularAmount` is greater than 0.
- `discountBanner` (string, optional) — free text shown as a banner alongside the offer. Maximum 510 characters. Blank or whitespace-only is saved as null.
- `discountText` (string, optional) — free text describing the offer. Maximum 510 characters. Blank or whitespace-only is saved as null.
- `isActive` (boolean) — **ignored.** It is always replaced by `promotion.isActive`.
- `siteId` (string, optional) — restricts the discount to a single site, taken from [Retrieve all sites](get-all-sites-with-optional-filtering.md). To apply the discount across every site, send `null`, an empty string or omit the field — all three are normalised to "no site restriction" before validation. Only one site can be named; there is no list. An id that does not exist fails with `Discount could not be created as siteId does not exist`.

## Sample request

```bash title="cURL"
curl -X POST '{base_url}/api/configuration/promotions' \
  -H 'Authorization: Bearer {accessToken}' \
  -H 'X-Use-SSO: true' \
  -H 'Content-Type: application/json' \
  -d '{
  "promotion": {
    "promotionName": "Summer 2026 joining offer",
    "validFrom": "2026-06-01T00:00:00Z",
    "validTo": "2026-08-31T00:00:00Z",
    "isActive": true,
    "promoCodes": [
      "SUMMER26"
    ],
    "promotionType": "Membership"
  },
  "discount": {
    "membershipTypeIds": [
      "GYMPLUS"
    ],
    "discountRegularAmount": 10,
    "discountIntroductoryAmount": 0,
    "discountOneOffAmount": 100,
    "discountUntilUnit": 3,
    "discountBanner": "Summer offer",
    "discountText": "10% off for your first three months and no joining fee",
    "isActive": true,
    "siteId": null
  }
}'
```

## Sample response

```json title="201 Created"
{
  "id": 4127,
  "name": "Summer 2026 joining offer"
}
```

## Errors

Every failure below is returned as a `400`, with the underlying message wrapped in the text `Error creating promotion code: {message}`, whichever of the three steps failed. Read the trailing message, not the prefix — it says "promotion code" even when the discount or the promotion itself was the problem.

| Status | Meaning | When |
|--------|---------|------|
| `400` | Missing top-level object | `Discount is required` or `Promotion is required`. These come back as a validation problem document (`application/problem+json`) with an `errors` map keyed by field name, before any other check. |
| `400` | Promotion name missing or too long | `PromotionName is required`, or `The field 'PromotionName' can not exceed 50 characters`. Also returned as a validation problem document. |
| `400` | Discount amounts missing | One of `Discount could not be created as the DiscountRegularAmount is not greater than or equal to 0`, `…DiscountIntroductoryAmount…`, `…DiscountOneOffAmount…` or `…DiscountUntilUnit…` — each fires when that field is absent or null. Send `0` for "no discount". |
| `400` | Discount amount out of range | `Discount could not be created as the DiscountRegularAmount cannot be greater than 100` (and the equivalents for the introductory and one-off amounts). The amounts are percentages. |
| `400` | No discount amount set | `Discount could not be created as no discount amounts have been provided` — all three amounts were `0`. |
| `400` | `discountUntilUnit` set without a regular discount | `Discount could not be created as DiscountUntilUnit should only be greater than 0 when DiscountRegularAmount is greater than 0`. |
| `400` | Membership types missing, duplicated or unknown | `Discount could not be created as no membership types have been provided`, `…as duplicate MembershipTypeIds have been provided`, or `…as at least one of the membership types does not exist.` |
| `400` | Unknown `siteId` | `Discount could not be created as siteId does not exist`. Only fires for a non-blank id that no site matches; a blank or omitted `siteId` means "all sites" and passes. |
| `400` | Promo code already exists | `A promo-code with the code '{code}' already exists with an ID of {id}`. Codes are unique across the account and this endpoint always creates them, never reuses them. |
| `400` | Promo code contains whitespace | `Promo-code code can not have any blank spaces.` |
| `400` | Dates rejected by the promo code step | `The Valid To date must be a future date` (a `validTo` of today), `The Valid From date must be from today onwards` (a back-dated `validFrom`), or `The Valid To date must be a future date and after the Valid From date`. These only occur when `promoCodes` is non-empty, because the codes inherit the promotion's dates and are validated more strictly than the promotion. |
| `400` | Promotion dates invalid | `The validTo date must be from today onwards`, or `The validFrom date must be before the validTo date`. |
| `400` | Unrecognised `promotionType` | `Invalid promotionType. Must be either Membership or Booking`. |
| `400` | Malformed JSON | Sending `null` for `validFrom`, `validTo` or `isActive` is rejected as malformed JSON, for example `Error converting value {null} to type 'System.DateTime'. Path 'Promotion.validFrom'`. Omit the field instead of nulling it. |
| `403` | Forbidden | The access token does not satisfy this endpoint's requirements — the `FOH` product and the `FOHGENERIC` feature — or its scope is neither `api` nor `partner`. |
| `500` | Internal server error | An unexpected server-side failure in any of the three steps. The body is empty. Because the steps are sequential, a `500` may still have created the discounts. |
