{
  "openapi": "3.0.0",
  "info": {
    "title": "1club Platform API",
    "version": "1.0.0",
    "description": "The 1club Platform API lets you programmatically access and manage your organization's data.\n\n## Official API Contract\n\nThis documentation is the official source of truth for the 1club Platform API.\nIf an integration relies on undocumented endpoints, fields, response shapes, or internal behavior outside this spec, we can't guarantee backward compatibility.\nBuild against what's documented here to stay stable as the platform evolves.\n\n## Authentication\n\nAll requests require a customer API key passed as a Bearer token:\n\n```\nAuthorization: Bearer 1club_sk_live_...\n```\n\nGenerate API keys from the admin portal under **Settings > API Tokens**.\nThe key is tied to your organization - all responses are scoped to your org's data.\n\n## Rate Limiting\n\n- **100 requests per minute** per API key\n- When exceeded, the API returns `429 Too Many Requests` with a `Retry-After` header\n- Rate limit headers are included in every response:\n  - `X-RateLimit-Limit` - max requests per window\n  - `X-RateLimit-Remaining` - requests remaining\n  - `X-RateLimit-Reset` - seconds until the window resets\n\n## Errors\n\n| Status | Meaning |\n|--------|---------|\n| `400` | Invalid request parameters |\n| `401` | Missing or invalid API key |\n| `404` | Resource not found (or doesn't belong to your organization) |\n| `429` | Rate limit exceeded |\n| `500` | Internal server error |",
    "contact": {
      "name": "1club API Support",
      "email": "support@1club.ai"
    }
  },
  "servers": [
    {
      "url": "https://api.1club.ai",
      "description": "Production API"
    }
  ],
  "components": {
    "securitySchemes": {
      "customerApiAuth": {
        "type": "http",
        "scheme": "bearer",
        "description": "Organization-scoped bearer credential: a customer API key (1club_sk_live_...) or an MCP OAuth access token."
      }
    },
    "schemas": {
      "PlatformClassCreate": {
        "type": "object",
        "required": [
          "clubId",
          "name",
          "startTime",
          "endTime"
        ],
        "properties": {
          "clubId": {
            "type": "integer"
          },
          "areaId": {
            "type": "integer",
            "nullable": true
          },
          "classTypeId": {
            "type": "integer",
            "nullable": true
          },
          "instructorIds": {
            "type": "array",
            "items": {
              "type": "integer"
            },
            "maxItems": 20
          },
          "name": {
            "type": "string",
            "maxLength": 100
          },
          "description": {
            "type": "string",
            "nullable": true,
            "maxLength": 5000
          },
          "priceBeforeTax": {
            "type": "number",
            "minimum": 0,
            "description": "The price to store, excluding tax - the same field reads return. The tax-inclusive `price` a class reads back with is computed and cannot be written: sending it is a 400, because posting it back would re-apply tax on every round trip."
          },
          "isFree": {
            "type": "boolean"
          },
          "maxCapacity": {
            "type": "integer",
            "nullable": true,
            "minimum": 1
          },
          "startTime": {
            "type": "string",
            "format": "date-time",
            "description": "ISO 8601 instant with an explicit offset."
          },
          "endTime": {
            "type": "string",
            "format": "date-time",
            "description": "ISO 8601 instant with an explicit offset."
          },
          "visibility": {
            "type": "string",
            "enum": [
              "Private",
              "Member_only",
              "Public"
            ]
          },
          "sport": {
            "type": "string",
            "nullable": true,
            "maxLength": 60
          },
          "color": {
            "type": "string",
            "pattern": "^#[0-9a-fA-F]{6}$"
          },
          "recurrence": {
            "type": "object",
            "description": "Repeat rule. Present, this one request creates the whole series rather than a single occurrence - `startTime`/`endTime` become the template slot, and the series runs from `startTime`. A bounded series (`ends.type` `until` or `after`) is written in full here and capped at 520 occurrences; an open-ended one materializes a rolling window and extends itself. Unknown fields are rejected.",
            "required": [
              "frequency"
            ],
            "properties": {
              "frequency": {
                "type": "string",
                "enum": [
                  "daily",
                  "weekly",
                  "monthly"
                ]
              },
              "interval": {
                "type": "integer",
                "minimum": 1,
                "maximum": 365,
                "default": 1,
                "description": "Repeat every N days, weeks or months."
              },
              "daysOfWeek": {
                "type": "array",
                "items": {
                  "type": "string",
                  "enum": [
                    "sunday",
                    "monday",
                    "tuesday",
                    "wednesday",
                    "thursday",
                    "friday",
                    "saturday"
                  ]
                },
                "minItems": 1,
                "maxItems": 7,
                "description": "Required for a weekly cadence, and rejected for any other - nothing else reads it. The first occurrence is the first listed day at or after `startTime`."
              },
              "endDate": {
                "type": "string",
                "format": "date-time",
                "description": "Last day the series may reach, as an ISO 8601 instant with an explicit offset; an occurrence starting at or before it is kept. Required when `ends.type` is `until`, and rejected otherwise."
              },
              "ends": {
                "type": "object",
                "description": "How the series terminates. Omitted on a create means `never`: an open-ended series kept on a rolling window.",
                "required": [
                  "type"
                ],
                "properties": {
                  "type": {
                    "type": "string",
                    "enum": [
                      "until",
                      "after",
                      "never"
                    ]
                  },
                  "occurrences": {
                    "type": "integer",
                    "minimum": 1,
                    "maximum": 520,
                    "description": "How many occurrences the series holds **in total**, counted from its first occurrence and across every day in `daysOfWeek` - not how many weeks. A Monday-and-Wednesday class running for twelve weeks is `24`, not `12`; sending `12` there creates six weeks. When the series ends on a date rather than after a count, `ends.type: \"until\"` with an `endDate` says so without the arithmetic. Required when `ends.type` is `after`, and rejected otherwise."
                  }
                }
              }
            }
          }
        }
      },
      "PlatformClassUpdate": {
        "type": "object",
        "properties": {
          "clubId": {
            "type": "integer"
          },
          "areaId": {
            "type": "integer",
            "nullable": true
          },
          "classTypeId": {
            "type": "integer",
            "nullable": true
          },
          "instructorIds": {
            "type": "array",
            "items": {
              "type": "integer"
            },
            "maxItems": 20
          },
          "name": {
            "type": "string",
            "maxLength": 100
          },
          "description": {
            "type": "string",
            "nullable": true,
            "maxLength": 5000
          },
          "priceBeforeTax": {
            "type": "number",
            "minimum": 0,
            "description": "The price to store, excluding tax - the same field reads return. The tax-inclusive `price` a class reads back with is computed and cannot be written: sending it is a 400, because posting it back would re-apply tax on every round trip."
          },
          "isFree": {
            "type": "boolean"
          },
          "maxCapacity": {
            "type": "integer",
            "nullable": true,
            "minimum": 1
          },
          "startTime": {
            "type": "string",
            "format": "date-time",
            "description": "ISO 8601 instant with an explicit offset."
          },
          "endTime": {
            "type": "string",
            "format": "date-time",
            "description": "ISO 8601 instant with an explicit offset."
          },
          "visibility": {
            "type": "string",
            "enum": [
              "Private",
              "Member_only",
              "Public"
            ]
          },
          "sport": {
            "type": "string",
            "nullable": true,
            "maxLength": 60
          },
          "color": {
            "type": "string",
            "pattern": "^#[0-9a-fA-F]{6}$"
          },
          "recurrence": {
            "type": "object",
            "description": "Moves where the series this occurrence belongs to ends; needs `scope=all_future`, and is sent on its own - a body that also carries class fields is rejected. Only the end - a cadence field is rejected. An earlier end removes the occurrences past it and is refused with 409 while any of them has an active booking or waitlist entry.",
            "required": [
              "ends"
            ],
            "properties": {
              "endDate": {
                "type": "string",
                "format": "date-time",
                "description": "Last day the series may reach, as an ISO 8601 instant with an explicit offset; an occurrence starting at or before it is kept. Required when `ends.type` is `until`, and rejected otherwise."
              },
              "ends": {
                "type": "object",
                "description": "How the series terminates. Omitted on a create means `never`: an open-ended series kept on a rolling window.",
                "required": [
                  "type"
                ],
                "properties": {
                  "type": {
                    "type": "string",
                    "enum": [
                      "until",
                      "after",
                      "never"
                    ]
                  },
                  "occurrences": {
                    "type": "integer",
                    "minimum": 1,
                    "maximum": 520,
                    "description": "How many occurrences the series holds **in total**, counted from its first occurrence and across every day in `daysOfWeek` - not how many weeks. A Monday-and-Wednesday class running for twelve weeks is `24`, not `12`; sending `12` there creates six weeks. When the series ends on a date rather than after a count, `ends.type: \"until\"` with an `endDate` says so without the arithmetic. Required when `ends.type` is `after`, and rejected otherwise."
                  }
                }
              }
            }
          },
          "notifyCustomer": {
            "type": "boolean",
            "default": false,
            "description": "Tell the people booked on this class what changed - email plus the in-app and WhatsApp notification. Only sent when the edit changes something visible on their booking (start/end time, area, club, or the instructors); a rename or a price change notifies nobody. Off by default, so callers normally send their own. Assigned instructors get their calendar update either way."
          }
        }
      },
      "PlatformPlanCreate": {
        "type": "object",
        "required": [
          "name",
          "price"
        ],
        "properties": {
          "name": {
            "type": "string",
            "maxLength": 120
          },
          "description": {
            "type": "string",
            "nullable": true,
            "maxLength": 5000
          },
          "clubId": {
            "type": "integer",
            "nullable": true
          },
          "planCategoryId": {
            "type": "integer",
            "nullable": true,
            "description": "Category the plan is grouped under on price lists, from GET /v1/platform/plan-categories. `null` takes it out of every category."
          },
          "revenueAccountId": {
            "type": "integer",
            "nullable": true,
            "description": "Ledger account a sale of this plan posts to, from GET /v1/platform/revenue-accounts. Leave it out and the sale falls to the organization's default account, resolved at the time of the sale - so omitting this is a choice, not a gap. `null` clears it back to that."
          },
          "price": {
            "type": "number",
            "minimum": 0
          },
          "signupFee": {
            "type": "number",
            "minimum": 0
          },
          "billingFrequency": {
            "type": "string",
            "enum": [
              "one_time",
              "weekly",
              "monthly",
              "quarterly",
              "annually",
              "custom"
            ]
          },
          "customPeriod": {
            "type": "integer",
            "nullable": true,
            "minimum": 1
          },
          "customPeriodUnit": {
            "type": "string",
            "nullable": true,
            "enum": [
              "days",
              "weeks",
              "months"
            ]
          },
          "isActive": {
            "type": "boolean"
          },
          "maxUses": {
            "type": "number",
            "nullable": true,
            "minimum": 0
          },
          "sessionUnit": {
            "type": "string",
            "nullable": true,
            "enum": [
              "booking",
              "hour"
            ]
          },
          "validityPeriod": {
            "type": "integer",
            "nullable": true,
            "minimum": 1
          },
          "autoRenews": {
            "type": "boolean"
          },
          "renewalDaysInAdvance": {
            "type": "integer",
            "minimum": 0,
            "maximum": 60
          },
          "requirePaymentUpfront": {
            "type": "boolean"
          },
          "sports": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "maxItems": 50
          },
          "features": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "maxItems": 100
          },
          "visibility": {
            "type": "string",
            "enum": [
              "Private",
              "Member_only",
              "Public"
            ]
          },
          "validFor": {
            "type": "object",
            "properties": {
              "scopes": {
                "type": "array",
                "items": {
                  "type": "string",
                  "enum": [
                    "classes",
                    "areas",
                    "instructors"
                  ]
                }
              },
              "classTypes": {
                "type": "array",
                "items": {
                  "type": "integer"
                }
              },
              "areaTypes": {
                "type": "array",
                "items": {
                  "type": "integer"
                }
              },
              "instructorTypes": {
                "type": "array",
                "items": {
                  "type": "integer"
                }
              }
            }
          }
        }
      },
      "PlatformPlanUpdate": {
        "type": "object",
        "properties": {
          "name": {
            "type": "string",
            "maxLength": 120
          },
          "description": {
            "type": "string",
            "nullable": true,
            "maxLength": 5000
          },
          "clubId": {
            "type": "integer",
            "nullable": true
          },
          "planCategoryId": {
            "type": "integer",
            "nullable": true,
            "description": "Category the plan is grouped under on price lists, from GET /v1/platform/plan-categories. `null` takes it out of every category."
          },
          "revenueAccountId": {
            "type": "integer",
            "nullable": true,
            "description": "Ledger account a sale of this plan posts to, from GET /v1/platform/revenue-accounts. Leave it out and the sale falls to the organization's default account, resolved at the time of the sale - so omitting this is a choice, not a gap. `null` clears it back to that."
          },
          "price": {
            "type": "number",
            "minimum": 0
          },
          "signupFee": {
            "type": "number",
            "minimum": 0
          },
          "billingFrequency": {
            "type": "string",
            "enum": [
              "one_time",
              "weekly",
              "monthly",
              "quarterly",
              "annually",
              "custom"
            ]
          },
          "customPeriod": {
            "type": "integer",
            "nullable": true,
            "minimum": 1
          },
          "customPeriodUnit": {
            "type": "string",
            "nullable": true,
            "enum": [
              "days",
              "weeks",
              "months"
            ]
          },
          "isActive": {
            "type": "boolean"
          },
          "maxUses": {
            "type": "number",
            "nullable": true,
            "minimum": 0
          },
          "sessionUnit": {
            "type": "string",
            "nullable": true,
            "enum": [
              "booking",
              "hour"
            ]
          },
          "validityPeriod": {
            "type": "integer",
            "nullable": true,
            "minimum": 1
          },
          "autoRenews": {
            "type": "boolean"
          },
          "renewalDaysInAdvance": {
            "type": "integer",
            "minimum": 0,
            "maximum": 60
          },
          "requirePaymentUpfront": {
            "type": "boolean"
          },
          "sports": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "maxItems": 50
          },
          "features": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "maxItems": 100
          },
          "visibility": {
            "type": "string",
            "enum": [
              "Private",
              "Member_only",
              "Public"
            ]
          },
          "validFor": {
            "type": "object",
            "properties": {
              "scopes": {
                "type": "array",
                "items": {
                  "type": "string",
                  "enum": [
                    "classes",
                    "areas",
                    "instructors"
                  ]
                }
              },
              "classTypes": {
                "type": "array",
                "items": {
                  "type": "integer"
                }
              },
              "areaTypes": {
                "type": "array",
                "items": {
                  "type": "integer"
                }
              },
              "instructorTypes": {
                "type": "array",
                "items": {
                  "type": "integer"
                }
              }
            }
          }
        }
      },
      "PlatformInstructor": {
        "type": "object",
        "description": "Public instructor shape returned alongside classes.",
        "properties": {
          "id": {
            "type": "integer"
          },
          "contactId": {
            "type": "integer"
          },
          "sports": {
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "typeId": {
            "type": "integer",
            "nullable": true
          },
          "type": {
            "type": "object",
            "nullable": true,
            "properties": {
              "id": {
                "type": "integer"
              },
              "name": {
                "type": "string"
              }
            }
          },
          "experience": {
            "type": "string",
            "nullable": true
          },
          "hourlyRate": {
            "type": "number",
            "format": "float",
            "nullable": true
          },
          "bio": {
            "type": "object",
            "nullable": true,
            "additionalProperties": true,
            "description": "EditorJS document for the instructor bio."
          },
          "certifications": {
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "isActive": {
            "type": "boolean"
          },
          "isBookable": {
            "type": "boolean"
          },
          "contact": {
            "type": "object",
            "nullable": true,
            "properties": {
              "name": {
                "type": "string",
                "nullable": true
              },
              "profileImage": {
                "type": "string",
                "nullable": true
              }
            }
          }
        }
      },
      "PlatformClass": {
        "type": "object",
        "description": "One class occurrence. A weekly class materializes a row per session, linked by `recurrenceId` and `occurrenceIndex`, so `id` is what a booking's `classId` points at - and what distinguishes two groups running in the same hour. Read the roster with GET /v1/platform/classes/{id}/bookings.",
        "properties": {
          "id": {
            "type": "integer"
          },
          "name": {
            "type": "string"
          },
          "slug": {
            "type": "string",
            "nullable": true
          },
          "description": {
            "type": "string",
            "nullable": true
          },
          "images": {
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "classTypeId": {
            "type": "integer",
            "nullable": true
          },
          "classType": {
            "type": "object",
            "nullable": true,
            "properties": {
              "id": {
                "type": "integer"
              },
              "name": {
                "type": "string",
                "nullable": true
              },
              "slug": {
                "type": "string",
                "nullable": true
              },
              "maxPartySize": {
                "type": "integer",
                "nullable": true
              }
            }
          },
          "clubId": {
            "type": "integer"
          },
          "club": {
            "type": "object",
            "nullable": true,
            "properties": {
              "id": {
                "type": "integer"
              },
              "name": {
                "type": "string"
              },
              "slug": {
                "type": "string",
                "nullable": true
              }
            }
          },
          "areaId": {
            "type": "integer",
            "nullable": true,
            "description": "Room or court the class occupies, when one is assigned."
          },
          "area": {
            "type": "object",
            "nullable": true,
            "properties": {
              "id": {
                "type": "integer"
              },
              "name": {
                "type": "string"
              }
            }
          },
          "startTime": {
            "type": "string",
            "format": "date-time"
          },
          "endTime": {
            "type": "string",
            "format": "date-time"
          },
          "durationMinutes": {
            "type": "integer"
          },
          "isMultiDay": {
            "type": "boolean"
          },
          "status": {
            "type": "string",
            "enum": [
              "active",
              "cancelled"
            ],
            "description": "A cancelled occurrence keeps its bookings and roster."
          },
          "visibility": {
            "type": "string",
            "enum": [
              "Public",
              "Member_only",
              "Private"
            ]
          },
          "sport": {
            "type": "string",
            "nullable": true
          },
          "color": {
            "type": "string",
            "nullable": true
          },
          "price": {
            "type": "number",
            "format": "float",
            "nullable": true,
            "description": "Price including the organization's default tax - what a member is charged."
          },
          "priceBeforeTax": {
            "type": "number",
            "format": "float",
            "nullable": true,
            "description": "The price as stored on the class, before tax."
          },
          "isFree": {
            "type": "boolean"
          },
          "maxCapacity": {
            "type": "integer",
            "nullable": true,
            "description": "Seats; null when uncapped."
          },
          "maxPartySize": {
            "type": "integer",
            "description": "Largest party one booking may bring, from the class type."
          },
          "bookedCount": {
            "type": "integer",
            "description": "Seats taken: attendee bookings plus standalone walk-in check-ins."
          },
          "currentBookings": {
            "type": "integer",
            "description": "Legacy alias of `bookedCount`."
          },
          "spotsLeft": {
            "type": "integer",
            "nullable": true,
            "description": "`maxCapacity - bookedCount`, floored at 0; null when capacity is unlimited."
          },
          "waitlistCount": {
            "type": "integer",
            "description": "Entries waiting (pending or notified)."
          },
          "allowWaitlist": {
            "type": "boolean"
          },
          "timeStatus": {
            "type": "string",
            "enum": [
              "past",
              "upcoming"
            ]
          },
          "availabilityStatus": {
            "type": "string",
            "enum": [
              "available",
              "full"
            ]
          },
          "recurrenceId": {
            "type": "integer",
            "nullable": true,
            "description": "The series this occurrence belongs to; null for a one-off class."
          },
          "occurrenceIndex": {
            "type": "integer",
            "nullable": true,
            "description": "Position within the series, as materialized."
          },
          "recurrence": {
            "type": "object",
            "nullable": true,
            "description": "The series rule, when this occurrence belongs to one.",
            "properties": {
              "id": {
                "type": "integer"
              },
              "frequency": {
                "type": "string",
                "enum": [
                  "daily",
                  "weekly",
                  "monthly"
                ]
              },
              "interval": {
                "type": "integer"
              },
              "daysOfWeek": {
                "type": "array",
                "items": {
                  "type": "string"
                }
              },
              "endsType": {
                "type": "string",
                "enum": [
                  "until",
                  "after",
                  "never"
                ]
              },
              "endsDate": {
                "type": "string",
                "format": "date-time",
                "nullable": true
              },
              "endsTotalOccurrences": {
                "type": "integer",
                "nullable": true
              },
              "status": {
                "type": "string",
                "enum": [
                  "active",
                  "paused",
                  "cancelled"
                ]
              }
            }
          },
          "instructors": {
            "type": "array",
            "description": "Who teaches the occurrence. The instructor's own weekly availability is not included; read it from GET /v1/platform/instructors.",
            "items": {
              "$ref": "#/components/schemas/PlatformInstructor"
            }
          },
          "externalSource": {
            "type": "string",
            "nullable": true,
            "description": "Import provenance - set when the occurrence came from an external system rather than being created in 1club."
          },
          "externalId": {
            "type": "string",
            "nullable": true,
            "description": "The source system's id for this occurrence, for reconciliation against it."
          },
          "createdAt": {
            "type": "string",
            "format": "date-time"
          },
          "updatedAt": {
            "type": "string",
            "format": "date-time"
          },
          "translations": {
            "type": "object",
            "nullable": true,
            "additionalProperties": true,
            "description": "Every locale's copy, keyed by language code (e.g. 'es', 'fr'). Returned alongside the `locale`-resolved `name`/`description`, so an integration syncing a multilingual timetable can read all of them in one call."
          },
          "series": {
            "type": "object",
            "description": "Present **only** on the response to a recurring create or to an update that moved where the series ends, never on a read: it describes what that one request did. An occurrence at a club that is closed, or past its closing date, is skipped rather than failing the request, so comparing `occurrencesCreated` with what you asked for is the only way a partially-skipped season is visible.",
            "properties": {
              "recurrenceId": {
                "type": "integer"
              },
              "occurrencesCreated": {
                "type": "integer",
                "description": "Occurrences written by this request. The whole series for a bounded one; the first window for an open-ended one, which the extension job grows from there."
              },
              "occurrencesRemoved": {
                "type": "integer",
                "description": "Occurrences past the new end that this request removed. Only on the response to an end change."
              }
            }
          }
        }
      },
      "PlatformClassPage": {
        "type": "object",
        "properties": {
          "data": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/PlatformClass"
            }
          },
          "total": {
            "type": "integer",
            "description": "Total classes matching the filters, ignoring pagination"
          },
          "limit": {
            "type": "integer"
          },
          "offset": {
            "type": "integer"
          }
        }
      },
      "PlatformClassBooking": {
        "type": "object",
        "description": "One attendee of a class occurrence. Class seats are one booking per person, so this is a booking row resolved with the attendee's name, check-in and payment.",
        "properties": {
          "bookingId": {
            "type": "integer"
          },
          "classId": {
            "type": "integer"
          },
          "contactId": {
            "type": "integer",
            "nullable": true,
            "description": "Null for an anonymous seat - a pass sold without naming who would use it. `bookerName` is then who paid for it."
          },
          "contactName": {
            "type": "string",
            "nullable": true
          },
          "bookerName": {
            "type": "string",
            "nullable": true,
            "description": "Who paid, when the seat is anonymous and was sold through an order - so a roster reads \"Anonymous (Ivan Petrov)\" rather than an opaque booking number."
          },
          "contactEmail": {
            "type": "string",
            "nullable": true,
            "description": "Null unless the key also holds `contacts:read` - an email address is contact PII, gated as it is on the contacts resource."
          },
          "status": {
            "type": "string",
            "description": "Booking status (confirmed, pending, checked_in, cancelled)."
          },
          "source": {
            "type": "string",
            "nullable": true,
            "description": "Channel that created it (admin, portal, app, api, kiosk, …)."
          },
          "startTime": {
            "type": "string",
            "format": "date-time"
          },
          "endTime": {
            "type": "string",
            "format": "date-time"
          },
          "checkedIn": {
            "type": "boolean",
            "description": "Whether the attendee was checked in for this class."
          },
          "checkedInAt": {
            "type": "string",
            "format": "date-time",
            "nullable": true
          },
          "membershipId": {
            "type": "integer",
            "nullable": true,
            "description": "The membership the seat was drawn from, when it was covered by one."
          },
          "entranceMethod": {
            "type": "string",
            "nullable": true,
            "description": "How the seat was paid for (direct_payment, membership, external_program)."
          },
          "payment": {
            "type": "object",
            "properties": {
              "status": {
                "type": "string",
                "enum": [
                  "paid",
                  "unpaid",
                  "no_charge"
                ],
                "description": "Classified exactly as on `PlatformBooking`. A class seat covered by a membership or an external program is `no_charge` - it owes nothing even though it carries a price - so do not read it as an outstanding balance."
              },
              "amount": {
                "type": "number",
                "nullable": true
              },
              "paidAmount": {
                "type": "number",
                "description": "What has been collected on the booking, gross, before any refund. `0` when nothing has."
              },
              "refundedAmount": {
                "type": "number",
                "description": "What has been returned to the customer, across every refund recorded. `paidAmount - refundedAmount` is what the club kept."
              },
              "reference": {
                "type": "string",
                "nullable": true,
                "description": "The collecting partner's own id for the payment, when one reported it through this API (`payment.reference`); null for money 1club collected itself."
              },
              "fee": {
                "type": "number",
                "nullable": true,
                "description": "What the collecting partner kept out of the amount, when it reported one (`payment.fee`)."
              }
            }
          },
          "notes": {
            "type": "string",
            "nullable": true
          },
          "createdAt": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "PlatformClassBookingPage": {
        "type": "object",
        "properties": {
          "data": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/PlatformClassBooking"
            }
          },
          "total": {
            "type": "integer",
            "description": "Total attendees matching the filters, ignoring pagination"
          },
          "limit": {
            "type": "integer"
          },
          "offset": {
            "type": "integer"
          }
        }
      },
      "PlatformClub": {
        "type": "object",
        "description": "A club with its address, contact info, amenities, sports, aggregated images, and average rating.",
        "properties": {
          "id": {
            "type": "integer"
          },
          "name": {
            "type": "string"
          },
          "slug": {
            "type": "string",
            "nullable": true
          },
          "address": {
            "type": "string",
            "nullable": true
          },
          "city": {
            "type": "string",
            "nullable": true
          },
          "state": {
            "type": "string",
            "nullable": true
          },
          "country": {
            "type": "string",
            "nullable": true
          },
          "postalCode": {
            "type": "string",
            "nullable": true
          },
          "phone": {
            "type": "string",
            "nullable": true
          },
          "email": {
            "type": "string",
            "nullable": true
          },
          "website": {
            "type": "string",
            "nullable": true
          },
          "logo": {
            "type": "string",
            "nullable": true
          },
          "images": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "All images aggregated from the club and its active areas."
          },
          "amenities": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "Raw amenity IDs stored on the club."
          },
          "amenitiesResolved": {
            "type": "array",
            "description": "Amenities resolved to display objects (id, name, locale-aware).",
            "items": {
              "type": "object",
              "properties": {
                "id": {
                  "type": "string"
                },
                "name": {
                  "type": "string"
                },
                "icon": {
                  "type": "string",
                  "nullable": true
                },
                "category": {
                  "type": "string",
                  "nullable": true
                }
              }
            }
          },
          "sports": {
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "rating": {
            "type": "object",
            "nullable": true,
            "description": "Average and total of public reviews. Omitted when there are no public reviews.",
            "properties": {
              "average": {
                "type": "number",
                "format": "float"
              },
              "total": {
                "type": "integer"
              }
            }
          },
          "socialProfiles": {
            "type": "object",
            "additionalProperties": {
              "type": "string"
            },
            "description": "Map of social network keys (e.g. 'instagram') to profile URLs."
          }
        }
      },
      "PlatformContent": {
        "type": "object",
        "description": "A content item (FAQ, post, document, block, or how-to guide) for the organization.",
        "properties": {
          "id": {
            "type": "integer"
          },
          "organizationId": {
            "type": "integer"
          },
          "title": {
            "type": "string"
          },
          "slug": {
            "type": "string"
          },
          "type": {
            "type": "string",
            "enum": [
              "faq",
              "post",
              "document",
              "block",
              "how-to-guide"
            ]
          },
          "status": {
            "type": "string"
          },
          "visibility": {
            "type": "string",
            "enum": [
              "Public",
              "Member_only",
              "Private"
            ]
          },
          "content": {
            "type": "object",
            "description": "Body of the content item. May contain HTML, EditorJS blocks, a summary, and keywords.",
            "properties": {
              "html": {
                "type": "string",
                "nullable": true
              },
              "blocks": {
                "type": "object",
                "nullable": true,
                "additionalProperties": true,
                "description": "EditorJS document."
              },
              "summary": {
                "type": "string",
                "nullable": true
              },
              "keywords": {
                "type": "array",
                "items": {
                  "type": "string"
                }
              }
            }
          },
          "excerpt": {
            "type": "string",
            "nullable": true
          },
          "metaTitle": {
            "type": "string",
            "nullable": true
          },
          "metaDescription": {
            "type": "string",
            "nullable": true
          },
          "metaKeywords": {
            "type": "string",
            "nullable": true
          },
          "canonicalUrl": {
            "type": "string",
            "nullable": true
          },
          "template": {
            "type": "string",
            "nullable": true
          },
          "layout": {
            "type": "string",
            "nullable": true
          },
          "customFields": {
            "type": "object",
            "nullable": true,
            "additionalProperties": true
          },
          "tags": {
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "summary": {
            "type": "string",
            "nullable": true
          },
          "keywords": {
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "images": {
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "featured": {
            "type": "boolean",
            "nullable": true
          },
          "authorId": {
            "type": "integer",
            "nullable": true
          },
          "author": {
            "type": "object",
            "nullable": true,
            "properties": {
              "id": {
                "type": "integer"
              },
              "name": {
                "type": "string"
              },
              "email": {
                "type": "string"
              },
              "profileImage": {
                "type": "string",
                "nullable": true
              }
            }
          },
          "publishedAt": {
            "type": "string",
            "format": "date-time",
            "nullable": true
          },
          "language": {
            "type": "string",
            "nullable": true
          },
          "createdAt": {
            "type": "string",
            "format": "date-time"
          },
          "updatedAt": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "PlatformMedia": {
        "type": "object",
        "description": "An item in the organization's media library. Files are hosted externally (Cloudinary for anything uploaded through the admin editor); this record is the library entry that website sections and content reference by `url`.",
        "properties": {
          "id": {
            "type": "integer"
          },
          "url": {
            "type": "string",
            "description": "Public URL of the file. Immutable - register a new item to replace a file."
          },
          "name": {
            "type": "string",
            "description": "Display name. Searched, along with altText, when looking an image up by name."
          },
          "altText": {
            "type": "string",
            "nullable": true,
            "description": "Alternative text for screen readers. Also searched."
          },
          "caption": {
            "type": "string",
            "nullable": true
          },
          "mediaType": {
            "type": "string",
            "enum": [
              "image",
              "video"
            ]
          },
          "createdAt": {
            "type": "string",
            "format": "date-time"
          },
          "updatedAt": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "PlatformWebsiteSection": {
        "type": "object",
        "description": "One section of a page or of the site layout. `config` fields depend on `type` - see GET /v1/platform/website/section-types for the definitions.",
        "properties": {
          "type": {
            "type": "string",
            "description": "Section type, e.g. \"hero\", \"plans\", \"faq\", \"header\"."
          },
          "htmlId": {
            "type": "string",
            "pattern": "^[a-z][a-z0-9-]*$",
            "description": "The section's anchor on the page (`#<htmlId>`), unique within the page. Send back the value a read returned to keep it; omit it and one is derived from the type."
          },
          "config": {
            "type": "object",
            "additionalProperties": true
          },
          "translations": {
            "type": "object",
            "additionalProperties": true,
            "description": "Per-locale overrides for the section's translatable string fields, keyed by locale then field: { \"bg\": { \"headline\": \"…\" } }. Config values themselves stay in the default language."
          }
        }
      },
      "PlatformWebsitePage": {
        "type": "object",
        "description": "A page on the website draft.",
        "properties": {
          "slug": {
            "type": "string",
            "description": "URL path segment. The homepage is served at \"/\" regardless of its slug."
          },
          "title": {
            "type": "string"
          },
          "isHomepage": {
            "type": "boolean"
          },
          "sectionCount": {
            "type": "integer"
          },
          "sectionTypes": {
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "sections": {
            "type": "array",
            "description": "Present when fetching a single page; omitted from list responses.",
            "items": {
              "$ref": "#/components/schemas/PlatformWebsiteSection"
            }
          },
          "metaTitle": {
            "type": "string",
            "nullable": true
          },
          "metaDescription": {
            "type": "string",
            "nullable": true
          },
          "ogImage": {
            "type": "string",
            "nullable": true
          },
          "noindex": {
            "type": "boolean",
            "description": "True when the page asks search engines not to index it and is left out of `sitemap.xml`."
          },
          "translations": {
            "type": "object",
            "nullable": true,
            "description": "Per-locale `metaTitle`/`metaDescription` overrides, keyed by locale: { \"bg\": { \"metaTitle\": \"…\" } }. Present when fetching a single page.",
            "additionalProperties": {
              "type": "object",
              "properties": {
                "metaTitle": {
                  "type": "string"
                },
                "metaDescription": {
                  "type": "string"
                }
              }
            }
          },
          "seo": {
            "$ref": "#/components/schemas/PlatformWebsitePageSeo"
          },
          "url": {
            "type": "string",
            "description": "The page's public URL once published."
          }
        }
      },
      "PlatformWebsitePageSeo": {
        "type": "object",
        "description": "What the published page will actually put in its `<head>`, after every fallback has been applied - the authored fields alone do not answer \"is my SEO right?\". Present when fetching a single page. Read-only.",
        "properties": {
          "metaTitle": {
            "type": "string",
            "description": "The `<title>`, after falling back to the page title."
          },
          "metaDescription": {
            "type": "string",
            "nullable": true,
            "description": "After falling back to `seoSettings.siteDescription`."
          },
          "ogImage": {
            "type": "string",
            "nullable": true,
            "description": "After falling back to `seoSettings.defaultOgImage`."
          },
          "canonicalUrl": {
            "type": "string",
            "description": "The `rel=canonical` the default-locale render emits, locale prefix included."
          },
          "indexable": {
            "type": "boolean",
            "description": "Whether search engines are asked to index this page."
          },
          "notIndexableReason": {
            "type": "string",
            "nullable": true,
            "enum": [
              "page_noindex",
              "site_unpublished",
              "site_inactive"
            ],
            "description": "Why `indexable` is false. An unpublished or switched-off site is not indexable however the page is configured."
          },
          "localizedLocales": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "Locales carrying their own metaTitle/metaDescription overrides."
          }
        }
      },
      "PlatformWebsite": {
        "type": "object",
        "description": "A website belonging to the organization. Page content, layout and theme tokens describe the **draft**. `url`, `published`, `activeReleaseVersion` and `lastPublishedAt` describe what the public is currently being served. `name` and `clubId` are live row fields — they take effect without a publish.",
        "properties": {
          "id": {
            "type": "integer"
          },
          "name": {
            "type": "string"
          },
          "slug": {
            "type": "string",
            "description": "Platform host label - the site answers on `<slug>.1club.me`."
          },
          "domain": {
            "type": "string",
            "nullable": true,
            "description": "Custom domain, when one is pointed at this site."
          },
          "isDefault": {
            "type": "boolean",
            "description": "True for the site an organization-wide caller means by \"the website\". Exactly one per organization."
          },
          "isActive": {
            "type": "boolean",
            "description": "False means the site is switched off and serves nothing publicly, whatever the draft contains."
          },
          "clubId": {
            "type": "integer",
            "nullable": true,
            "description": "Club this website represents. When set, the header and footer use that club's logo instead of the organization's. Live, like `name`."
          },
          "url": {
            "type": "string",
            "description": "Public URL - the custom domain when set, else the platform host."
          },
          "previewUrl": {
            "type": "string",
            "description": "The same site rendered from the unpublished draft - the link to review edits before publishing. Carries a signed grant that expires 24 hours after this response was generated; read the website again for a fresh one. Anyone holding the link can view the draft, so it is shareable without a login but should be treated as sensitive."
          },
          "published": {
            "type": "boolean",
            "description": "Whether the site has ever been published. False means nothing is publicly reachable yet."
          },
          "activeReleaseVersion": {
            "type": "integer",
            "nullable": true
          },
          "lastPublishedAt": {
            "type": "string",
            "format": "date-time",
            "nullable": true
          },
          "themeSupport": {
            "type": "string",
            "enum": [
              "both",
              "light",
              "dark"
            ],
            "nullable": true
          },
          "defaultTheme": {
            "type": "string",
            "enum": [
              "light",
              "dark",
              "system"
            ],
            "nullable": true
          },
          "supportedLocales": {
            "type": "array",
            "nullable": true,
            "items": {
              "type": "string"
            },
            "description": "Locales the site serves, which is what its `hreflang` alternates and `sitemap.xml` advertise. Organization-level - see `PATCH /v1/platform/website`."
          },
          "defaultLocale": {
            "type": "string",
            "nullable": true,
            "description": "Locale served at the site root. Organization-level."
          },
          "seoSettings": {
            "type": "object",
            "description": "Site-wide SEO defaults. Each field is a fallback a page can override.",
            "additionalProperties": false,
            "properties": {
              "siteDescription": {
                "type": "string",
                "description": "Meta-description fallback, and the `description` in the site's structured data."
              },
              "defaultOgImage": {
                "type": "string",
                "description": "Social-share image for pages with no `ogImage` of their own."
              },
              "verification": {
                "type": "object",
                "description": "Search-console ownership tokens, rendered as meta tags on every page.",
                "properties": {
                  "google": {
                    "type": "string"
                  },
                  "bing": {
                    "type": "string"
                  }
                }
              }
            }
          },
          "pages": {
            "type": "array",
            "description": "Present when fetching one website; omitted from the site list.",
            "items": {
              "$ref": "#/components/schemas/PlatformWebsitePage"
            }
          },
          "layout": {
            "type": "object",
            "description": "Header and footer, rendered on every page.",
            "properties": {
              "header": {
                "$ref": "#/components/schemas/PlatformWebsiteSection"
              },
              "footer": {
                "$ref": "#/components/schemas/PlatformWebsiteSection"
              }
            }
          },
          "hasCustomCss": {
            "type": "boolean"
          },
          "updatedAt": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "PlatformWebsiteRelease": {
        "type": "object",
        "description": "A published version of the website. The public site renders the release with `isActive: true`.",
        "properties": {
          "id": {
            "type": "integer"
          },
          "version": {
            "type": "integer"
          },
          "note": {
            "type": "string",
            "nullable": true
          },
          "isActive": {
            "type": "boolean"
          },
          "publishedAt": {
            "type": "string",
            "format": "date-time",
            "description": "When this release last went live. An ordinary publish amends the active release in place, so this moves while `createdAt` does not."
          },
          "createdAt": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "PlatformError": {
        "type": "object",
        "properties": {
          "error": {
            "type": "string"
          },
          "code": {
            "type": "string",
            "description": "A stable, machine-readable reason, when the error has one (e.g. `CONTACT_EXISTS`)."
          },
          "details": {
            "type": "object",
            "additionalProperties": true,
            "description": "Structured values behind the error. A 409 naming an existing record carries its id here, e.g. `contactId`."
          }
        }
      },
      "WebsiteSectionFieldCondition": {
        "type": "object",
        "description": "When a field is relevant, as a comparison against a SIBLING field of the same section. Deliberately data rather than an expression, and deliberately unable to reach platform data or anything resolved at render time, so the server, the editor and a model all answer it the same way. Exactly one form per object.",
        "properties": {
          "field": {
            "type": "string",
            "description": "The sibling field this condition reads. Always resolves before the field carrying the condition."
          },
          "equals": {
            "description": "Relevant when the sibling equals this value."
          },
          "oneOf": {
            "type": "array",
            "items": {},
            "description": "Relevant when the sibling is one of these values."
          },
          "isEmpty": {
            "type": "boolean",
            "description": "Relevant when the sibling is (true) or is not (false) unset, blank or an empty array."
          },
          "allOf": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/WebsiteSectionFieldCondition"
            }
          },
          "anyOf": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/WebsiteSectionFieldCondition"
            }
          }
        }
      },
      "WebsiteSectionField": {
        "type": "object",
        "description": "One config field of a website section. This is the ONLY description of the field: its control, its value space, where it belongs in an editor, whether it translates, and when it applies. Nothing about a field is documented elsewhere.",
        "properties": {
          "key": {
            "type": "string"
          },
          "type": {
            "type": "string",
            "enum": [
              "string",
              "text",
              "number",
              "select",
              "string_list",
              "boolean",
              "range",
              "url",
              "color",
              "image",
              "image_list",
              "entity",
              "entity_list",
              "object_list",
              "header",
              "paragraph"
            ],
            "description": "`header` and `paragraph` store nothing — they structure and explain an editing panel."
          },
          "options": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "Allowed values for `select`."
          },
          "defaultValue": {
            "description": "What a new section starts with. See the catalog's `defaults` for the resolved config."
          },
          "group": {
            "type": "string",
            "enum": [
              "content",
              "data",
              "layout",
              "style",
              "advanced"
            ]
          },
          "prominence": {
            "type": "string",
            "enum": [
              "card",
              "panel"
            ]
          },
          "order": {
            "type": "integer",
            "description": "Ascending display order. A conditional field always sorts after the field its condition names."
          },
          "width": {
            "type": "string",
            "enum": [
              "full",
              "half"
            ]
          },
          "translatable": {
            "type": "boolean",
            "description": "Whether this value carries per-locale overrides in the section’s `translations` map."
          },
          "entityType": {
            "type": "string",
            "enum": [
              "club",
              "block",
              "document",
              "classType",
              "planCategory",
              "areaType",
              "instructorType",
              "instructor",
              "signupForm",
              "contactCustomField",
              "contact"
            ],
            "description": "For `entity` / `entity_list`: which platform record the value references."
          },
          "contactFilterType": {
            "type": "string",
            "enum": [
              "contact",
              "member",
              "staff",
              "lead",
              "lapsed",
              "drop_in"
            ]
          },
          "itemFields": {
            "type": "array",
            "description": "For `object_list`: the shape of one item. Recursive, so a nav item’s dropdown children and a footer link group are declared rather than described in prose.",
            "items": {
              "$ref": "#/components/schemas/WebsiteSectionField"
            }
          },
          "maxItems": {
            "type": "integer"
          },
          "min": {
            "type": "number"
          },
          "max": {
            "type": "number"
          },
          "step": {
            "type": "number"
          },
          "unit": {
            "type": "string",
            "description": "For `range`: the suffix shown beside the value (e.g. \"%\")."
          },
          "visibleIf": {
            "$ref": "#/components/schemas/WebsiteSectionFieldCondition"
          },
          "deprecated": {
            "type": "boolean",
            "description": "Superseded: an existing section keeps its value and it still renders, but do not set it on a new section. The type’s `notes` name the replacement."
          }
        }
      },
      "PlatformIdentity": {
        "type": "object",
        "description": "Describes the authorized organization, the scopes granted to the bearer credential, and the catalog of available platform resources.",
        "properties": {
          "organization": {
            "type": "object",
            "properties": {
              "id": {
                "type": "integer"
              },
              "name": {
                "type": "string"
              },
              "slug": {
                "type": "string"
              },
              "currency": {
                "type": "string",
                "description": "ISO 4217 code the organization trades in (e.g. \"EUR\"). Every monetary amount on this API - booking `payment.amount`, plan and product prices, transaction totals - is expressed in it, and no other endpoint repeats it. Read it once when you connect."
              }
            }
          },
          "scopes": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "Scopes granted to the bearer credential - an API key or an MCP OAuth grant - exactly as held (e.g. \"classes:read\"). A full-access key holds the single wildcard scope \"*\"; read `effectiveScopes` to see what that resolves to. An MCP grant never holds \"*\": the wildcard is expanded at consent so each capability can be shown and approved."
          },
          "fullAccess": {
            "type": "boolean",
            "description": "True when the credential holds the wildcard scope \"*\", granting every scope including ones added after it was issued."
          },
          "effectiveScopes": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "The scopes this credential can actually use - identical to `scopes` for a narrow grant, and the full expansion of \"*\" for a full-access key. Check a capability against this list, not `scopes`."
          },
          "resources": {
            "type": "array",
            "description": "Catalog of resources and the actions each supports.",
            "items": {
              "type": "object",
              "properties": {
                "resource": {
                  "type": "string"
                },
                "label": {
                  "type": "string"
                },
                "actions": {
                  "type": "array",
                  "items": {
                    "type": "string"
                  }
                }
              }
            }
          }
        }
      },
      "PlatformTransaction": {
        "type": "object",
        "description": "A billing transaction with amounts, payment status, and links to the originating booking or membership.",
        "properties": {
          "id": {
            "type": "integer"
          },
          "uuid": {
            "type": "string",
            "format": "uuid",
            "description": "Stable unguessable identifier. Prefer this over `id` when storing a reference."
          },
          "description": {
            "type": "string",
            "nullable": true
          },
          "transactionType": {
            "type": "string",
            "nullable": true,
            "description": "TransactionType enum value (e.g. booking_creation, membership_creation, product_sale, membership_recurrence, etc.). A settlement recorded with `POST /v1/platform/transactions` is `settlement`."
          },
          "bookingId": {
            "type": "integer",
            "nullable": true
          },
          "membershipId": {
            "type": "integer",
            "nullable": true
          },
          "contactId": {
            "type": "integer",
            "nullable": true,
            "description": "The customer charged, when there is one."
          },
          "clubId": {
            "type": "integer",
            "nullable": true,
            "description": "Set on a settlement only; other transactions take their club from their booking or membership."
          },
          "createdAt": {
            "type": "string",
            "format": "date-time"
          },
          "date": {
            "type": "string",
            "format": "date-time"
          },
          "paymentStatus": {
            "type": "string",
            "enum": [
              "pending",
              "paid",
              "void",
              "failed",
              "settled",
              "overdue",
              "partially_paid",
              "refunded",
              "cancelled"
            ]
          },
          "amount": {
            "type": "string",
            "description": "Pre-tax amount as a decimal string (e.g. \"25.00\"). Serialized as a string to preserve precision."
          },
          "taxAmount": {
            "type": "string",
            "description": "Tax portion as a decimal string."
          },
          "totalAmount": {
            "type": "string",
            "description": "Total billed amount as a decimal string (amount + taxAmount)."
          }
        }
      },
      "PlatformBooking": {
        "type": "object",
        "description": "A booking created through the platform API. Recorded with a booking transaction so it counts for revenue, marked paid (settled against a payment method named after the caller's API key, so the club can reconcile what each partner collected) or unpaid (outstanding) per the caller. Collection stays with the caller; what it collected and what it gave back on cancellation are reported in `payment`.",
        "properties": {
          "bookingId": {
            "type": "integer",
            "description": "1club booking id"
          },
          "bookingUuid": {
            "type": "string",
            "format": "uuid",
            "description": "Stable unguessable identifier. Prefer this over `bookingId` when storing a reference."
          },
          "status": {
            "type": "string",
            "description": "Booking status (e.g. confirmed, cancelled)"
          },
          "areaId": {
            "type": "integer",
            "nullable": true,
            "description": "Area id"
          },
          "classId": {
            "type": "integer",
            "nullable": true,
            "description": "The class occurrence this booking attends, when it is a class booking. Every attendee holds their own booking row against the same `classId`, and `classes` rows are per-occurrence - so this is what tells two classes running in the same hour apart. Join it against GET /v1/platform/classes, or read the roster with GET /v1/platform/classes/{id}/bookings."
          },
          "eventId": {
            "type": "integer",
            "nullable": true,
            "description": "The event this booking attends, when it is an event booking"
          },
          "maxPlayers": {
            "type": "integer",
            "nullable": true,
            "description": "How many players the booking takes, the organizer included, when it differs from the area type's `defaultMaxPlayers`: `2` on a court booked for singles (area types with `allowsSingles`). `null` means the area type's own limit. Adding a player past it is refused."
          },
          "instructorId": {
            "type": "integer",
            "nullable": true,
            "description": "Instructor leading the session, when one is attached"
          },
          "contactId": {
            "type": "integer",
            "nullable": true,
            "description": "Contact the booking is for"
          },
          "startTime": {
            "type": "string",
            "format": "date-time"
          },
          "endTime": {
            "type": "string",
            "format": "date-time"
          },
          "customer": {
            "allOf": [
              {
                "$ref": "#/components/schemas/PlatformContact"
              }
            ],
            "nullable": true,
            "description": "The booking's customer, identical to what GET /v1/platform/contacts/{id} returns. Present only when the request passes `include=customer`, which additionally requires the `contacts:read` scope; `null` when the booking has no contact. Use it to import a window of bookings without a contact lookup per row."
          },
          "money": {
            "type": "object",
            "description": "What the booking costs and how it was paid. Present only with `include=money`, which additionally requires the `transactions:read` scope. `billed` is always `paid + outstanding`, on the same basis as the admin's Booking audit: tax-inclusive, net of refunds, so a cancelled booking bills only the Cancellation Charge it kept.",
            "properties": {
              "billed": {
                "type": "number"
              },
              "billedExTax": {
                "type": "number"
              },
              "paid": {
                "type": "number",
                "description": "Money the club kept: collected, net of refunds."
              },
              "outstanding": {
                "type": "number",
                "description": "Still owed on the booking. A closed charge owes nothing."
              },
              "paymentState": {
                "type": "string",
                "description": "paid, partial, unpaid, refunded, covered or free."
              },
              "paymentStatus": {
                "type": "string",
                "nullable": true,
                "description": "The charges' own payment statuses, `/`-joined."
              },
              "charges": {
                "type": "array",
                "items": {
                  "type": "object",
                  "properties": {
                    "transactionId": {
                      "type": "integer"
                    },
                    "transactionType": {
                      "type": "string",
                      "nullable": true
                    },
                    "payerContactId": {
                      "type": "integer",
                      "nullable": true,
                      "description": "Who the charge is on: the organizer, or a player paying their own share."
                    },
                    "date": {
                      "type": "string",
                      "format": "date-time"
                    },
                    "paymentStatus": {
                      "type": "string"
                    },
                    "amount": {
                      "type": "number",
                      "description": "What the charge bills, after any cancellation credit against it."
                    },
                    "paidAmount": {
                      "type": "number"
                    },
                    "refundedAmount": {
                      "type": "number",
                      "description": "Returned from its payments by a refund."
                    },
                    "outstandingAmount": {
                      "type": "number"
                    },
                    "invoiceIds": {
                      "type": "array",
                      "items": {
                        "type": "integer"
                      }
                    },
                    "orderId": {
                      "type": "integer",
                      "nullable": true
                    }
                  }
                }
              },
              "credits": {
                "type": "array",
                "description": "Cancellation credits: what a cancellation took off a charge.",
                "items": {
                  "type": "object",
                  "properties": {
                    "transactionId": {
                      "type": "integer"
                    },
                    "chargeTransactionId": {
                      "type": "integer",
                      "nullable": true
                    },
                    "amount": {
                      "type": "number"
                    },
                    "paymentStatus": {
                      "type": "string"
                    },
                    "date": {
                      "type": "string",
                      "format": "date-time"
                    }
                  }
                }
              },
              "payments": {
                "type": "array",
                "items": {
                  "type": "object",
                  "properties": {
                    "paymentId": {
                      "type": "integer"
                    },
                    "amount": {
                      "type": "number"
                    },
                    "status": {
                      "type": "string"
                    },
                    "method": {
                      "type": "string"
                    },
                    "source": {
                      "type": "string",
                      "nullable": true
                    },
                    "reference": {
                      "type": "string",
                      "nullable": true
                    },
                    "paidAt": {
                      "type": "string",
                      "format": "date-time"
                    },
                    "transactionIds": {
                      "type": "array",
                      "items": {
                        "type": "integer"
                      }
                    }
                  }
                }
              },
              "refunds": {
                "type": "array",
                "items": {
                  "type": "object",
                  "properties": {
                    "refundId": {
                      "type": "integer"
                    },
                    "transactionId": {
                      "type": "integer",
                      "nullable": true
                    },
                    "paymentId": {
                      "type": "integer"
                    },
                    "amount": {
                      "type": "number"
                    },
                    "destination": {
                      "type": "string",
                      "enum": [
                        "stripe",
                        "wallet",
                        "cash",
                        "manual"
                      ]
                    },
                    "reason": {
                      "type": "string",
                      "nullable": true
                    },
                    "refundedAt": {
                      "type": "string",
                      "format": "date-time"
                    }
                  }
                }
              }
            }
          },
          "cancellation": {
            "type": "object",
            "description": "The cancellation terms stamped on the booking and how it was cancelled. Present only with `include=cancellation`.",
            "properties": {
              "terms": {
                "type": "object",
                "nullable": true,
                "description": "The terms the customer accepted with the booking - not the club's policy today, which may since have changed. `null` for an event ticket, whose event keeps its own terms, and for a booking accepted before terms were stamped.",
                "properties": {
                  "acceptedAt": {
                    "type": "string",
                    "format": "date-time"
                  },
                  "rules": {
                    "type": "array",
                    "items": {
                      "type": "object",
                      "properties": {
                        "hoursInAdvance": {
                          "type": "number"
                        },
                        "refundPercentage": {
                          "type": "number"
                        },
                        "description": {
                          "type": "string"
                        }
                      }
                    }
                  },
                  "deadlines": {
                    "type": "array",
                    "description": "Every band that refunds something, as the moment to cancel by, widest notice first.",
                    "items": {
                      "type": "object",
                      "properties": {
                        "until": {
                          "type": "string",
                          "format": "date-time"
                        },
                        "refundPercentage": {
                          "type": "number"
                        }
                      }
                    }
                  },
                  "freeCancellationUntil": {
                    "type": "string",
                    "format": "date-time",
                    "nullable": true,
                    "description": "The last moment a cancellation refunds in full; `null` when no band does."
                  },
                  "fullyCovered": {
                    "type": "boolean",
                    "description": "A membership or pass covered the whole booking."
                  },
                  "collectsFromWallet": {
                    "type": "boolean",
                    "description": "The Cancellation Charge is taken from the Wallet."
                  },
                  "waiver": {
                    "type": "object",
                    "nullable": true,
                    "properties": {
                      "at": {
                        "type": "string",
                        "format": "date-time"
                      },
                      "byUserId": {
                        "type": "integer",
                        "nullable": true
                      },
                      "reason": {
                        "type": "string"
                      }
                    }
                  }
                }
              },
              "cancelled": {
                "type": "object",
                "nullable": true,
                "description": "`null` while the booking is not cancelled.",
                "properties": {
                  "at": {
                    "type": "string",
                    "format": "date-time"
                  },
                  "by": {
                    "type": "object",
                    "nullable": true,
                    "properties": {
                      "userId": {
                        "type": "integer"
                      },
                      "name": {
                        "type": "string"
                      }
                    }
                  },
                  "reason": {
                    "type": "string",
                    "nullable": true
                  },
                  "noticeHours": {
                    "type": "number",
                    "description": "Hours between the cancellation and the start; negative when it came after the start."
                  },
                  "refundPercentage": {
                    "type": "number",
                    "nullable": true
                  },
                  "refundAmount": {
                    "type": "number",
                    "nullable": true
                  },
                  "policyWaived": {
                    "type": "boolean"
                  },
                  "notes": {
                    "type": "string",
                    "nullable": true
                  }
                }
              }
            }
          },
          "payment": {
            "type": "object",
            "properties": {
              "status": {
                "type": "string",
                "enum": [
                  "paid",
                  "unpaid",
                  "no_charge"
                ],
                "description": "`no_charge` means there is nothing to collect: either nothing was ever owed (a free area, an amount of 0) or an entitlement covers it - a membership or external program the attendee already holds, which is why a covered booking can carry a price and still owe nothing. `paid` counts every financially closed transaction status (`paid`, `settled`, `refunded`), and every charge type, not only the one this API records."
              },
              "amount": {
                "type": "number",
                "nullable": true,
                "description": "The booking's recorded price in the organization's currency; `status` is what says whether anything was collectable. Where the price is 0 but a real charge exists - an external program's per-visit fee lives entirely in its transaction - the ledger total is reported instead."
              },
              "paidAmount": {
                "type": "number",
                "description": "What has been collected on the booking, gross, before any refund. `0` when nothing has."
              },
              "refundedAmount": {
                "type": "number",
                "description": "What has been returned to the customer, across every refund recorded. `paidAmount - refundedAmount` is what the club kept."
              },
              "reference": {
                "type": "string",
                "nullable": true,
                "description": "The collecting partner's own id for the payment, when one reported it through this API (`payment.reference`); null for money 1club collected itself."
              },
              "fee": {
                "type": "number",
                "nullable": true,
                "description": "What the collecting partner kept out of the amount, when it reported one (`payment.fee`)."
              }
            }
          }
        }
      },
      "PlatformBookingUpdate": {
        "type": "object",
        "description": "An edit to a booking. Every field is optional; send only what changes. A body with nothing but `notifyCustomer` is refused.",
        "properties": {
          "startTime": {
            "type": "string",
            "format": "date-time",
            "description": "New start. With `scope=all_future`, every later occurrence takes this time of day on its own date; change the weekday with `recurrence.daysOfWeek`."
          },
          "endTime": {
            "type": "string",
            "format": "date-time",
            "description": "New end. Must be after the start, stored or sent."
          },
          "areaId": {
            "type": "integer",
            "description": "Move the booking to this area (from GET /v1/platform/areas). Must be an area this API can sell, as on create."
          },
          "instructorId": {
            "type": "integer",
            "nullable": true,
            "description": "Attach this instructor to the session (from GET /v1/platform/instructors), or `null` to take the instructor off it. Must be free at the time."
          },
          "contactId": {
            "type": "integer",
            "description": "Reassign the booking to this contact (from GET /v1/platform/contacts). The outstanding charge, if any, moves with it."
          },
          "notes": {
            "type": "string",
            "maxLength": 2000
          },
          "amount": {
            "type": "number",
            "maximum": 1000000,
            "description": "The booking's new price, before tax, in the club's currency - your own figure, kept as sent. Omit it to keep the price you recorded on a booking this API created, or to have a booking taken through 1club re-quoted by the club's pricing engine when the slot, area, instructor or contact changes. Cannot be set on a booking a membership covers."
          },
          "recurrence": {
            "type": "object",
            "description": "Make a one-off booking recur, or (with `scope=all_future`) rewrite the cadence or end condition of an existing series. The series starts at the booking's own start time, so there is no start date here. `daysOfWeek` is required for a weekly pattern, and a day repeated in it counts once. Not accepted on a booking an API key created.",
            "required": [
              "frequency",
              "interval"
            ],
            "properties": {
              "frequency": {
                "type": "string",
                "enum": [
                  "daily",
                  "weekly",
                  "monthly"
                ]
              },
              "interval": {
                "type": "integer",
                "minimum": 1,
                "description": "Every N days / weeks / months"
              },
              "daysOfWeek": {
                "type": "array",
                "items": {
                  "type": "string",
                  "enum": [
                    "sunday",
                    "monday",
                    "tuesday",
                    "wednesday",
                    "thursday",
                    "friday",
                    "saturday"
                  ]
                },
                "description": "For a weekly pattern, the days it runs on"
              },
              "endDate": {
                "type": "string",
                "format": "date-time",
                "description": "Last date of the series, with `ends.type: until`"
              },
              "ends": {
                "type": "object",
                "required": [
                  "type"
                ],
                "properties": {
                  "type": {
                    "type": "string",
                    "enum": [
                      "until",
                      "after",
                      "never"
                    ],
                    "description": "`until` runs to `endDate`, `after` runs for `occurrences` sessions, `never` extends on a rolling window"
                  },
                  "occurrences": {
                    "type": "integer",
                    "minimum": 1,
                    "maximum": 520,
                    "description": "With `after`: how many sessions the series holds, counted from its start"
                  }
                }
              }
            }
          },
          "notifyCustomer": {
            "type": "boolean",
            "description": "Tell the customer their booking changed - email with the updated calendar invite plus the in-app and WhatsApp notification. Only sent when the edit names a time, area, instructor or class. Off by default for an API key, on by default over an OAuth connection, as on create."
          }
        }
      },
      "PlatformBookingListItem": {
        "allOf": [
          {
            "$ref": "#/components/schemas/PlatformBooking"
          },
          {
            "type": "object",
            "properties": {
              "clubId": {
                "type": "integer",
                "nullable": true
              },
              "source": {
                "type": "string",
                "nullable": true,
                "description": "Channel that created the booking (api, admin, portal, app, kiosk, network, website, integration)."
              },
              "notes": {
                "type": "string",
                "nullable": true
              },
              "createdAt": {
                "type": "string",
                "format": "date-time"
              }
            }
          }
        ]
      },
      "PlatformBookingPage": {
        "type": "object",
        "properties": {
          "data": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/PlatformBookingListItem"
            }
          },
          "total": {
            "type": "integer",
            "description": "Total bookings matching the filters, ignoring pagination"
          },
          "limit": {
            "type": "integer"
          },
          "offset": {
            "type": "integer"
          }
        }
      },
      "PlatformRevenueChange": {
        "type": "object",
        "properties": {
          "amount": {
            "type": "number"
          },
          "percent": {
            "type": "number",
            "nullable": true,
            "description": "Null when the comparison period had none."
          }
        }
      },
      "PlatformRevenueFigures": {
        "type": "object",
        "properties": {
          "revenue": {
            "type": "number",
            "description": "Recognised ex-tax, net of refunds and Wallet-funded parts."
          },
          "collected": {
            "type": "number",
            "description": "The part of it already paid, on the same basis."
          },
          "uncollected": {
            "type": "number"
          },
          "collectionRate": {
            "type": "number",
            "nullable": true,
            "description": "`collected / revenue`, 0-1."
          },
          "sales": {
            "type": "integer"
          },
          "averageSale": {
            "type": "number",
            "nullable": true
          }
        }
      },
      "PlatformRevenueBreakdown": {
        "type": "object",
        "properties": {
          "revenue": {
            "type": "number"
          },
          "collected": {
            "type": "number"
          },
          "sales": {
            "type": "integer"
          },
          "share": {
            "type": "number",
            "description": "Share of the period's revenue, 0-1."
          },
          "previousRevenue": {
            "type": "number",
            "description": "In the comparison period; absent without one."
          },
          "change": {
            "$ref": "#/components/schemas/PlatformRevenueChange"
          }
        }
      },
      "PlatformRevenueReport": {
        "type": "object",
        "properties": {
          "period": {
            "type": "object",
            "properties": {
              "from": {
                "type": "string",
                "format": "date"
              },
              "to": {
                "type": "string",
                "format": "date"
              },
              "days": {
                "type": "integer"
              }
            }
          },
          "timezone": {
            "type": "string",
            "description": "The clock the days are read on."
          },
          "currency": {
            "type": "string"
          },
          "totals": {
            "$ref": "#/components/schemas/PlatformRevenueFigures"
          },
          "comparison": {
            "type": "object",
            "nullable": true,
            "properties": {
              "kind": {
                "type": "string",
                "enum": [
                  "previous_period",
                  "previous_year"
                ]
              },
              "period": {
                "type": "object",
                "properties": {
                  "from": {
                    "type": "string",
                    "format": "date"
                  },
                  "to": {
                    "type": "string",
                    "format": "date"
                  }
                }
              },
              "totals": {
                "$ref": "#/components/schemas/PlatformRevenueFigures"
              },
              "change": {
                "type": "object",
                "properties": {
                  "revenue": {
                    "$ref": "#/components/schemas/PlatformRevenueChange"
                  },
                  "collected": {
                    "$ref": "#/components/schemas/PlatformRevenueChange"
                  },
                  "sales": {
                    "$ref": "#/components/schemas/PlatformRevenueChange"
                  }
                }
              }
            }
          },
          "byStream": {
            "type": "array",
            "items": {
              "allOf": [
                {
                  "$ref": "#/components/schemas/PlatformRevenueBreakdown"
                },
                {
                  "type": "object",
                  "properties": {
                    "stream": {
                      "type": "string",
                      "enum": [
                        "bookings",
                        "memberships",
                        "passes",
                        "drop_ins",
                        "products",
                        "other"
                      ]
                    }
                  }
                }
              ]
            }
          },
          "byClub": {
            "type": "array",
            "description": "Absent when the report is narrowed to one club.",
            "items": {
              "allOf": [
                {
                  "$ref": "#/components/schemas/PlatformRevenueBreakdown"
                },
                {
                  "type": "object",
                  "properties": {
                    "clubId": {
                      "type": "integer",
                      "nullable": true
                    },
                    "clubName": {
                      "type": "string",
                      "nullable": true
                    }
                  }
                }
              ]
            }
          },
          "byPaymentMethod": {
            "type": "array",
            "description": "What collected the money. A sale paid by several methods counts under the one that paid most; 'Unattributed' is collected with no real-money payment behind it.",
            "items": {
              "type": "object",
              "properties": {
                "method": {
                  "type": "string"
                },
                "channel": {
                  "type": "string",
                  "nullable": true
                },
                "collected": {
                  "type": "number"
                },
                "share": {
                  "type": "number"
                }
              }
            }
          },
          "topItems": {
            "type": "array",
            "description": "What sold most: a class or court, a plan, a product.",
            "items": {
              "allOf": [
                {
                  "$ref": "#/components/schemas/PlatformRevenueBreakdown"
                },
                {
                  "type": "object",
                  "properties": {
                    "stream": {
                      "type": "string"
                    },
                    "label": {
                      "type": "string"
                    }
                  }
                }
              ]
            }
          },
          "trend": {
            "type": "object",
            "properties": {
              "granularity": {
                "type": "string",
                "enum": [
                  "day",
                  "week",
                  "month"
                ]
              },
              "points": {
                "type": "array",
                "items": {
                  "type": "object",
                  "properties": {
                    "start": {
                      "type": "string",
                      "format": "date"
                    },
                    "revenue": {
                      "type": "number"
                    },
                    "collected": {
                      "type": "number"
                    },
                    "sales": {
                      "type": "integer"
                    }
                  }
                }
              }
            }
          },
          "movers": {
            "type": "array",
            "description": "The streams, clubs and items whose revenue moved most against the comparison period, largest move first. Empty without a comparison.",
            "items": {
              "type": "object",
              "properties": {
                "dimension": {
                  "type": "string",
                  "enum": [
                    "stream",
                    "club",
                    "item"
                  ]
                },
                "label": {
                  "type": "string"
                },
                "revenue": {
                  "type": "number"
                },
                "previousRevenue": {
                  "type": "number"
                },
                "change": {
                  "$ref": "#/components/schemas/PlatformRevenueChange"
                }
              }
            }
          }
        }
      },
      "PlatformContactWithOverview": {
        "allOf": [
          {
            "$ref": "#/components/schemas/PlatformContact"
          },
          {
            "type": "object",
            "properties": {
              "overview": {
                "type": "object",
                "description": "What staff see on the contact's profile before serving them, present only with `include=overview`.",
                "properties": {
                  "severity": {
                    "type": "string",
                    "enum": [
                      "error",
                      "warning"
                    ],
                    "nullable": true,
                    "description": "The loudest alert; null when there is nothing to say."
                  },
                  "alerts": {
                    "type": "array",
                    "description": "Most pressing first. `kind` is one of archived, access_suspended, amount_due (`amount`), deposit_shortfall (`held`, `required`, `shortfall`), rental_overdue / rental_out (`rentals[]`), required_document_unsigned / optional_document_unsigned / required_field_missing (`requirements[]`). `amount_due` is raised once some of what is owed is for something other than a visit still ahead; `money.amountDue` is the whole figure, upcoming visits included. Alerts inform; they never block a sale.",
                    "items": {
                      "type": "object",
                      "properties": {
                        "kind": {
                          "type": "string"
                        },
                        "severity": {
                          "type": "string",
                          "enum": [
                            "error",
                            "warning"
                          ]
                        }
                      },
                      "additionalProperties": true
                    }
                  },
                  "money": {
                    "type": "object",
                    "nullable": true,
                    "description": "Null when the key lacks `transactions:read`.",
                    "properties": {
                      "currency": {
                        "type": "string"
                      },
                      "amountDue": {
                        "type": "number",
                        "description": "Everything the contact still owes, as the profile shows it. Never reduced by Wallet credit."
                      },
                      "walletBalance": {
                        "type": "number"
                      },
                      "deposit": {
                        "type": "object",
                        "properties": {
                          "requiredAmount": {
                            "type": "number"
                          },
                          "depositHeld": {
                            "type": "number"
                          },
                          "availableToSpend": {
                            "type": "number"
                          },
                          "shortfall": {
                            "type": "number"
                          }
                        }
                      },
                      "totalBilled": {
                        "type": "number"
                      },
                      "totalPaid": {
                        "type": "number"
                      },
                      "transactions": {
                        "type": "integer",
                        "description": "Every transaction on the contact, void and reversal rows included."
                      }
                    }
                  },
                  "activity": {
                    "type": "object",
                    "properties": {
                      "lastVisitAt": {
                        "type": "string",
                        "format": "date-time",
                        "nullable": true
                      },
                      "bookings": {
                        "type": "integer",
                        "description": "Bookings that are not cancelled."
                      },
                      "checkins": {
                        "type": "integer"
                      },
                      "classCheckins": {
                        "type": "integer"
                      },
                      "activeMemberships": {
                        "type": "integer",
                        "description": "Plans still worth acting on, of any kind."
                      },
                      "memberships": {
                        "type": "integer",
                        "description": "Recurring plans held, any status."
                      },
                      "passes": {
                        "type": "integer"
                      },
                      "dropIns": {
                        "type": "integer"
                      },
                      "documents": {
                        "type": "integer"
                      }
                    }
                  },
                  "missingContactDetails": {
                    "type": "array",
                    "items": {
                      "type": "string",
                      "enum": [
                        "phone",
                        "email"
                      ]
                    }
                  },
                  "withheld": {
                    "type": "array",
                    "description": "What was left out because the key lacks its scope.",
                    "items": {
                      "type": "object",
                      "properties": {
                        "family": {
                          "type": "string",
                          "enum": [
                            "money",
                            "rentals"
                          ]
                        },
                        "scope": {
                          "type": "string"
                        }
                      }
                    }
                  }
                }
              }
            }
          }
        ]
      },
      "PlatformContact": {
        "type": "object",
        "description": "A contact (customer) in the organization. Reference the `id` as `contactId` when creating a booking.",
        "properties": {
          "id": {
            "type": "integer"
          },
          "uuid": {
            "type": "string",
            "format": "uuid",
            "description": "Stable unguessable identifier. Prefer this over `id` when storing a reference."
          },
          "name": {
            "type": "string"
          },
          "firstName": {
            "type": "string"
          },
          "lastName": {
            "type": "string",
            "nullable": true
          },
          "email": {
            "type": "string",
            "nullable": true
          },
          "phone": {
            "type": "string",
            "nullable": true
          },
          "type": {
            "type": "string",
            "nullable": true,
            "description": "Derived contact type (e.g. contact, member, lead). `archived` marks a retired contact — hidden from the list and from search, but still readable by id."
          },
          "createdAt": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "PlatformArchiveIncomplete": {
        "type": "object",
        "description": "A 409 with code `ARCHIVE_INCOMPLETE`. The contact IS archived — only the release of what it held is unfinished. Repeat the same DELETE (the same `Idempotency-Key` is fine, a partial result is never replayed) to reconcile what is listed.",
        "properties": {
          "error": {
            "type": "string"
          },
          "message": {
            "type": "string"
          },
          "code": {
            "type": "string",
            "enum": [
              "ARCHIVE_INCOMPLETE"
            ]
          },
          "details": {
            "type": "object",
            "properties": {
              "outstanding": {
                "type": "array",
                "description": "What is still live on the archived contact.",
                "items": {
                  "type": "object",
                  "properties": {
                    "kind": {
                      "type": "string",
                      "enum": [
                        "membership",
                        "booking",
                        "participation"
                      ]
                    },
                    "id": {
                      "type": "integer",
                      "description": "The membership id, or the booking id for a booking or participation."
                    }
                  }
                }
              }
            }
          }
        }
      },
      "PlatformContactCreate": {
        "type": "object",
        "description": "Fields accepted when creating a contact. At least one of `email` or `phone` is required so the person stays reachable and findable.",
        "required": [
          "firstName"
        ],
        "properties": {
          "firstName": {
            "type": "string",
            "maxLength": 255,
            "description": "Given name. The display name is always recomputed from first + last."
          },
          "lastName": {
            "type": "string",
            "maxLength": 255
          },
          "email": {
            "type": "string",
            "format": "email",
            "maxLength": 255,
            "description": "Unique within the organization. Cannot be changed for a contact whose email is also their 1club login."
          },
          "phone": {
            "type": "string",
            "maxLength": 50,
            "description": "Canonicalized to E.164 when the organization is configured for it."
          }
        }
      },
      "PlatformContactUpdate": {
        "type": "object",
        "description": "Fields accepted when updating a contact. Send only what changes; an omitted field is left alone, and `null` clears `lastName`, `email` or `phone`. At least one field is required, and the contact must keep at least one of `email` or `phone`. Clearing the family name stores it as an empty string rather than null, so a cleared `lastName` reads back as `\"\"`.",
        "minProperties": 1,
        "properties": {
          "firstName": {
            "type": "string",
            "maxLength": 255,
            "description": "Given name. The display name is always recomputed from first + last."
          },
          "lastName": {
            "type": "string",
            "maxLength": 255,
            "nullable": true
          },
          "email": {
            "type": "string",
            "format": "email",
            "maxLength": 255,
            "description": "Unique within the organization. Cannot be changed for a contact whose email is also their 1club login.",
            "nullable": true
          },
          "phone": {
            "type": "string",
            "maxLength": 50,
            "description": "Canonicalized to E.164 when the organization is configured for it.",
            "nullable": true
          }
        }
      },
      "PlatformContactPage": {
        "type": "object",
        "properties": {
          "data": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/PlatformContact"
            }
          },
          "total": {
            "type": "integer",
            "description": "Total contacts matching the filters, ignoring pagination"
          },
          "limit": {
            "type": "integer"
          },
          "offset": {
            "type": "integer"
          }
        }
      },
      "PlatformInstructorSummary": {
        "type": "object",
        "description": "An instructor (coach) in the organization, as returned by the instructors resource. Reference the `id` as `instructorId` when creating a booking. Leaner than the `PlatformInstructor` profile embedded in class payloads: this carries only what is needed to pick and price a coach.",
        "properties": {
          "id": {
            "type": "integer"
          },
          "name": {
            "type": "string",
            "description": "Public display name when set, otherwise the linked contact name."
          },
          "clubId": {
            "type": "integer",
            "nullable": true,
            "description": "Club the instructor belongs to; null when org-wide."
          },
          "instructorTypeId": {
            "type": "integer",
            "nullable": true,
            "description": "The kind of coach, from GET /v1/platform/instructor-types; null for one nobody has filed."
          },
          "sports": {
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "hourlyRate": {
            "type": "number",
            "nullable": true,
            "description": "Added to the area price when pricing a booking with this instructor, unless `priceIncludesArea` is true."
          },
          "priceIncludesArea": {
            "type": "boolean",
            "description": "When true, `hourlyRate` is an all-in price that already covers the booked area - the area's price is not added on top of it."
          },
          "bookability": {
            "type": "string",
            "enum": [
              "Admin_only",
              "Member_only",
              "Public"
            ],
            "description": "Who may book one-to-one sessions with this instructor. `Admin_only` means the club reserves those bookings to its own staff, so a customer-initiated booking is rejected; an API key acts for the organization and may still create one."
          },
          "isBookable": {
            "type": "boolean",
            "deprecated": true,
            "description": "Derived from `bookability`: true unless it is `Admin_only`. Prefer `bookability`, which distinguishes members from the public."
          },
          "maxConcurrentBookings": {
            "type": "integer"
          },
          "operatingHours": {
            "type": "object",
            "description": "Per-day-of-week availability schedule"
          },
          "visibility": {
            "type": "string",
            "enum": [
              "Private",
              "Member_only",
              "Public"
            ],
            "description": "Who may see the instructor on member-facing surfaces. `bookability` never outruns it."
          },
          "isActive": {
            "type": "boolean",
            "description": "False for a retired instructor: hidden from the list by default, not bookable, history kept."
          }
        }
      },
      "PlatformInstructorCreate": {
        "type": "object",
        "description": "Fields accepted when creating an instructor. Only `contactId` is required. Unknown fields are rejected, and `sendInvitation`, `organizationUserId`, `role`, `color` and `isBookable` are refused by name - this API creates a bookable coach and never a staff-portal user.",
        "required": [
          "contactId"
        ],
        "properties": {
          "contactId": {
            "type": "integer",
            "description": "The person, who must already be a contact in this organization. Not editable afterwards."
          },
          "clubId": {
            "type": "integer",
            "nullable": true,
            "description": "Club the instructor works at; null means every club in the organization."
          },
          "instructorTypeId": {
            "type": "integer",
            "nullable": true,
            "description": "The kind of coach, from GET /v1/platform/instructor-types. null leaves them unfiled, which is legal but loses the grouping every roster view reads from and the default revenue account their sessions would post to."
          },
          "sports": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "maxItems": 50
          },
          "displayName": {
            "type": "string",
            "nullable": true,
            "maxLength": 120,
            "description": "Public-facing name. Defaults to \"<first name> <last initial>.\" from the contact."
          },
          "hourlyRate": {
            "type": "number",
            "nullable": true,
            "minimum": 0,
            "description": "What a **member pays** for an hour of one-to-one time with them. Not what the club pays the coach - that is a pay rate policy. Added to the area price unless `priceIncludesArea` is true."
          },
          "bookability": {
            "type": "string",
            "enum": [
              "Admin_only",
              "Member_only",
              "Public"
            ],
            "description": "Who may book one-to-one sessions with them. Never outruns `visibility`: narrowing one pulls the other down with it, and deactivating the instructor retires this to `Admin_only`. The read-only `isBookable` is derived from this and cannot be written."
          },
          "priceIncludesArea": {
            "type": "boolean",
            "description": "When true, `hourlyRate` already covers the booked area, which is then not charged on top."
          },
          "maxConcurrentBookings": {
            "type": "integer",
            "minimum": 1
          },
          "operatingHours": {
            "type": "object",
            "description": "Per-day-of-week availability schedule",
            "additionalProperties": true
          },
          "visibility": {
            "type": "string",
            "enum": [
              "Private",
              "Member_only",
              "Public"
            ]
          },
          "isActive": {
            "type": "boolean",
            "description": "False retires the instructor: they leave the listings and stop being bookable, and their history is kept. There is no delete."
          }
        }
      },
      "PlatformInstructorUpdate": {
        "type": "object",
        "description": "Fields accepted when updating an instructor. Send only what changes; an omitted field is left alone. At least one field is required, and unknown fields are rejected. `contactId` is absent on purpose: re-pointing a profile at another person would transfer their bookings and pay.",
        "minProperties": 1,
        "properties": {
          "clubId": {
            "type": "integer",
            "nullable": true,
            "description": "Club the instructor works at; null means every club in the organization."
          },
          "instructorTypeId": {
            "type": "integer",
            "nullable": true,
            "description": "The kind of coach, from GET /v1/platform/instructor-types. null leaves them unfiled, which is legal but loses the grouping every roster view reads from and the default revenue account their sessions would post to."
          },
          "sports": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "maxItems": 50
          },
          "displayName": {
            "type": "string",
            "nullable": true,
            "maxLength": 120,
            "description": "Public-facing name. Defaults to \"<first name> <last initial>.\" from the contact."
          },
          "hourlyRate": {
            "type": "number",
            "nullable": true,
            "minimum": 0,
            "description": "What a **member pays** for an hour of one-to-one time with them. Not what the club pays the coach - that is a pay rate policy. Added to the area price unless `priceIncludesArea` is true."
          },
          "bookability": {
            "type": "string",
            "enum": [
              "Admin_only",
              "Member_only",
              "Public"
            ],
            "description": "Who may book one-to-one sessions with them. Never outruns `visibility`: narrowing one pulls the other down with it, and deactivating the instructor retires this to `Admin_only`. The read-only `isBookable` is derived from this and cannot be written."
          },
          "priceIncludesArea": {
            "type": "boolean",
            "description": "When true, `hourlyRate` already covers the booked area, which is then not charged on top."
          },
          "maxConcurrentBookings": {
            "type": "integer",
            "minimum": 1
          },
          "operatingHours": {
            "type": "object",
            "description": "Per-day-of-week availability schedule",
            "additionalProperties": true
          },
          "visibility": {
            "type": "string",
            "enum": [
              "Private",
              "Member_only",
              "Public"
            ]
          },
          "isActive": {
            "type": "boolean",
            "description": "False retires the instructor: they leave the listings and stop being bookable, and their history is kept. There is no delete."
          }
        }
      },
      "PlatformInstructorPage": {
        "type": "object",
        "properties": {
          "data": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/PlatformInstructorSummary"
            }
          },
          "total": {
            "type": "integer",
            "description": "Total instructors matching the filters, ignoring pagination"
          },
          "limit": {
            "type": "integer"
          },
          "offset": {
            "type": "integer"
          }
        }
      },
      "PlatformFormQuestion": {
        "type": "object",
        "description": "One question on a fill-in form, as read. Resend it with its `id` to keep it.",
        "properties": {
          "id": {
            "type": "integer",
            "description": "Keep this: answers are stored against it."
          },
          "label": {
            "type": "string"
          },
          "type": {
            "type": "string",
            "enum": [
              "text",
              "textarea",
              "yes_no",
              "select",
              "boolean",
              "date",
              "number",
              "email",
              "phone",
              "url",
              "time"
            ]
          },
          "required": {
            "type": "boolean"
          },
          "placeholder": {
            "type": "string",
            "nullable": true
          },
          "helpText": {
            "type": "string",
            "nullable": true
          },
          "sectionHeading": {
            "type": "string",
            "nullable": true,
            "description": "Starts a new section above this question."
          },
          "options": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "The choices of a `select` question."
          },
          "contactCustomFieldId": {
            "type": "integer",
            "nullable": true,
            "description": "A member profile field this question's answer is also saved to, linked in the admin portal. Send it back unchanged (or leave it out) to keep the link, or null to remove it."
          },
          "translations": {
            "type": "object",
            "nullable": true,
            "additionalProperties": {
              "type": "object",
              "additionalProperties": {
                "type": "string"
              }
            }
          }
        }
      },
      "PlatformFormQuestionWrite": {
        "type": "object",
        "required": [
          "label",
          "type",
          "required"
        ],
        "additionalProperties": false,
        "properties": {
          "id": {
            "type": "integer",
            "description": "An existing question of this form, to keep it. Leave out for a new question."
          },
          "label": {
            "type": "string",
            "maxLength": 300
          },
          "type": {
            "type": "string",
            "enum": [
              "text",
              "textarea",
              "yes_no",
              "select",
              "boolean",
              "date",
              "number",
              "email",
              "phone",
              "url",
              "time"
            ]
          },
          "required": {
            "type": "boolean",
            "description": "Whether it must be answered. Always sent: there is no default."
          },
          "placeholder": {
            "type": "string",
            "nullable": true,
            "maxLength": 200
          },
          "helpText": {
            "type": "string",
            "nullable": true,
            "maxLength": 500
          },
          "sectionHeading": {
            "type": "string",
            "nullable": true,
            "maxLength": 200
          },
          "options": {
            "type": "array",
            "nullable": true,
            "maxItems": 50,
            "items": {
              "type": "string",
              "maxLength": 200
            },
            "description": "Required for `select`. Not translatable: the choice is also the stored answer."
          },
          "contactCustomFieldId": {
            "type": "integer",
            "nullable": true,
            "description": "Keep or remove an existing link to a member profile field: send the value read from GET (or leave it out) to keep it, or null to remove it. Setting a new link is done in the admin portal (`FORM_QUESTION_LINK_NOT_SETTABLE`)."
          },
          "translations": {
            "type": "object",
            "nullable": true,
            "additionalProperties": {
              "type": "object",
              "additionalProperties": {
                "type": "string"
              }
            },
            "description": "Per-locale wording. Translatable fields: label, placeholder, helpText, sectionHeading."
          }
        }
      },
      "PlatformFormQuestionsPut": {
        "type": "object",
        "required": [
          "questions"
        ],
        "additionalProperties": false,
        "properties": {
          "questions": {
            "type": "array",
            "maxItems": 100,
            "items": {
              "$ref": "#/components/schemas/PlatformFormQuestionWrite"
            },
            "description": "Every question the form should ask, in order."
          },
          "removeQuestionIds": {
            "type": "array",
            "items": {
              "type": "integer"
            },
            "description": "Existing questions to delete. Every existing question must be either resent by id or listed here (`FORM_QUESTION_OMITTED`)."
          }
        }
      },
      "PlatformForm": {
        "type": "object",
        "description": "A form: a page people fill in, live at `publicUrl`.",
        "properties": {
          "id": {
            "type": "integer"
          },
          "slug": {
            "type": "string",
            "description": "The last part of `publicUrl`. Fixed once the form exists."
          },
          "createsAccount": {
            "type": "boolean",
            "description": "true for a sign-up form (registers the person, can sell); false for a fill-in form (collects answers). Fixed once the form exists."
          },
          "publicUrl": {
            "type": "string",
            "nullable": true,
            "description": "Where the form is filled in, on the organization's own website or member portal. It is live: anyone with the link can use it. null when the organization has neither switched on, so nothing can open the form until one is."
          },
          "name": {
            "type": "string",
            "maxLength": 200,
            "description": "Internal name, shown to staff in the admin portal."
          },
          "title": {
            "type": "string",
            "nullable": true,
            "maxLength": 200,
            "description": "Heading at the top of the form. Blank or null falls back to the wizard's own wording."
          },
          "subtitle": {
            "type": "string",
            "nullable": true,
            "maxLength": 200,
            "description": "Sub-heading under the title. Blank or null falls back to the wizard's own wording."
          },
          "planStepTitle": {
            "type": "string",
            "nullable": true,
            "maxLength": 200,
            "description": "Heading above the plan picker (sign-up forms). Blank or null falls back to the wizard's own wording."
          },
          "classTypeStepTitle": {
            "type": "string",
            "nullable": true,
            "maxLength": 200,
            "description": "Heading above the class-type picker (sign-up forms). Blank or null falls back to the wizard's own wording."
          },
          "planIds": {
            "type": "array",
            "items": {
              "type": "integer"
            },
            "maxItems": 100,
            "description": "Sign-up forms only. Plans the form sells, from GET /v1/platform/plans. Each must be active, publicly visible and not a season plan (`FORM_PLAN_NOT_SELLABLE` names each refused id and why). When any are set, the person must choose one to finish."
          },
          "classTypeIds": {
            "type": "array",
            "items": {
              "type": "integer"
            },
            "maxItems": 100,
            "description": "Sign-up forms only. Class types offered as an interest, from GET /v1/platform/class-types. Nothing is booked."
          },
          "productIds": {
            "type": "array",
            "items": {
              "type": "integer"
            },
            "maxItems": 100,
            "description": "Sign-up forms only. Products the form sells, from GET /v1/platform/products. Each must be active, publicly visible and sold as a product (`FORM_PRODUCT_NOT_SELLABLE`)."
          },
          "registrationFor": {
            "type": "string",
            "enum": [
              "individual",
              "child",
              "both"
            ],
            "description": "Who the form registers: the person filling it in, only their children, or both."
          },
          "signatureMethod": {
            "type": "string",
            "enum": [
              "draw",
              "type",
              "check"
            ],
            "description": "How documents are signed: drawn, typed name, or a checkbox."
          },
          "collectsDocuments": {
            "type": "boolean",
            "description": "Whether the form asks for the organization's documents and a signature. Which documents is set by the organization's document rules."
          },
          "requiresAuthentication": {
            "type": "boolean",
            "description": "Fill-in forms only: the person must be signed in to fill it in. Refused on a sign-up form (`FORM_AUTHENTICATION_FILL_IN_ONLY`)."
          },
          "combineSelectionAndDetails": {
            "type": "boolean",
            "description": "Put the \"choose what to buy\" step and the details step on one page. Needs a form that sells something (`FORM_NOTHING_TO_COMBINE`)."
          },
          "displayMode": {
            "type": "string",
            "enum": [
              "page",
              "dialog"
            ],
            "description": "How a link to the form opens: as its own page, or as a dialog over the page it was opened from."
          },
          "videoUrl": {
            "type": "string",
            "nullable": true,
            "maxLength": 500,
            "description": "A Cloudinary video URL from the media library, shown as a step before review. null removes the video step."
          },
          "videoRequired": {
            "type": "boolean",
            "description": "The person cannot continue until the video has played to the end. Needs a video (`FORM_VIDEO_REQUIRED_WITHOUT_VIDEO`)."
          },
          "videoTitle": {
            "type": "string",
            "nullable": true,
            "maxLength": 200,
            "description": "Heading above the video. Blank or null falls back to the wizard's own wording."
          },
          "fieldOverrides": {
            "type": "object",
            "nullable": true,
            "additionalProperties": false,
            "description": "Fill-in forms only (`FORM_FIELD_OVERRIDES_FILL_IN_ONLY`; an empty object is accepted and ignored on a sign-up form, which asks the organization's own sign-up fields instead). Which built-in contact fields the form asks: `required` asks it, `hidden` does not. A fill-in form cannot ask one optionally. `address` asks the whole postal address. On update the map is merged: the fields you send change and the rest stay as they are, so send `hidden` to stop asking one. null clears every override.",
            "properties": {
              "firstName": {
                "type": "string",
                "enum": [
                  "required",
                  "hidden"
                ]
              },
              "lastName": {
                "type": "string",
                "enum": [
                  "required",
                  "hidden"
                ]
              },
              "email": {
                "type": "string",
                "enum": [
                  "required",
                  "hidden"
                ]
              },
              "phone": {
                "type": "string",
                "enum": [
                  "required",
                  "hidden"
                ]
              },
              "dateOfBirth": {
                "type": "string",
                "enum": [
                  "required",
                  "hidden"
                ]
              },
              "gender": {
                "type": "string",
                "enum": [
                  "required",
                  "hidden"
                ]
              },
              "address": {
                "type": "string",
                "enum": [
                  "required",
                  "hidden"
                ]
              }
            }
          },
          "translations": {
            "type": "object",
            "nullable": true,
            "additionalProperties": {
              "type": "object",
              "additionalProperties": {
                "type": "string"
              }
            },
            "description": "Per-locale wording, as `{ \"es\": { \"title\": \"...\" } }`. Replaces the whole map. Translatable fields: title, subtitle, planStepTitle, classTypeStepTitle, videoTitle; any other key is refused (`INVALID_TRANSLATIONS`)."
          },
          "packageIds": {
            "type": "array",
            "items": {
              "type": "integer"
            },
            "description": "Read-only here: packages are added in the admin portal."
          },
          "submissionCount": {
            "type": "integer",
            "description": "How many people have completed it. The answers are not served by this API."
          },
          "createdAt": {
            "type": "string",
            "format": "date-time"
          },
          "updatedAt": {
            "type": "string",
            "format": "date-time"
          },
          "questions": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/PlatformFormQuestion"
            },
            "description": "In order. On a single-form read and on every write; not in the list."
          }
        }
      },
      "PlatformFormCreate": {
        "type": "object",
        "description": "Fields accepted when creating a form. Unknown fields are refused, and `packageIds`, `customFieldIds` and `showFamilyMembers` are refused by name with the reason. Set questions afterwards with PUT /v1/platform/forms/{id}/questions.",
        "required": [
          "name",
          "createsAccount"
        ],
        "additionalProperties": false,
        "properties": {
          "createsAccount": {
            "type": "boolean",
            "description": "Required and permanent. true: a sign-up form, which registers whoever completes it and can sell plans and products. false: a fill-in form, which only collects answers."
          },
          "slug": {
            "type": "string",
            "maxLength": 120,
            "pattern": "^[a-z0-9]+(-[a-z0-9]+)*$",
            "description": "Optional; derived from the name when left out. A taken slug is a 409 (`FORM_SLUG_TAKEN`)."
          },
          "name": {
            "type": "string",
            "maxLength": 200,
            "description": "Internal name, shown to staff in the admin portal."
          },
          "title": {
            "type": "string",
            "nullable": true,
            "maxLength": 200,
            "description": "Heading at the top of the form. Blank or null falls back to the wizard's own wording."
          },
          "subtitle": {
            "type": "string",
            "nullable": true,
            "maxLength": 200,
            "description": "Sub-heading under the title. Blank or null falls back to the wizard's own wording."
          },
          "planStepTitle": {
            "type": "string",
            "nullable": true,
            "maxLength": 200,
            "description": "Heading above the plan picker (sign-up forms). Blank or null falls back to the wizard's own wording."
          },
          "classTypeStepTitle": {
            "type": "string",
            "nullable": true,
            "maxLength": 200,
            "description": "Heading above the class-type picker (sign-up forms). Blank or null falls back to the wizard's own wording."
          },
          "planIds": {
            "type": "array",
            "items": {
              "type": "integer"
            },
            "maxItems": 100,
            "description": "Sign-up forms only. Plans the form sells, from GET /v1/platform/plans. Each must be active, publicly visible and not a season plan (`FORM_PLAN_NOT_SELLABLE` names each refused id and why). When any are set, the person must choose one to finish."
          },
          "classTypeIds": {
            "type": "array",
            "items": {
              "type": "integer"
            },
            "maxItems": 100,
            "description": "Sign-up forms only. Class types offered as an interest, from GET /v1/platform/class-types. Nothing is booked."
          },
          "productIds": {
            "type": "array",
            "items": {
              "type": "integer"
            },
            "maxItems": 100,
            "description": "Sign-up forms only. Products the form sells, from GET /v1/platform/products. Each must be active, publicly visible and sold as a product (`FORM_PRODUCT_NOT_SELLABLE`)."
          },
          "registrationFor": {
            "type": "string",
            "enum": [
              "individual",
              "child",
              "both"
            ],
            "description": "Who the form registers: the person filling it in, only their children, or both."
          },
          "signatureMethod": {
            "type": "string",
            "enum": [
              "draw",
              "type",
              "check"
            ],
            "description": "How documents are signed: drawn, typed name, or a checkbox."
          },
          "collectsDocuments": {
            "type": "boolean",
            "description": "Whether the form asks for the organization's documents and a signature. Which documents is set by the organization's document rules."
          },
          "requiresAuthentication": {
            "type": "boolean",
            "description": "Fill-in forms only: the person must be signed in to fill it in. Refused on a sign-up form (`FORM_AUTHENTICATION_FILL_IN_ONLY`)."
          },
          "combineSelectionAndDetails": {
            "type": "boolean",
            "description": "Put the \"choose what to buy\" step and the details step on one page. Needs a form that sells something (`FORM_NOTHING_TO_COMBINE`)."
          },
          "displayMode": {
            "type": "string",
            "enum": [
              "page",
              "dialog"
            ],
            "description": "How a link to the form opens: as its own page, or as a dialog over the page it was opened from."
          },
          "videoUrl": {
            "type": "string",
            "nullable": true,
            "maxLength": 500,
            "description": "A Cloudinary video URL from the media library, shown as a step before review. null removes the video step."
          },
          "videoRequired": {
            "type": "boolean",
            "description": "The person cannot continue until the video has played to the end. Needs a video (`FORM_VIDEO_REQUIRED_WITHOUT_VIDEO`)."
          },
          "videoTitle": {
            "type": "string",
            "nullable": true,
            "maxLength": 200,
            "description": "Heading above the video. Blank or null falls back to the wizard's own wording."
          },
          "fieldOverrides": {
            "type": "object",
            "nullable": true,
            "additionalProperties": false,
            "description": "Fill-in forms only (`FORM_FIELD_OVERRIDES_FILL_IN_ONLY`; an empty object is accepted and ignored on a sign-up form, which asks the organization's own sign-up fields instead). Which built-in contact fields the form asks: `required` asks it, `hidden` does not. A fill-in form cannot ask one optionally. `address` asks the whole postal address. On update the map is merged: the fields you send change and the rest stay as they are, so send `hidden` to stop asking one. null clears every override.",
            "properties": {
              "firstName": {
                "type": "string",
                "enum": [
                  "required",
                  "hidden"
                ]
              },
              "lastName": {
                "type": "string",
                "enum": [
                  "required",
                  "hidden"
                ]
              },
              "email": {
                "type": "string",
                "enum": [
                  "required",
                  "hidden"
                ]
              },
              "phone": {
                "type": "string",
                "enum": [
                  "required",
                  "hidden"
                ]
              },
              "dateOfBirth": {
                "type": "string",
                "enum": [
                  "required",
                  "hidden"
                ]
              },
              "gender": {
                "type": "string",
                "enum": [
                  "required",
                  "hidden"
                ]
              },
              "address": {
                "type": "string",
                "enum": [
                  "required",
                  "hidden"
                ]
              }
            }
          },
          "translations": {
            "type": "object",
            "nullable": true,
            "additionalProperties": {
              "type": "object",
              "additionalProperties": {
                "type": "string"
              }
            },
            "description": "Per-locale wording, as `{ \"es\": { \"title\": \"...\" } }`. Replaces the whole map. Translatable fields: title, subtitle, planStepTitle, classTypeStepTitle, videoTitle; any other key is refused (`INVALID_TRANSLATIONS`)."
          }
        }
      },
      "PlatformFormUpdate": {
        "type": "object",
        "description": "Fields accepted when updating a form. Send only what changes. `slug` and `createsAccount` are refused by name: both are fixed once the form exists.",
        "minProperties": 1,
        "additionalProperties": false,
        "properties": {
          "name": {
            "type": "string",
            "maxLength": 200,
            "description": "Internal name, shown to staff in the admin portal."
          },
          "title": {
            "type": "string",
            "nullable": true,
            "maxLength": 200,
            "description": "Heading at the top of the form. Blank or null falls back to the wizard's own wording."
          },
          "subtitle": {
            "type": "string",
            "nullable": true,
            "maxLength": 200,
            "description": "Sub-heading under the title. Blank or null falls back to the wizard's own wording."
          },
          "planStepTitle": {
            "type": "string",
            "nullable": true,
            "maxLength": 200,
            "description": "Heading above the plan picker (sign-up forms). Blank or null falls back to the wizard's own wording."
          },
          "classTypeStepTitle": {
            "type": "string",
            "nullable": true,
            "maxLength": 200,
            "description": "Heading above the class-type picker (sign-up forms). Blank or null falls back to the wizard's own wording."
          },
          "planIds": {
            "type": "array",
            "items": {
              "type": "integer"
            },
            "maxItems": 100,
            "description": "Sign-up forms only. Plans the form sells, from GET /v1/platform/plans. Each must be active, publicly visible and not a season plan (`FORM_PLAN_NOT_SELLABLE` names each refused id and why). When any are set, the person must choose one to finish."
          },
          "classTypeIds": {
            "type": "array",
            "items": {
              "type": "integer"
            },
            "maxItems": 100,
            "description": "Sign-up forms only. Class types offered as an interest, from GET /v1/platform/class-types. Nothing is booked."
          },
          "productIds": {
            "type": "array",
            "items": {
              "type": "integer"
            },
            "maxItems": 100,
            "description": "Sign-up forms only. Products the form sells, from GET /v1/platform/products. Each must be active, publicly visible and sold as a product (`FORM_PRODUCT_NOT_SELLABLE`)."
          },
          "registrationFor": {
            "type": "string",
            "enum": [
              "individual",
              "child",
              "both"
            ],
            "description": "Who the form registers: the person filling it in, only their children, or both."
          },
          "signatureMethod": {
            "type": "string",
            "enum": [
              "draw",
              "type",
              "check"
            ],
            "description": "How documents are signed: drawn, typed name, or a checkbox."
          },
          "collectsDocuments": {
            "type": "boolean",
            "description": "Whether the form asks for the organization's documents and a signature. Which documents is set by the organization's document rules."
          },
          "requiresAuthentication": {
            "type": "boolean",
            "description": "Fill-in forms only: the person must be signed in to fill it in. Refused on a sign-up form (`FORM_AUTHENTICATION_FILL_IN_ONLY`)."
          },
          "combineSelectionAndDetails": {
            "type": "boolean",
            "description": "Put the \"choose what to buy\" step and the details step on one page. Needs a form that sells something (`FORM_NOTHING_TO_COMBINE`)."
          },
          "displayMode": {
            "type": "string",
            "enum": [
              "page",
              "dialog"
            ],
            "description": "How a link to the form opens: as its own page, or as a dialog over the page it was opened from."
          },
          "videoUrl": {
            "type": "string",
            "nullable": true,
            "maxLength": 500,
            "description": "A Cloudinary video URL from the media library, shown as a step before review. null removes the video step."
          },
          "videoRequired": {
            "type": "boolean",
            "description": "The person cannot continue until the video has played to the end. Needs a video (`FORM_VIDEO_REQUIRED_WITHOUT_VIDEO`)."
          },
          "videoTitle": {
            "type": "string",
            "nullable": true,
            "maxLength": 200,
            "description": "Heading above the video. Blank or null falls back to the wizard's own wording."
          },
          "fieldOverrides": {
            "type": "object",
            "nullable": true,
            "additionalProperties": false,
            "description": "Fill-in forms only (`FORM_FIELD_OVERRIDES_FILL_IN_ONLY`; an empty object is accepted and ignored on a sign-up form, which asks the organization's own sign-up fields instead). Which built-in contact fields the form asks: `required` asks it, `hidden` does not. A fill-in form cannot ask one optionally. `address` asks the whole postal address. On update the map is merged: the fields you send change and the rest stay as they are, so send `hidden` to stop asking one. null clears every override.",
            "properties": {
              "firstName": {
                "type": "string",
                "enum": [
                  "required",
                  "hidden"
                ]
              },
              "lastName": {
                "type": "string",
                "enum": [
                  "required",
                  "hidden"
                ]
              },
              "email": {
                "type": "string",
                "enum": [
                  "required",
                  "hidden"
                ]
              },
              "phone": {
                "type": "string",
                "enum": [
                  "required",
                  "hidden"
                ]
              },
              "dateOfBirth": {
                "type": "string",
                "enum": [
                  "required",
                  "hidden"
                ]
              },
              "gender": {
                "type": "string",
                "enum": [
                  "required",
                  "hidden"
                ]
              },
              "address": {
                "type": "string",
                "enum": [
                  "required",
                  "hidden"
                ]
              }
            }
          },
          "translations": {
            "type": "object",
            "nullable": true,
            "additionalProperties": {
              "type": "object",
              "additionalProperties": {
                "type": "string"
              }
            },
            "description": "Per-locale wording, as `{ \"es\": { \"title\": \"...\" } }`. Replaces the whole map. Translatable fields: title, subtitle, planStepTitle, classTypeStepTitle, videoTitle; any other key is refused (`INVALID_TRANSLATIONS`)."
          }
        }
      },
      "PlatformFormPage": {
        "type": "object",
        "properties": {
          "data": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/PlatformForm"
            }
          },
          "total": {
            "type": "integer",
            "description": "Total forms matching the filters, ignoring pagination"
          },
          "limit": {
            "type": "integer"
          },
          "offset": {
            "type": "integer"
          }
        }
      },
      "PlatformMembership": {
        "type": "object",
        "description": "A customer's membership on a plan. The plan is snapshotted onto it at creation, so allowance and coverage behave exactly as they do for a membership sold in 1club.",
        "properties": {
          "membershipId": {
            "type": "integer"
          },
          "membershipUuid": {
            "type": "string",
            "format": "uuid",
            "description": "Stable unguessable identifier. Prefer this over `membershipId` when storing a reference."
          },
          "contactId": {
            "type": "integer",
            "description": "Customer the membership belongs to"
          },
          "planId": {
            "type": "integer"
          },
          "planName": {
            "type": "string",
            "nullable": true
          },
          "clubId": {
            "type": "integer",
            "nullable": true,
            "description": "Club the membership is limited to; null when it applies organization-wide."
          },
          "status": {
            "type": "string",
            "description": "active, paused, cancelled, expired, used, pending, or pending_payment"
          },
          "startDate": {
            "type": "string",
            "format": "date-time"
          },
          "endDate": {
            "type": "string",
            "format": "date-time",
            "nullable": true
          },
          "renewalDate": {
            "type": "string",
            "format": "date-time",
            "nullable": true
          },
          "autoRenew": {
            "type": "boolean"
          },
          "price": {
            "type": "number",
            "nullable": true
          },
          "billingFrequency": {
            "type": "string"
          },
          "maxUses": {
            "type": "number",
            "nullable": true,
            "description": "Session allowance snapshotted from the plan; null when unlimited."
          },
          "usesRemaining": {
            "type": "number",
            "nullable": true
          },
          "createdAt": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "PlatformMembershipPage": {
        "type": "object",
        "properties": {
          "data": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/PlatformMembership"
            }
          },
          "total": {
            "type": "integer",
            "description": "Total memberships matching the filters, ignoring pagination"
          },
          "limit": {
            "type": "integer"
          },
          "offset": {
            "type": "integer"
          }
        }
      },
      "PlatformPlan": {
        "type": "object",
        "description": "A membership plan. It carries its own price, joining fee, billing frequency and session allowance, so creating a membership only has to name the plan and the customer.",
        "properties": {
          "planId": {
            "type": "integer"
          },
          "name": {
            "type": "string"
          },
          "description": {
            "type": "string",
            "nullable": true
          },
          "clubId": {
            "type": "integer",
            "nullable": true,
            "description": "Club the plan is limited to; null when organization-wide."
          },
          "seasonId": {
            "type": "integer",
            "nullable": true,
            "description": "Set when the plan sells a season, and the marker that it is **not** for sale through this API: a season pass costs the sum of the slots the buyer picks, so the plan's own `price` is 0 and a membership created against it would put a member on a large contract for nothing. `GET /v1/platform/plans` excludes these; get-by-id resolves one so a membership already sold against it can be explained. Check it before treating any plan as sellable."
          },
          "planCategoryId": {
            "type": "integer",
            "nullable": true,
            "description": "Category the plan is grouped under on price lists; null when ungrouped."
          },
          "revenueAccountId": {
            "type": "integer",
            "nullable": true,
            "description": "Ledger account a sale posts to; null means the organization's default account is used at the time of the sale."
          },
          "price": {
            "type": "number",
            "description": "Charged each billing period."
          },
          "signupFee": {
            "type": "number",
            "description": "Charged once when the membership starts, on top of the price."
          },
          "billingFrequency": {
            "type": "string"
          },
          "isActive": {
            "type": "boolean",
            "description": "False for a retired plan that is no longer sold."
          },
          "maxUses": {
            "type": "number",
            "nullable": true,
            "description": "Session allowance a membership gets; null when unlimited."
          },
          "sessionUnit": {
            "type": "string",
            "nullable": true,
            "description": "What a use is counted in - a booking or an hour."
          },
          "validityPeriod": {
            "type": "integer",
            "nullable": true,
            "description": "Days the membership stays valid, when time-limited."
          },
          "autoRenews": {
            "type": "boolean",
            "description": "Whether a membership on this plan renews by default."
          },
          "renewalDaysInAdvance": {
            "type": "integer"
          },
          "requirePaymentUpfront": {
            "type": "boolean"
          },
          "customPeriod": {
            "type": "integer",
            "nullable": true
          },
          "customPeriodUnit": {
            "type": "string",
            "nullable": true
          },
          "features": {
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "visibility": {
            "type": "string",
            "enum": [
              "Private",
              "Member_only",
              "Public"
            ]
          },
          "validFor": {
            "type": "object",
            "additionalProperties": true,
            "description": "Booking resource scopes and optional type filters covered by the plan."
          },
          "sports": {
            "type": "array",
            "items": {
              "type": "string"
            }
          }
        }
      },
      "PlatformPlanPage": {
        "type": "object",
        "properties": {
          "data": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/PlatformPlan"
            }
          },
          "total": {
            "type": "integer",
            "description": "Total plans matching the filters, ignoring pagination"
          },
          "limit": {
            "type": "integer"
          },
          "offset": {
            "type": "integer"
          }
        }
      },
      "PlatformProduct": {
        "type": "object",
        "description": "A product the organization sells over the counter.",
        "properties": {
          "productId": {
            "type": "integer"
          },
          "name": {
            "type": "string"
          },
          "description": {
            "type": "string",
            "nullable": true
          },
          "clubId": {
            "type": "integer",
            "nullable": true,
            "description": "Club the product is sold at; null when available organization-wide."
          },
          "categoryId": {
            "type": "integer",
            "nullable": true,
            "description": "Product category it is grouped under."
          },
          "brandId": {
            "type": "integer",
            "nullable": true
          },
          "sku": {
            "type": "string",
            "nullable": true,
            "description": "Stock-keeping code, if the gym uses one."
          },
          "barcode": {
            "type": "string",
            "nullable": true,
            "description": "Scanned at the till. Unique across everything scannable in the organization."
          },
          "price": {
            "type": "number",
            "description": "What a customer pays, in the organization currency."
          },
          "costPrice": {
            "type": "number",
            "nullable": true,
            "description": "What the gym pays for it; null when not tracked. Never shown to customers."
          },
          "isActive": {
            "type": "boolean",
            "description": "False for a product taken off the till without deleting its history."
          },
          "visibility": {
            "type": "string",
            "enum": [
              "Public",
              "Member_only",
              "Private"
            ],
            "description": "Who may see it on customer-facing surfaces."
          },
          "images": {
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "features": {
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "taxRateId": {
            "type": "integer",
            "nullable": true,
            "description": "Per-product VAT rate; takes precedence over the revenue account default at the till."
          },
          "revenueAccountId": {
            "type": "integer",
            "nullable": true
          },
          "createdAt": {
            "type": "string",
            "format": "date-time"
          },
          "updatedAt": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "PlatformProductPage": {
        "type": "object",
        "properties": {
          "data": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/PlatformProduct"
            }
          },
          "total": {
            "type": "integer",
            "description": "Total products matching the filters, ignoring pagination"
          },
          "limit": {
            "type": "integer"
          },
          "offset": {
            "type": "integer"
          }
        }
      },
      "PlatformProductCreate": {
        "type": "object",
        "description": "Fields accepted when creating a product. Only `name` and `price` are required; the defaults shown are what an omitted field becomes.",
        "required": [
          "name",
          "price"
        ],
        "properties": {
          "name": {
            "type": "string",
            "maxLength": 255
          },
          "description": {
            "type": "string",
            "maxLength": 5000
          },
          "price": {
            "type": "number",
            "minimum": 0
          },
          "costPrice": {
            "type": "number",
            "minimum": 0,
            "nullable": true
          },
          "clubId": {
            "type": "integer",
            "nullable": true,
            "description": "Limit the product to one club; null sells it everywhere."
          },
          "categoryId": {
            "type": "integer",
            "nullable": true,
            "description": "From GET /v1/platform/product-categories"
          },
          "brandId": {
            "type": "integer",
            "nullable": true
          },
          "sku": {
            "type": "string",
            "maxLength": 64,
            "nullable": true,
            "description": "Stock-keeping code. Scannable at the till, so it must be unused by any other product, package, plan or access card in the organization."
          },
          "barcode": {
            "type": "string",
            "maxLength": 64,
            "nullable": true,
            "description": "Must be unused by any other product, package, plan or access card in the organization."
          },
          "isActive": {
            "type": "boolean",
            "default": true
          },
          "visibility": {
            "type": "string",
            "enum": [
              "Public",
              "Member_only",
              "Private"
            ],
            "default": "Public"
          },
          "taxRateId": {
            "type": "integer",
            "nullable": true
          },
          "revenueAccountId": {
            "type": "integer",
            "nullable": true
          },
          "images": {
            "type": "array",
            "items": {
              "type": "string",
              "format": "uri"
            },
            "description": "Replaces the whole list. Use already-hosted URLs."
          },
          "features": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "Replaces the whole list."
          }
        }
      },
      "PlatformProductUpdate": {
        "type": "object",
        "description": "Fields accepted when updating a product. Send only what changes; `null` clears a nullable field, and an omitted field is left alone. At least one field is required.",
        "minProperties": 1,
        "properties": {
          "name": {
            "type": "string",
            "maxLength": 255
          },
          "description": {
            "type": "string",
            "maxLength": 5000
          },
          "price": {
            "type": "number",
            "minimum": 0
          },
          "costPrice": {
            "type": "number",
            "minimum": 0,
            "nullable": true
          },
          "clubId": {
            "type": "integer",
            "nullable": true,
            "description": "Limit the product to one club; null sells it everywhere."
          },
          "categoryId": {
            "type": "integer",
            "nullable": true,
            "description": "From GET /v1/platform/product-categories"
          },
          "brandId": {
            "type": "integer",
            "nullable": true
          },
          "sku": {
            "type": "string",
            "maxLength": 64,
            "nullable": true,
            "description": "Stock-keeping code. Scannable at the till, so it must be unused by any other product, package, plan or access card in the organization."
          },
          "barcode": {
            "type": "string",
            "maxLength": 64,
            "nullable": true,
            "description": "Must be unused by any other product, package, plan or access card in the organization."
          },
          "isActive": {
            "type": "boolean"
          },
          "visibility": {
            "type": "string",
            "enum": [
              "Public",
              "Member_only",
              "Private"
            ]
          },
          "taxRateId": {
            "type": "integer",
            "nullable": true
          },
          "revenueAccountId": {
            "type": "integer",
            "nullable": true
          },
          "images": {
            "type": "array",
            "items": {
              "type": "string",
              "format": "uri"
            },
            "description": "Replaces the whole list. Use already-hosted URLs."
          },
          "features": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "Replaces the whole list."
          }
        }
      },
      "PlatformArea": {
        "type": "object",
        "description": "An area - a court, a studio, a lane. One shape for the list, get-by-id, create and update.",
        "properties": {
          "id": {
            "type": "integer"
          },
          "name": {
            "type": "string"
          },
          "description": {
            "type": "string",
            "nullable": true
          },
          "clubId": {
            "type": "integer"
          },
          "areaTypeId": {
            "type": "integer",
            "nullable": true,
            "description": "The kind of area, from GET /v1/platform/area-types. Decides `sports` and the default price."
          },
          "status": {
            "type": "string",
            "enum": [
              "active",
              "maintenance",
              "inactive"
            ],
            "description": "Only an `active` area is bookable, and only those appear in the list. The other two resolve by id."
          },
          "visibility": {
            "type": "string",
            "enum": [
              "Private",
              "Member_only",
              "Public"
            ],
            "description": "The list shows only `Public` areas; the others resolve by id."
          },
          "sports": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "Sports the area is bookable for, from its area type."
          },
          "pricePerHour": {
            "type": "number",
            "nullable": true
          },
          "maxConcurrentBookings": {
            "type": "integer"
          },
          "timezone": {
            "type": "string",
            "description": "IANA timezone the operating hours are expressed in (the club's, falling back to the organization's). Convert a local wall time in this zone when sending `startTime`/`endTime` to POST /v1/platform/bookings."
          },
          "operatingHours": {
            "type": "object",
            "description": "Per-day-of-week opening schedule",
            "additionalProperties": true
          }
        }
      },
      "PlatformAreaCreate": {
        "type": "object",
        "description": "Fields accepted when creating an area. The defaults shown are what an omitted field becomes. Unknown fields are rejected.",
        "required": [
          "name",
          "clubId",
          "areaTypeId"
        ],
        "properties": {
          "name": {
            "type": "string",
            "maxLength": 255
          },
          "areaTypeId": {
            "type": "integer",
            "description": "From GET /v1/platform/area-types. Decides which sports the area is bookable for and what it is priced from, so resolve it rather than guessing."
          },
          "description": {
            "type": "string",
            "nullable": true,
            "maxLength": 5000
          },
          "status": {
            "type": "string",
            "enum": [
              "active",
              "maintenance",
              "inactive"
            ],
            "description": "Only an `active` area is bookable. `maintenance` and `inactive` take it out of availability without losing its history - this is how a court is closed indefinitely, where an area block closes it for a stated window.",
            "default": "active"
          },
          "visibility": {
            "type": "string",
            "enum": [
              "Private",
              "Member_only",
              "Public"
            ],
            "default": "Public"
          },
          "pricePerHour": {
            "type": "number",
            "nullable": true,
            "minimum": 0,
            "description": "Overrides the area type's default hourly price; null falls back to it."
          },
          "operatingHours": {
            "type": "object",
            "description": "Per-day-of-week opening schedule, in the club's timezone. Intersected with the club's own hours - an area is never bookable outside them.",
            "additionalProperties": true
          },
          "maxConcurrentBookings": {
            "type": "integer",
            "minimum": 1,
            "description": "How many bookings may hold the area at the same time. 1 for an ordinary court."
          },
          "clubId": {
            "type": "integer",
            "description": "Club the area belongs to, from GET /v1/platform/clubs. Not editable afterwards."
          }
        }
      },
      "PlatformAreaUpdate": {
        "type": "object",
        "description": "Fields accepted when updating an area. Send only what changes; an omitted field is left alone. At least one field is required, and unknown fields are rejected. `clubId` is absent on purpose - moving a court to another club would strand its bookings, class holds and blocks at the old one.",
        "minProperties": 1,
        "properties": {
          "name": {
            "type": "string",
            "maxLength": 255
          },
          "areaTypeId": {
            "type": "integer",
            "description": "From GET /v1/platform/area-types. Decides which sports the area is bookable for and what it is priced from, so resolve it rather than guessing."
          },
          "description": {
            "type": "string",
            "nullable": true,
            "maxLength": 5000
          },
          "status": {
            "type": "string",
            "enum": [
              "active",
              "maintenance",
              "inactive"
            ],
            "description": "Only an `active` area is bookable. `maintenance` and `inactive` take it out of availability without losing its history - this is how a court is closed indefinitely, where an area block closes it for a stated window."
          },
          "visibility": {
            "type": "string",
            "enum": [
              "Private",
              "Member_only",
              "Public"
            ]
          },
          "pricePerHour": {
            "type": "number",
            "nullable": true,
            "minimum": 0,
            "description": "Overrides the area type's default hourly price; null falls back to it."
          },
          "operatingHours": {
            "type": "object",
            "description": "Per-day-of-week opening schedule, in the club's timezone. Intersected with the club's own hours - an area is never bookable outside them.",
            "additionalProperties": true
          },
          "maxConcurrentBookings": {
            "type": "integer",
            "minimum": 1,
            "description": "How many bookings may hold the area at the same time. 1 for an ordinary court."
          }
        }
      },
      "PlatformAreaType": {
        "type": "object",
        "description": "A kind of area, and the defaults every area of that kind inherits. One shape for the list, get-by-id, create and update.",
        "properties": {
          "id": {
            "type": "integer"
          },
          "name": {
            "type": "string"
          },
          "slug": {
            "type": "string",
            "description": "Stable key, derived from the name when a create does not send one. A rename never moves it."
          },
          "description": {
            "type": "string",
            "nullable": true
          },
          "revenueAccountId": {
            "type": "integer",
            "nullable": true,
            "description": "Where a booking on this kind of area posts. Null falls back to the organization's default account."
          },
          "areaCount": {
            "type": "integer",
            "description": "Areas currently of this type, whatever their status."
          },
          "createdAt": {
            "type": "string",
            "format": "date-time"
          },
          "updatedAt": {
            "type": "string",
            "format": "date-time"
          },
          "sports": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "Sports an area of this type can be booked for."
          },
          "isBookable": {
            "type": "boolean",
            "description": "False for a type that is not booked at all (a storeroom, a corridor). Do not build bookable inventory on one."
          },
          "defaultPricePerHour": {
            "type": "number",
            "nullable": true,
            "description": "Hourly price an area of this type starts from; an area may override it."
          },
          "defaultMaxPlayers": {
            "type": "integer",
            "nullable": true,
            "description": "How many people a slot on this type normally takes."
          },
          "allowsSingles": {
            "type": "boolean",
            "description": "Whether a booking on this type may be made for singles (2 players) instead of doubles. Only read when defaultMaxPlayers is 4; leave it off for padel."
          },
          "maxConcurrentBookings": {
            "type": "integer",
            "description": "How many bookings may hold an area of this type at once. 1 for an ordinary court."
          },
          "bookingAudience": {
            "$ref": "#/components/schemas/PlatformContactAudience"
          }
        }
      },
      "PlatformAreaTypeCreate": {
        "type": "object",
        "description": "Fields accepted when creating an area type. Only `name` is required; `slug` is derived from it when omitted, and suffixed if that is taken. The defaults shown are what an omitted field becomes, and unknown fields are rejected.",
        "required": [
          "name"
        ],
        "properties": {
          "name": {
            "type": "string",
            "maxLength": 255
          },
          "slug": {
            "type": "string",
            "maxLength": 120,
            "pattern": "^[a-z0-9]+(-[a-z0-9]+)*$",
            "description": "Stable key. Derived from the name when omitted; send one only when something outside 1club already refers to it. An explicit slug that clashes is a 409, and a rename never moves it."
          },
          "description": {
            "type": "string",
            "nullable": true,
            "maxLength": 2000
          },
          "revenueAccountId": {
            "type": "integer",
            "nullable": true,
            "description": "Where a booking on this kind of area posts, from GET /v1/platform/revenue-accounts. Leave it unset and the sale posts to whichever account the organization has marked default, resolved at the time of the sale."
          },
          "sports": {
            "type": "array",
            "maxItems": 20,
            "items": {
              "type": "string"
            },
            "description": "Canonical sport ids an area of this type can be booked for, e.g. [\"padel\"]. This is what makes the areas bookable for a sport at all; an unrecognised value is rejected rather than stored."
          },
          "isBookable": {
            "type": "boolean",
            "default": true,
            "description": "False for a kind of space that is not booked at all (a storeroom, a corridor)."
          },
          "defaultPricePerHour": {
            "type": "number",
            "nullable": true,
            "minimum": 0,
            "description": "Hourly price an area of this type starts from. An area may override it."
          },
          "defaultMaxPlayers": {
            "type": "integer",
            "nullable": true,
            "minimum": 1,
            "maximum": 1000,
            "description": "How many people a slot on this type normally takes."
          },
          "allowsSingles": {
            "type": "boolean",
            "default": false,
            "description": "Whether a booking on this type may be made for singles (2 players) instead of doubles. Only read when defaultMaxPlayers is 4; leave it off for padel."
          },
          "maxConcurrentBookings": {
            "type": "integer",
            "minimum": 1,
            "maximum": 100,
            "default": 1,
            "description": "How many bookings may hold an area of this type at once. 1 for an ordinary court."
          },
          "bookingAudience": {
            "$ref": "#/components/schemas/PlatformContactAudience"
          }
        }
      },
      "PlatformAreaTypeUpdate": {
        "type": "object",
        "description": "Fields accepted when updating an area type. Send only what changes; `null` clears a nullable field, an omitted field is left alone, and at least one is required. A rename does not move `slug`. Every area of the type inherits `sports`, `defaultPricePerHour`, `defaultMaxPlayers` and `isBookable`, so changing one of those reprices or re-sports every area at once and emits an availability change for each. `sports` replaces the whole list.",
        "minProperties": 1,
        "properties": {
          "name": {
            "type": "string",
            "maxLength": 255
          },
          "slug": {
            "type": "string",
            "maxLength": 120,
            "pattern": "^[a-z0-9]+(-[a-z0-9]+)*$",
            "description": "Stable key. Derived from the name when omitted; send one only when something outside 1club already refers to it. An explicit slug that clashes is a 409, and a rename never moves it."
          },
          "description": {
            "type": "string",
            "nullable": true,
            "maxLength": 2000
          },
          "revenueAccountId": {
            "type": "integer",
            "nullable": true,
            "description": "Where a booking on this kind of area posts, from GET /v1/platform/revenue-accounts. Leave it unset and the sale posts to whichever account the organization has marked default, resolved at the time of the sale."
          },
          "sports": {
            "type": "array",
            "maxItems": 20,
            "items": {
              "type": "string"
            },
            "description": "Canonical sport ids an area of this type can be booked for, e.g. [\"padel\"]. This is what makes the areas bookable for a sport at all; an unrecognised value is rejected rather than stored."
          },
          "isBookable": {
            "type": "boolean",
            "default": true,
            "description": "False for a kind of space that is not booked at all (a storeroom, a corridor)."
          },
          "defaultPricePerHour": {
            "type": "number",
            "nullable": true,
            "minimum": 0,
            "description": "Hourly price an area of this type starts from. An area may override it."
          },
          "defaultMaxPlayers": {
            "type": "integer",
            "nullable": true,
            "minimum": 1,
            "maximum": 1000,
            "description": "How many people a slot on this type normally takes."
          },
          "allowsSingles": {
            "type": "boolean",
            "default": false,
            "description": "Whether a booking on this type may be made for singles (2 players) instead of doubles. Only read when defaultMaxPlayers is 4; leave it off for padel."
          },
          "maxConcurrentBookings": {
            "type": "integer",
            "minimum": 1,
            "maximum": 100,
            "default": 1,
            "description": "How many bookings may hold an area of this type at once. 1 for an ordinary court."
          },
          "bookingAudience": {
            "$ref": "#/components/schemas/PlatformContactAudience"
          }
        }
      },
      "PlatformClassTypeCreate": {
        "type": "object",
        "description": "Fields accepted when creating a class type. Only `name` is required; `slug` is derived from it when omitted, and suffixed if that is taken. The defaults shown are what an omitted field becomes, and unknown fields are rejected.",
        "required": [
          "name"
        ],
        "properties": {
          "name": {
            "type": "string",
            "maxLength": 255
          },
          "slug": {
            "type": "string",
            "maxLength": 120,
            "pattern": "^[a-z0-9]+(-[a-z0-9]+)*$",
            "description": "Stable key. Derived from the name when omitted; send one only when something outside 1club already refers to it. An explicit slug that clashes is a 409, and a rename never moves it."
          },
          "description": {
            "type": "string",
            "nullable": true,
            "maxLength": 2000
          },
          "revenueAccountId": {
            "type": "integer",
            "nullable": true,
            "description": "Where a sale on this kind of class posts, from GET /v1/platform/revenue-accounts. Leave it unset and the sale posts to whichever account the organization has marked default, resolved at the time of the sale."
          },
          "maxPartySize": {
            "type": "integer",
            "minimum": 1,
            "maximum": 1000,
            "default": 1,
            "description": "How many people one booking may bring, including the booker."
          },
          "allowMultiDay": {
            "type": "boolean",
            "default": false,
            "description": "Whether a class of this kind may run across more than one calendar day."
          },
          "cancelIfAttendanceZero": {
            "type": "boolean",
            "default": false,
            "description": "Whether a class with nobody booked cancels itself."
          },
          "cancelIfAttendanceZeroHoursInAdvance": {
            "type": "integer",
            "minimum": 0,
            "maximum": 720,
            "default": 0,
            "description": "Hours before the start that cancellation runs. Meaningless unless `cancelIfAttendanceZero`."
          },
          "bookingAudience": {
            "$ref": "#/components/schemas/PlatformContactAudience"
          },
          "sport": {
            "type": "string",
            "nullable": true,
            "description": "The sport classes of this kind are for, a canonical sport id such as \"jiu-jitsu\". A default only: each class still carries its own `sport`. An unrecognised value is rejected."
          },
          "ageGroup": {
            "type": "string",
            "nullable": true,
            "enum": [
              "infant",
              "child",
              "junior",
              "adult",
              "senior",
              "all_ages"
            ],
            "description": "Who a class of this kind is for: an age band (infant 0–4, child 5–12, junior 13–17, adult 18–64, senior 65+), the same bands the age smart tags use, or all_ages. Marketplace search filters on it."
          },
          "difficulty": {
            "type": "string",
            "nullable": true,
            "enum": [
              "beginner",
              "intermediate",
              "advanced",
              "all_levels"
            ],
            "description": "How hard a class of this kind is. Marketplace search filters on it. Not a rank: see `progressionTrackId`."
          },
          "progressionTrackId": {
            "type": "integer",
            "nullable": true,
            "description": "The progression track sessions of this kind count toward, from GET /v1/platform/progression-tracks. Once any class type points at a track, only those sessions count toward its ranks; a track nothing points at counts every session. Must be a track the organization uses."
          }
        }
      },
      "PlatformClassTypeUpdate": {
        "type": "object",
        "description": "Fields accepted when updating a class type. Send only what changes; `null` clears a nullable field, an omitted field is left alone, and at least one is required. A rename does not move `slug`. Every field applies to classes already scheduled as this type, not only to new ones.",
        "minProperties": 1,
        "properties": {
          "name": {
            "type": "string",
            "maxLength": 255
          },
          "slug": {
            "type": "string",
            "maxLength": 120,
            "pattern": "^[a-z0-9]+(-[a-z0-9]+)*$",
            "description": "Stable key. Derived from the name when omitted; send one only when something outside 1club already refers to it. An explicit slug that clashes is a 409, and a rename never moves it."
          },
          "description": {
            "type": "string",
            "nullable": true,
            "maxLength": 2000
          },
          "revenueAccountId": {
            "type": "integer",
            "nullable": true,
            "description": "Where a sale on this kind of class posts, from GET /v1/platform/revenue-accounts. Leave it unset and the sale posts to whichever account the organization has marked default, resolved at the time of the sale."
          },
          "maxPartySize": {
            "type": "integer",
            "minimum": 1,
            "maximum": 1000,
            "default": 1,
            "description": "How many people one booking may bring, including the booker."
          },
          "allowMultiDay": {
            "type": "boolean",
            "default": false,
            "description": "Whether a class of this kind may run across more than one calendar day."
          },
          "cancelIfAttendanceZero": {
            "type": "boolean",
            "default": false,
            "description": "Whether a class with nobody booked cancels itself."
          },
          "cancelIfAttendanceZeroHoursInAdvance": {
            "type": "integer",
            "minimum": 0,
            "maximum": 720,
            "default": 0,
            "description": "Hours before the start that cancellation runs. Meaningless unless `cancelIfAttendanceZero`."
          },
          "bookingAudience": {
            "$ref": "#/components/schemas/PlatformContactAudience"
          },
          "sport": {
            "type": "string",
            "nullable": true,
            "description": "The sport classes of this kind are for, a canonical sport id such as \"jiu-jitsu\". A default only: each class still carries its own `sport`. An unrecognised value is rejected."
          },
          "ageGroup": {
            "type": "string",
            "nullable": true,
            "enum": [
              "infant",
              "child",
              "junior",
              "adult",
              "senior",
              "all_ages"
            ],
            "description": "Who a class of this kind is for: an age band (infant 0–4, child 5–12, junior 13–17, adult 18–64, senior 65+), the same bands the age smart tags use, or all_ages. Marketplace search filters on it."
          },
          "difficulty": {
            "type": "string",
            "nullable": true,
            "enum": [
              "beginner",
              "intermediate",
              "advanced",
              "all_levels"
            ],
            "description": "How hard a class of this kind is. Marketplace search filters on it. Not a rank: see `progressionTrackId`."
          },
          "progressionTrackId": {
            "type": "integer",
            "nullable": true,
            "description": "The progression track sessions of this kind count toward, from GET /v1/platform/progression-tracks. Once any class type points at a track, only those sessions count toward its ranks; a track nothing points at counts every session. Must be a track the organization uses."
          }
        }
      },
      "PlatformInstructorTypeCreate": {
        "type": "object",
        "description": "Fields accepted when creating an instructor type. Only `name` is required; `slug` is derived from it when omitted, and suffixed if that is taken. The defaults shown are what an omitted field becomes, and unknown fields are rejected.",
        "required": [
          "name"
        ],
        "properties": {
          "name": {
            "type": "string",
            "maxLength": 255
          },
          "slug": {
            "type": "string",
            "maxLength": 120,
            "pattern": "^[a-z0-9]+(-[a-z0-9]+)*$",
            "description": "Stable key. Derived from the name when omitted; send one only when something outside 1club already refers to it. An explicit slug that clashes is a 409, and a rename never moves it."
          },
          "description": {
            "type": "string",
            "nullable": true,
            "maxLength": 2000
          },
          "revenueAccountId": {
            "type": "integer",
            "nullable": true,
            "description": "Where a session with a coach of this kind posts, from GET /v1/platform/revenue-accounts. Leave it unset and the sale posts to whichever account the organization has marked default, resolved at the time of the sale."
          },
          "maxConcurrentBookings": {
            "type": "integer",
            "minimum": 1,
            "maximum": 100,
            "default": 1,
            "description": "How many one-to-one sessions a coach of this kind may hold in the same slot."
          }
        }
      },
      "PlatformInstructorTypeUpdate": {
        "type": "object",
        "description": "Fields accepted when updating an instructor type. Send only what changes; `null` clears a nullable field, an omitted field is left alone, and at least one is required. A rename does not move `slug`. `maxConcurrentBookings` is the shape of the type; what a booking is checked against is each instructor's own value.",
        "minProperties": 1,
        "properties": {
          "name": {
            "type": "string",
            "maxLength": 255
          },
          "slug": {
            "type": "string",
            "maxLength": 120,
            "pattern": "^[a-z0-9]+(-[a-z0-9]+)*$",
            "description": "Stable key. Derived from the name when omitted; send one only when something outside 1club already refers to it. An explicit slug that clashes is a 409, and a rename never moves it."
          },
          "description": {
            "type": "string",
            "nullable": true,
            "maxLength": 2000
          },
          "revenueAccountId": {
            "type": "integer",
            "nullable": true,
            "description": "Where a session with a coach of this kind posts, from GET /v1/platform/revenue-accounts. Leave it unset and the sale posts to whichever account the organization has marked default, resolved at the time of the sale."
          },
          "maxConcurrentBookings": {
            "type": "integer",
            "minimum": 1,
            "maximum": 100,
            "default": 1,
            "description": "How many one-to-one sessions a coach of this kind may hold in the same slot."
          }
        }
      },
      "PlatformClassType": {
        "type": "object",
        "description": "A kind of class the organization runs. Reference the `id` as `classTypeId` on a class, or in `classTypeIds` on a class pay rate policy. One shape for the list, get-by-id, create and update.",
        "properties": {
          "id": {
            "type": "integer"
          },
          "name": {
            "type": "string"
          },
          "slug": {
            "type": "string",
            "description": "Stable key, derived from the name when a create does not send one. A rename never moves it."
          },
          "description": {
            "type": "string",
            "nullable": true
          },
          "revenueAccountId": {
            "type": "integer",
            "nullable": true,
            "description": "Where a sale on this kind of class posts. Null falls back to the organization's default account."
          },
          "classCount": {
            "type": "integer",
            "description": "Classes currently run as this type, past and future."
          },
          "createdAt": {
            "type": "string",
            "format": "date-time"
          },
          "updatedAt": {
            "type": "string",
            "format": "date-time"
          },
          "maxPartySize": {
            "type": "integer",
            "description": "How many people one booking on a class of this kind may bring, including the booker."
          },
          "allowMultiDay": {
            "type": "boolean",
            "description": "Whether a class of this kind may run across more than one calendar day."
          },
          "cancelIfAttendanceZero": {
            "type": "boolean",
            "description": "Whether a class with nobody booked cancels itself."
          },
          "cancelIfAttendanceZeroHoursInAdvance": {
            "type": "integer",
            "description": "Hours before the start that cancellation runs. Meaningless unless `cancelIfAttendanceZero`."
          },
          "bookingAudience": {
            "$ref": "#/components/schemas/PlatformContactAudience"
          },
          "sport": {
            "type": "string",
            "nullable": true,
            "description": "The sport classes of this kind are for, a canonical sport id such as \"jiu-jitsu\". A default only: each class still carries its own `sport`. An unrecognised value is rejected."
          },
          "ageGroup": {
            "type": "string",
            "nullable": true,
            "enum": [
              "infant",
              "child",
              "junior",
              "adult",
              "senior",
              "all_ages"
            ],
            "description": "Who a class of this kind is for: an age band (infant 0–4, child 5–12, junior 13–17, adult 18–64, senior 65+), the same bands the age smart tags use, or all_ages. Marketplace search filters on it."
          },
          "difficulty": {
            "type": "string",
            "nullable": true,
            "enum": [
              "beginner",
              "intermediate",
              "advanced",
              "all_levels"
            ],
            "description": "How hard a class of this kind is. Marketplace search filters on it. Not a rank: see `progressionTrackId`."
          },
          "progressionTrackId": {
            "type": "integer",
            "nullable": true,
            "description": "The progression track sessions of this kind count toward, from GET /v1/platform/progression-tracks. Once any class type points at a track, only those sessions count toward its ranks; a track nothing points at counts every session. Must be a track the organization uses."
          }
        }
      },
      "PlatformProgressionTrack": {
        "type": "object",
        "description": "A ladder of ranks in one discipline that the organization uses. Reference the `id` as `progressionTrackId` on a class type to make its sessions count toward these ranks.",
        "properties": {
          "id": {
            "type": "integer"
          },
          "name": {
            "type": "string"
          },
          "sport": {
            "type": "string",
            "description": "Canonical sport id the ladder belongs to, e.g. \"jiu-jitsu\"."
          },
          "description": {
            "type": "string",
            "nullable": true
          },
          "custom": {
            "type": "boolean",
            "description": "True for the organization's own track, or its copy of a template; false for an enabled global template."
          },
          "levelCount": {
            "type": "integer",
            "description": "How many ranks the ladder has."
          },
          "classTypeIds": {
            "type": "array",
            "items": {
              "type": "integer"
            },
            "description": "Class types whose sessions count toward this track. Empty means every session counts; once any is linked, only sessions of the linked types do."
          }
        }
      },
      "PlatformInstructorType": {
        "type": "object",
        "description": "A kind of coach the organization has. Reference the `id` as `instructorTypeId` on an instructor. One shape for the list, get-by-id, create and update.",
        "properties": {
          "id": {
            "type": "integer"
          },
          "name": {
            "type": "string"
          },
          "slug": {
            "type": "string",
            "description": "Stable key, derived from the name when a create does not send one. A rename never moves it."
          },
          "description": {
            "type": "string",
            "nullable": true
          },
          "revenueAccountId": {
            "type": "integer",
            "nullable": true,
            "description": "Where a session with a coach of this kind posts. Null falls back to the organization's default account."
          },
          "instructorCount": {
            "type": "integer",
            "description": "Coaches currently filed under this type, active and retired."
          },
          "createdAt": {
            "type": "string",
            "format": "date-time"
          },
          "updatedAt": {
            "type": "string",
            "format": "date-time"
          },
          "maxConcurrentBookings": {
            "type": "integer",
            "description": "How many one-to-one sessions a coach of this kind may hold in the same slot. What a booking is actually checked against is the instructor's own value; this is the shape of the type, not an override."
          }
        }
      },
      "PlatformContactAudience": {
        "type": "object",
        "description": "Who something applies to, as a set of predicates a contact must match. `null` means everybody. The same shape a promotion segment and an automation entry condition use. `membershipPlanIds` is which plan the contact must HOLD, not which plan a purchase discounts.",
        "nullable": true,
        "properties": {
          "memberTypes": {
            "type": "array",
            "maxItems": 20,
            "items": {
              "type": "string"
            },
            "description": "Values of the contact's `type`, e.g. `member`, `lead`."
          },
          "tags": {
            "type": "array",
            "maxItems": 50,
            "items": {
              "oneOf": [
                {
                  "type": "string",
                  "maxLength": 100
                },
                {
                  "type": "integer"
                }
              ]
            },
            "description": "Tag ids or names the contact carries."
          },
          "membershipPlanIds": {
            "type": "array",
            "maxItems": 50,
            "items": {
              "type": "integer"
            },
            "description": "Membership plans the contact must hold, from GET /v1/platform/plans."
          },
          "membershipPlanCategoryIds": {
            "type": "array",
            "maxItems": 50,
            "items": {
              "type": "integer"
            },
            "description": "Plan categories the contact must hold a plan in."
          }
        }
      },
      "PlatformPayRatePolicyEarnings": {
        "type": "object",
        "description": "What the coach earns, and for what unit of work. `kind` decides which of the rate fields apply and which are rejected: an `individual` policy takes `perSession` and `sessionRevenuePercentage`, a `class` policy takes `perClass`, `perBooking` and `classRevenuePercentage`. At least one of the applicable rates must be greater than zero, and `kind` cannot be changed once the policy exists.",
        "required": [
          "kind"
        ],
        "properties": {
          "kind": {
            "type": "string",
            "enum": [
              "individual",
              "class"
            ]
          },
          "perSession": {
            "type": "number",
            "nullable": true,
            "minimum": 0,
            "description": "Individual policies: a flat amount for each private session."
          },
          "sessionRevenuePercentage": {
            "type": "number",
            "nullable": true,
            "minimum": 0,
            "maximum": 100,
            "description": "Individual policies: a share of what the session took."
          },
          "perClass": {
            "type": "number",
            "nullable": true,
            "minimum": 0,
            "description": "Class policies: a flat amount for running the class at all."
          },
          "perBooking": {
            "type": "number",
            "nullable": true,
            "minimum": 0,
            "description": "Class policies: a flat amount for each attendee."
          },
          "classRevenuePercentage": {
            "type": "number",
            "nullable": true,
            "minimum": 0,
            "maximum": 100,
            "description": "Class policies: a share of what the class took."
          }
        }
      },
      "PlatformPayRateEntranceScope": {
        "type": "object",
        "description": "One way an attendee got in, narrowing what a policy pays for. `membershipPlanId` applies only to `membership`, and `externalProgramId` only to `external_program`; sending the wrong pairing is a 400.",
        "required": [
          "entranceMethod"
        ],
        "properties": {
          "entranceMethod": {
            "type": "string",
            "enum": [
              "direct_payment",
              "membership",
              "external_program"
            ]
          },
          "membershipPlanId": {
            "type": "integer",
            "nullable": true,
            "description": "Narrow to one membership plan, from GET /v1/platform/plans."
          },
          "externalProgramId": {
            "type": "integer",
            "nullable": true,
            "description": "Narrow to one external program."
          }
        }
      },
      "PlatformPayRatePolicy": {
        "type": "object",
        "description": "A rule for what the club pays a coach. Not an instructor's `hourlyRate`, which is what a member pays for an hour of their time. Each coverage list means \"everything\" when empty: no `instructors` makes it the organization default, no `classTypes` covers every class type, no `entranceScopes` covers every way in.",
        "properties": {
          "policyId": {
            "type": "string",
            "format": "uuid"
          },
          "name": {
            "type": "string"
          },
          "kind": {
            "type": "string",
            "enum": [
              "individual",
              "class"
            ]
          },
          "earnings": {
            "$ref": "#/components/schemas/PlatformPayRatePolicyEarnings"
          },
          "requirePaidBooking": {
            "type": "boolean"
          },
          "instructors": {
            "type": "array",
            "description": "Coaches this policy covers. Empty means every coach with no policy of their own.",
            "items": {
              "type": "object",
              "properties": {
                "id": {
                  "type": "integer"
                },
                "name": {
                  "type": "string",
                  "nullable": true
                }
              }
            }
          },
          "classTypes": {
            "type": "array",
            "description": "Class types it covers. Empty means every class type.",
            "items": {
              "type": "object",
              "properties": {
                "id": {
                  "type": "integer"
                },
                "name": {
                  "type": "string"
                }
              }
            }
          },
          "entranceScopes": {
            "type": "array",
            "description": "How the attendee got in. Empty means every route in.",
            "items": {
              "$ref": "#/components/schemas/PlatformPayRateEntranceScope"
            }
          },
          "createdAt": {
            "type": "string",
            "format": "date-time"
          },
          "updatedAt": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "PlatformPayRatePolicyCreate": {
        "type": "object",
        "description": "Fields accepted when creating a pay rate policy. `instructorIds` must be present: an empty list is the organization default and applies to every coach without a policy of their own, so it has to be asked for rather than fallen into. The other coverage lists mean \"everything\" when omitted.",
        "required": [
          "name",
          "earnings",
          "instructorIds"
        ],
        "properties": {
          "name": {
            "type": "string",
            "maxLength": 255
          },
          "earnings": {
            "$ref": "#/components/schemas/PlatformPayRatePolicyEarnings"
          },
          "requirePaidBooking": {
            "type": "boolean",
            "default": false,
            "description": "When true, attendance that was never paid for earns the coach nothing."
          },
          "instructorIds": {
            "type": "array",
            "items": {
              "type": "integer"
            },
            "description": "Coaches this policy covers. An empty list makes it the organization default, applying to every coach with no policy of their own."
          },
          "classTypeIds": {
            "type": "array",
            "items": {
              "type": "integer"
            },
            "description": "Class types it covers, from GET /v1/platform/class-types. Empty covers every class type. Rejected on an `individual` policy, which has no classes to scope."
          },
          "entranceScopes": {
            "type": "array",
            "description": "How the attendee got in. Empty covers every route in.",
            "items": {
              "$ref": "#/components/schemas/PlatformPayRateEntranceScope"
            }
          }
        }
      },
      "PlatformPayRatePolicyUpdate": {
        "type": "object",
        "description": "Fields accepted when updating a pay rate policy. Send only what changes; an omitted field is left alone, and any list that IS sent replaces the whole set. `earnings.kind` must match the policy's existing kind.",
        "minProperties": 1,
        "properties": {
          "name": {
            "type": "string",
            "maxLength": 255
          },
          "earnings": {
            "$ref": "#/components/schemas/PlatformPayRatePolicyEarnings"
          },
          "requirePaidBooking": {
            "type": "boolean",
            "default": false,
            "description": "When true, attendance that was never paid for earns the coach nothing."
          },
          "instructorIds": {
            "type": "array",
            "items": {
              "type": "integer"
            },
            "description": "Coaches this policy covers. An empty list makes it the organization default, applying to every coach with no policy of their own."
          },
          "classTypeIds": {
            "type": "array",
            "items": {
              "type": "integer"
            },
            "description": "Class types it covers, from GET /v1/platform/class-types. Empty covers every class type. Rejected on an `individual` policy, which has no classes to scope."
          },
          "entranceScopes": {
            "type": "array",
            "description": "How the attendee got in. Empty covers every route in.",
            "items": {
              "$ref": "#/components/schemas/PlatformPayRateEntranceScope"
            }
          }
        }
      },
      "PlatformPlanCategory": {
        "type": "object",
        "description": "How membership plans are grouped - the headings on a price list and the sections of a plans page.",
        "properties": {
          "categoryId": {
            "type": "integer"
          },
          "name": {
            "type": "string"
          },
          "sortOrder": {
            "type": "integer",
            "description": "Ascending display order; ties fall back to name."
          },
          "planCount": {
            "type": "integer",
            "description": "Membership plans currently in this category, active and retired."
          },
          "createdAt": {
            "type": "string",
            "format": "date-time"
          },
          "updatedAt": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "PlatformRevenueAccount": {
        "type": "object",
        "description": "One account in the organization's chart of accounts. Reference the `id` as `revenueAccountId` on a membership plan, a product, a class type, an area type or an instructor type to decide where its sales post.",
        "properties": {
          "id": {
            "type": "integer"
          },
          "name": {
            "type": "string"
          },
          "code": {
            "type": "string",
            "description": "The chart-of-accounts code, which is how the club's bookkeeper names the account. Unique within the organization."
          },
          "description": {
            "type": "string",
            "nullable": true
          },
          "isActive": {
            "type": "boolean",
            "description": "False for an account retired from the chart. Still valid on existing rows; do not attach it to something new. Never false on the default."
          },
          "isDefault": {
            "type": "boolean",
            "description": "Where a sale posts when nothing on the item names an account. Exactly one account holds it, and it is always active."
          }
        }
      },
      "PlatformRevenueAccountCreate": {
        "type": "object",
        "description": "Fields accepted when creating a revenue account. `name` and `code` are required, and the code must be free within the organization. Unknown fields are rejected. The billing entity an account belongs to is not settable here - it is inherited, and re-pointing one rewrites who is selling.",
        "required": [
          "name",
          "code"
        ],
        "properties": {
          "name": {
            "type": "string",
            "maxLength": 255
          },
          "code": {
            "type": "string",
            "maxLength": 50,
            "description": "The bookkeeper's code for the account, e.g. \"4000\". Unique within the organization; a clash is a 409."
          },
          "description": {
            "type": "string",
            "nullable": true,
            "maxLength": 2000
          },
          "isActive": {
            "type": "boolean",
            "default": true,
            "description": "False retires the account. Everything already pointing at it keeps pointing at it; there is no delete. Refused for any request that would leave the default inactive - including the first account in an empty chart, which is always made the default."
          },
          "isDefault": {
            "type": "boolean",
            "default": false,
            "description": "Where a sale posts when the item names no account. Setting it true moves the default off whichever account currently holds it, in the same transaction and behind a per-organization lock. Ignored when the chart has no default yet - the first account always takes it, since creating one is the only way to give the organization a default. The default must be active, so anything landing an inactive one is refused."
          }
        }
      },
      "PlatformRevenueAccountUpdate": {
        "type": "object",
        "description": "Fields accepted when updating a revenue account. Send only what changes; an omitted field is left alone, and at least one is required. `isDefault: false` is refused only when this is the last account holding the default, or when the only other one is retired: the organization must be left with an active default. An organization carrying more than one default - possible from before this rule, or from a clone - can have the extras cleared, which is how that state is repaired.",
        "minProperties": 1,
        "properties": {
          "name": {
            "type": "string",
            "maxLength": 255
          },
          "code": {
            "type": "string",
            "maxLength": 50,
            "description": "The bookkeeper's code for the account, e.g. \"4000\". Unique within the organization; a clash is a 409."
          },
          "description": {
            "type": "string",
            "nullable": true,
            "maxLength": 2000
          },
          "isActive": {
            "type": "boolean",
            "default": true,
            "description": "False retires the account. Everything already pointing at it keeps pointing at it; there is no delete. Refused for any request that would leave the default inactive - including the first account in an empty chart, which is always made the default."
          },
          "isDefault": {
            "type": "boolean",
            "default": false,
            "description": "Where a sale posts when the item names no account. Setting it true moves the default off whichever account currently holds it, in the same transaction and behind a per-organization lock. Ignored when the chart has no default yet - the first account always takes it, since creating one is the only way to give the organization a default. The default must be active, so anything landing an inactive one is refused."
          }
        }
      },
      "PlatformProductCategory": {
        "type": "object",
        "description": "How products are grouped - the tabs on the till and the sections of a shop page.",
        "properties": {
          "categoryId": {
            "type": "integer"
          },
          "name": {
            "type": "string"
          },
          "sortOrder": {
            "type": "integer",
            "description": "Ascending display order; ties fall back to name."
          },
          "productCount": {
            "type": "integer",
            "description": "Products currently in this category, active and inactive."
          },
          "createdAt": {
            "type": "string",
            "format": "date-time"
          },
          "updatedAt": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "PlatformCheckin": {
        "type": "object",
        "description": "An attendance record for a booking, class, membership, or direct gym visit.",
        "properties": {
          "checkinId": {
            "type": "integer"
          },
          "contactId": {
            "type": "integer",
            "nullable": true
          },
          "clubId": {
            "type": "integer"
          },
          "classId": {
            "type": "integer",
            "nullable": true
          },
          "bookingId": {
            "type": "integer",
            "nullable": true
          },
          "membershipId": {
            "type": "integer",
            "nullable": true
          },
          "externalProgramId": {
            "type": "integer",
            "nullable": true
          },
          "entranceMethod": {
            "type": "string",
            "nullable": true
          },
          "date": {
            "type": "string",
            "format": "date-time"
          },
          "createdAt": {
            "type": "string",
            "format": "date-time"
          },
          "contact": {
            "type": "object",
            "nullable": true,
            "additionalProperties": true
          },
          "club": {
            "type": "object",
            "additionalProperties": true
          },
          "class": {
            "type": "object",
            "nullable": true,
            "additionalProperties": true
          },
          "booking": {
            "type": "object",
            "nullable": true,
            "additionalProperties": true
          },
          "membership": {
            "type": "object",
            "nullable": true,
            "additionalProperties": true
          },
          "externalProgram": {
            "type": "object",
            "nullable": true,
            "additionalProperties": true
          }
        }
      },
      "PlatformCheckinPage": {
        "type": "object",
        "properties": {
          "data": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/PlatformCheckin"
            }
          },
          "total": {
            "type": "integer",
            "description": "Total check-ins matching the filters, ignoring pagination"
          },
          "limit": {
            "type": "integer"
          },
          "offset": {
            "type": "integer"
          }
        }
      },
      "PlatformInvoiceTotals": {
        "type": "object",
        "description": "Invoices in one currency, added up. Amounts in different currencies are never summed together.",
        "properties": {
          "currency": {
            "type": "string"
          },
          "count": {
            "type": "integer"
          },
          "subtotal": {
            "type": "number",
            "description": "Before tax"
          },
          "taxAmount": {
            "type": "number"
          },
          "totalAmount": {
            "type": "number",
            "description": "Tax included"
          }
        }
      },
      "PlatformInvoiceSummary": {
        "type": "object",
        "properties": {
          "timezone": {
            "type": "string",
            "description": "The organization's timezone, whose calendar days the range is read on"
          },
          "startDate": {
            "type": "string",
            "format": "date"
          },
          "endDate": {
            "type": "string",
            "format": "date"
          },
          "billingEntityId": {
            "type": "integer",
            "nullable": true
          },
          "invoiced": {
            "type": "array",
            "description": "One entry per currency; void and cancelled invoices excluded. Empty when nothing was invoiced.",
            "items": {
              "$ref": "#/components/schemas/PlatformInvoiceTotals"
            }
          },
          "byStatus": {
            "type": "array",
            "description": "Every status present in the range, per currency, void and cancelled included",
            "items": {
              "type": "object",
              "properties": {
                "status": {
                  "type": "string"
                },
                "currency": {
                  "type": "string"
                },
                "count": {
                  "type": "integer"
                },
                "totalAmount": {
                  "type": "number"
                }
              }
            }
          },
          "overdue": {
            "type": "array",
            "description": "Unpaid invoices in the range whose due date has passed, per currency",
            "items": {
              "type": "object",
              "properties": {
                "currency": {
                  "type": "string"
                },
                "count": {
                  "type": "integer"
                },
                "totalAmount": {
                  "type": "number"
                }
              }
            }
          },
          "byBillingEntity": {
            "type": "array",
            "description": "`invoiced`, split by the billing entity that issued each invoice",
            "items": {
              "type": "object",
              "properties": {
                "billingEntityId": {
                  "type": "integer"
                },
                "name": {
                  "type": "string",
                  "nullable": true
                },
                "invoiced": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/PlatformInvoiceTotals"
                  }
                }
              }
            }
          }
        }
      },
      "PlatformInvoice": {
        "type": "object",
        "description": "A fiscal invoice. Pro-formas are never returned.",
        "properties": {
          "id": {
            "type": "integer"
          },
          "invoiceNumber": {
            "type": "string"
          },
          "invoiceDate": {
            "type": "string",
            "format": "date",
            "description": "The calendar day the invoice belongs to, on the organization's clock"
          },
          "dueDate": {
            "type": "string",
            "format": "date"
          },
          "status": {
            "type": "string",
            "enum": [
              "pending",
              "paid",
              "void",
              "failed",
              "settled",
              "overdue",
              "partially_paid",
              "refunded",
              "cancelled"
            ]
          },
          "overdue": {
            "type": "boolean",
            "description": "Unpaid and past its due date"
          },
          "currency": {
            "type": "string"
          },
          "subtotal": {
            "type": "number"
          },
          "taxAmount": {
            "type": "number"
          },
          "totalAmount": {
            "type": "number"
          },
          "sentAt": {
            "type": "string",
            "format": "date-time",
            "nullable": true
          },
          "createdAt": {
            "type": "string",
            "format": "date-time"
          },
          "contact": {
            "type": "object",
            "nullable": true,
            "properties": {
              "id": {
                "type": "integer"
              },
              "name": {
                "type": "string"
              }
            }
          },
          "billingEntity": {
            "type": "object",
            "properties": {
              "id": {
                "type": "integer"
              },
              "name": {
                "type": "string"
              }
            }
          }
        }
      },
      "PlatformInvoiceDetail": {
        "allOf": [
          {
            "$ref": "#/components/schemas/PlatformInvoice"
          },
          {
            "type": "object",
            "properties": {
              "lineItems": {
                "type": "array",
                "items": {
                  "type": "object",
                  "properties": {
                    "description": {
                      "type": "string"
                    },
                    "quantity": {
                      "type": "number"
                    },
                    "unitPrice": {
                      "type": "number"
                    },
                    "totalAmount": {
                      "type": "number"
                    }
                  }
                }
              },
              "payments": {
                "type": "array",
                "items": {
                  "type": "object",
                  "properties": {
                    "id": {
                      "type": "integer"
                    },
                    "amount": {
                      "type": "number"
                    },
                    "currency": {
                      "type": "string"
                    },
                    "status": {
                      "type": "string"
                    },
                    "paymentDate": {
                      "type": "string",
                      "format": "date-time"
                    }
                  }
                }
              }
            }
          }
        ]
      },
      "PlatformInvoicePage": {
        "allOf": [
          {
            "type": "object",
            "properties": {
              "data": {
                "type": "array",
                "items": {
                  "$ref": "#/components/schemas/PlatformInvoice"
                }
              },
              "total": {
                "type": "integer",
                "description": "Total invoices matching the filters, ignoring pagination"
              },
              "limit": {
                "type": "integer"
              },
              "offset": {
                "type": "integer"
              }
            }
          },
          {
            "type": "object",
            "properties": {
              "timezone": {
                "type": "string"
              },
              "startDate": {
                "type": "string",
                "format": "date"
              },
              "endDate": {
                "type": "string",
                "format": "date"
              }
            }
          }
        ]
      }
    },
    "responses": {
      "PlatformBadRequest": {
        "description": "Invalid request (bad parameters, or a body that fails validation)",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/PlatformError"
            }
          }
        }
      },
      "PlatformUnauthorized": {
        "description": "Invalid or missing API key",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/PlatformError"
            }
          }
        }
      },
      "PlatformForbidden": {
        "description": "The API key is missing the scope this endpoint requires",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/PlatformError"
            }
          }
        }
      },
      "PlatformNotFound": {
        "description": "The requested resource does not exist in this organization",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/PlatformError"
            }
          }
        }
      },
      "PlatformConflict": {
        "description": "The request conflicts with existing state, or reuses an Idempotency-Key with a different body",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/PlatformError"
            }
          }
        }
      },
      "PlatformRateLimited": {
        "description": "Rate limit exceeded",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/PlatformError"
            }
          }
        }
      }
    }
  },
  "security": [
    {
      "customerApiAuth": []
    }
  ],
  "paths": {
    "/v1/platform/area-blocks": {
      "get": {
        "summary": "List area blocks",
        "description": "Returns the blocks overlapping a window - the time-ranged unavailability of a court, the counterpart of instructor time off. A block with `areaId: null` is club-wide and closes every area in `clubId`; expand it against the club's areas the same way availability does. Filtering by `areaId` includes the club-wide blocks covering that area, so the result is everything that closes it.\n",
        "tags": [
          "Area Blocks"
        ],
        "security": [
          {
            "customerApiAuth": []
          }
        ],
        "parameters": [
          {
            "in": "query",
            "name": "from",
            "required": true,
            "schema": {
              "type": "string",
              "format": "date-time"
            },
            "description": "Window start (ISO date-time)"
          },
          {
            "in": "query",
            "name": "to",
            "required": true,
            "schema": {
              "type": "string",
              "format": "date-time"
            },
            "description": "Window end (ISO date-time). At most 31 days after `from`."
          },
          {
            "in": "query",
            "name": "clubId",
            "schema": {
              "type": "integer"
            },
            "description": "Only return blocks for this club"
          },
          {
            "in": "query",
            "name": "areaId",
            "schema": {
              "type": "integer"
            },
            "description": "Only return blocks affecting this area (club-wide ones included)"
          }
        ],
        "responses": {
          "200": {
            "description": "List of area blocks",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "type": "object",
                    "properties": {
                      "id": {
                        "type": "integer"
                      },
                      "clubId": {
                        "type": "integer"
                      },
                      "areaId": {
                        "type": "integer",
                        "nullable": true,
                        "description": "Null means every area in the club"
                      },
                      "areaName": {
                        "type": "string",
                        "nullable": true
                      },
                      "startTime": {
                        "type": "string",
                        "format": "date-time"
                      },
                      "endTime": {
                        "type": "string",
                        "format": "date-time"
                      },
                      "note": {
                        "type": "string",
                        "nullable": true
                      },
                      "createdAt": {
                        "type": "string",
                        "format": "date-time"
                      }
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "Invalid parameters",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformError"
                }
              }
            }
          },
          "401": {
            "description": "Invalid or missing API key",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformError"
                }
              }
            }
          },
          "403": {
            "description": "API key is missing the required `areas:read` scope",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformError"
                }
              }
            }
          },
          "429": {
            "description": "Rate limit exceeded",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformError"
                }
              }
            }
          }
        }
      },
      "post": {
        "summary": "Create an area block",
        "description": "Closes a court, or a whole club, for a time range. Send `areaId` to close one court (its club is derived), or `clubId` with `areaId` omitted/null to close every court in that club with a single record. Existing bookings in the window are left alone - a block stops NEW bookings; cancel or move the affected bookings separately.\n",
        "tags": [
          "Area Blocks"
        ],
        "security": [
          {
            "customerApiAuth": []
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "startTime",
                  "endTime"
                ],
                "properties": {
                  "areaId": {
                    "type": "integer",
                    "nullable": true,
                    "description": "The court to close. Omit or send null for a club-wide block."
                  },
                  "clubId": {
                    "type": "integer",
                    "description": "Required when `areaId` is omitted or null; otherwise derived from the area."
                  },
                  "startTime": {
                    "type": "string",
                    "format": "date-time"
                  },
                  "endTime": {
                    "type": "string",
                    "format": "date-time"
                  },
                  "note": {
                    "type": "string",
                    "nullable": true
                  }
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Block created"
          },
          "400": {
            "description": "Invalid body, or `clubId` missing for a club-wide block",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformError"
                }
              }
            }
          },
          "401": {
            "description": "Invalid or missing API key",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformError"
                }
              }
            }
          },
          "403": {
            "description": "API key is missing the required `areas:write` scope",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformError"
                }
              }
            }
          },
          "404": {
            "description": "Club or area not found",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformError"
                }
              }
            }
          },
          "429": {
            "description": "Rate limit exceeded",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformError"
                }
              }
            }
          }
        }
      }
    },
    "/v1/platform/area-blocks/{blockId}": {
      "delete": {
        "summary": "Delete an area block",
        "description": "Removes a block, reopening the court(s) it covered for booking. Idempotent from the caller's perspective only in the sense that a second delete returns 404 - the first one already reopened the slot.\n",
        "tags": [
          "Area Blocks"
        ],
        "security": [
          {
            "customerApiAuth": []
          }
        ],
        "parameters": [
          {
            "in": "path",
            "name": "blockId",
            "required": true,
            "schema": {
              "type": "integer"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Block deleted",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "success": {
                      "type": "boolean"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "Invalid block id",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformError"
                }
              }
            }
          },
          "401": {
            "description": "Invalid or missing API key",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformError"
                }
              }
            }
          },
          "403": {
            "description": "API key is missing the required `areas:write` scope",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformError"
                }
              }
            }
          },
          "404": {
            "description": "Block not found",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformError"
                }
              }
            }
          },
          "429": {
            "description": "Rate limit exceeded",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformError"
                }
              }
            }
          }
        }
      }
    },
    "/v1/platform/area-types": {
      "get": {
        "summary": "List area types",
        "description": "Returns the organization's area types - the kinds of space it books, such as \"Padel court\" or \"Studio\". Resolve one here, then pass its `id` as `areaTypeId` when creating an area: the type is what decides which sports the new area can be booked for and what it is priced from, so it is not a label.\n\nEvery type is returned, `isBookable: false` ones included, because an existing area may be of one and its type still has to be explicable. Do not build bookable inventory on a type that is not bookable.\n\nThere is no paging: an organization has a handful of these.\n",
        "tags": [
          "Areas"
        ],
        "security": [
          {
            "customerApiAuth": []
          }
        ],
        "responses": {
          "200": {
            "description": "Every area type in the organization, by name",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "data": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/PlatformAreaType"
                      }
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/PlatformUnauthorized"
          },
          "403": {
            "description": "API key is missing the required `areas:read` scope",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformError"
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/PlatformRateLimited"
          }
        }
      },
      "post": {
        "summary": "Create an area type",
        "description": "Adds a kind of space the organization books. List the existing types first: an organization is seeded with one per sport its clubs offer, and a second \"Padel court\" splits inventory that should be one group.\n\n`sports` is what makes areas of this type bookable for a sport at all, and it is canonicalized - send `padel`, not `Padel`. An unknown sport is refused rather than stored as a value nothing will match. `slug` is optional and derived from the name when omitted. Supports the `Idempotency-Key` header.\n",
        "tags": [
          "Areas"
        ],
        "security": [
          {
            "customerApiAuth": []
          }
        ],
        "parameters": [
          {
            "in": "header",
            "name": "Idempotency-Key",
            "schema": {
              "type": "string",
              "maxLength": 255
            },
            "description": "Retry-safe key; a repeat of the same request replays the first response"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/PlatformAreaTypeCreate"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Area type created",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformAreaType"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/PlatformBadRequest"
          },
          "401": {
            "$ref": "#/components/responses/PlatformUnauthorized"
          },
          "403": {
            "description": "API key is missing the required `areas:write` scope",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformError"
                }
              }
            }
          },
          "409": {
            "description": "An explicit `slug` is already used by another area type",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformError"
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/PlatformRateLimited"
          }
        }
      }
    },
    "/v1/platform/area-types/{id}": {
      "get": {
        "summary": "Get an area type",
        "description": "Returns one area type and how many areas are of it, whatever their status.",
        "tags": [
          "Areas"
        ],
        "security": [
          {
            "customerApiAuth": []
          }
        ],
        "parameters": [
          {
            "in": "path",
            "name": "id",
            "required": true,
            "schema": {
              "type": "integer"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "The area type",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformAreaType"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/PlatformBadRequest"
          },
          "401": {
            "$ref": "#/components/responses/PlatformUnauthorized"
          },
          "403": {
            "description": "API key is missing the required `areas:read` scope",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformError"
                }
              }
            }
          },
          "404": {
            "$ref": "#/components/responses/PlatformNotFound"
          },
          "429": {
            "$ref": "#/components/responses/PlatformRateLimited"
          }
        }
      },
      "patch": {
        "summary": "Update an area type",
        "description": "Changes an area type. Send only what changes; an omitted field is left alone, `null` clears a nullable one, and at least one field is required.\n\nThis is a wide edit. Every area of the type inherits `sports`, `defaultPricePerHour`, `defaultMaxPlayers` and `isBookable`, so changing one of those reprices or re-sports every court on it at once - and each affected area emits an availability change, which partners consuming the reconciliation feed will pull. `sports` replaces the whole list rather than adding to it. A rename leaves `slug` alone.\n",
        "tags": [
          "Areas"
        ],
        "security": [
          {
            "customerApiAuth": []
          }
        ],
        "parameters": [
          {
            "in": "path",
            "name": "id",
            "required": true,
            "schema": {
              "type": "integer"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/PlatformAreaTypeUpdate"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The updated area type",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformAreaType"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/PlatformBadRequest"
          },
          "401": {
            "$ref": "#/components/responses/PlatformUnauthorized"
          },
          "403": {
            "description": "API key is missing the required `areas:write` scope",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformError"
                }
              }
            }
          },
          "404": {
            "$ref": "#/components/responses/PlatformNotFound"
          },
          "409": {
            "description": "An explicit `slug` is already used by another area type",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformError"
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/PlatformRateLimited"
          }
        }
      }
    },
    "/v1/platform/areas": {
      "get": {
        "summary": "List bookable areas",
        "description": "Returns the organization's bookable areas (e.g. padel courts). Optionally filter by club or sport (e.g. `padel`). Use the returned area `id` as `areaId` when creating a booking.\n\nBookable is the filter: an area is listed only while it is `active`, publicly visible, and of a bookable area type (or of no type at all). An area closed for maintenance, or one just created `Private`, is absent here but still readable from the create or update that wrote it.\n",
        "tags": [
          "Areas"
        ],
        "security": [
          {
            "customerApiAuth": []
          }
        ],
        "parameters": [
          {
            "in": "query",
            "name": "clubId",
            "schema": {
              "type": "integer"
            },
            "description": "Only return areas belonging to this club"
          },
          {
            "in": "query",
            "name": "sport",
            "schema": {
              "type": "string"
            },
            "description": "Only return areas whose area type lists this sport (e.g. \"padel\")"
          }
        ],
        "responses": {
          "200": {
            "description": "List of bookable areas",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/PlatformArea"
                  }
                }
              }
            }
          },
          "400": {
            "description": "Invalid query parameters",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformError"
                }
              }
            }
          },
          "401": {
            "description": "Invalid or missing API key",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformError"
                }
              }
            }
          },
          "403": {
            "description": "API key is missing the required `areas:read` scope",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformError"
                }
              }
            }
          },
          "429": {
            "description": "Rate limit exceeded",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformError"
                }
              }
            }
          }
        }
      },
      "post": {
        "summary": "Create an area",
        "description": "Adds a bookable area - a court, a studio, a lane - to one of the organization's clubs. `clubId` comes from GET /v1/platform/clubs and `areaTypeId` from GET /v1/platform/area-types; both must belong to this organization. The area type is what decides which sports the area can be booked for and what it is priced from, so resolve it first rather than guessing.\n\nThe new area enters partner availability feeds immediately, with an `availability_changes` entry written in the same transaction - so a partner reconciling against the change cursor sees the court appear.\n\nCreated areas default to `active` and `Public`. An area created `maintenance`, `inactive` or non-public is returned here and resolves by id, but will not appear in GET /v1/platform/areas, which lists only what is bookable today. Supports the `Idempotency-Key` header.\n\nNeeds `areas:write`, the same scope that blocks and unblocks areas. Existing grants therefore gain this the moment it ships; an OAuth connection made before it will not, because a grant expands the wildcard at consent time - reconnect to pick it up.\n",
        "tags": [
          "Areas"
        ],
        "security": [
          {
            "customerApiAuth": []
          }
        ],
        "parameters": [
          {
            "in": "header",
            "name": "Idempotency-Key",
            "schema": {
              "type": "string",
              "maxLength": 255
            },
            "description": "Retry-safe key; a repeat of the same request replays the first response"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/PlatformAreaCreate"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Area created",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformArea"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/PlatformBadRequest"
          },
          "401": {
            "$ref": "#/components/responses/PlatformUnauthorized"
          },
          "403": {
            "description": "API key is missing the required `areas:write` scope",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformError"
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/PlatformRateLimited"
          }
        }
      }
    },
    "/v1/platform/areas/{areaId}/availability": {
      "get": {
        "summary": "Get area availability",
        "description": "Returns the area's club timezone, operating hours, the intervals already taken (`busy`), and computed open `slots` (with remaining capacity) for the window. Send `If-None-Match` with the last `version` you saw to get a cheap `304` when nothing changed. 1club remains the source of truth: booking creation re-checks availability atomically and rejects conflicts.\n",
        "tags": [
          "Areas"
        ],
        "security": [
          {
            "customerApiAuth": []
          }
        ],
        "parameters": [
          {
            "in": "path",
            "name": "areaId",
            "required": true,
            "schema": {
              "type": "integer"
            },
            "description": "Area id"
          },
          {
            "in": "query",
            "name": "from",
            "required": true,
            "schema": {
              "type": "string",
              "format": "date-time"
            },
            "description": "Window start (ISO date-time)"
          },
          {
            "in": "query",
            "name": "to",
            "required": true,
            "schema": {
              "type": "string",
              "format": "date-time"
            },
            "description": "Window end (ISO date-time). At most 31 days after `from`."
          },
          {
            "in": "query",
            "name": "slotMinutes",
            "required": false,
            "schema": {
              "type": "integer",
              "enum": [
                15,
                30,
                60,
                90,
                120
              ],
              "default": 60
            },
            "description": "Granularity of computed slots in minutes."
          }
        ],
        "responses": {
          "200": {
            "description": "Area availability",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "areaId": {
                      "type": "integer"
                    },
                    "from": {
                      "type": "string",
                      "format": "date-time"
                    },
                    "to": {
                      "type": "string",
                      "format": "date-time"
                    },
                    "timezone": {
                      "type": "string",
                      "description": "IANA timezone the hours are expressed in"
                    },
                    "maxConcurrentBookings": {
                      "type": "integer"
                    },
                    "version": {
                      "type": "string",
                      "description": "Consistency token (highest change sequence for this area)"
                    },
                    "operatingHours": {
                      "type": "object",
                      "description": "Per-day-of-week opening schedule"
                    },
                    "busy": {
                      "type": "array",
                      "items": {
                        "type": "object",
                        "properties": {
                          "startTime": {
                            "type": "string",
                            "format": "date-time"
                          },
                          "endTime": {
                            "type": "string",
                            "format": "date-time"
                          },
                          "subject": {
                            "type": "string",
                            "enum": [
                              "member_booking",
                              "class",
                              "event",
                              "area_block"
                            ],
                            "description": "What holds the court for this interval, in the same vocabulary as `details.conflicts[].subject` on a refused create. `class` is a scheduled class holding its court - which is a booking row underneath, so it is not distinguishable by shape - and `area_block` is the club closing the court (maintenance, a private hire) rather than a sale. No id or name is attached - an availability read is not a read of who booked; use GET /v1/platform/bookings for the rows you may see.\n"
                          }
                        }
                      }
                    },
                    "slots": {
                      "type": "array",
                      "items": {
                        "type": "object",
                        "properties": {
                          "start": {
                            "type": "string",
                            "format": "date-time"
                          },
                          "end": {
                            "type": "string",
                            "format": "date-time"
                          },
                          "capacity": {
                            "type": "integer"
                          },
                          "remaining": {
                            "type": "integer"
                          },
                          "bookable": {
                            "type": "boolean"
                          }
                        }
                      }
                    }
                  }
                }
              }
            }
          },
          "304": {
            "description": "Not modified (matched `If-None-Match`)"
          },
          "400": {
            "description": "Invalid parameters",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformError"
                }
              }
            }
          },
          "401": {
            "description": "Invalid or missing API key",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformError"
                }
              }
            }
          },
          "403": {
            "description": "API key is missing the required `areas:read` scope",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformError"
                }
              }
            }
          },
          "404": {
            "description": "Area not found",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformError"
                }
              }
            }
          },
          "429": {
            "description": "Rate limit exceeded",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformError"
                }
              }
            }
          }
        }
      }
    },
    "/v1/platform/areas/{areaId}": {
      "get": {
        "summary": "Get an area",
        "description": "Returns one area by id, whatever its state. Unlike GET /v1/platform/areas, which shows only what can be booked today, this resolves an area that is under maintenance, closed, or not publicly visible - so an area created or edited through this API can always be read back. `status` and `visibility` say which case it is.\n",
        "tags": [
          "Areas"
        ],
        "security": [
          {
            "customerApiAuth": []
          }
        ],
        "parameters": [
          {
            "in": "path",
            "name": "areaId",
            "required": true,
            "schema": {
              "type": "integer"
            },
            "description": "Area id"
          }
        ],
        "responses": {
          "200": {
            "description": "The area",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformArea"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/PlatformBadRequest"
          },
          "401": {
            "$ref": "#/components/responses/PlatformUnauthorized"
          },
          "403": {
            "description": "API key is missing the required `areas:read` scope",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformError"
                }
              }
            }
          },
          "404": {
            "$ref": "#/components/responses/PlatformNotFound"
          },
          "429": {
            "$ref": "#/components/responses/PlatformRateLimited"
          }
        }
      },
      "patch": {
        "summary": "Update an area",
        "description": "Edits an area. Send only what changes; an omitted field is left alone. Setting `status` to `maintenance` or `inactive`, or narrowing `visibility`, takes the area out of availability - which is the durable way to close a court indefinitely, where an area block closes it for a stated window.\n\nAny edit to hours, status, visibility, capacity or area type writes an `availability_changes` entry alongside the row, so partners see it on their next reconciliation.\n\n`clubId` is not editable: moving a court to another club would strand its bookings, class holds and blocks at the old one. There is no delete either - an area carries booking history, so close it with `status` instead. Supports the `Idempotency-Key` header.\n",
        "tags": [
          "Areas"
        ],
        "security": [
          {
            "customerApiAuth": []
          }
        ],
        "parameters": [
          {
            "in": "path",
            "name": "areaId",
            "required": true,
            "schema": {
              "type": "integer"
            },
            "description": "Area id"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/PlatformAreaUpdate"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The updated area",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformArea"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/PlatformBadRequest"
          },
          "401": {
            "$ref": "#/components/responses/PlatformUnauthorized"
          },
          "403": {
            "description": "API key is missing the required `areas:write` scope",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformError"
                }
              }
            }
          },
          "404": {
            "$ref": "#/components/responses/PlatformNotFound"
          },
          "429": {
            "$ref": "#/components/responses/PlatformRateLimited"
          }
        }
      }
    },
    "/v1/platform/availability": {
      "get": {
        "summary": "Get club-wide availability grid",
        "description": "Returns computed open slots for every bookable area in one call, so you can render a whole calendar without a request per court. Filter by `clubId` and/or `sport`. Each area carries its own `version`; the top-level `cursor` is the reconciliation cursor to pass to `/availability/changes` next.\n",
        "tags": [
          "Areas"
        ],
        "security": [
          {
            "customerApiAuth": []
          }
        ],
        "parameters": [
          {
            "in": "query",
            "name": "clubId",
            "schema": {
              "type": "integer"
            },
            "description": "Only include areas belonging to this club"
          },
          {
            "in": "query",
            "name": "sport",
            "schema": {
              "type": "string"
            },
            "description": "Only include areas whose type lists this sport (e.g. \"padel\")"
          },
          {
            "in": "query",
            "name": "from",
            "required": true,
            "schema": {
              "type": "string",
              "format": "date-time"
            },
            "description": "Window start (ISO date-time)"
          },
          {
            "in": "query",
            "name": "to",
            "required": true,
            "schema": {
              "type": "string",
              "format": "date-time"
            },
            "description": "Window end (ISO date-time). At most 31 days after `from`."
          },
          {
            "in": "query",
            "name": "slotMinutes",
            "schema": {
              "type": "integer",
              "enum": [
                15,
                30,
                60,
                90,
                120
              ],
              "default": 60
            },
            "description": "Granularity of computed slots in minutes."
          }
        ],
        "responses": {
          "200": {
            "description": "Club availability grid",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "clubId": {
                      "type": "integer",
                      "nullable": true
                    },
                    "timezone": {
                      "type": "string"
                    },
                    "from": {
                      "type": "string",
                      "format": "date-time"
                    },
                    "to": {
                      "type": "string",
                      "format": "date-time"
                    },
                    "cursor": {
                      "type": "string",
                      "description": "Reconciliation cursor at read time"
                    },
                    "areas": {
                      "type": "array",
                      "items": {
                        "type": "object",
                        "properties": {
                          "areaId": {
                            "type": "integer"
                          },
                          "name": {
                            "type": "string"
                          },
                          "sports": {
                            "type": "array",
                            "items": {
                              "type": "string"
                            }
                          },
                          "pricePerHour": {
                            "type": "number",
                            "nullable": true
                          },
                          "maxConcurrentBookings": {
                            "type": "integer"
                          },
                          "version": {
                            "type": "string"
                          },
                          "operatingHours": {
                            "type": "object"
                          },
                          "busy": {
                            "type": "array",
                            "items": {
                              "type": "object",
                              "properties": {
                                "startTime": {
                                  "type": "string",
                                  "format": "date-time"
                                },
                                "endTime": {
                                  "type": "string",
                                  "format": "date-time"
                                },
                                "subject": {
                                  "type": "string",
                                  "enum": [
                                    "member_booking",
                                    "class",
                                    "event",
                                    "area_block"
                                  ],
                                  "description": "What holds the court for this interval, in the same vocabulary as `details.conflicts[].subject` on a refused create. `class` is a scheduled class holding its court - which is a booking row underneath, so it is not distinguishable by shape - and `area_block` is the club closing the court (maintenance, a private hire) rather than a sale. No id or name is attached - an availability read is not a read of who booked; use GET /v1/platform/bookings for the rows you may see.\n"
                                }
                              }
                            }
                          },
                          "slots": {
                            "type": "array",
                            "items": {
                              "type": "object",
                              "properties": {
                                "start": {
                                  "type": "string",
                                  "format": "date-time"
                                },
                                "end": {
                                  "type": "string",
                                  "format": "date-time"
                                },
                                "capacity": {
                                  "type": "integer"
                                },
                                "remaining": {
                                  "type": "integer"
                                },
                                "bookable": {
                                  "type": "boolean"
                                }
                              }
                            }
                          }
                        }
                      }
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "Invalid parameters",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformError"
                }
              }
            }
          },
          "401": {
            "description": "Invalid or missing API key",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformError"
                }
              }
            }
          },
          "403": {
            "description": "API key is missing the required `areas:read` scope",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformError"
                }
              }
            }
          },
          "429": {
            "description": "Rate limit exceeded",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformError"
                }
              }
            }
          }
        }
      }
    },
    "/v1/platform/availability/changes": {
      "get": {
        "summary": "Availability change feed (reconciliation)",
        "description": "Returns the availability changes for your organization with `sequence > since`, in order. Use it to catch up after missed webhooks: pass the last `cursor` you processed, apply the returned changes by re-pulling `/availability` for the affected areas/windows, then store the new `cursor`. This feed is the authoritative backstop for the webhook stream.\n",
        "tags": [
          "Areas"
        ],
        "security": [
          {
            "customerApiAuth": []
          }
        ],
        "parameters": [
          {
            "in": "query",
            "name": "since",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "Cursor to read after (0 for the beginning of retention)."
          },
          {
            "in": "query",
            "name": "limit",
            "schema": {
              "type": "integer",
              "default": 100,
              "maximum": 500
            },
            "description": "Max changes to return."
          }
        ],
        "responses": {
          "200": {
            "description": "A page of availability changes",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "changes": {
                      "type": "array",
                      "items": {
                        "type": "object",
                        "properties": {
                          "sequence": {
                            "type": "string"
                          },
                          "clubId": {
                            "type": "integer",
                            "nullable": true
                          },
                          "areaId": {
                            "type": "integer"
                          },
                          "from": {
                            "type": "string",
                            "format": "date-time"
                          },
                          "to": {
                            "type": "string",
                            "format": "date-time"
                          },
                          "reason": {
                            "type": "string"
                          },
                          "occurredAt": {
                            "type": "string",
                            "format": "date-time"
                          }
                        }
                      }
                    },
                    "cursor": {
                      "type": "string"
                    },
                    "hasMore": {
                      "type": "boolean"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "Invalid parameters",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformError"
                }
              }
            }
          },
          "401": {
            "description": "Invalid or missing API key",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformError"
                }
              }
            }
          },
          "403": {
            "description": "API key is missing the required `areas:read` scope",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformError"
                }
              }
            }
          },
          "429": {
            "description": "Rate limit exceeded",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformError"
                }
              }
            }
          }
        }
      }
    },
    "/v1/platform/bookings": {
      "post": {
        "summary": "Create a booking",
        "description": "Creates a booking on an area (e.g. a padel court), optionally with an instructor leading the session. Identify the customer with `contactId` (resolve it via GET /v1/platform/contacts) or, for callers that do not track 1club ids, with an inline `customer` matched to a contact by email and created if new - exactly one of the two.\n\nA booking transaction is recorded so the booking counts for revenue: `paid` marks it settled against a payment method named after your API key (no gateway call), so the club can see and reconcile what you collected separately from every other channel; `unpaid` leaves it outstanding. `amount` is the price the customer paid for the booking, before any fee you kept, and defaults to the computed price for the slot - the area's price, plus the instructor's hourly rate when one is attached. Send your own payment id as `payment.reference` and your commission as `payment.fee`: the sale is recorded at `amount`, and the fee is what the club reconciles against your payout statement.\n\nThis API assumes the calling system enforces its own booking rules, so 1club's member-facing policy (minimum notice, maximum days in advance, operating-hours envelope, slots that have already ended, instructor availability) is not applied - which is what lets an existing schedule be recorded as-is.\n\nWhat the club physically has is never overridden, and a booking that breaks one of these is refused with `400` and a populated `details.conflicts[]`: the area's concurrency capacity (`AREA_AT_CAPACITY`), a court the club has blocked for maintenance or a private hire (`AREA_BLOCKED`), a live event on the court (`AREA_RUNNING_EVENT`), the instructor already booked or teaching (`INSTRUCTOR_AT_CAPACITY`, `INSTRUCTOR_TEACHING_CLASS`, `INSTRUCTOR_RUNNING_EVENT`), whether an instructor takes one-on-one bookings, and the slot alignment the area requires. There is no override parameter: a conflict is a refusal, not a warning.\n",
        "tags": [
          "Bookings"
        ],
        "security": [
          {
            "customerApiAuth": []
          }
        ],
        "parameters": [
          {
            "in": "header",
            "name": "Idempotency-Key",
            "schema": {
              "type": "string",
              "maxLength": 255
            },
            "description": "Retry-safe key; a repeat of the same request replays the first response instead of creating a second booking"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "areaId",
                  "startTime",
                  "endTime",
                  "payment"
                ],
                "properties": {
                  "areaId": {
                    "type": "integer",
                    "description": "Area id (from GET /v1/platform/areas)"
                  },
                  "startTime": {
                    "type": "string",
                    "format": "date-time"
                  },
                  "endTime": {
                    "type": "string",
                    "format": "date-time"
                  },
                  "contactId": {
                    "type": "integer",
                    "description": "Existing contact to book for (from GET /v1/platform/contacts). Mutually exclusive with `customer`."
                  },
                  "customer": {
                    "type": "object",
                    "description": "Inline find-or-create by email. Mutually exclusive with `contactId`.",
                    "required": [
                      "email"
                    ],
                    "properties": {
                      "email": {
                        "type": "string",
                        "format": "email"
                      },
                      "firstName": {
                        "type": "string"
                      },
                      "lastName": {
                        "type": "string"
                      },
                      "phone": {
                        "type": "string"
                      }
                    }
                  },
                  "instructorId": {
                    "type": "integer",
                    "description": "Instructor leading the session (from GET /v1/platform/instructors). Must be bookable for one-on-one sessions."
                  },
                  "payment": {
                    "type": "object",
                    "required": [
                      "status"
                    ],
                    "properties": {
                      "status": {
                        "type": "string",
                        "enum": [
                          "paid",
                          "unpaid"
                        ]
                      },
                      "amount": {
                        "type": "number",
                        "maximum": 1000000,
                        "description": "The price the customer paid for the booking, in the club's currency, before any fee you kept. Defaults to the area price plus the instructor's hourly rate. Stated the way the club states its prices: where the club's tax rate is exclusive, tax is added on top exactly as for a price entered in the admin portal.\n"
                      },
                      "reference": {
                        "type": "string",
                        "maxLength": 128,
                        "description": "Your own id for the payment you collected. Stored on the payment and returned on reads, so the club can reconcile it against your payout statement. Requires `status: paid`.\n"
                      },
                      "fee": {
                        "type": "number",
                        "maximum": 1000000,
                        "description": "What you kept out of `amount` - your commission. Recorded on the payment for reconciliation; it does not reduce the sale. Requires `status: paid` and an explicit `amount` it cannot exceed. Neither `reference` nor `fee` is stored on a booking that records no payment (an amount of 0, or a free area).\n"
                      }
                    }
                  },
                  "channel": {
                    "type": "string",
                    "description": "Optional channel label, stored for attribution"
                  },
                  "notes": {
                    "type": "string"
                  },
                  "notifyCustomer": {
                    "type": "boolean",
                    "description": "Tell the customer their booking is confirmed - email plus the in-app, WhatsApp and SMS notification, and the payment receipt for a `paid` booking. Off by default for an API key, since the calling system normally sends its own confirmation; on by default over an OAuth connection, where a staff member is booking through an assistant and their member expects to hear from the club. The club's own staff alerts are sent either way.\n"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Booking created",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformBooking"
                }
              }
            }
          },
          "400": {
            "description": "Invalid request, or the slot cannot be sold. A scheduling refusal carries a machine-readable `code` and a populated `details.conflicts[]` naming what overlaps (`resource`, `resourceId`, `subject`, and the counterpart's own `startTime`/`endTime`). Each conflict also carries the counterpart's `id` and `label` (the booking and its customer's name, or the class or event and its name), which are `null` unless the credential can read that counterpart: `bookings:read` for a booking, `classes:read` for a class or an event, `areas:read` for a block. Detect a taken slot on the presence of `details.conflicts[]` rather than on a list of codes, which grows as new kinds of occupancy are modelled.\n",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformError"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/PlatformUnauthorized"
          },
          "403": {
            "description": "API key is missing the required `bookings:write` scope",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformError"
                }
              }
            }
          },
          "409": {
            "description": "An `Idempotency-Key` was reused with a different request body. Scheduling conflicts are `400`, not `409`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformError"
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/PlatformRateLimited"
          }
        }
      },
      "get": {
        "summary": "List bookings",
        "description": "Returns bookings whose start time falls in the requested window, ordered by start time. Each bound is an ISO date-time, or a bare `YYYY-MM-DD` read as midnight UTC. When `from`/`to` are omitted the window defaults to the next 7 days, and passing one derives the other.\n\nA single request may span at most 93 days. To cover a longer range, walk it in consecutive windows of 93 days or less, paging each window with `limit`/`offset` against the `total` it reports. Ordering is stable (`startTime`, then id), so paging is well defined for a window that is not being written to; bookings created or cancelled mid-walk can still shift rows across page boundaries.\n\nBookings from every channel are returned, each tagged with `source`. That is deliberate: a caller reconciling its own writes has to be able to see member- and staff-created bookings to know whether a slot was already taken. Whether the writes (cancel, add/remove participant) reach those bookings too depends on the credential: an API key may only mutate the bookings it created (`source: api`), while a staff member acting through an authorized 1club assistant may mutate any of the organization's.\n\nA class booking carries the occurrence it attends as `classId`. Every attendee of a class holds their own booking row against the same `classId`, and `classes` rows are per-occurrence, so that is what tells two groups running in the same hour apart - filter by it, or read the roster from the class side with GET /v1/platform/classes/{id}/bookings.\n",
        "tags": [
          "Bookings"
        ],
        "security": [
          {
            "customerApiAuth": []
          }
        ],
        "parameters": [
          {
            "in": "query",
            "name": "from",
            "schema": {
              "type": "string"
            },
            "description": "Inclusive lower bound on booking start time, as an ISO date-time or `YYYY-MM-DD`. Defaults to `to - 7d`, or now."
          },
          {
            "in": "query",
            "name": "to",
            "schema": {
              "type": "string"
            },
            "description": "Exclusive upper bound on booking start time, as an ISO date-time or `YYYY-MM-DD`. Defaults to `from + 7d`."
          },
          {
            "in": "query",
            "name": "clubId",
            "schema": {
              "type": "integer"
            },
            "description": "Only bookings at this club"
          },
          {
            "in": "query",
            "name": "areaId",
            "schema": {
              "type": "integer"
            },
            "description": "Only bookings on this area"
          },
          {
            "in": "query",
            "name": "classId",
            "schema": {
              "type": "integer"
            },
            "description": "Only the attendees of this class occurrence. Equivalent to GET /v1/platform/classes/{id}/bookings, which additionally resolves each attendee's name and check-in.\n"
          },
          {
            "in": "query",
            "name": "eventId",
            "schema": {
              "type": "integer"
            },
            "description": "Only bookings for this event"
          },
          {
            "in": "query",
            "name": "instructorId",
            "schema": {
              "type": "integer"
            },
            "description": "Only bookings led by this instructor"
          },
          {
            "in": "query",
            "name": "contactId",
            "schema": {
              "type": "integer"
            },
            "description": "Only bookings for this contact"
          },
          {
            "in": "query",
            "name": "status",
            "schema": {
              "type": "string"
            },
            "description": "Only bookings with this status (e.g. confirmed, pending, cancelled)"
          },
          {
            "in": "query",
            "name": "include",
            "schema": {
              "type": "string",
              "pattern": "^(customer|money|cancellation)(,(customer|money|cancellation))*$",
              "example": "customer,money,cancellation"
            },
            "description": "Comma-separated expansions to embed on each booking, loaded once for the whole page. `customer` is the booking's customer, identical to what GET /v1/platform/contacts/{id} returns, and needs `contacts:read`. `money` is what the booking billed, what was paid and what is still outstanding, with the charges, cancellation credits, payments and refunds behind those figures, and needs `transactions:read`. `cancellation` is the cancellation terms stamped on the booking when it was accepted (with the deadlines they give, and until when it cancels for free) and, once cancelled, who cancelled it, when, why, with how much notice and at what refund. An expansion whose scope the key lacks is refused with 403 rather than left out.\n"
          },
          {
            "in": "query",
            "name": "limit",
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 100,
              "default": 50
            },
            "description": "Maximum number of bookings to return"
          },
          {
            "in": "query",
            "name": "offset",
            "schema": {
              "type": "integer",
              "minimum": 0,
              "default": 0
            },
            "description": "Number of bookings to skip"
          }
        ],
        "responses": {
          "200": {
            "description": "Page of bookings",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformBookingPage"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/PlatformBadRequest"
          },
          "401": {
            "$ref": "#/components/responses/PlatformUnauthorized"
          },
          "403": {
            "description": "API key is missing the required `bookings:read` scope, or an `include` was sent without the scope it needs",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformError"
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/PlatformRateLimited"
          }
        }
      }
    },
    "/v1/platform/bookings/{id}/cancel": {
      "post": {
        "summary": "Cancel a booking",
        "description": "Cancels the booking by its 1club id. Idempotent: cancelling an already-cancelled booking succeeds.\n\nWhich bookings are cancellable depends on the credential. An API key may cancel only the bookings it created (`source: api`); anything else is reported as not found. A staff member acting through an authorized 1club assistant may cancel any of the organization's bookings, as they can in the admin portal.\n\nRefunds follow the money. On a booking this API created, 1club never held the payment, so it moves none - but it records what you gave back, so the club's revenue matches yours. Cancelling with an API key records a refund of everything collected unless `refund.amount` says otherwise: send the amount you actually returned to keep a cancellation fee, or `0` if you kept it all, and your own refund id as `refund.reference`. Over a staff credential nothing is recorded unless `refund` is sent. On a booking taken through 1club (member portal, app, front desk), the club is the one cancelling: an unpaid booking owes nothing unless `keepCancellationCharge` is true. A paid booking the cancellation policy would keep money from needs an explicit `keepCancellationCharge` - `false` refunds it in full, `true` applies the policy - and without one the call is refused (409 `CANCELLATION_CHARGE_DECISION_REQUIRED`), exactly as the admin portal asks. `refund` is ignored there.\n\nA booking that has already been checked in cannot be cancelled (409) - voiding a check-in is an admin-portal action. One occurrence of a recurring booking is cancelled, not the series.\n",
        "tags": [
          "Bookings"
        ],
        "security": [
          {
            "customerApiAuth": []
          }
        ],
        "parameters": [
          {
            "in": "path",
            "name": "id",
            "required": true,
            "schema": {
              "type": "integer"
            },
            "description": "1club booking id (returned by create)"
          }
        ],
        "requestBody": {
          "required": false,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "reason": {
                    "type": "string",
                    "maxLength": 500,
                    "description": "Why the booking is cancelled, recorded on the cancellation and shown to staff wherever the booking's cancellation is (and returned by `include=cancellation`). Defaults to a note naming the API as the channel.\n"
                  },
                  "notifyCustomer": {
                    "type": "boolean",
                    "description": "Tell the customer their booking was cancelled - email plus the in-app and WhatsApp notification. Defaults to off for a booking this API created, since callers normally send their own notice, and to on for a booking taken through 1club, whose customer expects to hear it from the club. The club's own staff alerts are sent either way.\n"
                  },
                  "keepCancellationCharge": {
                    "type": "boolean",
                    "description": "On a booking taken through 1club: `true` applies the club's cancellation policy, so a late cancellation keeps its charge; `false` refunds what was paid in full and forgives the rest. Left out, an unpaid booking owes nothing, and a paid one the policy would keep money from is refused with 409 `CANCELLATION_CHARGE_DECISION_REQUIRED`. Ignored on a booking this API created.\n"
                  },
                  "refund": {
                    "type": "object",
                    "description": "What you returned to the customer, on a booking you collected the money for. Omit it to record a full refund of what was collected (API key) or nothing (staff credential).\n",
                    "properties": {
                      "amount": {
                        "type": "number",
                        "maximum": 1000000,
                        "description": "The amount returned to the customer. `0` records no refund (you kept the whole payment as a cancellation fee). Cannot exceed what was collected (`400 AMOUNT_EXCEEDS_REFUNDABLE`).\n"
                      },
                      "reference": {
                        "type": "string",
                        "maxLength": 128,
                        "description": "Your own id for the refund."
                      }
                    }
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Booking cancelled",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "bookingId": {
                      "type": "integer"
                    },
                    "status": {
                      "type": "string",
                      "example": "cancelled"
                    },
                    "refundedAmount": {
                      "type": "number",
                      "description": "What this call recorded as returned to the customer; `0` when nothing was."
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/PlatformBadRequest"
          },
          "401": {
            "$ref": "#/components/responses/PlatformUnauthorized"
          },
          "403": {
            "description": "API key is missing the required `bookings:write` scope",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformError"
                }
              }
            }
          },
          "404": {
            "$ref": "#/components/responses/PlatformNotFound"
          },
          "409": {
            "description": "The booking was paid and the cancellation policy would keep some of it, so `keepCancellationCharge` must say what happens to the money (`CANCELLATION_CHARGE_DECISION_REQUIRED`, with the bookings in `details.bookingIds`); or it has already been checked in, so it cannot be cancelled (`BOOKING_ALREADY_CHECKED_IN`); or it is managed by another API key that owns its own records, so only that key may release it (`BOOKING_EXTERNALLY_MANAGED`, with the managing integration's name in `details.managedBy`).\n",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformError"
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/PlatformRateLimited"
          }
        }
      }
    },
    "/v1/platform/bookings/{id}": {
      "get": {
        "summary": "Get a booking",
        "description": "Returns the current state of a booking (including payment status) by its 1club id - any of the organization's bookings, whichever channel created it. Nothing here is absent from the list endpoint, which has always returned every source.\n",
        "tags": [
          "Bookings"
        ],
        "security": [
          {
            "customerApiAuth": []
          }
        ],
        "parameters": [
          {
            "in": "path",
            "name": "id",
            "required": true,
            "schema": {
              "type": "integer"
            },
            "description": "1club booking id"
          },
          {
            "in": "query",
            "name": "include",
            "schema": {
              "type": "string",
              "pattern": "^(customer|money|cancellation)(,(customer|money|cancellation))*$",
              "example": "customer,money,cancellation"
            },
            "description": "Comma-separated expansions to embed on the booking. `customer` is the booking's customer, identical to what GET /v1/platform/contacts/{id} returns, and needs `contacts:read`. `money` is what the booking billed, what was paid and what is still outstanding, with the charges, cancellation credits, payments and refunds behind those figures, and needs `transactions:read`. `cancellation` is the cancellation terms stamped on the booking when it was accepted (with the deadlines they give, and until when it cancels for free) and, once cancelled, who cancelled it, when, why, with how much notice and at what refund. An expansion whose scope the key lacks is refused with 403 rather than left out.\n"
          }
        ],
        "responses": {
          "200": {
            "description": "Booking state",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformBookingListItem"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/PlatformBadRequest"
          },
          "401": {
            "$ref": "#/components/responses/PlatformUnauthorized"
          },
          "403": {
            "description": "API key is missing the required `bookings:read` scope, or an `include` was sent without the scope it needs",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformError"
                }
              }
            }
          },
          "404": {
            "$ref": "#/components/responses/PlatformNotFound"
          },
          "429": {
            "$ref": "#/components/responses/PlatformRateLimited"
          }
        }
      },
      "patch": {
        "summary": "Update a booking",
        "description": "Edits a booking by its 1club id: reschedule it, move it to another area, attach or remove an instructor, reassign the contact, change its notes or price, or make it recur. Send only the fields that change. Reaches the same bookings as cancel: an API key only those it created, a staff member through an authorized 1club assistant any of the organization's.\n\nThe edit follows the admin portal's rules. A cancelled booking is final, and a booking on a class takes its time and price from the class. As on create, the club's member-facing policy (operating hours, instructor availability) is not applied, but an occupied or blocked court, or a coach who is already booked, is refused with `details.conflicts[]`.\n\nMoney follows the edit. `amount` reprices the booking to your figure. Without it, a booking this API created keeps its price, and a booking taken through 1club is re-quoted when the slot, area, instructor or contact changes. The charge is then reconciled: an increase on a paid booking leaves the difference outstanding, and a decrease below what was paid needs a refund, which this endpoint does not perform. Payment status cannot be changed here.\n\nRecurring bookings. `recurrence` on a one-off booking makes it the first occurrence of a series. On an occurrence of a series, `scope=all_future` applies the edit to every occurrence that has not started yet, from the earliest upcoming one: a new `startTime`/`endTime` gives each that time of day on its own date (the weekday changes only through `recurrence.daysOfWeek`), a new area or instructor is checked against every date, and a new `recurrence` rewrites the cadence or end condition; days dropped from a weekly pattern are cancelled with their refund. A series bound to a recurring class follows the class and cannot be re-timed, and a booking an API key created cannot be made to recur at all (`RECURRENCE_NOT_AVAILABLE_TO_MANAGING_KEY`): 1club would be generating occurrences that key never created, so each one is its own booking. An edit whose series has already run touches nothing.\n",
        "tags": [
          "Bookings"
        ],
        "security": [
          {
            "customerApiAuth": []
          }
        ],
        "parameters": [
          {
            "in": "path",
            "name": "id",
            "required": true,
            "schema": {
              "type": "integer"
            },
            "description": "1club booking id"
          },
          {
            "in": "query",
            "name": "scope",
            "schema": {
              "type": "string",
              "enum": [
                "this_instance",
                "all_future"
              ],
              "default": "this_instance"
            },
            "description": "For an occurrence of a recurring booking, whether the edit applies to it alone or to every not-yet-started occurrence of the series (see the description)"
          },
          {
            "in": "header",
            "name": "Idempotency-Key",
            "schema": {
              "type": "string",
              "maxLength": 255
            },
            "description": "Retry-safe key; a repeat of the same request replays the first response"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/PlatformBookingUpdate"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The booking after the edit - for a series edit, the occurrence you named",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformBooking"
                }
              }
            }
          },
          "400": {
            "description": "Invalid request, or an edit that cannot hold: `BOOKING_CANCELLED`, `BOOKING_ON_CLASS_NOT_RESCHEDULABLE`, `BOOKING_NEEDS_RESOURCE`, `BOOKING_COVERED_CANNOT_PRICE`, `CLASS_AT_CAPACITY`, `AREA_NOT_FOUND`, `CONTACT_NOT_FOUND`, `RECURRENCE_REQUIRES_SERIES_SCOPE` (a `recurrence` sent to an occurrence without `scope=all_future`), `RECURRENCE_NOT_AVAILABLE_TO_MANAGING_KEY`, or a taken slot, which carries `details.conflicts[]` as on create.\n",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformError"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/PlatformUnauthorized"
          },
          "403": {
            "description": "API key is missing the required `bookings:write` scope",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformError"
                }
              }
            }
          },
          "404": {
            "$ref": "#/components/responses/PlatformNotFound"
          },
          "409": {
            "description": "The booking is managed by another API key that owns its own records (`BOOKING_EXTERNALLY_MANAGED`, with the managing integration's name in `details.managedBy`); or the new price is below what has already been paid on the booking (`BOOKING_CHARGE_OVERPAID`), which needs a refund rather than an edit; or a part-paid or invoiced charge cannot be rewritten (`BOOKING_CHARGE_NEEDS_ADJUSTMENT`); or an `Idempotency-Key` was reused with a different request body.\n",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformError"
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/PlatformRateLimited"
          }
        }
      }
    },
    "/v1/platform/bookings/{id}/participants": {
      "get": {
        "summary": "List the additional players on a booking",
        "description": "Returns the extra players on a booking - any of the organization's, whichever channel created it. The person the booking is for (the organizer) is on the booking itself as `contactId` and is not repeated here. Only the add and remove writes are restricted to bookings this API created.\n\nThis lists the co-players sharing *one* booking (a doubles court). The attendees of a class each hold their own booking - read those with GET /v1/platform/classes/{id}/bookings.\n",
        "tags": [
          "Bookings"
        ],
        "security": [
          {
            "customerApiAuth": []
          }
        ],
        "parameters": [
          {
            "in": "path",
            "name": "id",
            "required": true,
            "schema": {
              "type": "integer"
            },
            "description": "1club booking id"
          }
        ],
        "responses": {
          "200": {
            "description": "The booking's additional players",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "type": "object",
                    "properties": {
                      "contactId": {
                        "type": "integer"
                      },
                      "name": {
                        "type": "string"
                      },
                      "status": {
                        "type": "string"
                      }
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/PlatformBadRequest"
          },
          "401": {
            "$ref": "#/components/responses/PlatformUnauthorized"
          },
          "403": {
            "description": "API key is missing the required `bookings:read` scope",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformError"
                }
              }
            }
          },
          "404": {
            "$ref": "#/components/responses/PlatformNotFound"
          },
          "429": {
            "$ref": "#/components/responses/PlatformRateLimited"
          }
        }
      },
      "post": {
        "summary": "Add a player to a booking",
        "description": "Adds another player to a booking - for a doubles court, the three players joining the organizer. The contact must already exist (create it via POST /v1/platform/contacts). The total, counting the organizer, is capped by the area type's maximum players.\n\nReaches the same bookings as cancel: an API key may only add to the ones it created, a staff member acting through an authorized 1club assistant to any of the organization's.\n",
        "tags": [
          "Bookings"
        ],
        "security": [
          {
            "customerApiAuth": []
          }
        ],
        "parameters": [
          {
            "in": "path",
            "name": "id",
            "required": true,
            "schema": {
              "type": "integer"
            },
            "description": "1club booking id"
          },
          {
            "in": "header",
            "name": "Idempotency-Key",
            "schema": {
              "type": "string",
              "maxLength": 255
            },
            "description": "Retry-safe key; a repeat of the same request replays the first response"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "contactId"
                ],
                "properties": {
                  "contactId": {
                    "type": "integer",
                    "description": "Contact to add as a player"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Player added",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "bookingId": {
                      "type": "integer"
                    },
                    "contactId": {
                      "type": "integer"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/PlatformBadRequest"
          },
          "401": {
            "$ref": "#/components/responses/PlatformUnauthorized"
          },
          "403": {
            "description": "API key is missing the required `bookings:write` scope",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformError"
                }
              }
            }
          },
          "404": {
            "$ref": "#/components/responses/PlatformNotFound"
          },
          "409": {
            "$ref": "#/components/responses/PlatformConflict"
          },
          "429": {
            "$ref": "#/components/responses/PlatformRateLimited"
          }
        }
      }
    },
    "/v1/platform/bookings/{id}/participants/{contactId}": {
      "delete": {
        "summary": "Remove a player from a booking",
        "description": "Removes an additional player from a booking. Reaches the same bookings as cancel: an API key may only remove from the ones it created, a staff member acting through an authorized 1club assistant from any of the organization's.\n",
        "tags": [
          "Bookings"
        ],
        "security": [
          {
            "customerApiAuth": []
          }
        ],
        "parameters": [
          {
            "in": "path",
            "name": "id",
            "required": true,
            "schema": {
              "type": "integer"
            },
            "description": "1club booking id"
          },
          {
            "in": "path",
            "name": "contactId",
            "required": true,
            "schema": {
              "type": "integer"
            },
            "description": "Contact to remove"
          }
        ],
        "responses": {
          "204": {
            "description": "Player removed"
          },
          "400": {
            "$ref": "#/components/responses/PlatformBadRequest"
          },
          "401": {
            "$ref": "#/components/responses/PlatformUnauthorized"
          },
          "403": {
            "description": "API key is missing the required `bookings:write` scope",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformError"
                }
              }
            }
          },
          "404": {
            "$ref": "#/components/responses/PlatformNotFound"
          },
          "409": {
            "description": "The booking is managed by another API key that owns its own records (`BOOKING_EXTERNALLY_MANAGED`), so its roster is only that key's to change. The managing integration's name is in `details.managedBy`.\n",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformError"
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/PlatformRateLimited"
          }
        }
      }
    },
    "/v1/platform/checkins": {
      "get": {
        "summary": "List check-ins",
        "description": "Lists attendance records for the organization, newest first.",
        "tags": [
          "Check-ins"
        ],
        "security": [
          {
            "customerApiAuth": []
          }
        ],
        "parameters": [
          {
            "in": "query",
            "name": "contactId",
            "schema": {
              "type": "integer"
            }
          },
          {
            "in": "query",
            "name": "clubId",
            "schema": {
              "type": "integer"
            }
          },
          {
            "in": "query",
            "name": "classId",
            "schema": {
              "type": "integer"
            }
          },
          {
            "in": "query",
            "name": "bookingId",
            "schema": {
              "type": "integer"
            }
          },
          {
            "in": "query",
            "name": "startDate",
            "schema": {
              "type": "string",
              "format": "date-time"
            }
          },
          {
            "in": "query",
            "name": "endDate",
            "schema": {
              "type": "string",
              "format": "date-time"
            }
          },
          {
            "in": "query",
            "name": "limit",
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 100,
              "default": 25
            }
          },
          {
            "in": "query",
            "name": "offset",
            "schema": {
              "type": "integer",
              "minimum": 0,
              "default": 0
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Page of check-ins",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformCheckinPage"
                }
              }
            }
          },
          "403": {
            "description": "Missing `checkins:read` scope"
          }
        }
      },
      "post": {
        "summary": "Check in an existing booking",
        "description": "Records attendance for a booking during its configured check-in window. The booking must be `confirmed` or `pending`, and its payment and duplicate-attendance rules still apply. Supports the `Idempotency-Key` header.\n",
        "tags": [
          "Check-ins"
        ],
        "security": [
          {
            "customerApiAuth": []
          }
        ],
        "parameters": [
          {
            "in": "header",
            "name": "Idempotency-Key",
            "schema": {
              "type": "string",
              "maxLength": 255
            },
            "description": "Retry-safe key; a repeat of the same request replays the first response"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "bookingId"
                ],
                "properties": {
                  "bookingId": {
                    "type": "integer"
                  },
                  "clubId": {
                    "type": "integer",
                    "description": "Required only for a clubless event booking when no club can otherwise be derived. Must belong to the authorized organization."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Check-in created",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformCheckin"
                }
              }
            }
          },
          "400": {
            "description": "The booking is not `confirmed` or `pending`, has an unpaid balance, or `clubId` is missing or not in this organization"
          },
          "403": {
            "description": "Missing `checkins:write` scope or check-in is outside the allowed window"
          },
          "404": {
            "$ref": "#/components/responses/PlatformNotFound"
          },
          "409": {
            "description": "Booking is already checked in"
          }
        }
      }
    },
    "/v1/platform/checkins/{id}": {
      "get": {
        "summary": "Get a check-in",
        "tags": [
          "Check-ins"
        ],
        "security": [
          {
            "customerApiAuth": []
          }
        ],
        "parameters": [
          {
            "in": "path",
            "name": "id",
            "required": true,
            "schema": {
              "type": "integer"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Check-in details",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformCheckin"
                }
              }
            }
          },
          "403": {
            "description": "Missing `checkins:read` scope"
          },
          "404": {
            "$ref": "#/components/responses/PlatformNotFound"
          }
        }
      }
    },
    "/v1/platform/class-types": {
      "get": {
        "summary": "List class types",
        "description": "Returns the organization's class types - the kinds of class it runs, such as \"Padel clinic\" or \"Yoga\". Resolve one here to fill in `classTypeId` when creating a class, or `classTypeIds` when scoping a class pay rate policy to particular kinds of class.\n\nA class type is not a label: it decides how many people one booking may bring, whether an empty class cancels itself and how long beforehand, and which account the class posts to - so read the whole record before choosing one rather than matching on the name.\n\nThere is no paging: an organization has a handful of these.\n",
        "tags": [
          "Classes"
        ],
        "security": [
          {
            "customerApiAuth": []
          }
        ],
        "responses": {
          "200": {
            "description": "Every class type in the organization, by name",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "data": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/PlatformClassType"
                      }
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/PlatformUnauthorized"
          },
          "403": {
            "description": "API key is missing the required `classes:read` scope",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformError"
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/PlatformRateLimited"
          }
        }
      },
      "post": {
        "summary": "Create a class type",
        "description": "Adds a kind of class the organization runs. List the existing types first - a club rarely wants two types meaning the same thing, and this endpoint will happily create a duplicate name.\n\n`slug` is optional and derived from the name when omitted, suffixed if that is taken. Send one explicitly only when something outside 1club already refers to it; an explicit slug that clashes is a 409 rather than a row quietly filed under a different key. Supports the `Idempotency-Key` header.\n",
        "tags": [
          "Classes"
        ],
        "security": [
          {
            "customerApiAuth": []
          }
        ],
        "parameters": [
          {
            "in": "header",
            "name": "Idempotency-Key",
            "schema": {
              "type": "string",
              "maxLength": 255
            },
            "description": "Retry-safe key; a repeat of the same request replays the first response"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/PlatformClassTypeCreate"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Class type created",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformClassType"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/PlatformBadRequest"
          },
          "401": {
            "$ref": "#/components/responses/PlatformUnauthorized"
          },
          "403": {
            "description": "API key is missing the required `classes:write` scope",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformError"
                }
              }
            }
          },
          "409": {
            "description": "An explicit `slug` is already used by another class type",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformError"
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/PlatformRateLimited"
          }
        }
      }
    },
    "/v1/platform/class-types/{id}": {
      "get": {
        "summary": "Get a class type",
        "description": "Returns one class type and how many classes are run as it, past and future.",
        "tags": [
          "Classes"
        ],
        "security": [
          {
            "customerApiAuth": []
          }
        ],
        "parameters": [
          {
            "in": "path",
            "name": "id",
            "required": true,
            "schema": {
              "type": "integer"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "The class type",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformClassType"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/PlatformBadRequest"
          },
          "401": {
            "$ref": "#/components/responses/PlatformUnauthorized"
          },
          "403": {
            "description": "API key is missing the required `classes:read` scope",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformError"
                }
              }
            }
          },
          "404": {
            "$ref": "#/components/responses/PlatformNotFound"
          },
          "429": {
            "$ref": "#/components/responses/PlatformRateLimited"
          }
        }
      },
      "patch": {
        "summary": "Update a class type",
        "description": "Changes a class type. Send only what changes; an omitted field is left alone, `null` clears a nullable one, and at least one field is required.\n\nEvery field here applies to classes already scheduled as this type, not only to new ones: lowering `maxPartySize` does not evict anybody already booked, but it does cap the next booking, and changing `revenueAccountId` moves where future sales post without rewriting the ones already taken. A rename leaves `slug` alone - it is a stable key, so it moves only when you send one.\n",
        "tags": [
          "Classes"
        ],
        "security": [
          {
            "customerApiAuth": []
          }
        ],
        "parameters": [
          {
            "in": "path",
            "name": "id",
            "required": true,
            "schema": {
              "type": "integer"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/PlatformClassTypeUpdate"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The updated class type",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformClassType"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/PlatformBadRequest"
          },
          "401": {
            "$ref": "#/components/responses/PlatformUnauthorized"
          },
          "403": {
            "description": "API key is missing the required `classes:write` scope",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformError"
                }
              }
            }
          },
          "404": {
            "$ref": "#/components/responses/PlatformNotFound"
          },
          "409": {
            "description": "An explicit `slug` is already used by another class type",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformError"
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/PlatformRateLimited"
          }
        }
      }
    },
    "/v1/platform/classes": {
      "get": {
        "summary": "List classes",
        "description": "Returns the organization's class occurrences, ordered by start time. One row is one occurrence - a weekly class materializes a row per session, linked by `recurrenceId` and `occurrenceIndex` - so a row's `id` is what a booking's `classId` points at.\n\nThe window is open by default: with neither bound the whole schedule is in scope, past included, because this endpoint serves operators reconciling what happened rather than members shopping for a slot. `startDate` keeps occurrences still running at or after it, `endDate` keeps occurrences starting before it (exclusive), and each accepts an ISO date-time or a bare `YYYY-MM-DD` read as midnight UTC. Overlap, not containment, so a long or multi-day class shows up on every day it covers.\n\nPage with `limit`/`offset` against the `total` the response reports. Ordering is stable (`startTime`, then id).\n\nEvery class the organization owns is in scope - member-only and private occurrences included, and those at a deactivated club - because the key holder is the operator, not a visitor. `locale` resolves `name` and `description`; the full `translations` blob is returned either way.\n",
        "tags": [
          "Classes"
        ],
        "security": [
          {
            "customerApiAuth": []
          }
        ],
        "parameters": [
          {
            "in": "query",
            "name": "startDate",
            "schema": {
              "type": "string"
            },
            "description": "Keep occurrences ending after this instant. ISO date-time or `YYYY-MM-DD`. Omit for no lower bound."
          },
          {
            "in": "query",
            "name": "endDate",
            "schema": {
              "type": "string"
            },
            "description": "Keep occurrences starting before this instant (exclusive). ISO date-time or `YYYY-MM-DD`. Omit for no upper bound."
          },
          {
            "in": "query",
            "name": "clubId",
            "schema": {
              "type": "integer"
            },
            "description": "Only classes at this club"
          },
          {
            "in": "query",
            "name": "classTypeId",
            "schema": {
              "type": "integer"
            },
            "description": "Only classes of this class type"
          },
          {
            "in": "query",
            "name": "instructorId",
            "schema": {
              "type": "integer"
            },
            "description": "Only classes this instructor is assigned to teach"
          },
          {
            "in": "query",
            "name": "status",
            "schema": {
              "type": "string",
              "enum": [
                "active",
                "cancelled"
              ]
            },
            "description": "Only classes with this status. A cancelled occurrence keeps its bookings."
          },
          {
            "in": "query",
            "name": "sport",
            "schema": {
              "type": "string"
            },
            "description": "Only classes for this sport"
          },
          {
            "in": "query",
            "name": "search",
            "schema": {
              "type": "string"
            },
            "description": "Case-insensitive match on the class name"
          },
          {
            "in": "query",
            "name": "locale",
            "schema": {
              "type": "string"
            },
            "description": "Locale for translated fields (e.g. \"en\", \"es\")"
          },
          {
            "in": "query",
            "name": "limit",
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 100,
              "default": 50
            },
            "description": "Maximum number of classes to return"
          },
          {
            "in": "query",
            "name": "offset",
            "schema": {
              "type": "integer",
              "minimum": 0,
              "default": 0
            },
            "description": "Number of classes to skip"
          }
        ],
        "responses": {
          "200": {
            "description": "Page of classes",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformClassPage"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/PlatformBadRequest"
          },
          "401": {
            "$ref": "#/components/responses/PlatformUnauthorized"
          },
          "403": {
            "description": "API key is missing the required `classes:read` scope",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformError"
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/PlatformRateLimited"
          }
        }
      },
      "post": {
        "summary": "Create a class",
        "description": "Creates a class. Times must be ISO 8601 instants with an explicit offset. Supports the `Idempotency-Key` header.\n\nWithout `recurrence` this creates one occurrence. With it, one request creates the whole repeating series - do not call this once per week. `startTime`/`endTime` stay required either way: they are the template slot, giving every occurrence its time of day and its duration, and the series starts from `startTime` (so there is no separate start date). A weekly rule whose `daysOfWeek` do not include that day puts the first occurrence on the first listed day at or after it.\n\nA bounded series (`ends.type` of `until` or `after`) is materialized in full inside this request, so it is capped at 520 occurrences - over that the request is refused with `RECURRENCE_TOO_MANY_OCCURRENCES` rather than silently truncated. An open-ended series (`ends` omitted, or `ends.type: \"never\"`) materializes a rolling window and extends itself, so only the first weeks exist immediately.\n\n`ends.occurrences` counts **occurrences, not weeks**, across every day in `daysOfWeek` - twelve weeks of a Monday-and-Wednesday class is `24`. To end on a date instead, use `ends.type: \"until\"` with an `endDate` and skip the arithmetic.\n\nThe area is checked for a clash on the **first** occurrence only - the same rule the admin dashboard applies. Later occurrences are written without re-checking the court, so a series can land on a slot that is already held in a later week; read GET /v1/platform/areas/{areaId}/availability for the window first if that matters.\n\nThe response is the first occurrence, and for a recurring create it also carries `series.occurrencesCreated` - what this request actually wrote. Compare it with what you asked for: an occurrence at a club that is closed, or past its closing date, is skipped rather than failing the create. Read the rest of the series back with GET /v1/platform/classes filtered on the `recurrenceId` it carries.\n",
        "tags": [
          "Classes"
        ],
        "security": [
          {
            "customerApiAuth": []
          }
        ],
        "parameters": [
          {
            "in": "header",
            "name": "Idempotency-Key",
            "schema": {
              "type": "string",
              "maxLength": 255
            },
            "description": "Retry-safe key; a repeat of the same request replays the first response"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/PlatformClassCreate"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Class created. For a recurring create, the first occurrence of the new series.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformClass"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/PlatformBadRequest"
          },
          "403": {
            "description": "Missing `classes:write` scope"
          },
          "409": {
            "description": "The area is already occupied for the requested slot"
          }
        }
      }
    },
    "/v1/platform/classes/{id}/bookings": {
      "get": {
        "summary": "List a class's roster",
        "description": "Returns who is booked into one class occurrence, whether they were checked in, and what they paid - the roster, from the class side. This is the answer to \"two groups run at 10:00, who is in which\": every attendee holds their own booking row against this class's id.\n\nAttendees only: the occurrence's own area hold and any instructor slot are excluded by the same predicate that decides capacity, so the roster is drawn from exactly the population the class's `bookedCount` counts.\n\nDo not expect `total` to equal `bookedCount`. Capacity counts `confirmed`, `pending` and `checked_in`, and adds standalone walk-in check-ins that have no booking to list; the roster also keeps `no_show`, because whether someone was enrolled and failed to turn up is what an attendance reconciliation is asking. Cancelled seats are the one status left out, unless `status=cancelled` asks for them.\n\nRequires both `classes:read` and `bookings:read`: it is a class sub-resource that returns booking data. `contactEmail` additionally needs `contacts:read` and is null without it - a name identifies who is on the roster, an email address is contact PII and stays behind the scope that guards it elsewhere.\n",
        "tags": [
          "Classes"
        ],
        "security": [
          {
            "customerApiAuth": []
          }
        ],
        "parameters": [
          {
            "in": "path",
            "name": "id",
            "required": true,
            "schema": {
              "type": "integer"
            },
            "description": "Class id"
          },
          {
            "in": "query",
            "name": "status",
            "schema": {
              "type": "string"
            },
            "description": "Only attendees with this booking status (e.g. confirmed, checked_in, cancelled). Defaults to every status but cancelled."
          },
          {
            "in": "query",
            "name": "limit",
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 100,
              "default": 100
            },
            "description": "Maximum number of attendees to return"
          },
          {
            "in": "query",
            "name": "offset",
            "schema": {
              "type": "integer",
              "minimum": 0,
              "default": 0
            },
            "description": "Number of attendees to skip"
          }
        ],
        "responses": {
          "200": {
            "description": "Page of attendees",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformClassBookingPage"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/PlatformBadRequest"
          },
          "401": {
            "$ref": "#/components/responses/PlatformUnauthorized"
          },
          "403": {
            "description": "API key is missing the required `classes:read` or `bookings:read` scope",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformError"
                }
              }
            }
          },
          "404": {
            "$ref": "#/components/responses/PlatformNotFound"
          },
          "429": {
            "$ref": "#/components/responses/PlatformRateLimited"
          }
        }
      }
    },
    "/v1/platform/classes/{id}": {
      "get": {
        "summary": "Get a class by ID",
        "description": "Returns a single class occurrence. The class must belong to the organization the API key belongs to. Read its roster with GET /v1/platform/classes/{id}/bookings.\n",
        "tags": [
          "Classes"
        ],
        "security": [
          {
            "customerApiAuth": []
          }
        ],
        "parameters": [
          {
            "in": "path",
            "name": "id",
            "required": true,
            "schema": {
              "type": "integer"
            },
            "description": "Class ID"
          },
          {
            "in": "query",
            "name": "locale",
            "schema": {
              "type": "string"
            },
            "description": "Locale for translations"
          }
        ],
        "responses": {
          "200": {
            "description": "Class details",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformClass"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/PlatformBadRequest"
          },
          "401": {
            "$ref": "#/components/responses/PlatformUnauthorized"
          },
          "403": {
            "description": "API key is missing the required `classes:read` scope",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformError"
                }
              }
            }
          },
          "404": {
            "$ref": "#/components/responses/PlatformNotFound"
          },
          "429": {
            "$ref": "#/components/responses/PlatformRateLimited"
          }
        }
      },
      "patch": {
        "summary": "Update a class",
        "description": "Updates one occurrence, or this and all future occurrences when `scope=all_future`. A recurring class defaults to `this_instance`, so a series-wide edit must ask for it explicitly.\n\nWith `scope=all_future`, `recurrence` moves where the series ends - `ends` plus `endDate`, the same fields a create takes. Send it on its own: a body that also carries class fields is refused, so an end change never half-applies. Only the end: a `frequency`, `interval` or `daysOfWeek` is refused, and to change a cadence you delete the remaining series and create it again. A later end writes the new occurrences; an earlier one removes the occurrences past it, and is refused with 409 when any of those has an active booking or waitlist entry (cancel them first) - nothing a member holds is cancelled as a side effect. The occurrence named must be one the series keeps, and the end cannot fall before a session that has already run.\n\nThe response to an end change carries `series.occurrencesCreated` and `series.occurrencesRemoved`. As on a create, an occurrence at a club that is closed is skipped rather than failing the request, so compare the count with what you expected.\n",
        "tags": [
          "Classes"
        ],
        "security": [
          {
            "customerApiAuth": []
          }
        ],
        "parameters": [
          {
            "in": "path",
            "name": "id",
            "required": true,
            "schema": {
              "type": "integer"
            }
          },
          {
            "in": "query",
            "name": "scope",
            "schema": {
              "type": "string",
              "enum": [
                "this_instance",
                "all_future"
              ],
              "default": "this_instance"
            },
            "description": "Whether the update applies to this occurrence only or to this and every later one in the series"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/PlatformClassUpdate"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Class updated",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformClass"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/PlatformBadRequest"
          },
          "403": {
            "description": "Missing `classes:write` scope"
          },
          "404": {
            "$ref": "#/components/responses/PlatformNotFound"
          },
          "409": {
            "description": "The new time double-books the area, or an occurrence a new series end would remove has active bookings or waitlist entries"
          }
        }
      },
      "delete": {
        "summary": "Delete a class",
        "description": "Deletes one occurrence, or this and all future occurrences when `scope=all_future`.",
        "tags": [
          "Classes"
        ],
        "security": [
          {
            "customerApiAuth": []
          }
        ],
        "parameters": [
          {
            "in": "path",
            "name": "id",
            "required": true,
            "schema": {
              "type": "integer"
            }
          },
          {
            "in": "query",
            "name": "scope",
            "schema": {
              "type": "string",
              "enum": [
                "this_instance",
                "all_future"
              ],
              "default": "this_instance"
            },
            "description": "Whether the delete applies to this occurrence only or to this and every later one in the series"
          }
        ],
        "responses": {
          "204": {
            "description": "Class deleted"
          },
          "403": {
            "description": "Missing `classes:write` scope"
          },
          "404": {
            "$ref": "#/components/responses/PlatformNotFound"
          },
          "409": {
            "description": "Class has active bookings or waitlist entries"
          }
        }
      }
    },
    "/v1/platform/clubs/{slug}": {
      "get": {
        "summary": "Get club details by slug",
        "description": "Returns club details including amenities, rating, and images. The club must belong to the organization associated with the API token.\n",
        "tags": [
          "Clubs"
        ],
        "security": [
          {
            "customerApiAuth": []
          }
        ],
        "parameters": [
          {
            "in": "path",
            "name": "slug",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "Club slug"
          },
          {
            "in": "query",
            "name": "locale",
            "schema": {
              "type": "string"
            },
            "description": "Locale for amenity translations"
          }
        ],
        "responses": {
          "200": {
            "description": "Club details",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformClub"
                }
              }
            }
          },
          "401": {
            "description": "Invalid or missing API key",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformError"
                }
              }
            }
          },
          "403": {
            "description": "API key is missing the required `clubs:read` scope",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformError"
                }
              }
            }
          },
          "404": {
            "description": "Club not found",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformError"
                }
              }
            }
          },
          "429": {
            "description": "Rate limit exceeded",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformError"
                }
              }
            }
          }
        }
      }
    },
    "/v1/platform/contacts": {
      "get": {
        "summary": "List and search contacts",
        "description": "Returns the organization's contacts ordered by name, excluding archived ones. Use `search` to match on name, email, or phone - matching is case-insensitive and cross-script, so `ivan` also matches `Иван`. Resolve a customer here and pass the returned `id` as `contactId` when creating a booking.\n",
        "tags": [
          "Contacts"
        ],
        "security": [
          {
            "customerApiAuth": []
          }
        ],
        "parameters": [
          {
            "in": "query",
            "name": "search",
            "schema": {
              "type": "string",
              "maxLength": 120
            },
            "description": "Match on name, email, or phone"
          },
          {
            "in": "query",
            "name": "limit",
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 100,
              "default": 25
            },
            "description": "Maximum number of contacts to return"
          },
          {
            "in": "query",
            "name": "offset",
            "schema": {
              "type": "integer",
              "minimum": 0,
              "default": 0
            },
            "description": "Number of contacts to skip"
          }
        ],
        "responses": {
          "200": {
            "description": "Page of contacts",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformContactPage"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/PlatformBadRequest"
          },
          "401": {
            "$ref": "#/components/responses/PlatformUnauthorized"
          },
          "403": {
            "description": "API key is missing the required `contacts:read` scope",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformError"
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/PlatformRateLimited"
          }
        }
      },
      "post": {
        "summary": "Create a contact",
        "description": "Creates a contact in the organization. At least one of `email` or `phone` is required so the person stays reachable and findable. Creating a contact whose email already exists returns 409 (`code` CONFLICT), and so does one whose phone number already belongs to a contact with the same name (`code` CONTACT_EXISTS; a shared number under a different name - a household - is accepted). Both name the existing contact in `details.contactId`. Search first, then reference the existing id. A `phone` is stored in international form (`+359877762016`) when it is a valid number for the organization's country, and as given otherwise. Supports the `Idempotency-Key` header.\n",
        "tags": [
          "Contacts"
        ],
        "security": [
          {
            "customerApiAuth": []
          }
        ],
        "parameters": [
          {
            "in": "header",
            "name": "Idempotency-Key",
            "schema": {
              "type": "string",
              "maxLength": 255
            },
            "description": "Retry-safe key; a repeat of the same request replays the first response"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/PlatformContactCreate"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Contact created",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformContact"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/PlatformBadRequest"
          },
          "401": {
            "$ref": "#/components/responses/PlatformUnauthorized"
          },
          "403": {
            "description": "API key is missing the required `contacts:write` scope",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformError"
                }
              }
            }
          },
          "409": {
            "$ref": "#/components/responses/PlatformConflict"
          },
          "429": {
            "$ref": "#/components/responses/PlatformRateLimited"
          }
        }
      }
    },
    "/v1/platform/contacts/{id}": {
      "get": {
        "summary": "Get a contact",
        "description": "Returns a single contact by its 1club id. With `include=overview` it also returns the contact's overview as `overview` - what staff see on the contact's profile before serving them: the alerts (an Amount Due, a Wallet below its Required Deposit, gear overdue, a required document unsigned, a required profile field missing, access suspended, archived), the money (Amount Due, Wallet balance, deposit, billed and paid to date), activity counts and the last visit, and the contact details the record lacks.\n\nMoney needs `transactions:read` and gear needs `products:read` on top of `contacts:read`. Without them the rest is still returned, and `withheld` names what was left out and the scope it needs, so a missing alert is never mistaken for nothing to report.\n",
        "tags": [
          "Contacts"
        ],
        "security": [
          {
            "customerApiAuth": []
          }
        ],
        "parameters": [
          {
            "in": "path",
            "name": "id",
            "required": true,
            "schema": {
              "type": "integer"
            },
            "description": "1club contact id"
          },
          {
            "in": "query",
            "name": "include",
            "schema": {
              "type": "string",
              "enum": [
                "overview"
              ]
            },
            "description": "Embed the contact's overview as `overview`."
          }
        ],
        "responses": {
          "200": {
            "description": "The contact",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformContactWithOverview"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/PlatformBadRequest"
          },
          "401": {
            "$ref": "#/components/responses/PlatformUnauthorized"
          },
          "403": {
            "description": "API key is missing the required `contacts:read` scope",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformError"
                }
              }
            }
          },
          "404": {
            "$ref": "#/components/responses/PlatformNotFound"
          },
          "429": {
            "$ref": "#/components/responses/PlatformRateLimited"
          }
        }
      },
      "patch": {
        "summary": "Update a contact",
        "description": "Edits a contact's name and contact details. Send only what changes - `null` clears `lastName`, `email` or `phone`, and an omitted field is left alone. The display name is always recomputed from `firstName` and `lastName`, the phone is canonicalized, and a contact must keep at least one of `email` or `phone`, so an edit cannot leave the person unreachable. Clearing `lastName` stores an empty string rather than null, so it reads back as `\"\"`.\n\nRefused edits: an `email` already used by another contact in the organization (409); any change to the `email` of a contact whose address is also their 1club login (400) - that address is changed by the person themselves; and clearing a field the organization has marked required for staff (400, naming the `field`). An archived contact cannot be edited.\n",
        "tags": [
          "Contacts"
        ],
        "security": [
          {
            "customerApiAuth": []
          }
        ],
        "parameters": [
          {
            "in": "path",
            "name": "id",
            "required": true,
            "schema": {
              "type": "integer"
            },
            "description": "1club contact id"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/PlatformContactUpdate"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The updated contact",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformContact"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/PlatformBadRequest"
          },
          "401": {
            "$ref": "#/components/responses/PlatformUnauthorized"
          },
          "403": {
            "description": "API key is missing the required `contacts:write` scope",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformError"
                }
              }
            }
          },
          "404": {
            "$ref": "#/components/responses/PlatformNotFound"
          },
          "409": {
            "$ref": "#/components/responses/PlatformConflict"
          },
          "429": {
            "$ref": "#/components/responses/PlatformRateLimited"
          }
        }
      },
      "delete": {
        "summary": "Archive a contact",
        "description": "Retires a contact. Nothing is destroyed: the contact is archived, which drops them out of the contacts list and out of every search, while their transaction history stays intact - so the response is the contact itself, now with `type` `archived`, rather than an empty body. Archiving also releases everything they hold on the forward schedule - active memberships, bookings they organized, and bookings they are only a player on - and none of that is restored by un-archiving, which is done in 1club.\n\nTo preview the impact, `GET /v1/platform/memberships?contactId={id}` lists the memberships that will be cancelled, and `GET /v1/platform/bookings?contactId={id}` the bookings that will be. Neither is an exhaustive preview, so treat them as indicative: the bookings list matches only bookings the contact **organized** - never the ones they are an additional player on, which are released too - and it defaults to a **7-day** window, so pass `from` and `to` (up to 93 days per request, consecutive windows beyond that) to see further out.\n\nA release can fail on an individual membership or booking without blocking the archive - the contact is retired either way, so a stuck booking cannot leave it active. That answers **409 `ARCHIVE_INCOMPLETE`** (not a 200) with the unreleased items in `details.outstanding`, precisely so the result is never cached against an `Idempotency-Key`: **repeat the same request to reconcile.** On an already archived contact the release runs again instead of short-circuiting, which is what makes the retry finish the job. A repeat call after a 200 is a no-op.\n\nNeeds its own `contacts:delete` scope for that reason; `contacts:write` alone cannot retire a member. Two contacts are refused rather than archived, because each needs a decision the call cannot make: a **staff member or owner** who can reach the admin portal (archiving would revoke that login - remove their access in 1club first); an **instructor** whose profile still has upcoming classes or recurring templates (reassign those first); and a contact **paying part of a split bill** on a future booking (settle that split first, since taking someone off a bill moves money and archiving moves none). An ordinary member with a portal login is *not* refused - that is most of the people worth archiving.\n",
        "tags": [
          "Contacts"
        ],
        "security": [
          {
            "customerApiAuth": []
          }
        ],
        "parameters": [
          {
            "in": "path",
            "name": "id",
            "required": true,
            "schema": {
              "type": "integer"
            },
            "description": "1club contact id"
          },
          {
            "in": "query",
            "name": "keepCancellationCharge",
            "required": false,
            "schema": {
              "type": "boolean"
            },
            "description": "Archiving cancels the contact's future bookings. For a paid one the cancellation policy would keep money from, say what happens to it: `false` refunds it in full, `true` applies the policy. Without it such an archive is refused (409 `CANCELLATION_CHARGE_DECISION_REQUIRED`) and nothing changes. Unpaid bookings owe nothing either way unless it is `true`.\n"
          }
        ],
        "responses": {
          "200": {
            "description": "The contact is archived and everything it held was released",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformContact"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/PlatformBadRequest"
          },
          "401": {
            "$ref": "#/components/responses/PlatformUnauthorized"
          },
          "403": {
            "description": "API key is missing the required `contacts:delete` scope",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformError"
                }
              }
            }
          },
          "404": {
            "$ref": "#/components/responses/PlatformNotFound"
          },
          "409": {
            "description": "Either the archive was refused outright (`CONFLICT` - the contact can reach the admin portal, their instructor profile still has upcoming assignments, or they are paying part of a split bill; resolve it in 1club, then retry; or `CANCELLATION_CHARGE_DECISION_REQUIRED` - a paid future booking needs `keepCancellationCharge`), or the contact WAS archived and the release did not finish (`ARCHIVE_INCOMPLETE`; repeat the request to reconcile). Read `code` to tell them apart - one means nothing happened, the other means the archive did.\n",
            "content": {
              "application/json": {
                "schema": {
                  "oneOf": [
                    {
                      "$ref": "#/components/schemas/PlatformError"
                    },
                    {
                      "$ref": "#/components/schemas/PlatformArchiveIncomplete"
                    }
                  ]
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/PlatformRateLimited"
          }
        }
      }
    },
    "/v1/platform/content": {
      "get": {
        "summary": "List content",
        "description": "Returns content for the organization. By default this is the public view: published items with `Public` visibility, the same thing a website visitor could see.\nTo manage content you need the rest of it - pass `status=all` for drafts and `visibility=all` for member-only and private items. Both require the `content:write` scope, since they expose material the organization has not published.\nTranslations are separate items sharing one slug, each with its own `language` (see `POST /v1/platform/content`). `locale` *resolves* them - it returns one item per slug, the best match for that locale. To see every variant instead, omit `locale` and read each item's `language`; to list one language only, pass `language` (`language=none` for the untagged base items).\n",
        "tags": [
          "Content"
        ],
        "security": [
          {
            "customerApiAuth": []
          }
        ],
        "parameters": [
          {
            "in": "query",
            "name": "type",
            "schema": {
              "type": "string",
              "enum": [
                "post",
                "faq",
                "document",
                "block",
                "how-to-guide"
              ]
            },
            "description": "Filter by content type"
          },
          {
            "in": "query",
            "name": "status",
            "schema": {
              "type": "string",
              "enum": [
                "published",
                "draft",
                "all"
              ]
            },
            "description": "Defaults to `published`. `draft` and `all` require `content:write`."
          },
          {
            "in": "query",
            "name": "visibility",
            "schema": {
              "type": "string",
              "enum": [
                "Public",
                "Member_only",
                "Private",
                "all"
              ]
            },
            "description": "Defaults to public-only. Anything else requires `content:write`."
          },
          {
            "in": "query",
            "name": "locale",
            "schema": {
              "type": "string"
            },
            "description": "Resolve translations for this locale - one item per slug, preferring the variant in this language, then the untagged one, then English.\n"
          },
          {
            "in": "query",
            "name": "language",
            "schema": {
              "type": "string"
            },
            "description": "Return only variants tagged with this language, or `none` for the untagged base items. A filter, not a resolution - use it to find what has not been translated yet. Cannot be combined with `locale`, which asks the opposite question.\n"
          },
          {
            "in": "query",
            "name": "limit",
            "schema": {
              "type": "integer"
            },
            "description": "Max results (1-100, default 100)"
          },
          {
            "in": "query",
            "name": "offset",
            "schema": {
              "type": "integer"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "List of content",
            "headers": {
              "X-Total-Count": {
                "description": "How many items match the filters, before `limit`/`offset` are applied.",
                "schema": {
                  "type": "integer"
                }
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/PlatformContent"
                  }
                }
              }
            }
          },
          "400": {
            "description": "Invalid filter or pagination parameters",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformError"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/PlatformUnauthorized"
          },
          "403": {
            "description": "API key is missing `content:read`, or `content:write` for unpublished/non-public filters",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformError"
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/PlatformRateLimited"
          }
        }
      },
      "post": {
        "summary": "Create content",
        "description": "Creates a post, FAQ, document, block or how-to guide.\nSend the body as either `contentBlocks` (Editor.js blocks, what the admin editor writes) or `contentHtml` - not both. A `block` created here is what a website `block` section renders: reference it by the slug this returns.\nThe slug is derived from the title when omitted, and de-duplicated with a numeric suffix rather than rejected - read the returned `slug` rather than assuming the one you sent. Non-Latin titles transliterate (\"Работно време\" becomes `rabotno-vreme`), so a title in any language yields a usable URL.\nTo translate an item, create a second one with the **same slug** and a `language`. The two are one piece of content in two languages, and the public site serves whichever matches the visitor's locale.\n",
        "tags": [
          "Content"
        ],
        "security": [
          {
            "customerApiAuth": []
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "type",
                  "title"
                ],
                "properties": {
                  "type": {
                    "type": "string",
                    "enum": [
                      "post",
                      "faq",
                      "document",
                      "block",
                      "how-to-guide"
                    ]
                  },
                  "title": {
                    "type": "string"
                  },
                  "slug": {
                    "type": "string",
                    "description": "Derived from the title when omitted"
                  },
                  "contentBlocks": {
                    "type": "array",
                    "description": "Editor.js blocks, e.g. [{ type: 'paragraph', data: { text: 'Hello' } }]",
                    "items": {
                      "type": "object"
                    }
                  },
                  "contentHtml": {
                    "type": "string",
                    "description": "HTML body, as an alternative to contentBlocks"
                  },
                  "excerpt": {
                    "type": "string",
                    "nullable": true
                  },
                  "tags": {
                    "type": "array",
                    "items": {
                      "type": "string"
                    }
                  },
                  "images": {
                    "type": "array",
                    "items": {
                      "type": "string"
                    },
                    "description": "URLs from the media library - never invented URLs"
                  },
                  "imageNameQueries": {
                    "type": "array",
                    "items": {
                      "type": "string"
                    },
                    "description": "Media library search terms, resolved to URLs and merged into images"
                  },
                  "status": {
                    "type": "string",
                    "enum": [
                      "published",
                      "draft"
                    ]
                  },
                  "visibility": {
                    "type": "string",
                    "enum": [
                      "Public",
                      "Member_only",
                      "Private"
                    ]
                  },
                  "metaTitle": {
                    "type": "string",
                    "nullable": true,
                    "description": "SEO title. Falls back to the item's title."
                  },
                  "metaDescription": {
                    "type": "string",
                    "nullable": true,
                    "description": "SEO description. Falls back to `excerpt`."
                  },
                  "canonicalUrl": {
                    "type": "string",
                    "format": "uri",
                    "nullable": true,
                    "description": "Absolute URL this item canonicalizes to, for content that also lives elsewhere and should be indexed there instead. Leave unset for original content - the page then canonicalizes to itself.\n"
                  },
                  "language": {
                    "type": "string",
                    "nullable": true,
                    "description": "Locale code (`bg`, `de`, ...) marking this item as the variant of its slug for that language. Translations are separate items sharing one slug: send the **same slug** as the item you are translating, or the two are unrelated rows. Uniqueness is per (slug, language), so the same slug with a new language is accepted rather than de-duplicated. Leave unset for the base item, which every locale falls back to.\n"
                  },
                  "featured": {
                    "type": "boolean"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "The created content item",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformContent"
                }
              }
            }
          },
          "400": {
            "description": "Invalid body, or both contentBlocks and contentHtml were sent",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformError"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/PlatformUnauthorized"
          },
          "403": {
            "description": "API key is missing the required `content:write` scope",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformError"
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/PlatformRateLimited"
          }
        }
      }
    },
    "/v1/platform/content/{slug}": {
      "get": {
        "summary": "Get published content by slug",
        "description": "Returns a single published, publicly-visible content item by slug - the public view. Use the list endpoint with `status=all` to reach drafts.\n",
        "tags": [
          "Content"
        ],
        "security": [
          {
            "customerApiAuth": []
          }
        ],
        "parameters": [
          {
            "in": "path",
            "name": "slug",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "Content slug"
          },
          {
            "in": "query",
            "name": "locale",
            "schema": {
              "type": "string"
            },
            "description": "Locale for content resolution"
          }
        ],
        "responses": {
          "200": {
            "description": "Content item",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformContent"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/PlatformUnauthorized"
          },
          "403": {
            "description": "API key is missing the required `content:read` scope",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformError"
                }
              }
            }
          },
          "404": {
            "description": "Content not found",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformError"
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/PlatformRateLimited"
          }
        }
      }
    },
    "/v1/platform/content/{contentId}": {
      "patch": {
        "summary": "Update content",
        "description": "Updates a content item by id - the numeric `id` from the list endpoint, not the slug. Only the fields you send change.\n`tags` and `images` replace their arrays wholesale. Switching `status` to `draft` takes the item off the public site without deleting it.\n",
        "tags": [
          "Content"
        ],
        "security": [
          {
            "customerApiAuth": []
          }
        ],
        "parameters": [
          {
            "in": "path",
            "name": "contentId",
            "required": true,
            "schema": {
              "type": "integer"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "title": {
                    "type": "string"
                  },
                  "slug": {
                    "type": "string"
                  },
                  "contentBlocks": {
                    "type": "array",
                    "items": {
                      "type": "object"
                    }
                  },
                  "contentHtml": {
                    "type": "string"
                  },
                  "excerpt": {
                    "type": "string",
                    "nullable": true
                  },
                  "tags": {
                    "type": "array",
                    "items": {
                      "type": "string"
                    }
                  },
                  "images": {
                    "type": "array",
                    "items": {
                      "type": "string"
                    }
                  },
                  "imageNameQueries": {
                    "type": "array",
                    "items": {
                      "type": "string"
                    }
                  },
                  "status": {
                    "type": "string",
                    "enum": [
                      "published",
                      "draft"
                    ]
                  },
                  "visibility": {
                    "type": "string",
                    "enum": [
                      "Public",
                      "Member_only",
                      "Private"
                    ]
                  },
                  "metaTitle": {
                    "type": "string",
                    "nullable": true,
                    "description": "SEO title. Falls back to the item's title."
                  },
                  "metaDescription": {
                    "type": "string",
                    "nullable": true,
                    "description": "SEO description. Falls back to `excerpt`."
                  },
                  "canonicalUrl": {
                    "type": "string",
                    "format": "uri",
                    "nullable": true,
                    "description": "Absolute URL this item canonicalizes to, for content that also lives elsewhere and should be indexed there instead. Leave unset for original content - the page then canonicalizes to itself.\n"
                  },
                  "language": {
                    "type": "string",
                    "nullable": true,
                    "description": "Re-tag which language this variant is, or `null` to make it the base item every locale falls back to. A 409 when the slug already has a variant in that language.\n"
                  },
                  "featured": {
                    "type": "boolean"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The updated content item",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformContent"
                }
              }
            }
          },
          "400": {
            "description": "Invalid body or content id",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformError"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/PlatformUnauthorized"
          },
          "403": {
            "description": "API key is missing the required `content:write` scope",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformError"
                }
              }
            }
          },
          "404": {
            "description": "Content not found",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformError"
                }
              }
            }
          },
          "409": {
            "description": "Another item already occupies that (slug, language) pair. Unlike create, an update never renames around a clash.\n",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformError"
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/PlatformRateLimited"
          }
        }
      },
      "delete": {
        "summary": "Delete content",
        "description": "Permanently deletes a content item. A website `block` section still referencing its slug will render nothing, and a `document` with signatures against it should be taken out of use with `status: draft` instead - deleting it takes the signed text with it.\n",
        "tags": [
          "Content"
        ],
        "security": [
          {
            "customerApiAuth": []
          }
        ],
        "parameters": [
          {
            "in": "path",
            "name": "contentId",
            "required": true,
            "schema": {
              "type": "integer"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Deleted",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "success": {
                      "type": "boolean"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "Invalid content id",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformError"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/PlatformUnauthorized"
          },
          "403": {
            "description": "API key is missing the required `content:write` scope",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformError"
                }
              }
            }
          },
          "404": {
            "description": "Content not found",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformError"
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/PlatformRateLimited"
          }
        }
      }
    },
    "/v1/platform/forms": {
      "get": {
        "summary": "List forms",
        "description": "The organization's forms, newest first. A form with `createsAccount: true` is a sign-up form: it registers whoever completes it and can sell plans and products. One with `createsAccount: false` is a fill-in form that only collects answers. `publicUrl` is where each one is filled in, and every form is live there. Questions are not included; read a single form for them.\n",
        "tags": [
          "Forms"
        ],
        "security": [
          {
            "customerApiAuth": []
          }
        ],
        "parameters": [
          {
            "in": "query",
            "name": "search",
            "schema": {
              "type": "string",
              "maxLength": 120
            },
            "description": "Match on name or slug, case-insensitive"
          },
          {
            "in": "query",
            "name": "createsAccount",
            "schema": {
              "type": "string",
              "enum": [
                "true",
                "false"
              ]
            },
            "description": "Only sign-up forms (`true`) or only fill-in forms (`false`)"
          },
          {
            "in": "query",
            "name": "limit",
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 100,
              "default": 25
            }
          },
          {
            "in": "query",
            "name": "offset",
            "schema": {
              "type": "integer",
              "minimum": 0,
              "default": 0
            }
          }
        ],
        "responses": {
          "200": {
            "description": "A page of forms",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformFormPage"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/PlatformUnauthorized"
          },
          "403": {
            "description": "API key is missing the required `forms:read` scope",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformError"
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/PlatformRateLimited"
          }
        }
      },
      "post": {
        "summary": "Create a form",
        "description": "Creates a form. It is **live immediately** at the returned `publicUrl`: there is no draft, so confirm the content with the club before creating one. `publicUrl` is null when the organization has neither a member portal nor a live website: the form is created, but nothing can open it until one is switched on.\n\n`createsAccount` is required and permanent. `true` makes a sign-up form, which registers whoever completes it and sells what it offers: completing one with a plan creates a real membership, or asks for payment when the plan is paid up front. Only active, publicly visible, non-season plans and active, publicly visible products can be offered; anything else is a 400 naming each refused id and why. `false` makes a fill-in form, which offers nothing, collects answers to its questions (set them with PUT /v1/platform/forms/{id}/questions), and creates a lead for a visitor it does not know.\n\n`slug` is optional and derived from the name when left out. A slug you name that is already taken is a 409 rather than a quiet `-2`. Read the `slug` and `publicUrl` from the response. Supports the `Idempotency-Key` header.\n\nNeeds the `forms:write` scope, which is new. An API key holding the `*` wildcard picks it up automatically; an OAuth connection made before it shipped has to be re-authorized.\n",
        "tags": [
          "Forms"
        ],
        "security": [
          {
            "customerApiAuth": []
          }
        ],
        "parameters": [
          {
            "in": "header",
            "name": "Idempotency-Key",
            "schema": {
              "type": "string",
              "maxLength": 255
            },
            "description": "Retry-safe key; a repeat of the same request replays the first response"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/PlatformFormCreate"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Form created, and live",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformForm"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/PlatformBadRequest"
          },
          "401": {
            "$ref": "#/components/responses/PlatformUnauthorized"
          },
          "403": {
            "description": "API key is missing the required `forms:write` scope",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformError"
                }
              }
            }
          },
          "409": {
            "description": "The slug is already used by another form (`FORM_SLUG_TAKEN`)",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformError"
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/PlatformRateLimited"
          }
        }
      }
    },
    "/v1/platform/forms/{id}": {
      "get": {
        "summary": "Get a form",
        "description": "One form with its questions, in order. Keep each question's `id`: to change the questions you resend every one you are keeping by id. `submissionCount` is how many people have completed it; the answers themselves are not served by this API.\n",
        "tags": [
          "Forms"
        ],
        "security": [
          {
            "customerApiAuth": []
          }
        ],
        "parameters": [
          {
            "in": "path",
            "name": "id",
            "required": true,
            "schema": {
              "type": "integer"
            },
            "description": "1club form id"
          }
        ],
        "responses": {
          "200": {
            "description": "The form",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformForm"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/PlatformBadRequest"
          },
          "401": {
            "$ref": "#/components/responses/PlatformUnauthorized"
          },
          "403": {
            "description": "API key is missing the required `forms:read` scope",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformError"
                }
              }
            }
          },
          "404": {
            "$ref": "#/components/responses/PlatformNotFound"
          },
          "429": {
            "$ref": "#/components/responses/PlatformRateLimited"
          }
        }
      },
      "patch": {
        "summary": "Update a form",
        "description": "Edits a form. Send only what changes; an omitted field is left alone. The change is live immediately.\n\n`slug` and `createsAccount` cannot be changed: the slug is the address people were given, and the kind decides what the form is. Offerings on a sign-up form are held to the same bar as on create, and a fill-in form offers nothing. There is no delete: deleting a form deletes every submission made through it, so it stays in the 1club admin portal.\n",
        "tags": [
          "Forms"
        ],
        "security": [
          {
            "customerApiAuth": []
          }
        ],
        "parameters": [
          {
            "in": "path",
            "name": "id",
            "required": true,
            "schema": {
              "type": "integer"
            },
            "description": "1club form id"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/PlatformFormUpdate"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The updated form",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformForm"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/PlatformBadRequest"
          },
          "401": {
            "$ref": "#/components/responses/PlatformUnauthorized"
          },
          "403": {
            "description": "API key is missing the required `forms:write` scope",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformError"
                }
              }
            }
          },
          "404": {
            "$ref": "#/components/responses/PlatformNotFound"
          },
          "429": {
            "$ref": "#/components/responses/PlatformRateLimited"
          }
        }
      }
    },
    "/v1/platform/forms/{id}/questions": {
      "put": {
        "summary": "Set a fill-in form's questions",
        "description": "Sets the questions of a fill-in form (`createsAccount: false`), in the order given. The change is live immediately.\n\nResend every question you are keeping **with its `id`**, and list the ones to delete in `removeQuestionIds`. A question is never deleted by being left out: a list that is missing an existing question is a 400 (`FORM_QUESTION_OMITTED`) naming it. The ids matter because answers are stored against them, so a question resent without its id is a new question and the answers already given stop being linked to it. Deleting a question keeps past submissions readable with the wording they were asked under.\n\nA sign-up form has no questions of its own (`FORM_QUESTIONS_FILL_IN_ONLY`).\n",
        "tags": [
          "Forms"
        ],
        "security": [
          {
            "customerApiAuth": []
          }
        ],
        "parameters": [
          {
            "in": "path",
            "name": "id",
            "required": true,
            "schema": {
              "type": "integer"
            },
            "description": "1club form id"
          },
          {
            "in": "header",
            "name": "Idempotency-Key",
            "schema": {
              "type": "string",
              "maxLength": 255
            },
            "description": "Retry-safe key; a repeat of the same request replays the first response"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/PlatformFormQuestionsPut"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The form, with its new questions",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformForm"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/PlatformBadRequest"
          },
          "401": {
            "$ref": "#/components/responses/PlatformUnauthorized"
          },
          "403": {
            "description": "API key is missing the required `forms:write` scope",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformError"
                }
              }
            }
          },
          "404": {
            "$ref": "#/components/responses/PlatformNotFound"
          },
          "429": {
            "$ref": "#/components/responses/PlatformRateLimited"
          }
        }
      }
    },
    "/v1/platform/instructor-types": {
      "get": {
        "summary": "List instructor types",
        "description": "Returns the organization's instructor types - the kinds of coach it has, such as \"Padel coach\" or \"Personal trainer\". Resolve one here to fill in `instructorTypeId` when creating or updating an instructor.\n\nAn organization is seeded with a set derived from the sports its clubs offer, so read this list before adding to it: a second \"Tennis coach\" under a slightly different name splits a roster that should be one group. There is no paging - an organization has a handful of these.\n",
        "tags": [
          "Instructors"
        ],
        "security": [
          {
            "customerApiAuth": []
          }
        ],
        "responses": {
          "200": {
            "description": "Every instructor type in the organization, by name",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "data": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/PlatformInstructorType"
                      }
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/PlatformUnauthorized"
          },
          "403": {
            "description": "API key is missing the required `instructors:read` scope",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformError"
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/PlatformRateLimited"
          }
        }
      },
      "post": {
        "summary": "Create an instructor type",
        "description": "Adds a kind of coach. List the existing types first - most organizations already have one for each sport their clubs offer, and this endpoint will happily create a near-duplicate.\n\n`slug` is optional and derived from the name when omitted, suffixed if that is taken; an explicit slug that clashes is a 409. Supports the `Idempotency-Key` header.\n",
        "tags": [
          "Instructors"
        ],
        "security": [
          {
            "customerApiAuth": []
          }
        ],
        "parameters": [
          {
            "in": "header",
            "name": "Idempotency-Key",
            "schema": {
              "type": "string",
              "maxLength": 255
            },
            "description": "Retry-safe key; a repeat of the same request replays the first response"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/PlatformInstructorTypeCreate"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Instructor type created",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformInstructorType"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/PlatformBadRequest"
          },
          "401": {
            "$ref": "#/components/responses/PlatformUnauthorized"
          },
          "403": {
            "description": "API key is missing the required `instructors:write` scope",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformError"
                }
              }
            }
          },
          "409": {
            "description": "An explicit `slug` is already used by another instructor type",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformError"
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/PlatformRateLimited"
          }
        }
      }
    },
    "/v1/platform/instructor-types/{id}": {
      "get": {
        "summary": "Get an instructor type",
        "description": "Returns one instructor type and how many coaches are filed under it, active and retired.",
        "tags": [
          "Instructors"
        ],
        "security": [
          {
            "customerApiAuth": []
          }
        ],
        "parameters": [
          {
            "in": "path",
            "name": "id",
            "required": true,
            "schema": {
              "type": "integer"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "The instructor type",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformInstructorType"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/PlatformBadRequest"
          },
          "401": {
            "$ref": "#/components/responses/PlatformUnauthorized"
          },
          "403": {
            "description": "API key is missing the required `instructors:read` scope",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformError"
                }
              }
            }
          },
          "404": {
            "$ref": "#/components/responses/PlatformNotFound"
          },
          "429": {
            "$ref": "#/components/responses/PlatformRateLimited"
          }
        }
      },
      "patch": {
        "summary": "Update an instructor type",
        "description": "Changes an instructor type. Send only what changes; an omitted field is left alone, `null` clears a nullable one, and at least one field is required. A rename leaves `slug` alone - it is a stable key, so it moves only when you send one.\n\n`maxConcurrentBookings` here is the shape of the type. What a booking is actually checked against is the instructor's own value, so changing this does not retroactively widen or narrow the coaches already on it.\n",
        "tags": [
          "Instructors"
        ],
        "security": [
          {
            "customerApiAuth": []
          }
        ],
        "parameters": [
          {
            "in": "path",
            "name": "id",
            "required": true,
            "schema": {
              "type": "integer"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/PlatformInstructorTypeUpdate"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The updated instructor type",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformInstructorType"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/PlatformBadRequest"
          },
          "401": {
            "$ref": "#/components/responses/PlatformUnauthorized"
          },
          "403": {
            "description": "API key is missing the required `instructors:write` scope",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformError"
                }
              }
            }
          },
          "404": {
            "$ref": "#/components/responses/PlatformNotFound"
          },
          "409": {
            "description": "An explicit `slug` is already used by another instructor type",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformError"
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/PlatformRateLimited"
          }
        }
      }
    },
    "/v1/platform/instructors": {
      "get": {
        "summary": "List and search instructors",
        "description": "Returns the organization's active instructors. Use `search` to match on name - case-insensitive and cross-script, so `maya` also matches `Мая`. Resolve a coach here and pass the returned `id` as `instructorId` when creating a booking. `bookability` says who may book them: `Admin_only` means the club reserves those bookings to its own staff, and an API key acts for the organization itself, so it may still create one. Create one with POST /v1/platform/instructors - which makes a bookable coach and nothing more: no staff-portal login comes with it.\n\nOnly active instructors are listed by default. One retired with `isActive: false` is hidden here until `includeInactive=true` asks for it, and always resolves by id - so the caller that just retired a coach can read them back and undo it. Every row reports `isActive` and `visibility`.\n",
        "tags": [
          "Instructors"
        ],
        "security": [
          {
            "customerApiAuth": []
          }
        ],
        "parameters": [
          {
            "in": "query",
            "name": "search",
            "schema": {
              "type": "string",
              "maxLength": 120
            },
            "description": "Match on instructor name"
          },
          {
            "in": "query",
            "name": "clubId",
            "schema": {
              "type": "integer"
            },
            "description": "Only instructors assigned to this club"
          },
          {
            "in": "query",
            "name": "instructorTypeId",
            "schema": {
              "type": "integer"
            },
            "description": "Only coaches of this kind, from GET /v1/platform/instructor-types"
          },
          {
            "in": "query",
            "name": "sport",
            "schema": {
              "type": "string"
            },
            "description": "Only instructors who list this sport (e.g. \"padel\")"
          },
          {
            "in": "query",
            "name": "bookableOnly",
            "schema": {
              "type": "string",
              "enum": [
                "true",
                "false"
              ]
            },
            "description": "When \"true\", only instructors a customer may book (`bookability` other than `Admin_only`)"
          },
          {
            "in": "query",
            "name": "includeInactive",
            "schema": {
              "type": "string",
              "enum": [
                "true",
                "false"
              ]
            },
            "description": "When \"true\", also return retired instructors (`isActive` false), which are hidden by default. Use it to find a coach you just retired."
          },
          {
            "in": "query",
            "name": "limit",
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 100,
              "default": 25
            },
            "description": "Maximum number of instructors to return"
          },
          {
            "in": "query",
            "name": "offset",
            "schema": {
              "type": "integer",
              "minimum": 0,
              "default": 0
            },
            "description": "Number of instructors to skip"
          }
        ],
        "responses": {
          "200": {
            "description": "Page of instructors",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformInstructorPage"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/PlatformBadRequest"
          },
          "401": {
            "$ref": "#/components/responses/PlatformUnauthorized"
          },
          "403": {
            "description": "API key is missing the required `instructors:read` scope",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformError"
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/PlatformRateLimited"
          }
        }
      },
      "post": {
        "summary": "Create an instructor",
        "description": "Makes a bookable instructor (a coach) out of an existing contact. Resolve or create that contact first with the contacts resource, then pass its id as `contactId` - an instructor profile hangs off a person the organization already knows, and the name on it is theirs.\n\n**This grants no access to the staff portal.** The instructor gets no login, no role, no `organization_users` row and no invitation email; they are inventory that can be booked and paid, not a user. `sendInvitation`, `organizationUserId`, `role` and `color` are refused rather than ignored, so a payload copied from the admin API fails loudly. Invite a coach to the portal from the 1club admin portal instead.\n\n`hourlyRate` is what a **member pays** for an hour of one-to-one time, not what the club pays the coach - see POST /v1/platform/pay-rate-policies for the latter.\n\nOne instructor per contact. A contact that already has one is a 409 naming the existing profile, including when that profile is inactive: bringing a retired coach back is a PATCH, so whoever does it can see the rates and hours they are restoring. Supports the `Idempotency-Key` header.\n\nNeeds the `instructors:write` scope, which is new. An API key holding the `*` wildcard picks it up automatically; an OAuth connection does not, because the wildcard is expanded at consent time - that connection has to be re-authorized before this endpoint is reachable.\n",
        "tags": [
          "Instructors"
        ],
        "security": [
          {
            "customerApiAuth": []
          }
        ],
        "parameters": [
          {
            "in": "header",
            "name": "Idempotency-Key",
            "schema": {
              "type": "string",
              "maxLength": 255
            },
            "description": "Retry-safe key; a repeat of the same request replays the first response"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/PlatformInstructorCreate"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Instructor created",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformInstructorSummary"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/PlatformBadRequest"
          },
          "401": {
            "$ref": "#/components/responses/PlatformUnauthorized"
          },
          "403": {
            "description": "API key is missing the required `instructors:write` scope",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformError"
                }
              }
            }
          },
          "409": {
            "description": "This contact already has an instructor profile",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformError"
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/PlatformRateLimited"
          }
        }
      }
    },
    "/v1/platform/instructors/{id}": {
      "get": {
        "summary": "Get an instructor",
        "description": "Returns a single instructor by its 1club id. Retired instructors resolve here too - check `isActive` before offering one, since the list hides them by default.\n",
        "tags": [
          "Instructors"
        ],
        "security": [
          {
            "customerApiAuth": []
          }
        ],
        "parameters": [
          {
            "in": "path",
            "name": "id",
            "required": true,
            "schema": {
              "type": "integer"
            },
            "description": "1club instructor id"
          }
        ],
        "responses": {
          "200": {
            "description": "The instructor",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformInstructorSummary"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/PlatformBadRequest"
          },
          "401": {
            "$ref": "#/components/responses/PlatformUnauthorized"
          },
          "403": {
            "description": "API key is missing the required `instructors:read` scope",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformError"
                }
              }
            }
          },
          "404": {
            "$ref": "#/components/responses/PlatformNotFound"
          },
          "429": {
            "$ref": "#/components/responses/PlatformRateLimited"
          }
        }
      },
      "patch": {
        "summary": "Update an instructor",
        "description": "Edits an instructor's bookable profile - their sports, their member-facing rate, their availability, who may book them. Send only what changes; an omitted field is left alone.\n\nRetire a coach with `isActive: false` and bring them back with `isActive: true`; there is no delete, because an instructor carries bookings, classes and pay history. Two invariants are applied on the way through, so read the response rather than assuming the payload landed verbatim: deactivating always retires `bookability` to `Admin_only`, and `bookability` never outruns `visibility` - making someone `Private` pulls a `Public` bookability down with it.\n\n`contactId` is not editable: the profile is bound to one person, and re-pointing it would silently transfer their bookings and their pay. The staff-portal fields are refused here for the same reasons as on the create.\n",
        "tags": [
          "Instructors"
        ],
        "security": [
          {
            "customerApiAuth": []
          }
        ],
        "parameters": [
          {
            "in": "path",
            "name": "id",
            "required": true,
            "schema": {
              "type": "integer"
            },
            "description": "1club instructor id"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/PlatformInstructorUpdate"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The updated instructor",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformInstructorSummary"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/PlatformBadRequest"
          },
          "401": {
            "$ref": "#/components/responses/PlatformUnauthorized"
          },
          "403": {
            "description": "API key is missing the required `instructors:write` scope",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformError"
                }
              }
            }
          },
          "404": {
            "$ref": "#/components/responses/PlatformNotFound"
          },
          "429": {
            "$ref": "#/components/responses/PlatformRateLimited"
          }
        }
      }
    },
    "/v1/platform/invoices/summary": {
      "get": {
        "summary": "Summarize what was invoiced",
        "description": "Totals of the organization's fiscal invoices over a range of calendar days on the organization's own clock (`timezone` in the response). Both bounds are inclusive `YYYY-MM-DD` dates; with neither, the range is today, and with one, it is that single day. A range may span at most 93 days.\n\nAn invoice belongs to the day its invoice date names: a date entered without a time files on that date, and an invoice raised from a sale files on the organization's local day. Pro-formas are never included.\n\n`invoiced` excludes `void` and `cancelled` invoices; `byStatus` lists every status, those included. Every amount is grouped by currency - totals in different currencies are never added together. `overdue` counts unpaid invoices in the range whose due date has passed. `byBillingEntity` splits `invoiced` by the company that issued each invoice.\n",
        "tags": [
          "Invoices"
        ],
        "security": [
          {
            "customerApiAuth": []
          }
        ],
        "parameters": [
          {
            "in": "query",
            "name": "startDate",
            "schema": {
              "type": "string",
              "format": "date"
            },
            "description": "First day, inclusive. Defaults to endDate, or today."
          },
          {
            "in": "query",
            "name": "endDate",
            "schema": {
              "type": "string",
              "format": "date"
            },
            "description": "Last day, inclusive. Defaults to startDate."
          },
          {
            "in": "query",
            "name": "billingEntityId",
            "schema": {
              "type": "integer"
            },
            "description": "Only invoices issued by this billing entity"
          }
        ],
        "responses": {
          "200": {
            "description": "Invoice totals for the range",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformInvoiceSummary"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/PlatformBadRequest"
          },
          "401": {
            "$ref": "#/components/responses/PlatformUnauthorized"
          },
          "403": {
            "description": "Missing `invoices:read` scope"
          },
          "429": {
            "$ref": "#/components/responses/PlatformRateLimited"
          }
        }
      }
    },
    "/v1/platform/invoices": {
      "get": {
        "summary": "List invoices",
        "description": "Lists the organization's fiscal invoices over a range of calendar days, newest first. The days are read exactly as `GET /invoices/summary` reads them, so for the same range and filters this returns the invoices the summary counted. Pro-formas are never included.\n",
        "tags": [
          "Invoices"
        ],
        "security": [
          {
            "customerApiAuth": []
          }
        ],
        "parameters": [
          {
            "in": "query",
            "name": "startDate",
            "schema": {
              "type": "string",
              "format": "date"
            },
            "description": "First day, inclusive. Defaults to endDate, or today."
          },
          {
            "in": "query",
            "name": "endDate",
            "schema": {
              "type": "string",
              "format": "date"
            },
            "description": "Last day, inclusive. Defaults to startDate."
          },
          {
            "in": "query",
            "name": "billingEntityId",
            "schema": {
              "type": "integer"
            }
          },
          {
            "in": "query",
            "name": "contactId",
            "schema": {
              "type": "integer"
            }
          },
          {
            "in": "query",
            "name": "status",
            "schema": {
              "type": "string",
              "enum": [
                "pending",
                "paid",
                "void",
                "failed",
                "settled",
                "overdue",
                "partially_paid",
                "refunded",
                "cancelled"
              ]
            }
          },
          {
            "in": "query",
            "name": "limit",
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 100,
              "default": 25
            }
          },
          {
            "in": "query",
            "name": "offset",
            "schema": {
              "type": "integer",
              "minimum": 0,
              "default": 0
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Page of invoices",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformInvoicePage"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/PlatformBadRequest"
          },
          "401": {
            "$ref": "#/components/responses/PlatformUnauthorized"
          },
          "403": {
            "description": "Missing `invoices:read` scope"
          },
          "429": {
            "$ref": "#/components/responses/PlatformRateLimited"
          }
        }
      }
    },
    "/v1/platform/invoices/{id}": {
      "get": {
        "summary": "Get an invoice",
        "description": "One fiscal invoice with its line items and the payments recorded against it.",
        "tags": [
          "Invoices"
        ],
        "security": [
          {
            "customerApiAuth": []
          }
        ],
        "parameters": [
          {
            "in": "path",
            "name": "id",
            "required": true,
            "schema": {
              "type": "integer"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Invoice",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformInvoiceDetail"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/PlatformBadRequest"
          },
          "401": {
            "$ref": "#/components/responses/PlatformUnauthorized"
          },
          "403": {
            "description": "Missing `invoices:read` scope"
          },
          "404": {
            "$ref": "#/components/responses/PlatformNotFound"
          },
          "429": {
            "$ref": "#/components/responses/PlatformRateLimited"
          }
        }
      }
    },
    "/v1/platform/me": {
      "get": {
        "summary": "Describe the API key's organization and granted scopes",
        "description": "Returns the organization the API key belongs to, the scopes granted to the key, and the catalog of resources/actions the platform API exposes. Requires a valid API key but no specific scope - use it to verify a key and discover what it can access.\n\nA full-access key holds the single wildcard scope `*`. Test a capability against `effectiveScopes`, which expands the wildcard, rather than against `scopes`, which reports the grant verbatim.\n",
        "tags": [
          "Account"
        ],
        "security": [
          {
            "customerApiAuth": []
          }
        ],
        "responses": {
          "200": {
            "description": "API key identity and granted scopes",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformIdentity"
                }
              }
            }
          },
          "401": {
            "description": "Invalid or missing API key",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformError"
                }
              }
            }
          },
          "404": {
            "description": "Organization not found",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformError"
                }
              }
            }
          },
          "429": {
            "description": "Rate limit exceeded",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformError"
                }
              }
            }
          }
        }
      }
    },
    "/v1/platform/media": {
      "get": {
        "summary": "List media",
        "description": "The organization's media library, newest first. Pass `search` to match by name or alt text, which is how you find the URL for an image to put on a website section - never invent an image URL.\nA `search` returns the best matches ranked by relevance rather than a page of results, so `offset` does not apply to it and `total` reports how many matched.\n",
        "tags": [
          "Media"
        ],
        "security": [
          {
            "customerApiAuth": []
          }
        ],
        "parameters": [
          {
            "in": "query",
            "name": "search",
            "schema": {
              "type": "string"
            },
            "description": "Match against name and alt text"
          },
          {
            "in": "query",
            "name": "mediaType",
            "schema": {
              "type": "string",
              "enum": [
                "image",
                "video"
              ]
            }
          },
          {
            "in": "query",
            "name": "limit",
            "schema": {
              "type": "integer"
            },
            "description": "Max results (1-100, default 50)"
          },
          {
            "in": "query",
            "name": "offset",
            "schema": {
              "type": "integer"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Media and the total count",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "media": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/PlatformMedia"
                      }
                    },
                    "total": {
                      "type": "integer"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "Invalid query parameters",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformError"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/PlatformUnauthorized"
          },
          "403": {
            "description": "API key is missing the required `media:read` scope",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformError"
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/PlatformRateLimited"
          }
        }
      },
      "post": {
        "summary": "Register a file in the media library",
        "description": "Adds an already-hosted file to the library so website sections and content can reference it. The API does not accept file uploads - upload to your image host first (the admin editor uses Cloudinary) and register the resulting URL here.\nIdempotent per URL: registering one the organization already has updates whatever metadata you send and returns the existing item, with `created: false`.\n",
        "tags": [
          "Media"
        ],
        "security": [
          {
            "customerApiAuth": []
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "url"
                ],
                "properties": {
                  "url": {
                    "type": "string",
                    "description": "Absolute http(s) URL of the file"
                  },
                  "name": {
                    "type": "string",
                    "description": "Display name. Derived from the URL when omitted - this is what `search` matches on, so a descriptive name is worth sending."
                  },
                  "altText": {
                    "type": "string",
                    "nullable": true,
                    "description": "Alternative text, used by screen readers and searched alongside the name"
                  },
                  "caption": {
                    "type": "string",
                    "nullable": true
                  },
                  "mediaType": {
                    "type": "string",
                    "enum": [
                      "image",
                      "video"
                    ],
                    "description": "Inferred from the URL extension when omitted"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "The registered media item",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/PlatformMedia"
                    },
                    {
                      "type": "object",
                      "properties": {
                        "created": {
                          "type": "boolean",
                          "description": "False when the URL was already in the library"
                        }
                      }
                    }
                  ]
                }
              }
            }
          },
          "400": {
            "description": "Missing or non-http(s) URL",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformError"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/PlatformUnauthorized"
          },
          "403": {
            "description": "API key is missing the required `media:write` scope",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformError"
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/PlatformRateLimited"
          }
        }
      }
    },
    "/v1/platform/media/{mediaId}": {
      "get": {
        "summary": "Get one media item",
        "tags": [
          "Media"
        ],
        "security": [
          {
            "customerApiAuth": []
          }
        ],
        "parameters": [
          {
            "in": "path",
            "name": "mediaId",
            "required": true,
            "schema": {
              "type": "integer"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "The media item",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformMedia"
                }
              }
            }
          },
          "400": {
            "description": "Invalid media id",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformError"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/PlatformUnauthorized"
          },
          "403": {
            "description": "API key is missing the required `media:read` scope",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformError"
                }
              }
            }
          },
          "404": {
            "description": "Media not found",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformError"
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/PlatformRateLimited"
          }
        }
      },
      "patch": {
        "summary": "Update media metadata",
        "description": "Changes the name, alt text or caption. The file itself and its URL are immutable - register a new item to replace a file.\nAlt text is worth filling in: it is what screen readers announce, and it is searched alongside the name when finding an image to place.\n",
        "tags": [
          "Media"
        ],
        "security": [
          {
            "customerApiAuth": []
          }
        ],
        "parameters": [
          {
            "in": "path",
            "name": "mediaId",
            "required": true,
            "schema": {
              "type": "integer"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "name": {
                    "type": "string"
                  },
                  "altText": {
                    "type": "string",
                    "nullable": true
                  },
                  "caption": {
                    "type": "string",
                    "nullable": true
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The updated media item",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformMedia"
                }
              }
            }
          },
          "400": {
            "description": "Invalid body or media id",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformError"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/PlatformUnauthorized"
          },
          "403": {
            "description": "API key is missing the required `media:write` scope",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformError"
                }
              }
            }
          },
          "404": {
            "description": "Media not found",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformError"
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/PlatformRateLimited"
          }
        }
      },
      "delete": {
        "summary": "Delete a media item",
        "description": "Removes the item from the library **and deletes the file from Cloudinary**. This cannot be undone, and any website section or content item still referencing the URL will render a broken image - check before deleting.\nNeeds its own `media:delete` scope for that reason; `media:write` alone is not enough.\n",
        "tags": [
          "Media"
        ],
        "security": [
          {
            "customerApiAuth": []
          }
        ],
        "parameters": [
          {
            "in": "path",
            "name": "mediaId",
            "required": true,
            "schema": {
              "type": "integer"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Deleted",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "success": {
                      "type": "boolean"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "Invalid media id",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformError"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/PlatformUnauthorized"
          },
          "403": {
            "description": "API key is missing the required `media:delete` scope",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformError"
                }
              }
            }
          },
          "404": {
            "description": "Media not found",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformError"
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/PlatformRateLimited"
          }
        }
      }
    },
    "/v1/platform/memberships": {
      "get": {
        "summary": "List memberships",
        "description": "Returns the organization's memberships, most recently started first. Filter by customer, plan, or status to find the one you need.\n",
        "tags": [
          "Memberships"
        ],
        "security": [
          {
            "customerApiAuth": []
          }
        ],
        "parameters": [
          {
            "in": "query",
            "name": "contactId",
            "schema": {
              "type": "integer"
            },
            "description": "Only memberships belonging to this customer"
          },
          {
            "in": "query",
            "name": "planId",
            "schema": {
              "type": "integer"
            },
            "description": "Only memberships on this plan"
          },
          {
            "in": "query",
            "name": "status",
            "schema": {
              "type": "string",
              "enum": [
                "pending",
                "active",
                "expired",
                "cancelled",
                "used",
                "paused",
                "pending_payment"
              ]
            },
            "description": "Only memberships with this status"
          },
          {
            "in": "query",
            "name": "limit",
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 100,
              "default": 25
            }
          },
          {
            "in": "query",
            "name": "offset",
            "schema": {
              "type": "integer",
              "minimum": 0,
              "default": 0
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Page of memberships",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformMembershipPage"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/PlatformBadRequest"
          },
          "401": {
            "$ref": "#/components/responses/PlatformUnauthorized"
          },
          "403": {
            "description": "API key is missing the required `memberships:read` scope",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformError"
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/PlatformRateLimited"
          }
        }
      },
      "post": {
        "summary": "Create a membership",
        "description": "Puts a customer on a plan. The plan is snapshotted onto the membership exactly as it is when sold in 1club - club, session allowance, coverage, billing frequency and renewal window - so the membership draws down booking allowance identically.\n\n`paid` records a payment against the External / API method with no gateway call; `unpaid` leaves the membership's transactions outstanding. The total charged is the period amount plus any joining fee, both taken from the plan unless overridden. Collection and refunds stay with you.\n\nWhen nothing is owed at all - a free plan with no joining fee, or an `amount` of 0 on such a plan - the membership is created active straight away rather than awaiting a payment that will never come.\n\nSupports the `Idempotency-Key` header.\n",
        "tags": [
          "Memberships"
        ],
        "security": [
          {
            "customerApiAuth": []
          }
        ],
        "parameters": [
          {
            "in": "header",
            "name": "Idempotency-Key",
            "schema": {
              "type": "string",
              "maxLength": 255
            },
            "description": "Retry-safe key; a repeat of the same request replays the first response"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "contactId",
                  "planId",
                  "payment"
                ],
                "properties": {
                  "contactId": {
                    "type": "integer",
                    "description": "Customer to give the membership to (from GET /v1/platform/contacts)"
                  },
                  "planId": {
                    "type": "integer",
                    "description": "Plan to put them on"
                  },
                  "startDate": {
                    "type": "string",
                    "format": "date-time",
                    "description": "Defaults to now"
                  },
                  "autoRenew": {
                    "type": "boolean",
                    "description": "Defaults to the plan's setting"
                  },
                  "overrides": {
                    "type": "object",
                    "description": "The membership's own snapshot of the plan. Omit any field to take the plan's value; the admin Add membership dialog works the same way, so a negotiated sale is a normal case. Look the plan up first with GET /v1/platform/plans to see what you are overriding.\n",
                    "properties": {
                      "amount": {
                        "type": "number",
                        "maximum": 1000000,
                        "description": "Charged each period; defaults to the plan's price"
                      },
                      "signupFee": {
                        "type": "number",
                        "maximum": 1000000,
                        "description": "Charged once on top of `amount`; defaults to the plan's joining fee, 0 waives it"
                      },
                      "maxUses": {
                        "type": "number",
                        "nullable": true,
                        "description": "Session allowance; defaults to the plan's, null for unlimited"
                      },
                      "usesRemaining": {
                        "type": "number",
                        "nullable": true,
                        "description": "Sessions left now; defaults to the full allowance. Set it lower when migrating a partly-used package."
                      },
                      "sessionUnit": {
                        "type": "string",
                        "enum": [
                          "booking",
                          "hour"
                        ],
                        "description": "Whether a use is a booking or an hour; defaults to the plan's"
                      }
                    }
                  },
                  "payment": {
                    "type": "object",
                    "required": [
                      "status"
                    ],
                    "properties": {
                      "status": {
                        "type": "string",
                        "enum": [
                          "paid",
                          "unpaid"
                        ],
                        "description": "`paid` records money you already collected. An API key's customer is not sent a payment receipt for it - you issue your own; over an OAuth connection they are, as they would be for a sale at the club.\n"
                      },
                      "reference": {
                        "type": "string",
                        "maxLength": 128,
                        "description": "Your own id for the payment you collected, for the club to reconcile against your payout statement. Requires `status: paid`.\n"
                      },
                      "fee": {
                        "type": "number",
                        "maximum": 1000000,
                        "description": "What you kept out of the amount - your commission. Recorded for reconciliation; it does not reduce the sale. Requires `status: paid` and an explicit `overrides.amount` it cannot exceed. Not stored when nothing is owed (a free plan).\n"
                      }
                    }
                  }
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Membership created",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformMembership"
                }
              }
            }
          },
          "400": {
            "description": "Contact or plan not found in this organization, or the contact is archived",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformError"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/PlatformUnauthorized"
          },
          "403": {
            "description": "API key is missing the required `memberships:write` scope",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformError"
                }
              }
            }
          },
          "409": {
            "$ref": "#/components/responses/PlatformConflict"
          },
          "429": {
            "$ref": "#/components/responses/PlatformRateLimited"
          }
        }
      }
    },
    "/v1/platform/memberships/{id}": {
      "get": {
        "summary": "Get a membership",
        "description": "Returns a single membership by its 1club id, including its remaining session allowance.",
        "tags": [
          "Memberships"
        ],
        "security": [
          {
            "customerApiAuth": []
          }
        ],
        "parameters": [
          {
            "in": "path",
            "name": "id",
            "required": true,
            "schema": {
              "type": "integer"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "The membership",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformMembership"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/PlatformBadRequest"
          },
          "401": {
            "$ref": "#/components/responses/PlatformUnauthorized"
          },
          "403": {
            "description": "API key is missing the required `memberships:read` scope",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformError"
                }
              }
            }
          },
          "404": {
            "$ref": "#/components/responses/PlatformNotFound"
          },
          "429": {
            "$ref": "#/components/responses/PlatformRateLimited"
          }
        }
      },
      "patch": {
        "summary": "Update a membership",
        "description": "Changes whether the membership renews, when it ends, and whether it is active, paused, or cancelled. Cancelling stamps the cancellation date, as cancelling in 1club does.\n\nChanging the plan is not supported: that is a priced change needing proration, invoicing and possibly a refund. Move the customer to another plan in 1club instead.\n",
        "tags": [
          "Memberships"
        ],
        "security": [
          {
            "customerApiAuth": []
          }
        ],
        "parameters": [
          {
            "in": "path",
            "name": "id",
            "required": true,
            "schema": {
              "type": "integer"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "status": {
                    "type": "string",
                    "enum": [
                      "active",
                      "paused",
                      "cancelled"
                    ],
                    "description": "Statuses the platform derives itself (expired, used, pending_payment) cannot be set here. Send `status` on its own - `autoRenew` and the allowance fields cannot be combined with it, because a status transition sets them itself. `active` only applies to a paused membership.\n"
                  },
                  "cancelMode": {
                    "type": "string",
                    "enum": [
                      "immediate",
                      "end_of_period"
                    ],
                    "default": "end_of_period",
                    "description": "Only valid with `status: cancelled`. Keep access until the paid period ends, or end it today."
                  },
                  "resumeAt": {
                    "type": "string",
                    "format": "date-time",
                    "nullable": true,
                    "description": "Only valid with `status: paused`. When the pause lifts by itself; must be in the future."
                  },
                  "autoRenew": {
                    "type": "boolean"
                  },
                  "maxUses": {
                    "type": "number",
                    "nullable": true,
                    "description": "Session allowance; null for unlimited"
                  },
                  "usesRemaining": {
                    "type": "number",
                    "nullable": true,
                    "description": "Sessions left, for correcting a balance after a migration"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The updated membership",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformMembership"
                }
              }
            }
          },
          "400": {
            "description": "Invalid body, or no fields to change",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformError"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/PlatformUnauthorized"
          },
          "403": {
            "description": "API key is missing the required `memberships:write` scope",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformError"
                }
              }
            }
          },
          "404": {
            "$ref": "#/components/responses/PlatformNotFound"
          },
          "429": {
            "$ref": "#/components/responses/PlatformRateLimited"
          }
        }
      }
    },
    "/v1/platform/pay-rate-policies": {
      "get": {
        "summary": "List pay rate policies",
        "description": "Returns the organization's pay rate policies - the rules for what the club pays its coaches. This is not an instructor's `hourlyRate`, which is what a member pays for an hour of their time; the two travel in opposite directions.\n\nA policy earns a coach either a flat amount per unit of work (a private session, a class, an attendee) or a percentage of what that work took, and covers a slice of the schedule described by three independent filters. Each one means **everything** when it is empty: no `instructors` named makes it the organization's default policy, applying to every coach who has none of their own; no `classTypes` covers every kind of class; no `entranceScopes` covers every way an attendee got in.\n\nFiltering by `instructorId` returns the policies that actually decide that coach's pay - the ones naming them, plus the default policies that would otherwise apply.\n\nThere is no paging: an organization has a handful of these. The pay ledger - what a coach has actually accrued - is not on this API.\n",
        "tags": [
          "Pay Rate Policies"
        ],
        "security": [
          {
            "customerApiAuth": []
          }
        ],
        "parameters": [
          {
            "in": "query",
            "name": "instructorId",
            "schema": {
              "type": "integer"
            },
            "description": "Only the policies that decide this instructor's pay"
          },
          {
            "in": "query",
            "name": "kind",
            "schema": {
              "type": "string",
              "enum": [
                "individual",
                "class"
              ]
            },
            "description": "Only policies for one-to-one sessions, or only those for classes"
          }
        ],
        "responses": {
          "200": {
            "description": "The organization's pay rate policies",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "data": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/PlatformPayRatePolicy"
                      }
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/PlatformBadRequest"
          },
          "401": {
            "$ref": "#/components/responses/PlatformUnauthorized"
          },
          "403": {
            "description": "API key is missing the required `instructors:read` scope",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformError"
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/PlatformRateLimited"
          }
        }
      },
      "post": {
        "summary": "Create a pay rate policy",
        "description": "Creates a rule for what the club pays its coaches. `earnings.kind` picks what the policy is about and which rates it accepts: `individual` for one-to-one sessions (`perSession`, `sessionRevenuePercentage`) and `class` for group classes (`perClass`, `perBooking`, `classRevenuePercentage`). At least one rate must be non-zero, and an individual policy cannot be scoped by class type.\n\n`instructorIds` must be sent. An empty list makes this the organization's default policy - it then applies to every coach who has no policy of their own, and creating it re-resolves every coach's pay - so that has to be asked for, never fallen into by omitting the field. `classTypeIds` and `entranceScopes` may be omitted; empty covers every class type or every way in.\n\nTwo policies may not claim the same coach for overlapping work. That is a 409 with code `PAY_RATE_POLICY_COLLISION`, carrying the coaches involved and the policy each one is already on, so the caller can say which assignment to undo. Supports the `Idempotency-Key` header.\n\nNeeds `instructors:write` - the same scope that creates a coach, because both are \"how this organization staffs and pays its sessions\". An OAuth connection made before that scope shipped has to be re-authorized.\n",
        "tags": [
          "Pay Rate Policies"
        ],
        "security": [
          {
            "customerApiAuth": []
          }
        ],
        "parameters": [
          {
            "in": "header",
            "name": "Idempotency-Key",
            "schema": {
              "type": "string",
              "maxLength": 255
            },
            "description": "Retry-safe key; a repeat of the same request replays the first response"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/PlatformPayRatePolicyCreate"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Pay rate policy created",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformPayRatePolicy"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/PlatformBadRequest"
          },
          "401": {
            "$ref": "#/components/responses/PlatformUnauthorized"
          },
          "403": {
            "description": "API key is missing the required `instructors:write` scope",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformError"
                }
              }
            }
          },
          "409": {
            "description": "An instructor on this policy is already covered by another one for the same work",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformError"
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/PlatformRateLimited"
          }
        }
      }
    },
    "/v1/platform/pay-rate-policies/{id}": {
      "get": {
        "summary": "Get a pay rate policy",
        "description": "Returns one pay rate policy, with the coaches, class types and entrance routes it covers.",
        "tags": [
          "Pay Rate Policies"
        ],
        "security": [
          {
            "customerApiAuth": []
          }
        ],
        "parameters": [
          {
            "in": "path",
            "name": "id",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uuid"
            },
            "description": "Pay rate policy id"
          }
        ],
        "responses": {
          "200": {
            "description": "The pay rate policy",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformPayRatePolicy"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/PlatformBadRequest"
          },
          "401": {
            "$ref": "#/components/responses/PlatformUnauthorized"
          },
          "403": {
            "description": "API key is missing the required `instructors:read` scope",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformError"
                }
              }
            }
          },
          "404": {
            "$ref": "#/components/responses/PlatformNotFound"
          },
          "429": {
            "$ref": "#/components/responses/PlatformRateLimited"
          }
        }
      },
      "patch": {
        "summary": "Update a pay rate policy",
        "description": "Edits a pay rate policy. Send only what changes; an omitted field is left alone. `instructorIds`, `classTypeIds` and `entranceScopes` each **replace** the whole list when sent - there is no element-wise merge for a set, so read the policy first and send the list you want.\n\n`earnings.kind` cannot change once the policy exists: an individual policy and a class policy earn against different units of work, so switching one would silently re-price everything it covers. Create the other kind instead. Sending `classTypeIds` to an individual policy is refused for the same reason it is on create. Both are 400s.\n\nA change that puts a coach on two overlapping policies is a 409 with code `PAY_RATE_POLICY_COLLISION`. Changing rates re-resolves the coach's not-yet-finalized pay from today onward; days already closed stay frozen.\n",
        "tags": [
          "Pay Rate Policies"
        ],
        "security": [
          {
            "customerApiAuth": []
          }
        ],
        "parameters": [
          {
            "in": "path",
            "name": "id",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uuid"
            },
            "description": "Pay rate policy id"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/PlatformPayRatePolicyUpdate"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The updated pay rate policy",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformPayRatePolicy"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/PlatformBadRequest"
          },
          "401": {
            "$ref": "#/components/responses/PlatformUnauthorized"
          },
          "403": {
            "description": "API key is missing the required `instructors:write` scope",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformError"
                }
              }
            }
          },
          "404": {
            "$ref": "#/components/responses/PlatformNotFound"
          },
          "409": {
            "description": "The change puts an instructor on two overlapping policies",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformError"
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/PlatformRateLimited"
          }
        }
      }
    },
    "/v1/platform/pay-rate-policies/{id}/instructors": {
      "post": {
        "summary": "Put instructors on a pay rate policy",
        "description": "Adds coaches to a policy, leaving the ones already on it alone - the additive counterpart of PATCH, which replaces the whole list. Adding a coach who is already covered by another policy for the same work is a 409 with code `PAY_RATE_POLICY_COLLISION`.\n\nNote that adding the first coach to a policy that named none narrows it: an empty list means \"every coach without a policy of their own\", so it stops being the organization's default.\n",
        "tags": [
          "Pay Rate Policies"
        ],
        "security": [
          {
            "customerApiAuth": []
          }
        ],
        "parameters": [
          {
            "in": "path",
            "name": "id",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uuid"
            },
            "description": "Pay rate policy id"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "instructorIds"
                ],
                "properties": {
                  "instructorIds": {
                    "type": "array",
                    "minItems": 1,
                    "items": {
                      "type": "integer"
                    },
                    "description": "Instructors to add. Already-assigned ones are left as they are."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The policy, with its instructors as they now stand",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformPayRatePolicy"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/PlatformBadRequest"
          },
          "401": {
            "$ref": "#/components/responses/PlatformUnauthorized"
          },
          "403": {
            "description": "API key is missing the required `instructors:write` scope",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformError"
                }
              }
            }
          },
          "404": {
            "$ref": "#/components/responses/PlatformNotFound"
          },
          "409": {
            "description": "An instructor is already covered by another policy for the same work",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformError"
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/PlatformRateLimited"
          }
        }
      }
    },
    "/v1/platform/pay-rate-policies/{id}/instructors/{instructorId}": {
      "delete": {
        "summary": "Take an instructor off a pay rate policy",
        "description": "Removes one coach from a policy. Their not-yet-finalized pay re-resolves to whichever policy covers them next, or to nothing if none does; days already closed stay frozen.\n\nRemoving the last named coach does not disable the policy - it turns it into the organization's default, applying to every coach without one of their own. The response shows the policy as it now stands, which is where that is visible.\n",
        "tags": [
          "Pay Rate Policies"
        ],
        "security": [
          {
            "customerApiAuth": []
          }
        ],
        "parameters": [
          {
            "in": "path",
            "name": "id",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uuid"
            },
            "description": "Pay rate policy id"
          },
          {
            "in": "path",
            "name": "instructorId",
            "required": true,
            "schema": {
              "type": "integer"
            },
            "description": "1club instructor id"
          }
        ],
        "responses": {
          "200": {
            "description": "The policy, with its instructors as they now stand",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformPayRatePolicy"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/PlatformBadRequest"
          },
          "401": {
            "$ref": "#/components/responses/PlatformUnauthorized"
          },
          "403": {
            "description": "API key is missing the required `instructors:write` scope",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformError"
                }
              }
            }
          },
          "404": {
            "$ref": "#/components/responses/PlatformNotFound"
          },
          "429": {
            "$ref": "#/components/responses/PlatformRateLimited"
          }
        }
      }
    },
    "/v1/platform/plan-categories": {
      "get": {
        "summary": "List membership plan categories",
        "description": "Returns every membership plan category in the organization, in display order - the headings on a price list and the sections of a plans page. Resolve a category here, then pass its `categoryId` as `planCategoryId` when creating or updating a plan.\n\nThere is no paging: an organization has a handful of these, and a caller composing a price list wants all of them. Carries the `plans` scopes rather than one of its own - nobody grants \"may edit categories but not plans\".\n",
        "tags": [
          "Plans"
        ],
        "security": [
          {
            "customerApiAuth": []
          }
        ],
        "responses": {
          "200": {
            "description": "Every plan category, in display order",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "data": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/PlatformPlanCategory"
                      }
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/PlatformUnauthorized"
          },
          "403": {
            "description": "API key is missing the required `plans:read` scope",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformError"
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/PlatformRateLimited"
          }
        }
      },
      "post": {
        "summary": "Create a membership plan category",
        "description": "Adds a membership plan category. Omit `sortOrder` and it goes to the end of the list, which is what you want unless you are rebuilding the whole ordering. Supports the `Idempotency-Key` header.\n",
        "tags": [
          "Plans"
        ],
        "security": [
          {
            "customerApiAuth": []
          }
        ],
        "parameters": [
          {
            "in": "header",
            "name": "Idempotency-Key",
            "schema": {
              "type": "string",
              "maxLength": 255
            },
            "description": "Retry-safe key; a repeat of the same request replays the first response"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "name"
                ],
                "properties": {
                  "name": {
                    "type": "string",
                    "maxLength": 255
                  },
                  "sortOrder": {
                    "type": "integer",
                    "minimum": 0,
                    "description": "Display position; defaults to the end of the list"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Plan category created",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformPlanCategory"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/PlatformBadRequest"
          },
          "401": {
            "$ref": "#/components/responses/PlatformUnauthorized"
          },
          "403": {
            "description": "API key is missing the required `plans:write` scope",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformError"
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/PlatformRateLimited"
          }
        }
      }
    },
    "/v1/platform/plan-categories/{id}": {
      "get": {
        "summary": "Get a membership plan category",
        "description": "Returns a single plan category and how many membership plans sit in it.",
        "tags": [
          "Plans"
        ],
        "security": [
          {
            "customerApiAuth": []
          }
        ],
        "parameters": [
          {
            "in": "path",
            "name": "id",
            "required": true,
            "schema": {
              "type": "integer"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "The plan category",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformPlanCategory"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/PlatformBadRequest"
          },
          "401": {
            "$ref": "#/components/responses/PlatformUnauthorized"
          },
          "403": {
            "description": "API key is missing the required `plans:read` scope",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformError"
                }
              }
            }
          },
          "404": {
            "$ref": "#/components/responses/PlatformNotFound"
          },
          "429": {
            "$ref": "#/components/responses/PlatformRateLimited"
          }
        }
      },
      "patch": {
        "summary": "Update a membership plan category",
        "description": "Renames a category or moves it in the display order. The plans in it are unaffected.",
        "tags": [
          "Plans"
        ],
        "security": [
          {
            "customerApiAuth": []
          }
        ],
        "parameters": [
          {
            "in": "path",
            "name": "id",
            "required": true,
            "schema": {
              "type": "integer"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "minProperties": 1,
                "properties": {
                  "name": {
                    "type": "string",
                    "maxLength": 255
                  },
                  "sortOrder": {
                    "type": "integer",
                    "minimum": 0
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The updated plan category",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformPlanCategory"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/PlatformBadRequest"
          },
          "401": {
            "$ref": "#/components/responses/PlatformUnauthorized"
          },
          "403": {
            "description": "API key is missing the required `plans:write` scope",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformError"
                }
              }
            }
          },
          "404": {
            "$ref": "#/components/responses/PlatformNotFound"
          },
          "429": {
            "$ref": "#/components/responses/PlatformRateLimited"
          }
        }
      }
    },
    "/v1/platform/plans": {
      "get": {
        "summary": "List and search membership plans",
        "description": "Returns the organization's membership plans. Resolve a plan here, then pass its `planId` to POST /v1/platform/memberships - the plan carries its own price, joining fee, billing frequency and session allowance, so the sale does not restate any of it.\n\nOnly plans the gym still sells are returned by default - a retired plan is not something an integration should surface or sell. Pass `includeInactive=true` to see retired plans as well, which is how you find a plan again after retiring it. Use the write endpoints below to create, update, retire, or delete plans.\n",
        "tags": [
          "Plans"
        ],
        "security": [
          {
            "customerApiAuth": []
          }
        ],
        "parameters": [
          {
            "in": "query",
            "name": "search",
            "schema": {
              "type": "string",
              "maxLength": 120
            },
            "description": "Match on plan name or description"
          },
          {
            "in": "query",
            "name": "clubId",
            "schema": {
              "type": "integer"
            },
            "description": "Only plans sellable at this club - the plans limited to it plus the organization-wide ones"
          },
          {
            "in": "query",
            "name": "sport",
            "schema": {
              "type": "string"
            },
            "description": "Only plans listing this sport (e.g. \"padel\")"
          },
          {
            "in": "query",
            "name": "includeInactive",
            "schema": {
              "type": "string",
              "enum": [
                "true",
                "false"
              ],
              "default": "false"
            },
            "description": "Include retired plans, which are hidden by default. Retired means `isActive` is false.\n"
          },
          {
            "in": "query",
            "name": "limit",
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 100,
              "default": 25
            }
          },
          {
            "in": "query",
            "name": "offset",
            "schema": {
              "type": "integer",
              "minimum": 0,
              "default": 0
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Page of plans",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformPlanPage"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/PlatformBadRequest"
          },
          "401": {
            "$ref": "#/components/responses/PlatformUnauthorized"
          },
          "403": {
            "description": "API key is missing the required `plans:read` scope",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformError"
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/PlatformRateLimited"
          }
        }
      },
      "post": {
        "summary": "Create a membership plan",
        "description": "Creates a membership plan for the organization. Supports the `Idempotency-Key` header.",
        "tags": [
          "Plans"
        ],
        "security": [
          {
            "customerApiAuth": []
          }
        ],
        "parameters": [
          {
            "in": "header",
            "name": "Idempotency-Key",
            "schema": {
              "type": "string",
              "maxLength": 255
            },
            "description": "Retry-safe key; a repeat of the same request replays the first response"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/PlatformPlanCreate"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Plan created",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformPlan"
                }
              }
            }
          },
          "403": {
            "description": "Missing `plans:write` scope"
          }
        }
      }
    },
    "/v1/platform/plans/{id}": {
      "get": {
        "summary": "Get a membership plan",
        "description": "Returns a single plan, including what a membership on it costs and what it grants. Retired plans resolve here too - hiding them is the list's job, and a caller that just retired a plan still needs to read it back or undo it.\n",
        "tags": [
          "Plans"
        ],
        "security": [
          {
            "customerApiAuth": []
          }
        ],
        "parameters": [
          {
            "in": "path",
            "name": "id",
            "required": true,
            "schema": {
              "type": "integer"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "The plan",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformPlan"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/PlatformBadRequest"
          },
          "401": {
            "$ref": "#/components/responses/PlatformUnauthorized"
          },
          "403": {
            "description": "API key is missing the required `plans:read` scope",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformError"
                }
              }
            }
          },
          "404": {
            "$ref": "#/components/responses/PlatformNotFound"
          },
          "429": {
            "$ref": "#/components/responses/PlatformRateLimited"
          }
        }
      },
      "patch": {
        "summary": "Update a membership plan",
        "description": "Updates a membership plan. Send only what changes; an omitted field is left alone.\n\nA **season plan** can be reached here even though GET /v1/platform/plans never lists one, because an id resolves by GET /v1/platform/plans/{id}. Those carry rules an ordinary plan does not: a season plan must bill `one_time`, belong to the season's own club, and count sessions in hours. An edit that would break one is refused with a 400 naming the rule (`SEASON_PLAN_BILLING_FREQUENCY`, `SEASON_PLAN_CLUB_MISMATCH`, `SEASON_PLAN_SESSION_UNIT`, …) rather than saved - a mis-set season plan does not error later, it sells a season pass with the wrong renewal behaviour or at the wrong venue. Renaming one, or retiring it with `isActive`, is unaffected.\n",
        "tags": [
          "Plans"
        ],
        "security": [
          {
            "customerApiAuth": []
          }
        ],
        "parameters": [
          {
            "in": "path",
            "name": "id",
            "required": true,
            "schema": {
              "type": "integer"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/PlatformPlanUpdate"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Plan updated",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformPlan"
                }
              }
            }
          },
          "403": {
            "description": "Missing `plans:write` scope"
          },
          "404": {
            "$ref": "#/components/responses/PlatformNotFound"
          }
        }
      },
      "delete": {
        "summary": "Delete a membership plan",
        "description": "Delete a plan with no dependent records. Set `isActive` to false to retire a plan that is already in use - a retired plan is still readable by id, and `includeInactive=true` brings it back into the list.\n",
        "tags": [
          "Plans"
        ],
        "security": [
          {
            "customerApiAuth": []
          }
        ],
        "parameters": [
          {
            "in": "path",
            "name": "id",
            "required": true,
            "schema": {
              "type": "integer"
            }
          }
        ],
        "responses": {
          "204": {
            "description": "Plan deleted"
          },
          "403": {
            "description": "Missing `plans:write` scope"
          },
          "409": {
            "description": "Plan is still referenced"
          }
        }
      }
    },
    "/v1/platform/product-categories": {
      "get": {
        "summary": "List product categories",
        "description": "Returns every product category in the organization, in display order - the tabs on the till and the sections of a shop page. Resolve a category here, then pass its `categoryId` when creating a product or filtering the catalogue.\n\nThere is no paging: an organization has a handful of these, and a caller composing a menu wants all of them.\n",
        "tags": [
          "Products"
        ],
        "security": [
          {
            "customerApiAuth": []
          }
        ],
        "responses": {
          "200": {
            "description": "Every product category, in display order",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "data": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/PlatformProductCategory"
                      }
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/PlatformUnauthorized"
          },
          "403": {
            "description": "API key is missing the required `products:read` scope",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformError"
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/PlatformRateLimited"
          }
        }
      },
      "post": {
        "summary": "Create a product category",
        "description": "Adds a product category. Omit `sortOrder` and it goes to the end of the list, which is what you want unless you are rebuilding the whole ordering. Supports the `Idempotency-Key` header.\n",
        "tags": [
          "Products"
        ],
        "security": [
          {
            "customerApiAuth": []
          }
        ],
        "parameters": [
          {
            "in": "header",
            "name": "Idempotency-Key",
            "schema": {
              "type": "string",
              "maxLength": 255
            },
            "description": "Retry-safe key; a repeat of the same request replays the first response"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "name"
                ],
                "properties": {
                  "name": {
                    "type": "string",
                    "maxLength": 255
                  },
                  "sortOrder": {
                    "type": "integer",
                    "minimum": 0,
                    "description": "Display position; defaults to the end of the list"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Product category created",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformProductCategory"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/PlatformBadRequest"
          },
          "401": {
            "$ref": "#/components/responses/PlatformUnauthorized"
          },
          "403": {
            "description": "API key is missing the required `products:write` scope",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformError"
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/PlatformRateLimited"
          }
        }
      }
    },
    "/v1/platform/product-categories/{id}": {
      "get": {
        "summary": "Get a product category",
        "description": "Returns a single product category and how many products sit in it.",
        "tags": [
          "Products"
        ],
        "security": [
          {
            "customerApiAuth": []
          }
        ],
        "parameters": [
          {
            "in": "path",
            "name": "id",
            "required": true,
            "schema": {
              "type": "integer"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "The product category",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformProductCategory"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/PlatformBadRequest"
          },
          "401": {
            "$ref": "#/components/responses/PlatformUnauthorized"
          },
          "403": {
            "description": "API key is missing the required `products:read` scope",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformError"
                }
              }
            }
          },
          "404": {
            "$ref": "#/components/responses/PlatformNotFound"
          },
          "429": {
            "$ref": "#/components/responses/PlatformRateLimited"
          }
        }
      },
      "patch": {
        "summary": "Update a product category",
        "description": "Renames a category or moves it in the display order. Products in it are unaffected.",
        "tags": [
          "Products"
        ],
        "security": [
          {
            "customerApiAuth": []
          }
        ],
        "parameters": [
          {
            "in": "path",
            "name": "id",
            "required": true,
            "schema": {
              "type": "integer"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "minProperties": 1,
                "properties": {
                  "name": {
                    "type": "string",
                    "maxLength": 255
                  },
                  "sortOrder": {
                    "type": "integer",
                    "minimum": 0
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The updated product category",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformProductCategory"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/PlatformBadRequest"
          },
          "401": {
            "$ref": "#/components/responses/PlatformUnauthorized"
          },
          "403": {
            "description": "API key is missing the required `products:write` scope",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformError"
                }
              }
            }
          },
          "404": {
            "$ref": "#/components/responses/PlatformNotFound"
          },
          "429": {
            "$ref": "#/components/responses/PlatformRateLimited"
          }
        }
      }
    },
    "/v1/platform/products": {
      "get": {
        "summary": "List and search products",
        "description": "Returns the organization's products - everything it sells over the counter, from drinks to racket grips. `search` matches name and description as a substring, and `sku` and `barcode` exactly - a partial code matches nothing, since a scan code is only useful whole.\n\nUnlike plans, deactivated products are returned too: an integration managing a catalogue has to be able to find what it switched off in order to reprice or restore it. Filter with `isActive=true` for what is currently on sale.\n",
        "tags": [
          "Products"
        ],
        "security": [
          {
            "customerApiAuth": []
          }
        ],
        "parameters": [
          {
            "in": "query",
            "name": "search",
            "schema": {
              "type": "string",
              "maxLength": 120
            },
            "description": "Substring of the name or description, or an exact SKU or barcode"
          },
          {
            "in": "query",
            "name": "clubId",
            "schema": {
              "type": "integer"
            },
            "description": "Only products limited to this club"
          },
          {
            "in": "query",
            "name": "categoryId",
            "schema": {
              "type": "integer"
            },
            "description": "Only products in this product category"
          },
          {
            "in": "query",
            "name": "brandId",
            "schema": {
              "type": "integer"
            },
            "description": "Only products of this brand"
          },
          {
            "in": "query",
            "name": "isActive",
            "schema": {
              "type": "boolean"
            },
            "description": "Only products that are (or are not) on sale"
          },
          {
            "in": "query",
            "name": "limit",
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 100,
              "default": 25
            }
          },
          {
            "in": "query",
            "name": "offset",
            "schema": {
              "type": "integer",
              "minimum": 0,
              "default": 0
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Page of products",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformProductPage"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/PlatformBadRequest"
          },
          "401": {
            "$ref": "#/components/responses/PlatformUnauthorized"
          },
          "403": {
            "description": "API key is missing the required `products:read` scope",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformError"
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/PlatformRateLimited"
          }
        }
      },
      "post": {
        "summary": "Create a product",
        "description": "Adds a product to the catalogue. `name` and `price` are all that is required; everything else refines how it behaves at the till and on customer-facing surfaces.\n\n`clubId`, `categoryId`, `brandId`, `taxRateId` and `revenueAccountId` must reference rows in this organization - a `400` names the first one that does not. A `barcode` **or `sku`** already claimed by another product, package, plan or access card is a `409`: both are scannable at the till, so a scanned code has to mean exactly one thing.\n\nCreating a product does not give it stock - putting units on the shelf is done in the admin portal. Supports the `Idempotency-Key` header.\n",
        "tags": [
          "Products"
        ],
        "security": [
          {
            "customerApiAuth": []
          }
        ],
        "parameters": [
          {
            "in": "header",
            "name": "Idempotency-Key",
            "schema": {
              "type": "string",
              "maxLength": 255
            },
            "description": "Retry-safe key; a repeat of the same request replays the first response"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/PlatformProductCreate"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Product created",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformProduct"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/PlatformBadRequest"
          },
          "401": {
            "$ref": "#/components/responses/PlatformUnauthorized"
          },
          "403": {
            "description": "API key is missing the required `products:write` scope",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformError"
                }
              }
            }
          },
          "409": {
            "$ref": "#/components/responses/PlatformConflict"
          },
          "429": {
            "$ref": "#/components/responses/PlatformRateLimited"
          }
        }
      }
    },
    "/v1/platform/products/{id}": {
      "get": {
        "summary": "Get a product",
        "description": "Returns a single product, including its price, cost price, category, and stock-keeping codes.",
        "tags": [
          "Products"
        ],
        "security": [
          {
            "customerApiAuth": []
          }
        ],
        "parameters": [
          {
            "in": "path",
            "name": "id",
            "required": true,
            "schema": {
              "type": "integer"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "The product",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformProduct"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/PlatformBadRequest"
          },
          "401": {
            "$ref": "#/components/responses/PlatformUnauthorized"
          },
          "403": {
            "description": "API key is missing the required `products:read` scope",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformError"
                }
              }
            }
          },
          "404": {
            "$ref": "#/components/responses/PlatformNotFound"
          },
          "429": {
            "$ref": "#/components/responses/PlatformRateLimited"
          }
        }
      },
      "patch": {
        "summary": "Update a product",
        "description": "Changes the fields you send and leaves the rest alone - nothing is required. Send `null` to clear a nullable field (`categoryId`, `barcode`, `costPrice`, ...); omit it to keep what is there. `images` and `features` are arrays and replace wholesale, so read the product first if you are adding to one.\n\nA `barcode` or `sku` is only checked for collisions when you actually send it, so re-saving a product without touching its codes never conflicts with itself.\n\nSet `isActive: false` to retire a product rather than deleting it - that keeps its sales history intact and takes it off the till.\n",
        "tags": [
          "Products"
        ],
        "security": [
          {
            "customerApiAuth": []
          }
        ],
        "parameters": [
          {
            "in": "path",
            "name": "id",
            "required": true,
            "schema": {
              "type": "integer"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/PlatformProductUpdate"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The updated product",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformProduct"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/PlatformBadRequest"
          },
          "401": {
            "$ref": "#/components/responses/PlatformUnauthorized"
          },
          "403": {
            "description": "API key is missing the required `products:write` scope",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformError"
                }
              }
            }
          },
          "404": {
            "$ref": "#/components/responses/PlatformNotFound"
          },
          "409": {
            "$ref": "#/components/responses/PlatformConflict"
          },
          "429": {
            "$ref": "#/components/responses/PlatformRateLimited"
          }
        }
      }
    },
    "/v1/platform/products/{id}/availability": {
      "get": {
        "summary": "Get how many are left to sell",
        "description": "Returns the number of units of a product that can be sold right now at each club.\n\nThis is the only view of stock on this API - receiving deliveries, corrections and the ledger behind them stay in the admin portal. It is also not simply the shelf count: a product assembled from a recipe has no shelf, and its figure is the limit its scarcest ingredient imposes, floored to whole units.\n\n`available: null` means the product is not stock-tracked at that club, so the till will sell it freely - it does not mean the product has run out.\n",
        "tags": [
          "Products"
        ],
        "security": [
          {
            "customerApiAuth": []
          }
        ],
        "parameters": [
          {
            "in": "path",
            "name": "id",
            "required": true,
            "schema": {
              "type": "integer"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Per-club availability",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "productId": {
                      "type": "integer"
                    },
                    "data": {
                      "type": "array",
                      "items": {
                        "type": "object",
                        "properties": {
                          "clubId": {
                            "type": "integer"
                          },
                          "available": {
                            "type": "integer",
                            "nullable": true,
                            "description": "Units sellable now; null when the product is not stock-tracked at this club."
                          }
                        }
                      }
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/PlatformBadRequest"
          },
          "401": {
            "$ref": "#/components/responses/PlatformUnauthorized"
          },
          "403": {
            "description": "API key is missing the required `products:read` scope",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformError"
                }
              }
            }
          },
          "404": {
            "$ref": "#/components/responses/PlatformNotFound"
          },
          "429": {
            "$ref": "#/components/responses/PlatformRateLimited"
          }
        }
      }
    },
    "/v1/platform/progression-tracks": {
      "get": {
        "summary": "List progression tracks",
        "description": "Returns the progression tracks the organization uses - its own and the global templates it has enabled - each a ladder of ranks in one discipline (a belt system, a swim-stage scheme). Resolve a track here, then pass its `id` as `progressionTrackId` when creating or updating a class type to make that type's sessions count toward its ranks.\n\nEach track lists the class types already counting toward it in `classTypeIds`. An empty list means every session counts; once any class type is linked, only sessions of the linked types do. Read-only: tracks and their levels are set up in the 1club admin dashboard. There is no paging: an organization has a handful of these. Carries the `classes` scopes rather than one of its own.\n",
        "tags": [
          "Classes"
        ],
        "security": [
          {
            "customerApiAuth": []
          }
        ],
        "responses": {
          "200": {
            "description": "Every progression track the organization uses",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "data": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/PlatformProgressionTrack"
                      }
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/PlatformUnauthorized"
          },
          "403": {
            "description": "API key is missing the required `classes:read` scope",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformError"
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/PlatformRateLimited"
          }
        }
      }
    },
    "/v1/platform/reports/revenue": {
      "get": {
        "summary": "Revenue report for a period",
        "description": "What the organization earned over a period, and from what, where and how it compares - in one request rather than a walk of the transactions list.\n\nRevenue is recognised ex-tax on the day of the sale, on the organization's own clock, net of refunds and of anything Wallet credit paid for. `collected` is the part of it already paid, on the same basis, so `uncollected` is what the period sold that has not been paid yet. These are the figures of the admin's daily revenue chart for the same days.\n\nThe period is compared with the same number of days just before it (`compare=previous_period`, the default) or the same dates a year earlier (`previous_year`); every breakdown row then carries its revenue in that period and the change, and `movers` lists the streams, clubs and items that moved most. Breakdowns: by revenue stream (bookings, memberships, passes, drop-ins, products, other), by club (unless `clubId` narrows the report to one), by the payment method that collected the money, and the top items sold (a class or court, a plan, a product). `trend` buckets the period by day, week (starting Monday) or month - by default a month or less by day, up to six months by week, anything longer by month.\n\nAmounts are in the organization's `currency`. A period may span at most 366 days. Reports have their own limit of 20 requests a minute, on top of the platform's.\n",
        "tags": [
          "Reports"
        ],
        "security": [
          {
            "customerApiAuth": []
          }
        ],
        "parameters": [
          {
            "in": "query",
            "name": "from",
            "required": true,
            "schema": {
              "type": "string",
              "format": "date"
            },
            "description": "First day of the period (`YYYY-MM-DD`), on the organization's clock."
          },
          {
            "in": "query",
            "name": "to",
            "required": true,
            "schema": {
              "type": "string",
              "format": "date"
            },
            "description": "Last day of the period, inclusive."
          },
          {
            "in": "query",
            "name": "clubId",
            "schema": {
              "type": "integer"
            },
            "description": "Only the revenue of this club - its bookings, plans, check-ins and the sales rung up there."
          },
          {
            "in": "query",
            "name": "billingEntityId",
            "schema": {
              "type": "integer"
            },
            "description": "Only the revenue of this selling company, for an organization that trades through more than one."
          },
          {
            "in": "query",
            "name": "compare",
            "schema": {
              "type": "string",
              "enum": [
                "previous_period",
                "previous_year",
                "none"
              ],
              "default": "previous_period"
            },
            "description": "What to compare the period with."
          },
          {
            "in": "query",
            "name": "granularity",
            "schema": {
              "type": "string",
              "enum": [
                "day",
                "week",
                "month"
              ]
            },
            "description": "How `trend` buckets the period. Chosen from the period's length when omitted."
          },
          {
            "in": "query",
            "name": "topItems",
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 50,
              "default": 10
            },
            "description": "How many items `topItems` lists."
          }
        ],
        "responses": {
          "200": {
            "description": "The revenue report",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformRevenueReport"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/PlatformBadRequest"
          },
          "401": {
            "$ref": "#/components/responses/PlatformUnauthorized"
          },
          "403": {
            "description": "API key is missing the required `transactions:read` scope",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformError"
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/PlatformRateLimited"
          }
        }
      }
    },
    "/v1/platform/revenue-accounts": {
      "get": {
        "summary": "List revenue accounts",
        "description": "Returns the organization's chart of accounts, ordered by code. This is how a `revenueAccountId` is resolved before creating or updating a membership plan or a product - a sale of that item then posts to the account named here.\n\nAdding or renaming an account changes how every future sale is reported and what the club's accountant reconciles against, so the writes are gated on `revenue-accounts:write`, which nothing else grants. There is no delete: every historical row pointing at an account would be stranded, so an account is taken out of use with `isActive: false`.\n\nRetired accounts are included, flagged with `isActive: false`: an existing plan or product may still point at one, so the id has to stay explicable. Do not attach one to something new.\n\nReadable with any scope that carries a revenue account id: the `plans`, `products`, `classes`, `areas` and `instructors` scopes (read *and* write - they are granted independently), plus `transactions:read` and `revenue-accounts:write`. Every one of those resources either reports `revenueAccountId` or accepts it, so requiring one scope in particular would leave a key able to set an account it cannot look up. There is no `revenue-accounts:read`.\n\nLeaving `revenueAccountId` unset is a valid choice: the sale then posts to whichever account the organization has marked `isDefault`, resolved at the time of the sale rather than frozen onto the item.\n",
        "tags": [
          "Revenue Accounts"
        ],
        "security": [
          {
            "customerApiAuth": []
          }
        ],
        "responses": {
          "200": {
            "description": "Every revenue account, ordered by code",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "data": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/PlatformRevenueAccount"
                      }
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/PlatformUnauthorized"
          },
          "403": {
            "description": "API key holds no scope that carries a revenue account id",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformError"
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/PlatformRateLimited"
          }
        }
      },
      "post": {
        "summary": "Create a revenue account",
        "description": "Adds an account to the chart of accounts. `code` is what the club's bookkeeper reconciles against and must be free within the organization - a clash is a 409, not a second account under the same code.\n\nSending `isDefault: true` moves the default off whichever account currently holds it, in the same transaction and behind a per-organization lock, so concurrent promotions cannot both win. The default is where a sale posts when the item names no account, resolved at the time of the sale rather than frozen onto the item, so moving it changes where future unattributed sales land and leaves the ones already taken alone.\n\nTwo things about the default are decided for you. An organization whose chart has no default yet gets one from this call whatever `isDefault` says, since creating an account is the only way to give it one - so the first account in an empty chart is always the default, and is therefore always created active. And a default can never be inactive, so `isActive: false` is refused for any request that would land one, whether `isDefault` was sent or inferred.\n\nRequires `revenue-accounts:write`. Supports the `Idempotency-Key` header.\n",
        "tags": [
          "Revenue Accounts"
        ],
        "security": [
          {
            "customerApiAuth": []
          }
        ],
        "parameters": [
          {
            "in": "header",
            "name": "Idempotency-Key",
            "schema": {
              "type": "string",
              "maxLength": 255
            },
            "description": "Retry-safe key; a repeat of the same request replays the first response"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/PlatformRevenueAccountCreate"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Revenue account created",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformRevenueAccount"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/PlatformBadRequest"
          },
          "401": {
            "$ref": "#/components/responses/PlatformUnauthorized"
          },
          "403": {
            "description": "API key is missing the required `revenue-accounts:write` scope",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformError"
                }
              }
            }
          },
          "409": {
            "description": "Another account in this organization already uses that `code`",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformError"
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/PlatformRateLimited"
          }
        }
      }
    },
    "/v1/platform/revenue-accounts/{id}": {
      "get": {
        "summary": "Get a revenue account",
        "description": "Returns one account from the chart of accounts, retired ones included. Readable with any scope that carries an account id, exactly as the list is - including `revenue-accounts:write`, so a key that maintains the chart can read back what it wrote.\n",
        "tags": [
          "Revenue Accounts"
        ],
        "security": [
          {
            "customerApiAuth": []
          }
        ],
        "parameters": [
          {
            "in": "path",
            "name": "id",
            "required": true,
            "schema": {
              "type": "integer"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "The revenue account",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformRevenueAccount"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/PlatformBadRequest"
          },
          "401": {
            "$ref": "#/components/responses/PlatformUnauthorized"
          },
          "403": {
            "description": "API key holds no scope that carries a revenue account id",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformError"
                }
              }
            }
          },
          "404": {
            "$ref": "#/components/responses/PlatformNotFound"
          },
          "429": {
            "$ref": "#/components/responses/PlatformRateLimited"
          }
        }
      },
      "patch": {
        "summary": "Update a revenue account",
        "description": "Changes an account. Send only what changes; an omitted field is left alone, and at least one field is required.\n\nThree things this will refuse. A `code` another account already uses is a 409. `isDefault: false` on the *last* account holding the default is a 400: an organization with no default has nowhere to post a sale that names no account. (An organization carrying more than one default - possible from before these rules, or from a clone - can have the extras cleared, which is how that state is repaired.) And anything that would leave the default inactive - retiring the current default, or promoting a retired account - is a 400, because the readers that resolve the default match on `isDefault` alone and would go on posting sales to an account this API calls retired. Move the default onto another account first, or reactivate and promote in the same call.\n\nRetiring an account (`isActive: false`) is otherwise how one is taken out of use. Everything already pointing at it keeps pointing at it - the flag is what tells a caller not to attach it to something new.\n\nRequires `revenue-accounts:write`.\n",
        "tags": [
          "Revenue Accounts"
        ],
        "security": [
          {
            "customerApiAuth": []
          }
        ],
        "parameters": [
          {
            "in": "path",
            "name": "id",
            "required": true,
            "schema": {
              "type": "integer"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/PlatformRevenueAccountUpdate"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The updated revenue account",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformRevenueAccount"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/PlatformBadRequest"
          },
          "401": {
            "$ref": "#/components/responses/PlatformUnauthorized"
          },
          "403": {
            "description": "API key is missing the required `revenue-accounts:write` scope",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformError"
                }
              }
            }
          },
          "404": {
            "$ref": "#/components/responses/PlatformNotFound"
          },
          "409": {
            "description": "Another account in this organization already uses that `code`",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformError"
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/PlatformRateLimited"
          }
        }
      }
    },
    "/v1/platform/transactions": {
      "get": {
        "summary": "List transactions for the organization",
        "description": "Returns transactions for the organization associated with the API token, filtered by transaction creation time and ordered by `createdAt` descending, then by id. Each bound is an ISO date-time, or a bare `YYYY-MM-DD` read as midnight UTC. `startDate` is inclusive and `endDate` is exclusive. When both are omitted, the window defaults to the last 24 hours (`endDate = now`, `startDate = now - 24h`); when only one is given, the other is derived from that same 24-hour span.\n\nA single request may span at most 93 days. Cover a longer range in consecutive windows, passing the previous window's `endDate` as the next one's `startDate` - because the upper bound is exclusive, that chains without counting a boundary transaction twice.\n\nFiltering by `contactId`, `bookingId` or `membershipId` lists that record's transactions. With one of those and no `startDate`/`endDate`, there is no window at all: every transaction of the contact, booking or membership comes back, newest first. Send a bound to window it as usual.\n\nNote that this endpoint returns a bare array with no total - a full page (`limit` rows) means there are probably more, so keep advancing `offset` until a short page comes back.\n\nOrdering is total (`createdAt` descending, then id), so `offset` paging is well defined for a fixed set of rows. It is not stable against concurrent writes: rows are ordered newest first, so a transaction created part-way through a walk lands on the first page and shifts every later row down by one, which can push an unread row past an `offset` already passed. Pass an explicit `endDate` in the past rather than relying on the `now` default - anything written after it falls outside the window and cannot shift the pages underneath you.\n",
        "tags": [
          "Transactions"
        ],
        "security": [
          {
            "customerApiAuth": []
          }
        ],
        "parameters": [
          {
            "in": "query",
            "name": "startDate",
            "schema": {
              "type": "string"
            },
            "description": "Inclusive lower bound on transaction `createdAt`, as an ISO date-time or `YYYY-MM-DD`. Defaults to `endDate - 24h`, or to no bound when filtering by contact, booking or membership."
          },
          {
            "in": "query",
            "name": "endDate",
            "schema": {
              "type": "string"
            },
            "description": "Exclusive upper bound on transaction `createdAt`, as an ISO date-time or `YYYY-MM-DD`. Defaults to `now`, or to no bound when filtering by contact, booking or membership."
          },
          {
            "in": "query",
            "name": "paymentStatus",
            "schema": {
              "type": "string",
              "enum": [
                "pending",
                "paid",
                "void",
                "failed",
                "settled",
                "overdue",
                "partially_paid",
                "refunded",
                "cancelled"
              ]
            },
            "description": "Filter by payment status. When omitted, all statuses are returned."
          },
          {
            "in": "query",
            "name": "contactId",
            "schema": {
              "type": "integer"
            },
            "description": "Only transactions charged to this contact (the payer). Lifts the default window when no date bound is sent."
          },
          {
            "in": "query",
            "name": "bookingId",
            "schema": {
              "type": "integer"
            },
            "description": "Only this booking's transactions - its charge, and any cancellation credit against it. Lifts the default window when no date bound is sent."
          },
          {
            "in": "query",
            "name": "membershipId",
            "schema": {
              "type": "integer"
            },
            "description": "Only this membership's transactions - its sale, renewals, signup fee and adjustments. Lifts the default window when no date bound is sent."
          },
          {
            "in": "query",
            "name": "transactionType",
            "schema": {
              "type": "string",
              "enum": [
                "booking_creation",
                "booking_cancellation",
                "membership_creation",
                "membership_recurrence",
                "membership_signup_fee",
                "membership_price_adjustment",
                "membership_cancellation",
                "product_sale",
                "external_program_entry",
                "checkin_creation",
                "settlement"
              ]
            },
            "description": "Only transactions of this kind."
          },
          {
            "in": "query",
            "name": "limit",
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 100,
              "default": 100
            },
            "description": "Maximum number of transactions to return."
          },
          {
            "in": "query",
            "name": "offset",
            "schema": {
              "type": "integer",
              "minimum": 0,
              "default": 0
            },
            "description": "Number of transactions to skip."
          }
        ],
        "responses": {
          "200": {
            "description": "List of transactions",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/PlatformTransaction"
                  }
                }
              }
            }
          },
          "400": {
            "description": "Invalid query parameters (e.g. malformed date, window > 93 days)",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformError"
                }
              }
            }
          },
          "401": {
            "description": "Invalid or missing API key",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformError"
                }
              }
            }
          },
          "403": {
            "description": "API key is missing the required `transactions:read` scope",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformError"
                }
              }
            }
          },
          "429": {
            "description": "Rate limit exceeded",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformError"
                }
              }
            }
          }
        }
      },
      "post": {
        "summary": "Record a settlement",
        "description": "Records revenue you collected outside 1club as one sum for a club - for example a period's takings for bookings you recorded at 0. It is a paid transaction of type `settlement` on the club, settled against your key's payment method, and counts in the club's revenue under the revenue account you name. It is not split across bookings or customers.\n\n`amount` is what customers paid, tax included; the tax is taken out at the revenue account's rate. A `payment.reference` your key already recorded is refused with `409 DUPLICATE_REFERENCE` (`details.transactionId` names the existing one). No receipt is sent. Supports the `Idempotency-Key` header.\n",
        "tags": [
          "Transactions"
        ],
        "security": [
          {
            "customerApiAuth": []
          }
        ],
        "parameters": [
          {
            "in": "header",
            "name": "Idempotency-Key",
            "schema": {
              "type": "string",
              "maxLength": 255
            },
            "description": "Retry-safe key; a repeat of the same request replays the first response"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "clubId",
                  "date",
                  "amount",
                  "revenueAccountId",
                  "payment"
                ],
                "properties": {
                  "clubId": {
                    "type": "integer"
                  },
                  "contactId": {
                    "type": "integer",
                    "description": "The customer the whole sum belongs to, if there is one."
                  },
                  "date": {
                    "type": "string",
                    "format": "date",
                    "description": "The day it is reported on."
                  },
                  "amount": {
                    "type": "number",
                    "description": "What was collected, tax included."
                  },
                  "description": {
                    "type": "string",
                    "maxLength": 500,
                    "description": "Defaults to your key's name."
                  },
                  "revenueAccountId": {
                    "type": "integer",
                    "description": "The account the revenue posts to; its tax rate applies."
                  },
                  "payment": {
                    "type": "object",
                    "required": [
                      "status",
                      "reference"
                    ],
                    "properties": {
                      "status": {
                        "type": "string",
                        "enum": [
                          "paid"
                        ]
                      },
                      "reference": {
                        "type": "string",
                        "maxLength": 128,
                        "description": "Your id for the money, e.g. a payout id."
                      },
                      "fee": {
                        "type": "number",
                        "description": "Your commission. Recorded, does not reduce the revenue."
                      }
                    }
                  }
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Settlement recorded",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformTransaction"
                }
              }
            }
          },
          "400": {
            "description": "Invalid request",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformError"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/PlatformUnauthorized"
          },
          "403": {
            "description": "API key is missing the required `transactions:write` scope",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformError"
                }
              }
            }
          },
          "409": {
            "description": "The reference is already recorded",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformError"
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/PlatformRateLimited"
          }
        }
      }
    },
    "/v1/platform/transactions/{id}": {
      "get": {
        "summary": "Get a transaction by ID",
        "description": "Returns a single transaction. The transaction must belong to the organization associated with the API token.\n",
        "tags": [
          "Transactions"
        ],
        "security": [
          {
            "customerApiAuth": []
          }
        ],
        "parameters": [
          {
            "in": "path",
            "name": "id",
            "required": true,
            "schema": {
              "type": "integer"
            },
            "description": "Transaction ID"
          }
        ],
        "responses": {
          "200": {
            "description": "Transaction details",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformTransaction"
                }
              }
            }
          },
          "400": {
            "description": "Invalid transaction ID",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformError"
                }
              }
            }
          },
          "401": {
            "description": "Invalid or missing API key",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformError"
                }
              }
            }
          },
          "403": {
            "description": "API key is missing the required `transactions:read` scope",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformError"
                }
              }
            }
          },
          "404": {
            "description": "Transaction not found",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformError"
                }
              }
            }
          },
          "429": {
            "description": "Rate limit exceeded",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformError"
                }
              }
            }
          }
        }
      }
    },
    "/v1/platform/transactions/{id}/refund": {
      "post": {
        "summary": "Refund a settlement",
        "description": "Records money given back out of a settlement your key recorded, reducing the club's revenue by it. Nothing is moved. Omit `amount` to refund everything still refundable. A `reference` already recorded on this settlement is refused with `409 DUPLICATE_REFERENCE`; send an `Idempotency-Key` too, so two retries in flight at once cannot both be recorded.\n",
        "tags": [
          "Transactions"
        ],
        "security": [
          {
            "customerApiAuth": []
          }
        ],
        "parameters": [
          {
            "in": "header",
            "name": "Idempotency-Key",
            "schema": {
              "type": "string",
              "maxLength": 255
            },
            "description": "Retry-safe key; a repeat of the same request replays the first response"
          },
          {
            "in": "path",
            "name": "id",
            "required": true,
            "schema": {
              "type": "integer"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "reference"
                ],
                "properties": {
                  "amount": {
                    "type": "number",
                    "description": "Tax included. Defaults to everything still refundable."
                  },
                  "reference": {
                    "type": "string",
                    "maxLength": 128,
                    "description": "Your refund id."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Refund recorded",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformTransaction"
                }
              }
            }
          },
          "400": {
            "description": "Invalid request, or more than is still refundable",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformError"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/PlatformUnauthorized"
          },
          "403": {
            "description": "API key is missing the required `transactions:write` scope",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformError"
                }
              }
            }
          },
          "404": {
            "description": "No settlement with this id was recorded by your key",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformError"
                }
              }
            }
          },
          "409": {
            "description": "Already refunded in full, or the reference is already recorded",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformError"
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/PlatformRateLimited"
          }
        }
      }
    },
    "/v1/platform/website": {
      "get": {
        "summary": "Get the website",
        "description": "Returns one website with its page list, layout, publish state and public URLs. Omit `siteId` for the organization's default website.\n`previewUrl` renders the unpublished draft, which is how you review edits made through this API before publishing them; `url` is what the public sees and only reflects the last published release.\n",
        "tags": [
          "Website"
        ],
        "security": [
          {
            "customerApiAuth": []
          }
        ],
        "parameters": [
          {
            "in": "query",
            "name": "siteId",
            "schema": {
              "type": "integer"
            },
            "description": "Which website, when the organization has more than one. Defaults to its default website."
          }
        ],
        "responses": {
          "200": {
            "description": "The website, its pages and its layout",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformWebsite"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/PlatformUnauthorized"
          },
          "403": {
            "description": "API key is missing the required `website:read` scope",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformError"
                }
              }
            }
          },
          "404": {
            "description": "No website with that id in this organization",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformError"
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/PlatformRateLimited"
          }
        }
      },
      "patch": {
        "summary": "Update website-level settings",
        "description": "Changes the website's name, theme behaviour and site-wide SEO defaults. Every field is optional; omitted fields are left alone.\n`supportedLocales` and `defaultLocale` are **organization-level** and need the additional `organization:write` scope. They also decide which languages the member portal, the mobile app and outbound email offer, and they take effect immediately rather than on publish - so they are a separate grant from editing the draft. They are reachable here at all because they are what decides which localized URLs the website serves: a page or section translated into an unlisted locale has no URL to be served at and no `hreflang` alternate pointing to it. A `defaultLocale` outside `supportedLocales` is a 400.\nEverything else in this body edits the **draft**, `seoSettings` included - it is stored in the site's settings, so a release carries it and the public site keeps serving the previously published values until you publish.\nDeliberately cannot change the site's address (`slug`, `domain`), which website is the organization's default, or whether the site is switched on - those move a customer's public presence and are not side effects a content edit should have.\n",
        "tags": [
          "Website"
        ],
        "security": [
          {
            "customerApiAuth": []
          }
        ],
        "parameters": [
          {
            "in": "query",
            "name": "siteId",
            "schema": {
              "type": "integer"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "name": {
                    "type": "string"
                  },
                  "themeSupport": {
                    "type": "string",
                    "enum": [
                      "both",
                      "light",
                      "dark"
                    ],
                    "description": "`both` lets a visitor switch; `light`/`dark` fixes the theme."
                  },
                  "defaultTheme": {
                    "type": "string",
                    "enum": [
                      "light",
                      "dark",
                      "system"
                    ],
                    "description": "Only meaningful when themeSupport is `both`."
                  },
                  "seoSettings": {
                    "type": "object",
                    "description": "Site-wide SEO defaults, replacing the whole object - read the current one from `GET /website` first. Every field is a fallback a page can override.\n",
                    "additionalProperties": false,
                    "properties": {
                      "siteDescription": {
                        "type": "string",
                        "nullable": true,
                        "description": "Meta description for pages that set none, and the `description` in the site's Organization / LocalBusiness / WebSite structured data.\n"
                      },
                      "defaultOgImage": {
                        "type": "string",
                        "format": "uri",
                        "nullable": true,
                        "description": "Social-share image for pages with no `ogImage`. Get a URL from `GET /media`."
                      },
                      "verification": {
                        "type": "object",
                        "nullable": true,
                        "additionalProperties": false,
                        "description": "Search-console ownership tokens, rendered as meta tags on every page.",
                        "properties": {
                          "google": {
                            "type": "string",
                            "nullable": true,
                            "description": "Google Search Console token"
                          },
                          "bing": {
                            "type": "string",
                            "nullable": true,
                            "description": "Bing Webmaster Tools token"
                          }
                        }
                      }
                    }
                  },
                  "buttonStyle": {
                    "type": "object",
                    "description": "How buttons look across the WHOLE website. Site-wide on purpose: a section chooses each button's emphasis (`primary`/`secondary`/`outline`/`ghost`), but a site with pill buttons in the hero and square ones in a CTA reads as broken. Unlike `seoSettings` this MERGES field by field, so setting the shape leaves the casing alone. An all-default style is stored as nothing, and the renderer falls back to `pill`.\n",
                    "additionalProperties": false,
                    "properties": {
                      "shape": {
                        "type": "string",
                        "enum": [
                          "pill",
                          "rounded",
                          "square"
                        ],
                        "description": "Corner treatment for every button. Defaults to `pill`."
                      },
                      "uppercase": {
                        "type": "boolean",
                        "description": "Uppercase button labels, with a little letter-spacing so they stay readable."
                      }
                    }
                  },
                  "cornerRadius": {
                    "type": "string",
                    "enum": [
                      "sharp",
                      "soft",
                      "round"
                    ],
                    "nullable": true,
                    "description": "How round every corner on the site is - section cards, media frames, form controls, and buttons on `sharp`/`round`. Defaults to `soft`, which is what every site rendered before this existed. `null` returns to the default.\n"
                  },
                  "spacing": {
                    "type": "string",
                    "enum": [
                      "compact",
                      "default",
                      "airy"
                    ],
                    "nullable": true,
                    "description": "The site's baseline vertical rhythm: the gap BETWEEN sections and the default padding inside one. A section's own `paddingY` overrides the padding half; the gap is the part no per-section control reaches. `null` returns to the default.\n"
                  },
                  "typeScale": {
                    "type": "string",
                    "enum": [
                      "compact",
                      "default",
                      "editorial"
                    ],
                    "nullable": true,
                    "description": "How large headings run relative to body text. A multiplier on the heading ladder rather than a set of sizes. `null` returns to the default.\n"
                  },
                  "clubId": {
                    "type": "integer",
                    "nullable": true,
                    "description": "Club this website represents. When set, the header and footer use that club's logo instead of the organization's. `null` clears the binding. Takes effect immediately, like `name` — logos are branding assets rather than a published token.\n"
                  },
                  "supportedLocales": {
                    "type": "array",
                    "description": "Locales this organization serves, in display order. Organization-level, requires `organization:write`, and applies immediately - see the endpoint description. Must contain `defaultLocale`.\n",
                    "items": {
                      "type": "string",
                      "enum": [
                        "en",
                        "es",
                        "fr",
                        "de",
                        "bg",
                        "cs"
                      ]
                    }
                  },
                  "defaultLocale": {
                    "type": "string",
                    "enum": [
                      "en",
                      "es",
                      "fr",
                      "de",
                      "bg",
                      "cs"
                    ],
                    "description": "Locale served at the site root. Organization-level, requires `organization:write`."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The updated website",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformWebsite"
                }
              }
            }
          },
          "400": {
            "description": "Invalid body",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformError"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/PlatformUnauthorized"
          },
          "403": {
            "description": "API key is missing `website:write`, or `organization:write` when the body carries locale fields",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformError"
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/PlatformRateLimited"
          }
        }
      }
    },
    "/v1/platform/website/sites": {
      "get": {
        "summary": "List the organization's websites",
        "description": "Every website the organization owns, its default one first. Use this when the organization runs more than one site to find the `siteId` the other endpoints take.\n",
        "tags": [
          "Website"
        ],
        "security": [
          {
            "customerApiAuth": []
          }
        ],
        "responses": {
          "200": {
            "description": "List of websites",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/PlatformWebsite"
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/PlatformUnauthorized"
          },
          "403": {
            "description": "API key is missing the required `website:read` scope",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformError"
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/PlatformRateLimited"
          }
        }
      }
    },
    "/v1/platform/website/section-types": {
      "get": {
        "summary": "Get the section-type catalog",
        "description": "Every section type a page or layout may use, with its config fields, their allowed values, and authoring notes.\nRead this before composing sections: it is the machine-readable definition of what a `hero` or a `plans` block accepts, and section types are added over time. `content` types belong in a page's `sections`; `layout` types (header, footer) belong to the site layout and appear on every page.\n",
        "tags": [
          "Website"
        ],
        "security": [
          {
            "customerApiAuth": []
          }
        ],
        "responses": {
          "200": {
            "description": "Section types with their config field definitions",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "content": {
                      "type": "array",
                      "items": {
                        "type": "string"
                      }
                    },
                    "layout": {
                      "type": "array",
                      "items": {
                        "type": "string"
                      }
                    },
                    "sectionTypes": {
                      "type": "object",
                      "additionalProperties": {
                        "type": "object",
                        "properties": {
                          "fields": {
                            "type": "array",
                            "items": {
                              "$ref": "#/components/schemas/WebsiteSectionField"
                            }
                          },
                          "notes": {
                            "type": "string"
                          },
                          "deprecated": {
                            "type": "boolean",
                            "description": "Retired — still stored and rendered, never offered for a new section."
                          },
                          "presets": {
                            "type": "array",
                            "description": "Named starting points. Each `config` is an overlay on the type's defaults.",
                            "items": {
                              "type": "object",
                              "properties": {
                                "key": {
                                  "type": "string"
                                },
                                "config": {
                                  "type": "object",
                                  "additionalProperties": true
                                }
                              }
                            }
                          }
                        }
                      }
                    },
                    "sharedStyle": {
                      "type": "object",
                      "description": "The style fields EVERY content section accepts, stated once instead of repeated in each entry of `sectionTypes`.\n",
                      "properties": {
                        "fields": {
                          "type": "array",
                          "items": {
                            "$ref": "#/components/schemas/WebsiteSectionField"
                          }
                        },
                        "notes": {
                          "type": "string"
                        }
                      }
                    },
                    "defaults": {
                      "type": "object",
                      "description": "The starting config for a new section, keyed by section type.",
                      "additionalProperties": {
                        "type": "object",
                        "additionalProperties": true
                      }
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/PlatformUnauthorized"
          },
          "403": {
            "description": "API key is missing the required `website:read` scope",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformError"
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/PlatformRateLimited"
          }
        }
      }
    },
    "/v1/platform/website/theme-tokens": {
      "get": {
        "summary": "Get the design-token catalog",
        "description": "Every design token the website accepts, what it means, what it falls back to when unset, and the named looks on offer.\nRead this before writing a theme. It is the machine-readable counterpart to `/section-types`: that one says what may go IN a page, this one says how the site LOOKS. Tokens are added over time, so read it rather than hardcoding the values.\nTwo things worth knowing before you write anything. **Unset is the normal state** — every token falls back to the organization's branding or to a value derived from the tokens above it, so a good theme sets the fewest tokens it needs and leaves the rest inherited. And **the tokens are only coherent in combinations**, which is what `presets` are for: start from one and adjust two or three tokens, rather than composing 32 from scratch.\n",
        "tags": [
          "Website"
        ],
        "security": [
          {
            "customerApiAuth": []
          }
        ],
        "responses": {
          "200": {
            "description": "The token catalog",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "tokens": {
                      "type": "array",
                      "description": "Every writable token.",
                      "items": {
                        "type": "object",
                        "properties": {
                          "key": {
                            "type": "string"
                          },
                          "type": {
                            "type": "string",
                            "description": "The control — `color`, `select` or `boolean`."
                          },
                          "group": {
                            "type": "string",
                            "description": "`palette`, `darkPalette`, `typography`, `shape` or `motion`."
                          },
                          "options": {
                            "type": "array",
                            "items": {
                              "type": "string"
                            },
                            "description": "Allowed values, for a `select`."
                          },
                          "defaultValue": {
                            "description": "What the token renders as when unset, where that is a fixed value."
                          },
                          "inheritsFrom": {
                            "type": "string",
                            "description": "What supplies the value when unset, where it is not a fixed value."
                          },
                          "order": {
                            "type": "integer"
                          },
                          "visibleIf": {
                            "type": "object",
                            "description": "When the token is relevant, as a comparison against a sibling."
                          }
                        }
                      }
                    },
                    "notes": {
                      "type": "object",
                      "description": "Authoring guidance, one entry per group.",
                      "additionalProperties": {
                        "type": "string"
                      }
                    },
                    "presets": {
                      "type": "array",
                      "description": "Named looks. Each `tokens` is a complete, coordinated set — apply one with `PUT /theme` and adjust from there.\n",
                      "items": {
                        "type": "object",
                        "properties": {
                          "key": {
                            "type": "string"
                          },
                          "tokens": {
                            "type": "object",
                            "additionalProperties": true
                          }
                        }
                      }
                    },
                    "fonts": {
                      "type": "array",
                      "description": "The families the site can load, with the category each belongs to — which is what a heading/body pairing is chosen on.\n",
                      "items": {
                        "type": "object",
                        "properties": {
                          "name": {
                            "type": "string"
                          },
                          "category": {
                            "type": "string"
                          }
                        }
                      }
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/PlatformUnauthorized"
          },
          "403": {
            "description": "API key is missing the required `website:read` scope",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformError"
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/PlatformRateLimited"
          }
        }
      }
    },
    "/v1/platform/website/theme": {
      "get": {
        "summary": "Get the website's theme",
        "description": "What the site has chosen, what that resolves to once the organization's branding and the declared defaults are applied, and which of the three supplied each value.\n`sources` is the field to read before changing anything: it tells you whether a colour is one the customer picked for this website (`site`), the organization's brand colour (`branding`), or a platform default. Overwriting a `branding` value is how a site quietly stops matching the rest of the customer's brand, so prefer leaving those alone unless asked.\nA token missing from `resolved` is one whose fallback is computed by the stylesheet from the palette — the surface, muted-text and border colours — rather than one that has no value.\n",
        "tags": [
          "Website"
        ],
        "security": [
          {
            "customerApiAuth": []
          }
        ],
        "parameters": [
          {
            "in": "query",
            "name": "siteId",
            "schema": {
              "type": "integer"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "The site theme",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "stored": {
                      "type": "object",
                      "description": "Exactly what this website has set. Absent keys are inherited.",
                      "additionalProperties": true
                    },
                    "resolved": {
                      "type": "object",
                      "description": "What each token renders as, inheritance applied.",
                      "additionalProperties": true
                    },
                    "sources": {
                      "type": "object",
                      "description": "Per token: `site`, `branding` or `default`.",
                      "additionalProperties": {
                        "type": "string"
                      }
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/PlatformUnauthorized"
          },
          "403": {
            "description": "API key is missing the required `website:read` scope",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformError"
                }
              }
            }
          },
          "404": {
            "description": "The organization has no website"
          },
          "429": {
            "$ref": "#/components/responses/PlatformRateLimited"
          }
        }
      },
      "put": {
        "summary": "Set the website's design tokens",
        "description": "Merges a flat token patch into the site's theme, optionally on top of a named preset. Read `GET /theme-tokens` first for the tokens, their allowed values and the presets.\n**This writes the draft**, like every other edit here — nothing a visitor sees changes until `POST /releases` publishes it. That is the point of tokens living on the website rather than on the organization's branding: a palette change is previewable, versioned in the release, and revertible.\nMerges field by field, so setting the heading font leaves the palette alone. Send `null` for a token to return it to what it inherits — from the organization's branding, from another token, or from the platform default. That is the only way back once a value has been written; there is no \"unset\" string.\nA `preset` is the exception to that merge: it REPLACES the look rather than adding to it. Every token any preset owns is cleared first, then the chosen preset's values are written. Otherwise switching from `night` to `editorial` would keep night's whole dark palette underneath and leave the site describing a hybrid nobody chose. Tokens no preset owns — a hand-picked brand colour — are untouched.\nWhen `preset` and `tokens` are both sent the preset is applied first and the tokens land on top, so \"use the editorial look but keep our heading font\" is one call.\nEvery problem with a patch is reported at once rather than one at a time. Tokens are validated against the catalog: an unknown key, a value outside a token's options, a font the site cannot load, or a colour that is not a hex are all refused rather than stored — a colour CSS cannot parse does not fall back, it paints the surface transparent.\n",
        "tags": [
          "Website"
        ],
        "security": [
          {
            "customerApiAuth": []
          }
        ],
        "parameters": [
          {
            "in": "query",
            "name": "siteId",
            "schema": {
              "type": "integer"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "preset": {
                    "type": "string",
                    "description": "A named look from the catalog, applied before `tokens`."
                  },
                  "tokens": {
                    "type": "object",
                    "description": "Token key to value. `null` returns a token to what it inherits. See `GET /theme-tokens` for the keys.\n",
                    "additionalProperties": {
                      "nullable": true,
                      "oneOf": [
                        {
                          "type": "string"
                        },
                        {
                          "type": "boolean"
                        }
                      ]
                    }
                  }
                }
              },
              "examples": {
                "preset": {
                  "summary": "Apply a named look, keeping the brand font",
                  "value": {
                    "preset": "editorial",
                    "tokens": {
                      "headingFontFamily": "Montserrat"
                    }
                  }
                },
                "adjust": {
                  "summary": "Flatten the surfaces and slow the motion down",
                  "value": {
                    "tokens": {
                      "shadow": "none",
                      "borderWidth": "bold",
                      "motion": "subtle"
                    }
                  }
                },
                "clear": {
                  "summary": "Go back to the organization's brand colour",
                  "value": {
                    "tokens": {
                      "primaryColor": null
                    }
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The theme after the patch, resolved"
          },
          "400": {
            "description": "An unknown token, a value outside its options, or an unparseable colour",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformError"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/PlatformUnauthorized"
          },
          "403": {
            "description": "API key is missing the required `website:write` scope",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformError"
                }
              }
            }
          },
          "404": {
            "description": "The organization has no website"
          },
          "429": {
            "$ref": "#/components/responses/PlatformRateLimited"
          }
        }
      }
    },
    "/v1/platform/website/css": {
      "get": {
        "summary": "Get the website's custom CSS",
        "description": "Returns the site-wide custom stylesheet, or an empty string when none is set.",
        "tags": [
          "Website"
        ],
        "security": [
          {
            "customerApiAuth": []
          }
        ],
        "parameters": [
          {
            "in": "query",
            "name": "siteId",
            "schema": {
              "type": "integer"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "The custom stylesheet",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "css": {
                      "type": "string"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/PlatformUnauthorized"
          },
          "403": {
            "description": "API key is missing the required `website:read` scope",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformError"
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/PlatformRateLimited"
          }
        }
      },
      "put": {
        "summary": "Replace the website's custom CSS",
        "description": "Sets the site-wide stylesheet, replacing it entirely - read `GET /css` first and send the whole thing back with your changes.\nApplied globally on the public site. Target sections with `.section-{type}` (`.section-hero`, `.section-footer`) and prefer theme variables such as `var(--primary-color)` so styles survive a light/dark switch. Script tags are rejected.\n",
        "tags": [
          "Website"
        ],
        "security": [
          {
            "customerApiAuth": []
          }
        ],
        "parameters": [
          {
            "in": "query",
            "name": "siteId",
            "schema": {
              "type": "integer"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "css"
                ],
                "properties": {
                  "css": {
                    "type": "string",
                    "description": "The complete stylesheet. Send an empty string to remove it."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The stored stylesheet"
          },
          "400": {
            "description": "Stylesheet too large, or contains a script tag",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformError"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/PlatformUnauthorized"
          },
          "403": {
            "description": "API key is missing the required `website:write` scope",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformError"
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/PlatformRateLimited"
          }
        }
      }
    },
    "/v1/platform/website/pages": {
      "get": {
        "summary": "List the website's pages",
        "description": "Every page on the site with its section types and SEO fields, but not the section bodies - fetch one page for those.\n",
        "tags": [
          "Website"
        ],
        "security": [
          {
            "customerApiAuth": []
          }
        ],
        "parameters": [
          {
            "in": "query",
            "name": "siteId",
            "schema": {
              "type": "integer"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "List of pages",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/PlatformWebsitePage"
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/PlatformUnauthorized"
          },
          "403": {
            "description": "API key is missing the required `website:read` scope",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformError"
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/PlatformRateLimited"
          }
        }
      },
      "post": {
        "summary": "Add a page",
        "description": "Creates a page on the draft. `slug` becomes its URL path segment and must be unique on the site; it is lowercased and stripped of surrounding slashes.\nAdding a page does not link to it - update the header's `navItems` and the footer's links afterwards, or visitors will never find it.\n",
        "tags": [
          "Website"
        ],
        "security": [
          {
            "customerApiAuth": []
          }
        ],
        "parameters": [
          {
            "in": "query",
            "name": "siteId",
            "schema": {
              "type": "integer"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "slug",
                  "title"
                ],
                "properties": {
                  "slug": {
                    "type": "string",
                    "description": "Lowercase letters, numbers and hyphens, e.g. \"personal-training\""
                  },
                  "title": {
                    "type": "string"
                  },
                  "isHomepage": {
                    "type": "boolean",
                    "description": "Makes this the homepage, clearing the flag on whichever page held it"
                  },
                  "sections": {
                    "type": "array",
                    "description": "Initial sections. See GET /section-types for the types and their config.",
                    "items": {
                      "type": "object",
                      "required": [
                        "type"
                      ],
                      "properties": {
                        "type": {
                          "type": "string"
                        },
                        "config": {
                          "type": "object"
                        },
                        "translations": {
                          "type": "object"
                        }
                      }
                    }
                  },
                  "metaTitle": {
                    "type": "string",
                    "nullable": true,
                    "description": "SEO title. Falls back to the page title."
                  },
                  "metaDescription": {
                    "type": "string",
                    "nullable": true,
                    "description": "Falls back to `seoSettings.siteDescription`."
                  },
                  "ogImage": {
                    "type": "string",
                    "nullable": true,
                    "description": "Absolute URL. Get one from GET /media rather than composing it."
                  },
                  "noindex": {
                    "type": "boolean",
                    "nullable": true,
                    "description": "Ask search engines not to index this page, and leave it out of `sitemap.xml`. For thank-you pages and campaign landing variants. The page stays publicly reachable - this is not a privacy control.\n"
                  },
                  "translations": {
                    "type": "object",
                    "nullable": true,
                    "description": "Per-locale `metaTitle`/`metaDescription`, keyed by locale: `{ \"bg\": { \"metaTitle\": \"…\" } }`. Replaces the whole map. `ogImage` is not localizable - one social card serves every locale.\nAn override only reaches a visitor for a locale the organization actually serves: check `supportedLocales` on `GET /website` and add the locale there first, or the translation has no URL to be served at.\n",
                    "additionalProperties": {
                      "type": "object",
                      "additionalProperties": false,
                      "properties": {
                        "metaTitle": {
                          "type": "string"
                        },
                        "metaDescription": {
                          "type": "string"
                        }
                      }
                    }
                  }
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "The created page",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformWebsitePage"
                }
              }
            }
          },
          "400": {
            "description": "Invalid slug, invalid section type, or the page limit is reached",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformError"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/PlatformUnauthorized"
          },
          "403": {
            "description": "API key is missing the required `website:write` scope",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformError"
                }
              }
            }
          },
          "409": {
            "description": "A page with that slug already exists",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformError"
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/PlatformRateLimited"
          }
        }
      }
    },
    "/v1/platform/website/pages/{slug}": {
      "get": {
        "summary": "Get one page",
        "description": "A single page with its full section list, config and translations.",
        "tags": [
          "Website"
        ],
        "security": [
          {
            "customerApiAuth": []
          }
        ],
        "parameters": [
          {
            "in": "path",
            "name": "slug",
            "required": true,
            "schema": {
              "type": "string"
            }
          },
          {
            "in": "query",
            "name": "siteId",
            "schema": {
              "type": "integer"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "The page",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformWebsitePage"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/PlatformUnauthorized"
          },
          "403": {
            "description": "API key is missing the required `website:read` scope",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformError"
                }
              }
            }
          },
          "404": {
            "description": "No page with that slug",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformError"
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/PlatformRateLimited"
          }
        }
      },
      "put": {
        "summary": "Update a page",
        "description": "Updates a page on the draft. Omitted fields are left alone, so this doubles as a partial update.\n`sections`, when present, **replaces the entire list** - which is the intended way to rebuild a page: one request that either applies whole or fails whole, rather than a sequence of per-section calls that can leave the page half-edited. `translations` replaces the whole locale map for the same reason. Send `metaTitle`/`metaDescription`/`ogImage` as `null` to clear them.\n",
        "tags": [
          "Website"
        ],
        "security": [
          {
            "customerApiAuth": []
          }
        ],
        "parameters": [
          {
            "in": "path",
            "name": "slug",
            "required": true,
            "schema": {
              "type": "string"
            }
          },
          {
            "in": "query",
            "name": "siteId",
            "schema": {
              "type": "integer"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "slug": {
                    "type": "string",
                    "description": "Renames the page, moving its public URL. Nothing redirects from the old one."
                  },
                  "title": {
                    "type": "string"
                  },
                  "isHomepage": {
                    "type": "boolean"
                  },
                  "sections": {
                    "type": "array",
                    "description": "Replaces every section on the page.",
                    "items": {
                      "type": "object",
                      "required": [
                        "type"
                      ],
                      "properties": {
                        "type": {
                          "type": "string"
                        },
                        "config": {
                          "type": "object"
                        },
                        "translations": {
                          "type": "object"
                        }
                      }
                    }
                  },
                  "metaTitle": {
                    "type": "string",
                    "nullable": true
                  },
                  "metaDescription": {
                    "type": "string",
                    "nullable": true
                  },
                  "ogImage": {
                    "type": "string",
                    "nullable": true
                  },
                  "noindex": {
                    "type": "boolean",
                    "nullable": true,
                    "description": "Keep the page out of search indexes and `sitemap.xml`. `false` or `null` makes it indexable again."
                  },
                  "translations": {
                    "type": "object",
                    "nullable": true,
                    "description": "Per-locale `metaTitle`/`metaDescription`, keyed by locale. **Replaces the whole map** - read the page first and send every locale back, the same way `sections` works. `null` clears all overrides.\n",
                    "additionalProperties": {
                      "type": "object",
                      "additionalProperties": false,
                      "properties": {
                        "metaTitle": {
                          "type": "string"
                        },
                        "metaDescription": {
                          "type": "string"
                        }
                      }
                    }
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The updated page",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformWebsitePage"
                }
              }
            }
          },
          "400": {
            "description": "Invalid body, invalid section type, or an attempt to leave the site without a homepage",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformError"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/PlatformUnauthorized"
          },
          "403": {
            "description": "API key is missing the required `website:write` scope",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformError"
                }
              }
            }
          },
          "404": {
            "description": "No page with that slug",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformError"
                }
              }
            }
          },
          "409": {
            "description": "The requested slug is taken by another page",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformError"
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/PlatformRateLimited"
          }
        }
      },
      "delete": {
        "summary": "Delete a page",
        "description": "Removes a page from the draft. A website must keep at least one page, so the last one cannot be deleted - delete the website itself instead. The homepage cannot be deleted while other pages remain - make another page the homepage first. Remember to drop the page from the header and footer links afterwards.\n",
        "tags": [
          "Website"
        ],
        "security": [
          {
            "customerApiAuth": []
          }
        ],
        "parameters": [
          {
            "in": "path",
            "name": "slug",
            "required": true,
            "schema": {
              "type": "string"
            }
          },
          {
            "in": "query",
            "name": "siteId",
            "schema": {
              "type": "integer"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Confirmation and the remaining page count",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "success": {
                      "type": "boolean"
                    },
                    "slug": {
                      "type": "string"
                    },
                    "pageCount": {
                      "type": "integer"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "The page is the homepage and other pages remain",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformError"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/PlatformUnauthorized"
          },
          "403": {
            "description": "API key is missing the required `website:write` scope",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformError"
                }
              }
            }
          },
          "404": {
            "description": "No page with that slug",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformError"
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/PlatformRateLimited"
          }
        }
      }
    },
    "/v1/platform/website/releases": {
      "get": {
        "summary": "List publish history",
        "description": "Releases newest first. The one with `isActive: true` is what the public site is serving; its id is what `POST /releases/{releaseId}/rollback` takes to undo a publish.\n",
        "tags": [
          "Website"
        ],
        "security": [
          {
            "customerApiAuth": []
          }
        ],
        "parameters": [
          {
            "in": "query",
            "name": "siteId",
            "schema": {
              "type": "integer"
            }
          },
          {
            "in": "query",
            "name": "limit",
            "schema": {
              "type": "integer"
            },
            "description": "Max results (1-100, default 20)"
          },
          {
            "in": "query",
            "name": "offset",
            "schema": {
              "type": "integer"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Releases and the total count",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "releases": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/PlatformWebsiteRelease"
                      }
                    },
                    "total": {
                      "type": "integer"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "Invalid pagination parameters",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformError"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/PlatformUnauthorized"
          },
          "403": {
            "description": "API key is missing the required `website:read` scope",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformError"
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/PlatformRateLimited"
          }
        }
      },
      "post": {
        "summary": "Publish the website",
        "description": "Makes the current draft live. Until this is called, edits made through this API are invisible to the public - this is the one endpoint that changes what visitors see, which is why it needs `website:publish` rather than `website:write`.\n`amend` (the default) overwrites the active release in place, so routine edits do not litter the history. `snapshot` cuts a new version, which is what you want before a large change so there is a version to roll back to.\nRequires the Website plan addon, the same as publishing from the admin editor.\n",
        "tags": [
          "Website"
        ],
        "security": [
          {
            "customerApiAuth": []
          }
        ],
        "parameters": [
          {
            "in": "query",
            "name": "siteId",
            "schema": {
              "type": "integer"
            }
          }
        ],
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "mode": {
                    "type": "string",
                    "enum": [
                      "amend",
                      "snapshot"
                    ],
                    "description": "`amend` (default) updates the live release in place; `snapshot` records a new version."
                  },
                  "note": {
                    "type": "string",
                    "description": "What changed. Shown in the publish history, most useful with `snapshot`."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The release now serving the public site",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformWebsiteRelease"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/PlatformUnauthorized"
          },
          "403": {
            "description": "API key is missing the `website:publish` scope, or the organization does not have the Website plan addon",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformError"
                }
              }
            }
          },
          "404": {
            "description": "No website with that id in this organization",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformError"
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/PlatformRateLimited"
          }
        }
      }
    },
    "/v1/platform/website/pages/{slug}/sections": {
      "post": {
        "summary": "Add a section to a page",
        "description": "Appends a section, or inserts it at `position`. A position past the end appends rather than failing.\nFor rebuilding a page, prefer `PUT /pages/{slug}` with the full `sections` array - it is one request instead of many, and cannot leave the page half-built.\n",
        "tags": [
          "Website"
        ],
        "security": [
          {
            "customerApiAuth": []
          }
        ],
        "parameters": [
          {
            "in": "path",
            "name": "slug",
            "required": true,
            "schema": {
              "type": "string"
            }
          },
          {
            "in": "query",
            "name": "siteId",
            "schema": {
              "type": "integer"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "type"
                ],
                "properties": {
                  "type": {
                    "type": "string",
                    "description": "A content section type from GET /section-types"
                  },
                  "config": {
                    "type": "object"
                  },
                  "translations": {
                    "type": "object"
                  },
                  "position": {
                    "type": "integer",
                    "description": "0-based insert position. Omit to append."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "The page, with the section in place",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformWebsitePage"
                }
              }
            }
          },
          "400": {
            "description": "Invalid section type, or the page's section limit is reached",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformError"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/PlatformUnauthorized"
          },
          "403": {
            "description": "API key is missing the required `website:write` scope",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformError"
                }
              }
            }
          },
          "404": {
            "description": "No page with that slug",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformError"
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/PlatformRateLimited"
          }
        }
      }
    },
    "/v1/platform/website/pages/{slug}/sections/{index}": {
      "patch": {
        "summary": "Update a section's config",
        "description": "Merges the given fields into the section's existing config; fields you do not send keep their current values. Array fields are replaced wholesale, so send the whole array when changing one entry.\n`index` is the section's 0-based position as returned by `GET /pages/{slug}`. Indexes shift when sections are added or removed, so re-read the page rather than reusing a stale one.\n`translations` is how one section's copy is localized without rewriting the page. Unlike `config` it **replaces** the whole locale map, so read the section first and send back every locale you are keeping.\n",
        "tags": [
          "Website"
        ],
        "security": [
          {
            "customerApiAuth": []
          }
        ],
        "parameters": [
          {
            "in": "path",
            "name": "slug",
            "required": true,
            "schema": {
              "type": "string"
            }
          },
          {
            "in": "path",
            "name": "index",
            "required": true,
            "schema": {
              "type": "integer"
            }
          },
          {
            "in": "query",
            "name": "siteId",
            "schema": {
              "type": "integer"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "config"
                ],
                "properties": {
                  "config": {
                    "type": "object",
                    "description": "Config fields to merge in"
                  },
                  "translations": {
                    "type": "object",
                    "description": "Per-locale overrides of this section's config, keyed by locale then field. Replaces the whole map. Leave `config` in the language the site is written in; a locale with no entry renders it as written.\n"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The page, with the section updated",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformWebsitePage"
                }
              }
            }
          },
          "400": {
            "description": "Invalid body, or the index is out of range",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformError"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/PlatformUnauthorized"
          },
          "403": {
            "description": "API key is missing the required `website:write` scope",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformError"
                }
              }
            }
          },
          "404": {
            "description": "No page with that slug",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformError"
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/PlatformRateLimited"
          }
        }
      },
      "delete": {
        "summary": "Remove a section from a page",
        "description": "Deletes the section at `index`. Later sections shift down by one, so re-read the page before deleting another.\n",
        "tags": [
          "Website"
        ],
        "security": [
          {
            "customerApiAuth": []
          }
        ],
        "parameters": [
          {
            "in": "path",
            "name": "slug",
            "required": true,
            "schema": {
              "type": "string"
            }
          },
          {
            "in": "path",
            "name": "index",
            "required": true,
            "schema": {
              "type": "integer"
            }
          },
          {
            "in": "query",
            "name": "siteId",
            "schema": {
              "type": "integer"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "The page, with the section gone",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformWebsitePage"
                }
              }
            }
          },
          "400": {
            "description": "The index is out of range",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformError"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/PlatformUnauthorized"
          },
          "403": {
            "description": "API key is missing the required `website:write` scope",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformError"
                }
              }
            }
          },
          "404": {
            "description": "No page with that slug",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformError"
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/PlatformRateLimited"
          }
        }
      }
    },
    "/v1/platform/website/layout/{slot}": {
      "put": {
        "summary": "Update the site header or footer",
        "description": "The header and footer are site-level and render on every page. Config is merged into what is there, creating the slot if the site has none.\nThe link arrays (`navItems` on the header, `linkCategories`/`links` on the footer) **replace** rather than merge - read the current layout from `GET /v1/platform/website` and send the complete array, or you will drop the links you left out.\n",
        "tags": [
          "Website"
        ],
        "security": [
          {
            "customerApiAuth": []
          }
        ],
        "parameters": [
          {
            "in": "path",
            "name": "slot",
            "required": true,
            "schema": {
              "type": "string",
              "enum": [
                "header",
                "footer"
              ]
            }
          },
          {
            "in": "query",
            "name": "siteId",
            "schema": {
              "type": "integer"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "config"
                ],
                "properties": {
                  "config": {
                    "type": "object",
                    "description": "Header or footer config. See GET /section-types for the fields."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The updated layout slot"
          },
          "400": {
            "description": "Invalid slot or body",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformError"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/PlatformUnauthorized"
          },
          "403": {
            "description": "API key is missing the required `website:write` scope",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformError"
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/PlatformRateLimited"
          }
        }
      }
    },
    "/v1/platform/website/releases/{releaseId}/rollback": {
      "post": {
        "summary": "Roll back to an earlier release",
        "description": "Restores a previous release: its content becomes the draft and is published as a new version, so the rollback is itself recorded rather than rewriting history. Use `GET /releases` to find the id.\nThis discards unpublished draft edits - the draft is overwritten with the release's content.\n",
        "tags": [
          "Website"
        ],
        "security": [
          {
            "customerApiAuth": []
          }
        ],
        "parameters": [
          {
            "in": "path",
            "name": "releaseId",
            "required": true,
            "schema": {
              "type": "integer"
            }
          },
          {
            "in": "query",
            "name": "siteId",
            "schema": {
              "type": "integer"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "The new release created by the rollback",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformWebsiteRelease"
                }
              }
            }
          },
          "400": {
            "description": "Invalid release id",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformError"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/PlatformUnauthorized"
          },
          "403": {
            "description": "API key is missing the `website:publish` scope, or the organization does not have the Website plan addon",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformError"
                }
              }
            }
          },
          "404": {
            "description": "No such release on this website",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlatformError"
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/PlatformRateLimited"
          }
        }
      }
    }
  },
  "tags": []
}