Create promotion
POST /api/configuration/promotions
Success: 201 Created
- Products: Operator (
FOH) - Features: Front of House Generic Access (
FOHGENERIC)
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, Update promotion and Delete promotion.
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, and discount.siteId, if you send one, against the sites.
Endpoint
POST {base_url}/api/configuration/promotions
{base_url} is the environment URL issued to your integration. See Authenticating with Gladstone APIs.
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. 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. 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 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. 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 indiscountUntilUnit. Between 0 and 100;0means no regular discount. Send0rather 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.0combined with adiscountRegularAmountabove 0 makes the discount perpetual — it never lapses. Must be 0 or greater, and may only be greater than 0 whendiscountRegularAmountis 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 bypromotion.isActive.siteId(string, optional) — restricts the discount to a single site, taken from Retrieve all sites. To apply the discount across every site, sendnull, 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 withDiscount could not be created as siteId does not exist.
Sample request
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
{
"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. |