openapi: 3.1.0 info: title: FullClass API version: "1.0" description: | The FullClass API lets you integrate booking, customer management, gift cards, and promo codes into your own application using your tenant API key. ## Authentication Most endpoints require a **tenant API key** passed as a Bearer token: ``` Authorization: Bearer pk_your_public_key_here ``` The `Bearer` scheme name is matched case-insensitively, per RFC 7235, so `bearer` and `BEARER` work too. API keys are managed from **Admin → API Keys**. - Public keys start with `pk_`. They are safe to embed in browser JavaScript and are limited by scopes and optional allowed origins. - Secret keys start with `sk_`. They are for server-side use only. A secret key has every scope unless it was created **Restricted**, in which case it holds only the scopes ticked when it was created (they cannot be changed later; create a new key instead). ## Scopes Each operation's description names the scopes it requires, and its security requirement in this document lists them too (`ApiKey: [scope, ...]`). The key needs **all** of them. A missing one answers `403 insufficient_scope`, and the message names it. Some operations also refuse a public key whatever scopes it carries, and answer `403 secret_key_required`. Their descriptions say so. ## Cross-origin requests Browser clients calling from another origin get `Access-Control-Allow-Headers: Authorization, Content-Type, Idempotency-Key, X-FullClass-Page-Load`. That list is exact. **Sending any other request header makes the call non-simple, triggers a CORS preflight, and the preflight fails** — the request never reaches the server. This is worth knowing because of how it looks when it happens: every call is blocked, the browser reports a CORS error that reads like a misconfiguration on our side, and nothing appears in any server-side log to suggest a custom header was the cause. If you need to pass extra context, put it in the request body or the query string rather than a header. ## Money All monetary amounts are integers in the **smallest currency unit** (e.g. cents for USD). `price_per_seat: 2500` means $25.00. ## Pagination `GET /events` and `GET /bookings` use cursor-based pagination. Pass the `next_cursor` value from one response as the `cursor` parameter in the next request, with the same filters. When `has_more` is `false` you have reached the end, and `next_cursor` is an **empty string**, not `null`. A cursor the server did not issue is refused with `400 invalid_param`. `GET /courses` uses `limit`/`offset` instead. ## Errors All errors follow the same shape: ```json { "error": { "code": "not_found", "message": "event not found" } } ``` Two errors can come back from **any** endpoint and are listed on every operation below: - `403 insufficient_scope` — the key authenticated, but it does not carry the scope that operation requires. Scopes are enforced on every endpoint, including ones that read like public lookups: `GET /giftcards/pricing`, `GET /giftcards/passes/{id}`, `GET /giftcards/{code}/validate`, `GET /promocodes/{code}` and `GET /referrals/link/{token}` all require `bookings:write`. - `429 rate_limited` — see below. And four that describe the request itself rather than the operation: - `404 not_found` for a path that does not exist, and for an id your studio does not own (another studio's id answers exactly as an unknown one). - `405 method_not_allowed` for a method the path does not take, with the methods it does take in the `Allow` header and the message. - `415 unsupported_media_type` for a request body not sent as `Content-Type: application/json` (a `charset=utf-8` parameter is fine). - `413 request_too_large` for a body over 1 MiB. A body that is not one JSON value (malformed, or with anything after the value) is `400 invalid_json`. A well-formed body whose field has the wrong type (`"seat_count": 1.5`, or a number where a string goes) is `422 validation_error`, naming the field. Unknown fields in a body are ignored, so a client that sends a field this API does not know yet keeps working. Unknown or blank **query** parameters on a list are the opposite: they are refused with `400 invalid_param`, because a filter silently dropped returns the unfiltered list. ## Rate limits Two ceilings apply to every request, both counted per minute: - **300 per IP** across all of `/v1`. - **1000 per tenant** across all source IPs, so spreading requests over many IPs does not multiply a tenant's budget. Some endpoints are capped much lower, and these are the ones ordinary integration testing runs into: | Endpoint | Per-IP limit | | --- | --- | | `POST /events/{id}/waitlist`, `POST /courses/{id}/waitlist` | 5 / min | | `GET /promocodes/{code}`, `GET /referrals/link/{token}` | 10 / min | | `GET /giftcards/{code}/validate` | 10 / min | | `POST /bookings`, `POST /courses/{id}/bookings` | 20 / min | | `POST /events/{id}/checkout-attempt`, `POST /courses/{id}/checkout-attempt` | 20 / min | | `POST /prefill` | 20 / min | | `POST /events/{id}/hold` (and extend / release) | 20 / min | | `POST /giftcards` | 20 / min | | `GET /giftcards/pricing`, `GET /giftcards/passes/{id}`, `POST /courses/{id}/quote` | 30 / min | The checkout writes sit at 20 rather than a lower figure on purpose: the bucket key is the caller's IP, and several of a studio's customers routinely share one — an office, a school, a venue's guest wifi, a mobile carrier's CGNAT range. If you proxy customer traffic through your own server, every booking you create shares a single bucket, so send `CF-Connecting-IP` with the end customer's address or expect to hit this. A throttled response carries a `Retry-After` header in whole seconds. ## Verifying webhook signatures Every webhook delivery is a JSON `POST` signed with the endpoint's signing secret (`whsec_...`, shown once in **Settings → Webhooks**). The secret itself is never sent: do not look for it in the request or compare it like a password. Verify the signature header instead: ``` FullClass-Signature: t=1767225600,v1=<64 hex characters> ``` - `t` is the send time in Unix seconds. - `v1` is the hex HMAC-SHA256 of the string `"."`. - The HMAC key is the **whole secret string, `whsec_` prefix included**, as UTF-8 bytes. Do not base64-decode it. - Compare with a constant-time function (`hmac.compare_digest`, `crypto.timingSafeEqual`), not `==`. - Reject the delivery if `t` is more than **5 minutes** from your clock in either direction. The timestamp is inside the HMAC, so this is what stops a captured delivery being replayed. - Compute over the **raw body bytes**, before parsing. Parsing the JSON and re-serializing it changes whitespace and key order, and the signature will not match. Each delivery also carries `FullClass-Event-Id`, `FullClass-Event-Type` and `FullClass-Delivery-Id`. Retries are re-signed with a new timestamp, so dedupe on `FullClass-Event-Id`, never on the signature. Subscriptions created through `POST /webhooks` are signed too, but their secret is never revealed (see `WebhookSubscription`). Worked Node and Python examples are in the help center under **Integrations → Webhooks → Verifying the signature**; the help center opens with the studio's dashboard login, and the endpoint's own page in the dashboard links straight to that section. servers: - url: /v1 description: Current host components: securitySchemes: ApiKey: type: http scheme: bearer description: Tenant API key from Admin → API Keys (`pk_...` for public/browser use, `sk_...` for secret/server-side use) responses: Unauthorized: description: Invalid or missing API key (`unauthorized`) content: application/json: schema: $ref: "#/components/schemas/Error" Forbidden: description: | The API key does not carry a scope this operation requires (`insufficient_scope`; the message names the missing scope), or the operation needs a secret key and was called with a public one (`secret_key_required`). content: application/json: schema: $ref: "#/components/schemas/Error" RateLimited: description: | Too many requests (`rate_limited`). See **Rate limits** above for the per-endpoint ceilings. headers: Retry-After: description: Seconds to wait before retrying. schema: type: integer example: 60 content: application/json: schema: $ref: "#/components/schemas/Error" schemas: Error: type: object properties: error: type: object properties: code: type: string example: not_found message: type: string example: event not found decline_reason: type: string description: >- Provider-neutral reason a payment was declined. Present only on 402 payment_failed responses so clients can show a localized message; omitted for all other errors. enum: - cvv - postal - insufficient_funds - expired - card_details - card_not_supported - over_limit - session_expired - generic example: insufficient_funds customer_id: type: string description: >- The account that already holds the address. Present only on 409 email_taken from POST /v1/customers, and only to a secret key. reason: type: string description: >- Why a promo code that exists cannot be applied. Present only on 422 promo_code_invalid from GET /promocodes/{code}. Treat an unknown or absent value as a generic "not valid". enum: [expired, used_up, inactive, not_yet_valid, event_mismatch] valid_from: type: string format: date description: >- The calendar date a not_yet_valid promo code starts, as the studio entered it. Present only alongside that reason. required: [code, message] required: [error] Instructor: type: object description: | The public profile of whoever teaches an event or a course, as the booking page shows it. Events and courses build it the same way, so the same person reads the same on both. Only these four fields exist; an instructor's email, phone and role are never exposed. The object is omitted (not null) when nobody is assigned, or when the assigned user is no longer marked as an instructor. It is returned regardless of the tenant's `show_instructor_name` and `show_instructor_bio` settings; see those on the tenant config for whether to display it. properties: id: type: string example: usr_01HXYZ nickname: type: string description: | The instructor's chosen nickname, or their first name when they set none. Omitted when both are blank. Never a surname. example: Alex bio: type: string description: Omitted when the instructor has not written one. photo_url: type: string format: uri description: Omitted when the instructor has no photo. Address: type: object properties: formatted: type: string example: "123 Main St, New York, NY 10001" street_address: type: string example: "123 Main St" address_line2: type: string example: "Suite 100" city: type: string example: "New York" state: type: string example: "NY" postal_code: type: string example: "10001" country: type: string example: "United States" country_code: type: string example: "US" place_id: type: string description: Google Places ID lat: type: number format: float example: 40.7128 lng: type: number format: float example: -74.0060 district: type: string suburb: type: string Location: type: object properties: id: type: string example: loc_01HXYZ name: type: string example: "Main Location" kind: type: string enum: [physical, online, customer_address] description: | Where the class happens. `physical` is a venue with the address below. `online` is a meeting link. `customer_address` means the business travels to each customer, so the location has no address and a booking must send `visit_address`. address: $ref: "#/components/schemas/Address" description: | Always present, but returned as an empty object `{}` when the location has no address on file. Every field inside is itself omitted when empty, so treat any of them as optional. timezone: type: string example: "America/New_York" arrival_instructions: type: string description: Optional free-text arrival / parking directions for customers. Omitted when empty. example: "Free parking in the rear lot. Enter through the blue door." required: [id, name] CancellationPolicyTier: type: object properties: min_hours_before: type: integer description: Minimum hours before event start that this tier applies example: 168 refund_type: type: string enum: [full, partial, credit, no_refund, none] description: | What the customer gets when cancelling inside this tier: - `full` — full refund. - `partial` — refund of `refund_percent` of the amount paid. - `credit` — store credit for the full amount instead of money back. - `no_refund` — cancellation is allowed, but nothing is refunded. - `none` — online self-cancellation is blocked; the customer must contact the business. This is the only value that makes the booking non-cancellable through the API. example: full refund_percent: type: integer description: Refund percentage (only present when refund_type is 'partial') example: 50 required: [min_hours_before, refund_type] CancellationPolicy: type: object properties: id: type: string example: cp_01HXYZ name: type: string example: "Standard Cancellation" description: type: string example: "Full refund if canceled 24 hours in advance" tiers: type: array description: Time-based rules ordered from longest to shortest notice period. Find the tier where hours_until_event >= min_hours_before. items: $ref: "#/components/schemas/CancellationPolicyTier" allow_credit_instead_of_refund: type: boolean example: true no_show_policy: type: string enum: [no_refund, partial, full] example: "no_refund" required: [id, name, tiers, allow_credit_instead_of_refund, no_show_policy] TaxRate: type: object properties: id: type: string example: tr_01HXYZ name: type: string example: GST rate: type: integer description: Rate in millipercent (5% = 5000, 9.975% = 9975). To calculate tax amount, use ROUND(subtotal * rate / 100000) example: 5000 required: [id, name, rate] Addon: type: object properties: id: type: string example: adn_01HXYZ name: type: string example: Equipment rental description: type: string price: type: integer description: Price in smallest currency unit (e.g. cents) example: 1500 currency: type: string example: usd limit_type: type: string enum: [one_per_guest, one_per_booking, unlimited] required: [id, name, price, currency, limit_type] Picture: type: object properties: url: type: string format: uri thumbnail_url: type: string format: uri required: [url, thumbnail_url] Category: type: object description: A class category label, shown as a filter tab on the hosted booking page. properties: id: type: string example: cat_01HXYZ name: type: string example: Cooking required: [id, name] ZoneStamp: type: object description: > One instant resolved against the venue's timezone, so a consumer can render it without a timezone database of its own. Shift the paired timestamp by `offset_minutes` and format the result as UTC to get the venue's wall clock; use `abbreviation` for the label beside it. Supplied per instant rather than per event, because an event running across a daylight-saving change starts and ends on different offsets. Omitted when the event has no location timezone. properties: offset_minutes: type: integer description: Minutes east of UTC in effect at this instant. Negative in the Americas. example: -360 abbreviation: type: string description: > The label to show beside the clock. Usually what the timezone database emits, but not always — Alberta's permanent -06 is published here as ABT, the province's own abbreviation, where the database prints CST. example: ABT required: - offset_minutes - abbreviation Event: type: object properties: id: type: string example: evt_01HXYZ tenant_id: type: string class_id: type: string example: cls_01HXYZ instructor: $ref: "#/components/schemas/Instructor" starts_at: type: string format: date-time example: "2024-06-01T10:00:00Z" ends_at: type: string format: date-time example: "2024-06-01T11:00:00Z" starts_at_zone: $ref: "#/components/schemas/ZoneStamp" ends_at_zone: $ref: "#/components/schemas/ZoneStamp" booking_closes_at_zone: $ref: "#/components/schemas/ZoneStamp" description: The venue's clock at starts_at minus min_advance_booking_mins. Omitted when there is no minimum-advance cutoff or no venue zone. booking_opens_at_zone: $ref: "#/components/schemas/ZoneStamp" description: The venue's clock at starts_at minus max_advance_booking_mins. Omitted when there is no maximum-advance cutoff or no venue zone. capacity: type: integer example: 20 seats_remaining: type: integer example: 12 status: type: string enum: [scheduled, canceled] example: scheduled price_per_seat: type: integer description: Price per seat for a single seat, in the smallest currency unit (e.g. cents) example: 2500 price_options: type: array description: > The event's named prices (for example Adult and Child, or a sliding scale), in the studio's order. Present only for an event that sells spots at named prices; such an event is booked with `price_options` on POST /bookings, and refuses `seat_count` alone with `price_option_required`. Its `price_per_seat` is then only the cheapest option (a "from" price), and it never has `price_tiers`. items: type: object properties: id: type: string description: The option to name in `price_options[].price_option_id` on POST /bookings. class_option_id: type: string description: The class's option this event's option was copied from. Stable across the class's events; it is what a `?fc_option=` link carries. label: type: string description: The studio's own name for the option, untranslated. description: type: string price_per_seat: type: integer description: Price of one spot at this option, in the smallest currency unit. price_tiers: type: array description: > Optional quantity-based group-discount tiers, sorted ascending by min_seats. When a booking has at least `min_seats` seats, every seat costs that tier's `price_per_seat`; the highest matching tier wins. Seat counts below the first tier use the event's base `price_per_seat`. Omitted or empty means flat per-seat pricing. Returned by both the list and detail endpoints, so a list-only client can compute group pricing without a second request. items: type: object properties: min_seats: type: integer description: Seat threshold (2 or more) at which this per-seat price applies example: 3 price_per_seat: type: integer description: Per-seat price at this tier, in the smallest currency unit example: 8500 currency: type: string example: usd max_seats_per_booking: type: ["integer", "null"] description: Per-booking seat cap. Returned as `null` when the event has no cap. example: 4 class_name: type: string example: Morning Yoga class_description: type: string class_description_html: type: string ages: type: string customer_notes_prompt: type: string description: If set, customers should be shown a notes field with this label when booking (e.g. "Anything we should know before you arrive?") terms: type: array description: Terms & conditions id and name the customer must accept (full content via GET /events/{id}/terms). Returned by GET /events/{id}; omitted when empty. items: type: object properties: id: type: string name: type: string pictures: type: array items: $ref: "#/components/schemas/Picture" thumbnail_url: type: string description: First class picture's thumbnail. Populated on list responses, where `pictures` is omitted. image_url: type: string description: The same first class picture at full size (long edge up to 1600px). Populated alongside `thumbnail_url` on list responses. Use this rather than `thumbnail_url` for social cards and schema.org Event markup, where the 400px thumbnail is below Google's 696px minimum width. categories: type: array description: Active categories assigned to the event's class. Omitted when empty. items: $ref: "#/components/schemas/Category" location: $ref: "#/components/schemas/Location" description: Full location details (null if no location assigned) cancellation_policy: $ref: "#/components/schemas/CancellationPolicy" description: Cancellation policy details. Detail-only (`GET /events/{id}`); never returned by the list endpoint. Omitted when the event has no policy. arrival_time_minutes: type: integer description: Minutes before event start that customers should arrive. **Omitted** (not returned as `0`) when no early arrival is required. example: 15 is_tax_exempt: type: boolean description: If true, no tax will be applied to bookings for this event example: false is_private: type: boolean description: | True when the event has been bought out as a private booking. Such an event is **not bookable**, and the API deliberately reveals nothing about it: - `GET /events` still lists it, but masked — `class_name` is replaced with "Private Event" and `seats_remaining` is forced to `0`, so it renders as sold out. It drops out entirely when you pass `has_availability=true`. - `GET /events/{id}` (and its `/waivers`, `/terms`, `/addons` sub-resources) returns `404 not_found`. - `POST /bookings` and the seat-hold endpoints return `422 private_class`. An owner who wants the event gone from the list as well hides it from the public calendar separately; that is not implied by `is_private`. example: false booking_paused: type: boolean description: | True when the studio has temporarily switched off new bookings for this event. Unlike `is_private`, nothing is hidden or masked — the pause governs **buying**, not seeing: - `GET /events` lists it normally, with its real `class_name`, `price_per_seat` and `seats_remaining`. It drops out when you pass `has_availability=true`, because it cannot be bought. - `GET /events/{id}` returns it in full, so a client can render the event and explain why it is unavailable. - `POST /bookings` and the seat-hold endpoints return `422 booking_paused`. - Joining the waitlist is refused for the same reason: a paused event never promotes an entry into an offer. The event still runs and every existing booking stands — a pause is forward-only and refunds nothing. Render it as unavailable with a reason rather than as sold out; `seats_remaining` is still truthful and will not be `0`. example: false tax_rates: type: array description: | Tax rates applicable to this event based on its location. Detail-only (`GET /events/{id}`); never returned by the list endpoint. Omitted entirely, not returned as `[]`, when `is_tax_exempt` is true or the location has no tax rates. items: $ref: "#/components/schemas/TaxRate" tax_inclusive: type: boolean description: | True when the business's prices already include tax (the EU VAT convention). `price_per_seat` is then what the customer pays, and each of `tax_rates` is contained in it rather than added on top: a rate's share of a price `p` is `ROUND(p * rate / (100000 + sum of rates))`. False when tax is added on top of the price. example: false addons: type: array description: Active add-ons for the event's class. Returned by both the list and detail endpoints. Omitted when empty. items: $ref: "#/components/schemas/Addon" waivers: type: array description: Waiver id and name the customer must accept (full content via GET /events/{id}/waivers). Returned by GET /events/{id}; omitted when empty. items: type: object properties: id: type: string name: type: string min_advance_booking_mins: type: integer description: Minimum minutes before start that booking is allowed. **Omitted** (not returned as `null`) when there is no minimum restriction. max_advance_booking_mins: type: integer description: Maximum minutes before start that booking is allowed. **Omitted** (not returned as `null`) when there is no maximum restriction. has_promo_codes: type: boolean description: Whether this tenant currently has active promo codes available for booking. has_gift_cards: type: boolean description: Whether this tenant currently has gift cards available for booking. created_at: type: string format: date-time required: [ id, tenant_id, class_id, starts_at, ends_at, capacity, seats_remaining, status, price_per_seat, currency, class_name, is_tax_exempt, created_at, ] Course: type: object properties: id: type: string example: crs_01HXYZ tenant_id: type: string name: type: string example: Beginner Pottery (6 weeks) description: type: string description: Raw markdown source of the course description. Omitted when empty. description_html: type: string description: The course description rendered to HTML. Omitted when the description is empty. price: type: integer description: Total course price, in the smallest currency unit (e.g. cents). For a course with `price_options`, the cheapest option (a "from" price). example: 24000 price_options: type: array description: > The course's named prices (for example Full price and Sibling), in the studio's order. Each price is the whole course, per spot. Present only for a course that sells spots at named prices; such a course is quoted and booked with `price_options`, and refuses `seat_count` alone with `price_option_required`. items: type: object properties: id: type: string description: The option to name in `price_options[].price_option_id` on the quote and bookings calls. It is also what a `?fc_option=` link carries beside `?fc_course=`. label: type: string description: The studio's own name for the option, untranslated. description: type: string price: type: integer description: The whole course, per spot, in the smallest currency unit (e.g. cents). currency: type: string example: usd capacity: type: integer example: 12 seats_remaining: type: integer example: 5 status: type: string enum: [scheduled, canceled] example: scheduled session_count: type: integer description: Number of sessions that will run. Canceled sessions are not counted; `sessions` still lists them with their status. example: 6 is_tax_exempt: type: boolean late_signup: type: boolean description: | Whether the course keeps selling after it has started. `false` (the default): public sales close when the first non-canceled session starts. `true`: the course stays on sale until its last non-canceled session starts, at the full course price, and the buyer joins from the next session (see `has_started` on each session). max_seats_per_booking: type: integer description: Per-booking seat cap. Omitted (not `null`) when the course has no cap. starts_at: type: string format: date-time description: Start of the first session that will run (canceled sessions are skipped, as in `session_count`; when every session is canceled, the first of them). Omitted when the course has no sessions scheduled yet. ends_at: type: string format: date-time description: End of the last session that will run (canceled sessions are skipped, as in `session_count`; when every session is canceled, the last of them). Omitted when the course has no sessions scheduled yet. starts_at_zone: $ref: "#/components/schemas/ZoneStamp" ends_at_zone: $ref: "#/components/schemas/ZoneStamp" instructor_name: type: string description: | The assigned user's nickname, or full name when they set none. Kept for older integrations; prefer `instructor`, which matches an event's. instructor: $ref: "#/components/schemas/Instructor" location_name: type: string location_timezone: type: string location_kind: type: string enum: [physical, online, customer_address] description: | The course location's kind. `customer_address` means the business travels to the buyer, so a course booking must send `visit_address`. Omitted when the course has no location. cancellation_policy: $ref: "#/components/schemas/CancellationPolicy" description: Cancellation policy details (null if no policy). Returned by GET /courses/{id}. sessions: type: array description: | Per-session schedule. Returned by GET /courses/{id}, and by the list endpoint with `include=sessions`; omitted from the list otherwise. Sessions in the list carry no per-session `categories`. items: $ref: "#/components/schemas/CourseSession" categories: type: array description: | Active categories of the classes the course's non-canceled sessions run, name-sorted, each listed once. A course has no categories of its own; a course whose sessions all run one class carries that class's categories. Returned by both the list and GET /courses/{id}. Omitted when empty. items: $ref: "#/components/schemas/Category" color: type: string customer_notes_prompt: type: string description: If set, customers should be shown a notes field with this label when booking. waivers: type: array description: Waiver id and name the customer must accept (full content via GET /courses/{id}/waivers). Returned by GET /courses/{id}. items: type: object properties: id: type: string name: type: string terms: type: array description: Terms & conditions id and name the customer must accept (full content via GET /courses/{id}/terms). Returned by GET /courses/{id}. items: type: object properties: id: type: string name: type: string has_promo_codes: type: boolean has_gift_cards: type: boolean created_at: type: string format: date-time required: [ id, tenant_id, name, price, currency, capacity, seats_remaining, status, session_count, is_tax_exempt, late_signup, created_at, ] CourseSession: type: object properties: event_id: type: string example: evt_01HXYZ starts_at: type: string format: date-time ends_at: type: string format: date-time starts_at_zone: $ref: "#/components/schemas/ZoneStamp" ends_at_zone: $ref: "#/components/schemas/ZoneStamp" class_id: type: string class_name: type: string class_color: type: string seats_remaining: type: integer status: type: string enum: [scheduled, canceled] session_number: type: integer example: 1 categories: type: array description: Active categories assigned to the session's class. Omitted when empty. items: $ref: "#/components/schemas/Category" has_started: type: boolean description: | Whether the session had already started when the response was built, by the server's clock. Only a course with `late_signup` is sold once one has; a buyer joins from the first non-canceled session with `has_started: false`. required: [ event_id, starts_at, ends_at, class_id, class_name, seats_remaining, status, session_number, has_started, ] Waiver: type: object properties: id: type: string example: wvr_01HXYZ name: type: string example: Liability Waiver content: type: string description: Raw markdown source content_html: type: string description: Rendered HTML version: type: integer example: 1 required: [id, name, content, content_html, version] Terms: type: object properties: id: type: string example: trm_01HXYZ name: type: string example: Terms & Conditions content: type: string description: Raw markdown source content_html: type: string description: Rendered HTML version: type: integer example: 1 required: [id, name, content, content_html, version] Attendee: type: object properties: first_name: type: string last_name: type: string email: type: string format: email phone: type: string is_lead_booker: type: boolean required: [first_name, last_name] BookingAddon: type: object properties: addon_id: type: string name: type: string price: type: integer description: Price in smallest currency unit currency: type: string quantity: type: integer required: [addon_id, name, price, currency, quantity] Booking: type: object properties: id: type: string example: bkg_01HXYZ booking_number: type: string description: Short human-readable reference for the customer, e.g. "BK4F9X" example: BK4F9X tenant_id: type: string event_id: type: string description: The event (scheduled class) ID that was booked seat_count: type: integer example: 2 price_options: type: array description: The booking's spots at each named price, from what each seat was sold at (renaming or repricing the option later does not change it). Omitted for a booking at one price. items: type: object properties: price_option_id: type: string label: type: string quantity: type: integer unit_price_cents: type: integer subtotal_amount: type: integer description: Seats plus add-ons before any discount, in smallest currency unit example: 5000 discount_amount: type: integer description: Discount applied (promo code, referral or price adjustment), in smallest currency unit example: 0 tax_amount: type: integer description: Tax charged, in smallest currency unit example: 0 total_amount: type: integer description: | Total in smallest currency unit: subtotal minus discount plus tax, BEFORE any gift card or class pass. The amount taken by the payment provider is `total_amount - gift_card_amount`. example: 5000 gift_card_amount: type: integer description: Part of the total covered by gift cards or class passes, in smallest currency unit example: 0 currency: type: string example: usd status: type: string enum: [confirmed, canceled] example: confirmed payment_status: type: string example: pending cancellation_token: type: string description: | Opaque token used to cancel this booking without authentication. **Present on the first successful create, and omitted on an idempotent replay** — so treat it as optional and store it when you first see it. `GET /bookings/{id}` returns it to a secret key only; a public-key read omits it, because a public key is readable by anyone who can view the studio's website. The replay short-circuit matches on the idempotency key alone, so echoing the token there would let anyone who learned a key take over the booking. If you lost the original response, read the booking back with `GET /bookings/{id}` (needs `bookings:read`); the buyer also has the token in their confirmation email. customer_notes: type: string description: Optional free-text notes about this booking visit_address: type: string description: | Where the business goes for this booking, when the event's location is `customer_address`. Returned only to a secret API key, and never on an idempotent replay. Omitted when empty. addons: type: array items: $ref: "#/components/schemas/BookingAddon" created_at: type: string format: date-time required: [ id, tenant_id, event_id, seat_count, total_amount, currency, status, payment_status, created_at, ] CourseQuote: type: object properties: course_id: type: string course_name: type: string course_price: type: integer description: Total course list price, in the smallest currency unit (e.g. cents). For a course with price options, the cheapest option. price_options: type: array description: The spots priced at each named price, for a course with price options. Omitted for a course at one price. items: type: object properties: price_option_id: type: string label: type: string quantity: type: integer unit_price_cents: type: integer description: The whole course, per spot. session_count: type: integer subtotal_amount: type: integer discount_amount: type: integer tax_amount: type: integer tax_inclusive: type: boolean description: | True when `tax_amount` is already inside the subtotal (the business's prices include tax), so `total_amount` is `subtotal_amount - discount_amount`. False when tax is added on top, so `total_amount` is `subtotal_amount - discount_amount + tax_amount`. total_amount: type: integer gift_card_amount: type: integer description: Amount covered by an applied balance gift card charge_amount: type: integer description: Amount to charge the payment provider (total minus any gift card balance) currency: type: string example: usd promo_code_id: type: string promo_code_code: type: string gift_card_id: type: string gift_card_code: type: string taxes: type: array items: type: object properties: tax_rate_id: type: string name: type: string rate: type: integer description: Rate in millipercent (5% = 5000, 9.975% = 9975) amount: type: integer required: [tax_rate_id, name, rate, amount] gift_cards: type: array description: Each applied gift card's share of `gift_card_amount`, in the order the cards are spent. Absent when no card applies. items: type: object properties: code: type: string amount_applied: type: integer description: Amount this card covers, in the smallest currency unit required: [code, amount_applied] required: [ course_id, course_name, course_price, session_count, subtotal_amount, discount_amount, tax_amount, total_amount, gift_card_amount, charge_amount, currency, ] BookingDetail: description: A booking as `GET /v1/bookings/{id}` returns it. allOf: - $ref: "#/components/schemas/Booking" - type: object properties: course_id: type: string description: Omitted unless the booking is one session of a course purchase. course_purchase_id: type: string description: Omitted unless the booking is one session of a course purchase. course_purchase: description: | `null` for a standalone booking. For a course session, the purchase as a whole, with amounts summed across every session. oneOf: - $ref: "#/components/schemas/CoursePurchaseSummary" - type: "null" required: [course_purchase] CourseBooking: type: object properties: id: type: string example: bkg_01HXYZ booking_number: type: string description: Short human-readable reference for the customer, e.g. "BK4F9X" example: BK4F9X course_id: type: string course_purchase_id: type: string description: Shared ID linking the per-session bookings created for this course purchase seat_count: type: integer price_options: type: array description: The purchase's spots at each named price, for a course with price options. Omitted for a purchase at one price. items: type: object properties: price_option_id: type: string label: type: string quantity: type: integer unit_price_cents: type: integer description: The whole course, per spot. subtotal_amount: type: integer discount_amount: type: integer tax_amount: type: integer total_amount: type: integer gift_card_amount: type: integer currency: type: string example: usd status: type: string enum: [confirmed, canceled] payment_status: type: string example: paid description: | One of `pending`, `paid`, `partially_refunded`, `refund_pending`, `refunded`, `failed`, `disputed` or `chargeback_lost`. Treat an unrecognized value as unknown rather than failing. `chargeback_lost` is a dispute the studio lost: the customer's bank took the card payment back, but it is not a refund, so the refunded total does not include it. Bookings whose dispute was lost before this value existed read `refunded`. payment_provider: type: string payment_reference: type: string cancellation_token: type: string description: Opaque token used to cancel this course booking without authentication created_at: type: string format: date-time course_purchase: description: | The whole purchase, as `GET /v1/course-purchases/{id}` reports it, except that each session's `booking_id` is left out: this response is read with a public key, and a booking id would let anyone holding the idempotency key read every session's row through `GET /v1/bookings/{id}`. Read the purchase with a secret key for the ids. The top-level `id`, `booking_number` and amounts above are the primary session's booking with the whole purchase's totals, as before. Omitted in the rare case the purchase could not be read back after it was created. allOf: - $ref: "#/components/schemas/CoursePurchase" required: [ id, course_id, course_purchase_id, seat_count, total_amount, currency, status, payment_status, created_at, ] CoursePurchase: type: object description: | One course purchase. A course is bought as a whole but stored as one booking per session, each holding its share of the money; this is the purchase with its amounts summed across every session. properties: id: type: string description: The `course_purchase_id` every session booking of the purchase carries. example: cpc_01HXYZ course_id: type: string course_name: type: string booking_number: type: string description: The number checkout gave the customer, and the widget's thank-you redirect passes on. example: SHG8GS status: type: string enum: [confirmed, canceled] description: "`canceled` once every session is canceled; `confirmed` while any session is still booked." payment_status: type: string example: paid description: | The payment state of the sessions still standing (the first non-canceled session's). A canceled session keeps its own state, such as `refunded` once its share went back, so it does not speak for the purchase unless every session is canceled. seat_count: type: integer currency: type: string example: usd subtotal_amount: type: integer discount_amount: type: integer tax_amount: type: integer total_amount: type: integer description: What the customer paid for the whole course, in the smallest currency unit. gift_card_amount: type: integer refund_amount: type: integer price_options: type: array description: The purchase's spots at each named price, for a course with price options, read from the purchase itself. They add up to `subtotal_amount`. Omitted for a purchase at one price. items: type: object properties: price_option_id: type: string label: type: string quantity: type: integer unit_price_cents: type: integer description: The whole course, per spot. required: [price_option_id, label, quantity, unit_price_cents] customer_id: type: string customer_email: type: string format: email customer_first_name: type: string customer_last_name: type: string customer_phone: type: string description: E.164 phone number; omitted when the customer has none. created_at: type: string format: date-time sessions: type: array description: Every session booking of the purchase, in class order. items: $ref: "#/components/schemas/CoursePurchaseSession" required: [ id, course_id, course_name, booking_number, status, payment_status, seat_count, currency, total_amount, customer_id, created_at, sessions, ] CoursePurchaseSession: type: object properties: booking_id: type: string description: Omitted from the course checkout response. See `CourseBooking.course_purchase`. booking_number: type: string event_id: type: string class_name: type: string starts_at: type: string format: date-time description: In the session's own zone, offset included. ends_at: type: string format: date-time event_timezone: type: string example: America/Edmonton status: type: string enum: [confirmed, canceled, attended] total_amount: type: integer description: This session's share of the purchase total. is_primary: type: boolean description: True for the session whose booking number checkout returned. canceled_at: type: string format: date-time required: [ booking_number, event_id, class_name, starts_at, ends_at, event_timezone, status, total_amount, is_primary, ] WaitlistOffer: type: object description: | A seat currently offered to someone on a waitlist. The fields are the `waitlist.offered` webhook payload's fields, so a mapping built against the trigger works here unchanged. An offer is for a seat in one class event or in a whole course; `waitlist_for` says which. For a course offer, `event_id` is empty, `course_id` and `course_name` are set, `class_name` repeats the course's name, and `event_starts_at` is the course's first session still ahead when the offer went out. properties: entry_id: type: string description: The waitlist entry, stable across the offer's lifetime. example: wle_01HXYZ waitlist_for: type: string enum: [event, course] description: What the offered seat is in. event_id: type: string description: The class event the seat is on. Empty for a course offer. example: evt_01HXYZ course_id: type: string description: The course the seat is in. Empty for a class offer. course_name: type: string description: The course's name. Empty for a class offer. class_name: type: string description: The class's name, or the course's name for a course offer. example: Sunset Pottery email: type: string format: email first_name: type: string last_name: type: string offer_expires_at: type: string format: date-time description: | When the claim window closes and the seat passes to the next person in line. Omitted (not `null`) in the rare case an entry carries no expiry. event_starts_at: type: string format: date-time description: | When the class starts, in UTC. For a course offer, when its next session starts. Customer: type: object description: | A customer as the read endpoints report them. Note the TWO consent fields: `marketing_subscribed` is whether this person may be mailed now and is the one to act on; `marketing_opt_in` is what they chose when the account was created and never changes. properties: id: type: string example: acc_01HXYZ tenant_id: type: string email: type: string format: email first_name: type: string last_name: type: string phone: type: string description: Omitted (not `null`) when the customer has no phone number on file. marketing_opt_in: type: boolean description: | What the customer chose at the moment their account was created, and the value the `customer.created` webhook reports. It never changes: someone who later unsubscribes still reads `true` here. Do NOT use it to decide whether to mail someone. marketing_subscribed: type: boolean description: | Effective consent right now. A per-customer preference outranks the account column, and a later unsubscribe outranks both. This is the field to check before sending anyone anything. created_at: type: string format: date-time required: [ id, tenant_id, email, first_name, last_name, marketing_opt_in, marketing_subscribed, created_at, ] TaxLine: type: object properties: name: type: string example: GST rate_millipercent: type: integer description: The percentage times 1000, so 9.975% is 9975. example: 5000 amount_cents: type: integer example: 250 required: [name, rate_millipercent, amount_cents] BookingListItem: type: object description: | A booking as the list endpoint reports it. It deliberately omits `cancellation_token`, which the single-booking read carries: that token lets whoever holds it cancel the booking without signing in, and a page of them would be a bulk grant of cancel rights rather than a read. The `customer_*` fields match the ones embedded in the booking webhooks, so the same field names work whichever way a booking reached you. properties: id: type: string example: bkg_01HXYZ tenant_id: type: string event_id: type: string booking_number: type: string seat_count: type: integer price_options: type: array description: The booking's spots at each named price, from what each seat was sold at (renaming or repricing the option later does not change it). Omitted for a booking at one price. items: type: object properties: price_option_id: type: string label: type: string quantity: type: integer unit_price_cents: type: integer total_amount: type: integer description: Total in the smallest currency unit (e.g. cents). refund_amount: type: integer description: Cumulative amount refunded, in the smallest currency unit. currency: type: string status: type: string enum: [confirmed, canceled, attended] payment_status: type: string customer_notes: type: string visit_address: type: string description: | Where the business goes for a booking at a `customer_address` location. Omitted when empty. subtotal_amount: type: integer description: | Price before discount, add-ons included, in the smallest currency unit. Before tax, so `subtotal_amount - discount_amount + tax_amount = total_amount`, unless `tax_inclusive` is true: then the tax is already inside it and `subtotal_amount - discount_amount = total_amount`. discount_amount: type: integer description: Promo code and staff price adjustment together. tax_amount: type: integer description: The sum of `taxes`. tax_inclusive: type: boolean description: | True when the studio's prices included tax at the time of the booking (the EU convention): `tax_amount` is contained in the subtotal rather than added to it. Either way, `total_amount - tax_amount` is the pre-tax revenue. gift_card_amount: type: integer description: The part of the total a gift card or class pass covered. taxes: type: array description: | One entry per tax rate the booking was charged, in checkout order. Empty for an untaxed booking, never omitted. The name and rate are what was charged at the time, not the business's current rates, and they are the lines the tax report files on. items: $ref: "#/components/schemas/TaxLine" customer_id: type: string customer_email: type: string format: email customer_first_name: type: string customer_last_name: type: string customer_phone: type: string description: E.164 phone number; omitted when the customer has none. class_name: type: string event_starts_at: type: string format: date-time description: The class start time in the venue's own zone, offset included. event_ends_at: type: string format: date-time event_timezone: type: string example: America/Edmonton created_at: type: string format: date-time canceled_at: type: string format: date-time description: Omitted unless the booking was canceled. course_purchase: description: | `null` for a standalone booking. A course purchase is stored as one booking per session, each holding only its share of the money (a four-session course at 120 is four bookings of 30). For a session booking this is the purchase as a whole: its amounts are summed across every session, so `total_amount` here is the course price the customer paid. List every session with `?course_purchase_id=`, or read the whole purchase with `GET /v1/course-purchases/{id}`. oneOf: - $ref: "#/components/schemas/CoursePurchaseSummary" - type: "null" required: [ id, tenant_id, event_id, seat_count, total_amount, currency, status, payment_status, customer_id, created_at, course_purchase, ] CoursePurchaseSummary: type: object description: One course purchase, totalled across all of its session bookings. properties: id: type: string description: The `course_purchase_id` shared by every session booking of the purchase. example: cpc_01HXYZ course_id: type: string course_name: type: string session_count: type: integer description: Session bookings in the purchase, canceled ones included. subtotal_amount: type: integer discount_amount: type: integer tax_amount: type: integer total_amount: type: integer description: What the customer paid for the whole course, in the smallest currency unit. gift_card_amount: type: integer refund_amount: type: integer price_options: type: array description: The whole purchase's spots at each named price, for a course with price options. They add up to the purchase's `subtotal_amount`, not to this session's own amounts, which never carry `price_options`. Omitted for a purchase at one price. items: type: object properties: price_option_id: type: string label: type: string quantity: type: integer unit_price_cents: type: integer description: The whole course, per spot. required: [price_option_id, label, quantity, unit_price_cents] required: [ id, course_id, course_name, session_count, subtotal_amount, discount_amount, tax_amount, total_amount, gift_card_amount, refund_amount, ] WebhookSubscription: type: object description: | One REST-hook subscription. It carries no signing secret: nothing that subscribes through the API can be shown a value once, because there is no human at the other end of the call. Register the endpoint in **Settings → Webhooks** instead if you need to verify signatures. properties: id: type: string example: whk_01HXYZ tenant_id: type: string url: type: string format: uri event: type: string example: booking.created active: type: boolean created_at: type: string format: date-time required: [id, tenant_id, url, event, active, created_at] Account: type: object properties: id: type: string example: cus_01HXYZ tenant_id: type: string email: type: string format: email first_name: type: string last_name: type: string phone: type: string description: Omitted (not `null`) when the customer has no phone number on file. marketing_opt_in: type: boolean created_at: type: string format: date-time required: [ id, tenant_id, email, first_name, last_name, marketing_opt_in, created_at, ] GiftCardPurchaseRequest: type: object description: | A gift card is never issued without a payment, so besides `type` every purchase must supply **one of** `payment_source_id` or `payment_intent_id`, **and** an `idempotency_key`. Omitting the payment token returns `402 payment_required`; omitting the key returns `400 missing_idempotency_key`. The amount charged is always computed server-side. The exception is a sale you already collected: send `external_purchase` and no payment token, from a secret key, and the card is issued against the payment you name. `idempotency_key` is not used there — the payment reference is the idempotency key. properties: type: type: string enum: [balance, class_pass] description: "`balance` — monetary value in cents. `class_pass` — fixed number of classes." code: type: string description: Optional custom code (min 8 characters). Auto-generated if omitted. minLength: 8 balance_amount: type: integer description: Required when type is `balance`. Amount in smallest currency unit (e.g. cents). example: 5000 class_pass_count: type: integer description: Required when type is `class_pass`. Number of classes. example: 5 currency: type: string description: ISO 4217 currency code. Defaults to the tenant's configured currency. example: usd restricted_class_id: type: string description: Restrict this gift card to a specific class template. purchaser_id: type: string description: Customer account ID to associate as the purchaser. purchased_by_email: type: string format: email purchased_by_name: type: string recipient_email: type: string format: email recipient_name: type: string message: type: string validity_days: type: integer description: | Days until expiry, for a class pass bought without a `class_pass_product_id` (a product's own validity always wins). `0` means no expiration. A `balance` card is paid for and never expires, so a positive value on one is refused with `invalid_request`. default: 0 marketing_opt_in: type: boolean description: | Whether the buyer consented to marketing. Honored only when the purchase creates a brand-new customer account; an existing customer's preference is never changed by a gift card purchase. default: false external_purchase_id: type: string deprecated: true description: | **Ignored.** Any value sent here is discarded. The payment reference stored on the gift card is always the ID of the payment this endpoint captured. To record a payment taken outside FullClass, use the `external_purchase` object below, which is gated on a secret key. external_purchase: type: object description: | Send this **instead of** a payment token to issue a card for a sale you already collected (your own Stripe payment link, an in-person terminal, a bank transfer). Nothing is charged. Requires a secret (`sk_`) key — a public key or the hosted booking page gets `403 secret_key_required`. Sending it together with `payment_source_id` or `payment_intent_id` is `400 invalid_request`: a sale is either charged here or collected elsewhere. The card is recorded as sold, not comped, so it counts as prepaid liability on the gift-card reports and appears in gift card sales at `amount_cents` — it does not show up on the complimentary report. No `payment.succeeded` webhook is emitted: FullClass did not take this payment, so the system that did is the one that can announce it. required: [reference, amount_cents] properties: reference: type: string maxLength: 255 description: | The payment's ID in the system that took the money — a Stripe PaymentIntent id, a terminal receipt number, a transfer reference. Stored on the card and unique per tenant, so it is also the idempotency key for this mode: reissuing against the same reference returns `409 payment_already_used` and creates nothing. FullClass never looks it up at a provider; it is a reconciliation handle, which is why this mode needs a secret key. example: pi_3RxAbCdEfGhIjKlM amount_cents: type: integer minimum: 1 description: | What you actually collected, in the card's currency, **tax included**: send the full amount the buyer paid. Recorded as the sale value and **not** checked against the tenant's class-pass price, so the same 5-class pass can be sold at a community rate and a standard rate. For a `class_pass`, the amount is treated as including the tax a class pass carries at the studio (the tax rates set for class passes), and that tax is accounted for inside it; a `balance` card carries no tax at sale, so its whole amount is its value. Must be greater than 0 — a card issued for nothing is a comp, which you create in the admin app instead. example: 8000 total_amount_cents: type: integer description: | Total charge amount in cents for `class_pass` gift cards (including tax). Checked against the server-computed total (±1 cent for rounding) and rejected with `400 invalid_amount` if it disagrees. Omit it to accept the server's total. Ignored when `external_purchase` is present — `external_purchase.amount_cents` is the sale value there. payment_source_id: type: string description: | Single-use payment token from the client-side SDK — a Square Web Payments nonce or a Stripe PaymentMethod ID. Required unless you are supplying `payment_intent_id`. The provider is determined by the tenant's payment configuration. payment_intent_id: type: string description: | Stripe PaymentIntent ID. Provide this instead of `payment_source_id` after completing a Stripe 3DS authentication challenge (see `payment_action_required` response). The PaymentIntent must have succeeded for the exact server-computed amount and currency, or the request is rejected with `422 payment_amount_mismatch`. idempotency_key: type: string description: Required on every purchase. Must be unique per purchase attempt. required: [type, idempotency_key] GiftCardPurchaseResponse: type: object properties: id: type: string code: type: string type: type: string enum: [balance, class_pass] status: type: string enum: [active, redeemed, canceled, expired] value_display: type: string example: "$50.00" remaining_display: type: string example: "$50.00" valid_from: type: string format: date-time valid_until: type: string format: date-time description: Expiry. Omitted (not `null`) when the card never expires. created_at: type: string format: date-time required: [ id, code, type, status, value_display, remaining_display, valid_from, created_at, ] GiftCardLookupResponse: type: object properties: id: type: string code: type: string type: type: string enum: [balance, class_pass] status: type: string enum: [active, redeemed, canceled, expired] value_display: type: string example: "$50.00" remaining_display: type: string example: "$35.00" restricted_class_id: type: string description: Class template this card is restricted to. Omitted (not `null`) when unrestricted. valid_from: type: string format: date-time valid_until: type: string format: date-time description: Expiry. Omitted (not `null`) when the card never expires. required: [id, code, type, status, value_display, remaining_display, valid_from] CheckoutAttemptRequest: type: object description: | The buyer's details as the booking widget has them when they leave the details step. There is no price: the server prices the attempt itself from the event or course. properties: email: type: string format: email maxLength: 254 first_name: type: string maxLength: 100 description: Letters, spaces, hyphens and apostrophes only. last_name: type: string maxLength: 100 description: Letters, spaces, hyphens and apostrophes only. phone: type: string maxLength: 30 description: Optional. At least 7 digits, plus `+ - ( ) .` and spaces. seat_count: type: integer description: | Seats being bought. Below 1 is read as 1; above the per-booking limit (or the capacity, when there is no limit) is capped to it. supersedes_email: type: string description: | An address this same buyer already recorded an attempt under in this checkout before changing it. The earlier attempt is replaced rather than left as a second abandoned checkout. Optional and not validated: a value that matches nothing replaces nothing. wallet_pending: type: boolean description: | Optional. `true` while an Apple Pay or Google Pay sheet is open for this buyer, `false` when it closed without paying. Leave it out when you have nothing to say about a wallet: absent changes nothing, while `false` clears a mark an earlier `true` set. required: [email, first_name, last_name] ClassPassProduct: type: object description: | A class pass the business sells: a number of classes for one price. Amounts are in the business's currency, which `GET /giftcards/pricing` reports. properties: id: type: string name: type: string description: type: string description: Omitted when the business wrote none. class_count: type: integer price_cents: type: integer validity_days: type: integer description: Days the pass stays usable after purchase. Omitted when it never expires. restricted_class_id: type: string description: The only class the pass can be spent on. Omitted when it is good for any class. required: [id, name, class_count, price_cents] PrefillResponse: type: object description: | The contact details of the customer a prefill token names. Every field is omitted when it is unknown, and the whole object is empty when the token could not be used. properties: first_name: type: string last_name: type: string email: type: string phone: type: string MembershipTax: type: object properties: name: type: string rate: type: integer description: Millipercent (5% is 5000). amount_cents: type: integer MembershipCharge: type: object properties: price_cents: type: integer signup_fee_cents: type: integer tax_cents: type: integer total_cents: type: integer taxes: type: array items: $ref: "#/components/schemas/MembershipTax" MembershipPlan: type: object properties: id: type: string name: type: string description: type: string interval: type: string enum: [week, month, quarter, year] classes_per_period: type: ["integer", "null"] description: Classes each period covers. Null means unlimited. daily_cap: type: ["integer", "null"] description: Most classes on one day. Null means no cap. price_cents: type: integer intro_price_cents: type: ["integer", "null"] description: The price of the first `intro_periods` periods, when the plan has an intro price. intro_periods: type: integer free_trial: type: boolean description: The intro price is 0, so buying saves the card and charges nothing today. signup_fee_cents: type: integer min_periods: type: integer description: Periods the member commits to before a cancel can take effect. max_periods: type: ["integer", "null"] description: Periods after which the membership ends on its own. Null means it renews until cancelled. first_charge: $ref: "#/components/schemas/MembershipCharge" intro_renewal_charge: oneOf: - $ref: "#/components/schemas/MembershipCharge" - type: "null" description: Each renewal still at the intro price. Present only when the intro lasts more than one period. renewal_charge: $ref: "#/components/schemas/MembershipCharge" MembershipPlansResponse: type: object properties: currency: type: string example: cad prices_include_tax: type: boolean payment_provider: type: string enum: [stripe, square] mandate_version: type: string description: Send back on purchase as `mandate_version`. plans: type: array items: $ref: "#/components/schemas/MembershipPlan" MembershipPurchaseRequest: type: object required: - plan_id - email - first_name - accept_auto_renew - mandate_version - idempotency_key properties: plan_id: type: string email: type: string format: email first_name: type: string last_name: type: string marketing_opt_in: type: boolean accept_auto_renew: type: boolean description: The buyer ticked the automatic-renewal consent. Must be true. mandate_version: type: string payment_source_id: type: string description: A Stripe PaymentMethod id or a Square card nonce. payment_intent_id: type: string description: The Stripe PaymentIntent, when resending after 3-D Secure. setup_intent_id: type: string description: The Stripe SetupIntent, when resending a free trial after 3-D Secure. idempotency_key: type: string MembershipPurchaseResponse: type: object properties: id: type: string plan_name: type: string status: type: string example: active current_period_end: type: string format: date-time card_brand: type: string card_last4: type: string total_charged_cents: type: integer currency: type: string MembershipVerifyStartRequest: type: object required: [email] properties: email: type: string MembershipVerifyConfirmRequest: type: object required: [email, code] properties: email: type: string code: type: string example: "042913" remember_device: type: boolean MembershipVerifyDeviceRequest: type: object required: [device_token] properties: device_token: type: string MemberMembership: type: object properties: id: type: string plan_name: type: string status: type: string enum: [active, past_due, paused] description: Only an active membership covers a booking. period_end: type: string format: date-time classes_left: type: ["integer", "null"] description: Classes left this period; null when unlimited. MemberSessionResponse: type: object properties: member_token: type: string description: Send as `membership_token` when booking. Valid 30 minutes. expires_at: type: string format: date-time device_token: type: string description: Present when the member asked to be remembered, and on every device resume. memberships: type: array items: $ref: "#/components/schemas/MemberMembership" MembershipCoverRequest: type: object required: [member_token, membership_id, event_id] properties: member_token: type: string membership_id: type: string event_id: type: string MembershipCoverResponse: type: object properties: covered: type: boolean classes_left: type: ["integer", "null"] description: Classes left once this one is booked; null when unlimited. GiftCardPricingResponse: type: object properties: gift_card_types: type: array description: >- What the tenant's booking page offers, derived from `gift_card_balance_enabled` and the class-pass sale state. May be empty, which means the booking page offers neither and no gift-card banner should render. A `?fc_pass=` link still sells the pass it names regardless of this list. items: type: string enum: [balance, class_pass] gift_card_class_pass_price_cents: type: integer description: Price per class pass in cents. `0` if class pass gift cards are not configured. class_pass_mode: type: string enum: [quantity, products, "off"] description: | How the booking page prices a class pass. `quantity`: the buyer picks a number of classes, each at `gift_card_class_pass_price_cents`. `products`: the buyer picks one of `class_pass_products`, each with its own price. `off`: the booking page offers no class passes, although a `?fc_pass=` link still sells the pass it names. class_pass_products: type: array description: | The class passes on sale, in the business's display order. Present only when `class_pass_mode` is `products`; otherwise the key is absent. One pass can be looked up by id with `GET /giftcards/passes/{id}` in any mode. items: $ref: "#/components/schemas/ClassPassProduct" class_pass_offered: type: boolean description: | Whether the booking page offers class passes at all: the mode is not `off` and something is priced (a per-class price in `quantity` mode, at least one pass in `products` mode). Use this rather than working it out from the other fields. gift_card_amount_presets_cents: type: array description: | The suggested amounts for a balance gift card, in cents, already resolved: a business that has chosen none gets the defaults (2500, 5000, 10000) here. items: type: integer gift_card_tax_rates: type: array items: type: object properties: id: type: string name: type: string rate: type: integer description: Rate in millipercent (5% = 5000, 9.975% = 9975). To calculate tax amount, use ROUND(subtotal * rate / 100000), or see `tax_inclusive`. required: [id, name, rate] tax_inclusive: type: boolean description: | True when the business's prices include tax. A class pass's price is then what the buyer pays, and each rate's share of it is `ROUND(price * rate / (100000 + sum of rates))`, contained rather than added on top. currency: type: string example: usd required: [ gift_card_types, gift_card_class_pass_price_cents, class_pass_mode, class_pass_offered, gift_card_amount_presets_cents, gift_card_tax_rates, currency, ] GiftCardValidateResponse: type: object properties: valid: type: boolean id: type: string code: type: string type: type: string enum: [balance, class_pass] status: type: string balance_remaining: type: integer description: Remaining balance in cents (balance type) or remaining class count (class_pass type). classes_remaining: type: integer description: Remaining class count for `class_pass` gift cards. Only present for `class_pass` cards. balance_display: type: string description: Human-readable remaining value, e.g. "$35.00" or "3 classes". reason: type: object description: | Why the card cannot be used. Only present when `valid` is `false`. Branch on `code`; `message` is human-readable text that may change. properties: code: type: string enum: [ code_required, not_found, canceled, redeemed, expired, inactive, class_restricted, class_passes_not_accepted, ] message: type: string required: [code, message] error: type: string deprecated: true description: | Deprecated: use `reason.message` instead. The same text as `reason.message`, kept as a bare string for existing clients. Note it is a string here, unlike the object-shaped `error` every other endpoint returns, which is why `reason` exists. required: [valid] TenantConfig: type: object description: | Public configuration for one tenant, used by browser booking widgets to configure themselves in a single request. Fields are grouped below as: identity, payment, booking behaviour, checkout holds, display flags, and widget theme. properties: tenant_id: type: string tenant_name: type: string description: The business's display name. example: Stirring It Up legal_name: type: string description: Registered legal entity name, used on receipts. Falls back to `tenant_name` when not set separately. currency: type: string example: usd currency_symbol: type: string description: Symbol for `currency`, for formatting amounts client-side. example: $ timezone: type: string description: The tenant's reporting timezone (IANA name). Omitted when not set. example: America/New_York date_time_format: type: string enum: [auto, mdy-12, mdy-24, dmy-12, dmy-24] description: | How this studio writes dates and times for its customers: the first part is the date order (month-first or day-first), the second is the clock. Widgets format with it so the schedule agrees with the confirmation email and the receipt. Omitted when the studio follows its booking language, which is the default. example: dmy-24 terms: type: object description: | The tenant's customer-facing vocabulary, already resolved to singular/plural forms in the tenant's booking locale. Widgets interpolate these instead of hardcoding "class", so a rafting outfitter reads "Upcoming trips". Always present; defaults to class / session / course. properties: item: $ref: "#/components/schemas/TenantTerm" occurrence: $ref: "#/components/schemas/TenantTerm" bundle: $ref: "#/components/schemas/TenantTerm" payment_provider: type: string enum: [none, square, stripe] square: type: object properties: enabled: type: boolean application_id: type: string location_id: type: string environment: type: string required: [enabled] stripe: type: object properties: enabled: type: boolean publishable_key: type: string required: [enabled] wallets: type: object description: | Whether Apple Pay / Google Pay may be offered **on the calling origin**. Each flag is true only once that origin has been registered with the provider for this tenant, so the value depends on the `Origin` header of your request. properties: square: type: boolean stripe: type: boolean required: [square, stripe] tracking: type: object description: | The tenant's marketing tag IDs, so the widget can bootstrap gtag.js or GTM itself and scope conversions to the tenant. Each field is omitted when not configured; skip the corresponding script then. properties: google_ads_conversion_id: type: string google_ads_purchase_label: type: string ga4_measurement_id: type: string gtm_container_id: type: string booking_enabled: type: boolean description: | False when the business's own FullClass subscription is canceled or locked for non-payment. Render an "unavailable" notice rather than a booking form: the write endpoints will refuse the booking anyway. Defaults to `true`, and fails open if the billing lookup errors. booking_payment_timing: type: string enum: [online_required, pay_on_site] description: | When `pay_on_site`, the widget may offer a "Pay on site" option that reserves a seat without charging (the booking is created `pending`). When `online_required` (default), online payment is required to book. gift_card_purchase_enabled: type: boolean description: Whether the widget shows the gift card purchase banner. gift_card_banner_subtitle: type: string description: Optional custom subtitle for the widget's gift card banner. Omitted when not set. gift_card_types: type: array description: >- What that banner opens into, matching the field of the same name on `GET /giftcards/pricing`. Empty means the banner must not render whatever `gift_card_purchase_enabled` says, because there is nothing behind it. items: type: string enum: [balance, class_pass] membership_banner: type: boolean description: Whether to show a "Become a member" banner above the class list that opens the membership plan picker (`GET /memberships/plans`). True only when the studio has it switched on, sells memberships, has at least one plan on sale, and can take card payments. membership_banner_title: type: string description: The studio's own heading for the membership banner. Omitted when it has none, so the widget shows its default ("Become a member") in the customer's language. membership_banner_subtitle: type: string description: The studio's own line under that heading. Omitted when it has none, like `membership_banner_title`. membership_checkout_link: type: boolean description: Whether to show a link to the membership plan picker beside "Have a membership?" at checkout. Same conditions as `membership_banner`, with its own studio switch. referral_program_active: type: boolean description: Whether the referral program is running, i.e. whether a `?fc_ref=` invite link should be resolved with `GET /referrals/link/{token}` and its `referral_link_token` sent with the booking. There is no typed referral code field. require_phone: type: boolean description: Whether the checkout form must collect a phone number. require_heard_about_us: type: boolean description: Whether the checkout form must collect the `heard_about_us` attribution answer. refund_policy: type: string description: The tenant's free-text refund policy, for display at checkout. Omitted when not set. checkout_holds_enabled: type: boolean description: | Whether this tenant reserves seats while a customer is in checkout. When false the seat-hold endpoints return `422 checkout_holds_disabled`, so do not call them. checkout_hold_duration_secs: type: integer description: How long an acquired seat hold lasts before expiring, in seconds. Only meaningful when `checkout_holds_enabled` is true. example: 300 single_gift_card_only: type: boolean description: | When `true`, a booking or course purchase may carry at most one gift card, and sending more is refused with `422 gift_card_stacking_not_allowed`. `false` (the default for every tenant) lets balance cards stack until the total is covered. Stop offering a second code once one is applied. block_promo_with_gift_card: type: boolean description: | When `true`, a booking or course purchase may carry a promo code or a gift card but not both, and sending both is refused with `422 promo_gift_card_stacking_not_allowed`. `false` is the default. A referral link's referee discount is not a promo code for this purpose and is never affected. waitlist_enabled: type: boolean description: | When `true`, sold-out events and courses accept waitlist joins via `POST /events/{id}/waitlist` and `POST /courses/{id}/waitlist`, and the widget shows a "Join waitlist" option on them. Off by default. show_remaining_seats: type: boolean description: Whether to show the exact number of seats left on each event. show_low_seat_warning: type: boolean description: Whether to show a scarcity warning once seats left drop to `low_seat_threshold`. low_seat_threshold: type: integer description: Seat count at or below which the low-seat warning applies. example: 3 show_instructor_name: type: boolean description: | Whether the booking page shows each class's instructor name and avatar. A display hint only; events and courses still carry their `instructor` object when this is false. show_instructor_bio: type: boolean description: | Whether the booking page shows the instructor bio. A display hint only; the `instructor` object still carries `bio` when this is false. show_class_photos: type: boolean schedule_view: type: string enum: [list, calendar] description: | Which class-listing layout to open on. With `show_view_switcher` true this only picks the initial view; with it false this is the only view customers get. schedule_page_size: type: integer description: How many events the list view fetches per "Load more" batch. Does not apply to the calendar view, which loads a whole month. example: 20 show_view_switcher: type: boolean description: False when the tenant holds customers to `schedule_view` instead of offering the List/Calendar switch. brand_color: type: string description: Tenant brand color (hex). The widget's accent and buttons fall back to it. border_color: type: string description: Widget theme. Each theme field is omitted when the tenant keeps the widget default. background_color: type: string surface_color: type: string button_color: type: string date_block_color: type: string date_block_text_color: type: string text_primary_color: type: string text_secondary_color: type: string accent_color: type: string button_text_color: type: string font_family: type: string description: CSS font-family list applied to the widget. border_radius: type: string description: CSS length (e.g. `9px`) scaling the widget's corner radii. required: [ tenant_id, tenant_name, legal_name, currency, currency_symbol, terms, payment_provider, square, stripe, wallets, tracking, booking_enabled, booking_payment_timing, gift_card_purchase_enabled, gift_card_types, membership_banner, membership_checkout_link, referral_program_active, require_phone, require_heard_about_us, waitlist_enabled, checkout_holds_enabled, checkout_hold_duration_secs, single_gift_card_only, block_promo_with_gift_card, show_remaining_seats, show_low_seat_warning, low_seat_threshold, show_instructor_name, show_instructor_bio, show_class_photos, schedule_view, schedule_page_size, show_view_switcher, ] TenantTerm: type: object description: One noun from the tenant's vocabulary, in singular and plural form. properties: one: type: string example: class other: type: string example: classes required: [one, other] SeatHold: type: object description: A checkout seat hold, returned by the acquire and extend endpoints. properties: hold_id: type: string description: Pass this to `extend`, `DELETE`, and as `hold_id` on `POST /bookings`. example: hld_01HXYZ expires_at: type: string format: date-time description: When the hold lapses and its seats return to general availability. expires_in_seconds: type: integer description: The tenant's configured hold duration, i.e. the full window this hold was just given. example: 300 required: [hold_id, expires_at, expires_in_seconds] PromoCodeValidateResponse: type: object properties: code: type: string discount_type: type: string enum: [percentage, fixed] discount_value: type: integer description: Percentage (0–100) for `percentage` type, or cents for `fixed` type. description: type: string example: "20% off" required: [code, discount_type, discount_value, description] paths: /events: get: summary: List events description: | Returns scheduled, publicly visible events (classes) for the tenant, ordered by start time ascending. `/v1/classes` is a backward-compatible alias for this endpoint. Course sessions are not listed here, and `GET /v1/events/{id}` returns 404 for one: a session is sold only as part of its course. Use `GET /v1/courses/{id}`, whose `sessions` carry each session's schedule, class and categories. Requires the `events:read` scope. operationId: listEvents tags: [Events] security: - ApiKey: [events:read] parameters: - name: from in: query description: Only return events starting at or after this time (ISO 8601) schema: type: string format: date-time example: "2024-06-01T00:00:00Z" - name: to in: query description: Only return events starting before this time (ISO 8601) schema: type: string format: date-time - name: class_id in: query description: Filter by class template ID schema: type: string - name: instructor_id in: query description: Filter by instructor ID schema: type: string - name: category_id in: query description: Only return events whose class is assigned this category schema: type: string - name: has_availability in: query description: Set to `true` to only return events with seats remaining schema: type: boolean - name: hide_unopened in: query description: Set to `true` to leave out events whose booking window has not opened yet schema: type: boolean - name: cursor in: query description: Pagination cursor from the previous response's `next_cursor` schema: type: string - name: limit in: query description: Results per page (1–100, default 20) schema: type: integer minimum: 1 maximum: 100 default: 20 responses: "200": description: OK content: application/json: schema: type: object properties: data: type: array items: $ref: "#/components/schemas/Event" next_cursor: type: string description: Cursor for the next page. An empty string (not `null`) on the last page. has_more: type: boolean "400": description: | `invalid_param` — a parameter this endpoint does not know, a known one sent blank (`?category_id=`), `from` or `to` not ISO 8601, a boolean other than `true`/`1`/`false`/`0`, or a `limit` that is not an integer between 1 and 100. A filter is never silently ignored. content: application/json: schema: $ref: "#/components/schemas/Error" "401": $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/Forbidden" "429": $ref: "#/components/responses/RateLimited" /categories: get: summary: List categories description: | Returns the tenant's active class categories, name-sorted. Categories drive the filter tabs on the hosted booking page; filter the event and course lists with the `category_id` parameter on `GET /v1/events` and `GET /v1/courses`. Requires the `events:read` scope. operationId: listCategories tags: [Events] security: - ApiKey: [events:read] responses: "200": description: OK content: application/json: schema: type: object properties: data: type: array items: $ref: "#/components/schemas/Category" "401": $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/Forbidden" "429": $ref: "#/components/responses/RateLimited" /tenant/config: get: summary: Get tenant widget configuration description: | Returns public tenant configuration for browser booking widgets. Requires the `events:read` scope. operationId: getTenantConfig tags: [Configuration] security: - ApiKey: [events:read] responses: "200": description: OK content: application/json: schema: $ref: "#/components/schemas/TenantConfig" "401": $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/Forbidden" "429": $ref: "#/components/responses/RateLimited" /prefill: post: summary: Exchange a prefill token for the customer's details description: | Trades a booking-form prefill token for the contact details of the customer it names, so the booking widget can fill in the details step. Requires the `events:read` scope. A signed-in customer who follows **Book now** out of the customer portal lands on the business's booking page with the token in the `fc_prefill` query parameter. The hosted booking page reads it on the server; a widget embedded on the business's own website sends it here. The token is spent by its first exchange and expires 30 minutes after it was issued. It must have been issued by the business the API key belongs to. **Every failure answers `200` with an empty object**, and the failures are indistinguishable from one another: an expired, already spent, malformed or foreign token, or a customer who no longer exists. Treat an empty object as "no prefill" and show the empty form. operationId: exchangePrefillToken tags: [Customers] security: - ApiKey: [events:read] requestBody: required: true content: application/json: schema: type: object properties: token: type: string description: The value of the `fc_prefill` query parameter. required: [token] responses: "200": description: The customer's details, or an empty object content: application/json: schema: $ref: "#/components/schemas/PrefillResponse" "401": $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/Forbidden" "429": $ref: "#/components/responses/RateLimited" /address/suggest: post: summary: Suggest addresses for a visit description: | Address suggestions for the booking widget's visit address box, for a class at the customer's address. Matches come from Google Places and are limited to the business's billing country (worldwide when none is on file). Requires the `events:read` scope. Each call is a paid lookup, so send one only after the customer has typed a few characters, and reuse one `session_token` for one address until it is picked. When address lookup is not configured the answer is `200` with `enabled: false` and no suggestions: stop calling and keep a plain text box. operationId: suggestVisitAddress tags: [Bookings] security: - ApiKey: [events:read] requestBody: required: true content: application/json: schema: type: object properties: input: type: string maxLength: 200 description: What the customer has typed so far. session_token: type: string description: Groups one address's lookups and its pick into one Google billing session. language: type: string description: Language for the suggestions, such as `fr`. required: [input] responses: "200": description: Suggestions, or `enabled false` when lookup is off content: application/json: schema: type: object properties: enabled: type: boolean suggestions: type: array items: type: object properties: place_id: type: string main_text: type: string description: The street part, such as `12 Elm St`. secondary_text: type: string description: The rest, such as `Calgary, AB, Canada`. "400": description: The input is empty or longer than 200 characters "401": $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/Forbidden" "429": $ref: "#/components/responses/RateLimited" /address/resolve: post: summary: Resolve a suggested address description: | The full address of a suggestion from `POST /address/suggest`, as one line for the visit address. Requires the `events:read` scope. operationId: resolveVisitAddress tags: [Bookings] security: - ApiKey: [events:read] requestBody: required: true content: application/json: schema: type: object properties: place_id: type: string required: [place_id] responses: "200": description: The full address content: application/json: schema: type: object properties: address: type: string description: Such as `12 Elm St SW, Calgary, AB T2T 1A1, Canada`. "400": description: No place_id was sent "401": $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/Forbidden" "429": $ref: "#/components/responses/RateLimited" "502": description: Google could not look the address up "503": description: Address lookup is not configured /events/{id}: get: summary: Get event description: | Returns a single event by ID, including its active add-ons. `/v1/classes/{id}` is a backward-compatible alias. Requires the `events:read` scope. operationId: getEvent tags: [Events] security: - ApiKey: [events:read] parameters: - name: id in: path required: true schema: type: string - name: exclude_hold in: query description: | A `hold_id` from `POST /events/{id}/hold` to leave out of the `seats_remaining` figure. Pass your own hold while polling during checkout so the seats you are holding do not read back to you as taken ("sold out" by your own hand). An unknown or foreign hold id matches nothing and is ignored. schema: type: string responses: "200": description: OK content: application/json: schema: $ref: "#/components/schemas/Event" "401": $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/Forbidden" "429": $ref: "#/components/responses/RateLimited" "404": description: | Event not found, private, hidden, or one session of a course: `not_found`. A listed event that has already ended answers `class_ended` instead, and one the studio has canceled answers `class_canceled`, so a client following an old link or a stale schedule to it can say so rather than show nothing. content: application/json: schema: $ref: "#/components/schemas/Error" /events/{id}/waivers: get: summary: Get event waivers description: | Returns active waivers that must be accepted before booking this event. `/v1/classes/{id}/waivers` is a backward-compatible alias. Requires the `events:read` scope. operationId: getEventWaivers tags: [Events] security: - ApiKey: [events:read] parameters: - name: id in: path required: true schema: type: string responses: "200": description: OK content: application/json: schema: type: object properties: waivers: type: array items: $ref: "#/components/schemas/Waiver" "401": $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/Forbidden" "429": $ref: "#/components/responses/RateLimited" "404": description: Event not found, private, or one session of a course (as on `GET /v1/events/{id}`) content: application/json: schema: $ref: "#/components/schemas/Error" /events/{id}/terms: get: summary: Get event terms & conditions description: | Returns active terms & conditions that must be accepted before booking this event. `/v1/classes/{id}/terms` is a backward-compatible alias. Requires the `events:read` scope. operationId: getEventTerms tags: [Events] security: - ApiKey: [events:read] parameters: - name: id in: path required: true schema: type: string responses: "200": description: OK content: application/json: schema: type: object properties: terms: type: array items: $ref: "#/components/schemas/Terms" "401": $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/Forbidden" "429": $ref: "#/components/responses/RateLimited" "404": description: Event not found, private, or one session of a course (as on `GET /v1/events/{id}`) content: application/json: schema: $ref: "#/components/schemas/Error" /events/{id}/addons: get: summary: Get event add-ons description: | Returns active purchasable add-ons for this event. `/v1/classes/{id}/addons` is a backward-compatible alias. Requires the `events:read` scope. operationId: getEventAddons tags: [Events] security: - ApiKey: [events:read] parameters: - name: id in: path required: true schema: type: string responses: "200": description: OK content: application/json: schema: type: object properties: addons: type: array items: $ref: "#/components/schemas/Addon" "401": $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/Forbidden" "429": $ref: "#/components/responses/RateLimited" "404": description: Event not found, private, or one session of a course (as on `GET /v1/events/{id}`) content: application/json: schema: $ref: "#/components/schemas/Error" /events/{id}/hold: post: summary: Acquire checkout seat hold description: | Reserves `seat_count` seats for one in-progress checkout, so a second customer browsing at the same time sees accurate availability instead of racing you to the last seat. Requires both the `events:read` and `bookings:write` scopes. Holds are **opt-in per tenant**: check `checkout_holds_enabled` on `GET /tenant/config` first, and skip the whole flow when it is false (this endpoint returns `422 checkout_holds_disabled` then). The hold lasts `checkout_hold_duration_secs`, also from that response. A hold is an availability courtesy, not a guarantee. The authoritative oversell check is still the `POST /bookings` transaction, which can return `409 seats_unavailable` even while you hold seats. **Typical flow:** acquire on entering checkout → `extend` as the customer advances through the steps → pass `hold_id` to `POST /bookings` so your own hold does not count against you → `DELETE` it if they abandon. While polling `GET /events/{id}` during checkout, pass `?exclude_hold={hold_id}` so your own hold is not subtracted from the seats shown back to you. operationId: acquireSeatHold tags: [Events] security: - ApiKey: [bookings:write, events:read] parameters: - name: id in: path required: true schema: type: string requestBody: required: true content: application/json: schema: type: object properties: seat_count: type: integer minimum: 1 example: 2 required: [seat_count] responses: "200": description: Hold acquired content: application/json: schema: $ref: "#/components/schemas/SeatHold" "400": description: "`invalid_json` — the request body is not valid JSON." content: application/json: schema: $ref: "#/components/schemas/Error" "401": $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/Forbidden" "404": description: "`class_not_found` — no such event." content: application/json: schema: $ref: "#/components/schemas/Error" "409": description: "`seats_unavailable` — not enough seats left to hold." content: application/json: schema: $ref: "#/components/schemas/Error" "422": description: | One of `checkout_holds_disabled` (the business has not enabled seat holds), `validation_error` (`seat_count` below 1, or above the event's per-booking cap), `booking_window_closed`, `private_class`, or `booking_paused` (the studio has switched off new bookings for this event; seats DO remain, so do not report it as sold out). content: application/json: schema: $ref: "#/components/schemas/Error" "429": $ref: "#/components/responses/RateLimited" /events/{id}/hold/{holdId}/extend: post: summary: Extend checkout seat hold description: | Pushes an existing hold's expiry out by another `checkout_hold_duration_secs` as the customer advances through checkout. Requires both the `events:read` and `bookings:write` scopes. A `409` means the hold already lapsed, or outlived the total lifetime cap that stops an abandoned tab from holding seats forever. Re-acquire in that case, which re-checks real availability. operationId: extendSeatHold tags: [Events] security: - ApiKey: [bookings:write, events:read] parameters: - name: id in: path required: true schema: type: string - name: holdId in: path required: true schema: type: string responses: "200": description: Hold extended content: application/json: schema: $ref: "#/components/schemas/SeatHold" "401": $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/Forbidden" "404": description: "`class_not_found` — no such event." content: application/json: schema: $ref: "#/components/schemas/Error" "409": description: "`seats_unavailable` — the hold has expired or hit its lifetime cap. Acquire a new one." content: application/json: schema: $ref: "#/components/schemas/Error" "422": description: "`checkout_holds_disabled`, `validation_error`, `booking_window_closed`, `private_class`, or `booking_paused`." content: application/json: schema: $ref: "#/components/schemas/Error" "429": $ref: "#/components/responses/RateLimited" /events/{id}/hold/{holdId}: delete: summary: Release checkout seat hold description: | Frees a hold when the customer abandons checkout. Requires both the `events:read` and `bookings:write` scopes. Idempotent, and deliberately **not** gated on `checkout_holds_enabled` — releasing a seat is always safe, so a repeated or stale release is a no-op success rather than an error. operationId: releaseSeatHold tags: [Events] security: - ApiKey: [bookings:write, events:read] parameters: - name: id in: path required: true schema: type: string - name: holdId in: path required: true schema: type: string responses: "200": description: Hold released (or was already gone) content: application/json: schema: type: object properties: released: type: boolean example: true required: [released] "401": $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/Forbidden" "429": $ref: "#/components/responses/RateLimited" /events/{id}/waitlist: post: summary: Join event waitlist description: | Adds a customer to a sold-out event's waitlist. Requires the `events:read` and `bookings:write` scopes, and the business must have waitlists enabled (`waitlist_enabled` on `GET /tenant/config`; the feature is off by default). The event must be scheduled, in the future, and have zero seats remaining. When a seat frees up, the first waiting customer receives an email with a claim window; claiming is a normal booking. Joining again with the same email returns `200` with the existing position instead of creating a duplicate entry. operationId: joinEventWaitlist tags: [Events] security: - ApiKey: [bookings:write, events:read] parameters: - name: id in: path required: true schema: type: string requestBody: required: true content: application/json: schema: type: object properties: email: type: string format: email first_name: type: string last_name: type: string phone: type: string description: Optional phone number required: [email, first_name, last_name] responses: "201": description: Joined the waitlist content: application/json: schema: type: object properties: position: type: integer description: 1-based place in line already_joined: type: boolean "200": description: Already on the waitlist; returns the existing position content: application/json: schema: type: object properties: position: type: integer already_joined: type: boolean "401": $ref: "#/components/responses/Unauthorized" "403": description: | Either the API key lacks the `bookings:write` scope (`insufficient_scope`), or waitlists are not enabled for this business (`feature_disabled`). content: application/json: schema: $ref: "#/components/schemas/Error" "404": description: Event not found or not open to waitlist joins content: application/json: schema: $ref: "#/components/schemas/Error" "409": description: The event still has seats available; book instead content: application/json: schema: $ref: "#/components/schemas/Error" "422": description: Validation error content: application/json: schema: $ref: "#/components/schemas/Error" "429": $ref: "#/components/responses/RateLimited" /events/{id}/checkout-attempt: post: summary: Record a checkout attempt description: | Records that a buyer finished the details step and is on the way to payment, so a checkout they abandon still leaves the business a name on its checkout-recovery report. Requires the `events:read` and `bookings:write` scopes. Call it when the buyer leaves the details step, and again if they come back and change their email address (pass the old one as `supersedes_email`). When the buyer pays, the booking call records the outcome over this attempt; there is nothing to delete. Rate limited per IP like the other checkout writes, and to 300 a minute per business. operationId: recordEventCheckoutAttempt tags: [Events] security: - ApiKey: [bookings:write, events:read] parameters: - name: id in: path required: true schema: type: string requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/CheckoutAttemptRequest" responses: "204": description: Recorded "400": description: "`invalid_json`: the request body is not valid JSON." content: application/json: schema: $ref: "#/components/schemas/Error" "401": $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/Forbidden" "404": description: "`class_not_found`: no such event." content: application/json: schema: $ref: "#/components/schemas/Error" "422": description: | `validation_error`: `email` is not a valid address, `first_name` or `last_name` is missing or holds something other than letters, spaces, hyphens and apostrophes, or `phone` is not a phone number. content: application/json: schema: $ref: "#/components/schemas/Error" "429": $ref: "#/components/responses/RateLimited" /classes: get: summary: List events (deprecated alias) deprecated: true description: | The name `GET /events` had before it was renamed. It takes the same query parameters, returns the same response and requires the same `events:read` scope. Every other `/events` path answers under `/classes` too, with the same scopes. Kept so integrations written before the rename keep working; use `/events` in new code. operationId: listClassesDeprecated tags: [Events] security: - ApiKey: [events:read] responses: "200": description: OK, the same as `GET /events` content: application/json: schema: type: object properties: data: type: array items: $ref: "#/components/schemas/Event" next_cursor: type: string has_more: type: boolean "401": $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/Forbidden" "429": $ref: "#/components/responses/RateLimited" /classes/{id}: get: summary: Get event (deprecated alias) deprecated: true description: | The name `GET /events/{id}` had before it was renamed: same parameters, response and `events:read` scope. Use `/events/{id}` in new code. operationId: getClassDeprecated tags: [Events] security: - ApiKey: [events:read] parameters: - name: id in: path required: true schema: type: string responses: "200": description: OK, the same as `GET /events/{id}` content: application/json: schema: $ref: "#/components/schemas/Event" "401": $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/Forbidden" "404": description: Event not found content: application/json: schema: $ref: "#/components/schemas/Error" "429": $ref: "#/components/responses/RateLimited" /courses: get: summary: List courses description: | Returns scheduled, publicly visible multi-session courses for the tenant. A course is listed only until its first non-canceled session starts, or with `late_signup` on, until its last one starts; after that it is no longer sold publicly and is left out. Courses come in order of their next session, soonest first. Per-session details are omitted unless `include=sessions` is passed; fetch a single course for everything else. Each course's `categories` are included. Requires the `events:read` scope. operationId: listCourses tags: [Courses] security: - ApiKey: [events:read] parameters: - name: has_availability in: query description: Set to `true` to only return courses with seats remaining schema: type: boolean - name: category_id in: query description: Only return courses with a non-canceled session whose class is assigned this category schema: type: string - name: include in: query description: | Set to `sessions` to include each course's `sessions` (every session, canceled ones marked by `status`), as the booking widget's calendar does to place each session on its day. Omitted by default to keep the list small. schema: type: string enum: [sessions] - name: limit in: query description: Results per page (1–100, default 50) schema: type: integer minimum: 1 maximum: 100 default: 50 - name: offset in: query description: Pagination offset (default 0) schema: type: integer minimum: 0 default: 0 responses: "200": description: OK content: application/json: schema: type: object properties: data: type: array items: $ref: "#/components/schemas/Course" has_more: type: boolean "400": description: | `invalid_param` — a parameter this endpoint does not know, a known one sent blank, a `has_availability` other than `true`/`1`/`false`/`0`, an `include` other than `sessions`, a `limit` that is not an integer between 1 and 100, or an `offset` that is not a non-negative integer. content: application/json: schema: $ref: "#/components/schemas/Error" "401": $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/Forbidden" "429": $ref: "#/components/responses/RateLimited" /courses/{id}: get: summary: Get course description: | Returns a single scheduled course by ID, including its per-session schedule, attached waiver names, and cancellation policy. A course whose first non-canceled session has started (its last, with `late_signup` on) is no longer sold publicly and returns 404, as do its quote (`course_not_found`) and bookings (`course_not_found`) endpoints. Requires the `events:read` scope. operationId: getCourse tags: [Courses] security: - ApiKey: [events:read] parameters: - name: id in: path required: true schema: type: string responses: "200": description: OK content: application/json: schema: $ref: "#/components/schemas/Course" "401": $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/Forbidden" "429": $ref: "#/components/responses/RateLimited" "404": description: Course not found content: application/json: schema: $ref: "#/components/schemas/Error" /courses/{id}/waivers: get: summary: Get course waivers description: | Returns the active waivers that must be accepted before booking this course. Returns an empty list for an unknown course ID. Requires the `events:read` scope. operationId: getCourseWaivers tags: [Courses] security: - ApiKey: [events:read] parameters: - name: id in: path required: true schema: type: string responses: "200": description: OK content: application/json: schema: type: object properties: waivers: type: array items: $ref: "#/components/schemas/Waiver" "401": $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/Forbidden" "429": $ref: "#/components/responses/RateLimited" "404": description: | `not_found` — no such course for this studio, or one `GET /v1/courses/{id}` would not show (archived, or closed to sale). content: application/json: schema: $ref: "#/components/schemas/Error" /courses/{id}/terms: get: summary: Get course terms & conditions description: | Returns the active terms & conditions that must be accepted before booking this course. Returns an empty list for an unknown course ID. Requires the `events:read` scope. operationId: getCourseTerms tags: [Courses] security: - ApiKey: [events:read] parameters: - name: id in: path required: true schema: type: string responses: "200": description: OK content: application/json: schema: type: object properties: terms: type: array items: $ref: "#/components/schemas/Terms" "401": $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/Forbidden" "429": $ref: "#/components/responses/RateLimited" "404": description: | `not_found` — no such course for this studio, or one `GET /v1/courses/{id}` would not show (archived, or closed to sale). content: application/json: schema: $ref: "#/components/schemas/Error" /courses/{id}/quote: post: summary: Quote course booking description: | Returns the pricing breakdown for booking `seat_count` seats on a course, including any promo code or balance gift card and computed tax. Use this to preview the total and charge amount before calling the booking endpoint. Requires the `bookings:write` scope. Class-pass gift cards cannot be applied to courses — use the single-event booking flow for those. operationId: quoteCourse tags: [Courses] security: - ApiKey: [bookings:write] parameters: - name: id in: path required: true schema: type: string requestBody: required: true content: application/json: schema: type: object properties: seat_count: type: integer minimum: 1 example: 2 description: Spots to book. For a course with price options, omit it (it is their total) or send that total. price_options: type: array description: For a course with price options, how many spots at each. Each option's price is the whole course, per spot; the price comes from the stored option, never the request. A course with options is refused without them (`422 price_option_required`), never priced at its one price. Refused with `price_option_invalid` for an option this course does not have (another course's or another business's looks the same as an unknown id), the same option twice, a quantity below 1, or a `seat_count` that disagrees; with `price_options_not_offered` for a course without options. items: type: object required: [price_option_id, quantity] properties: price_option_id: type: string quantity: type: integer minimum: 1 promo_code: type: string description: Promo code to apply a discount. gift_card_code: type: string description: "Deprecated: a single balance gift card code. Prefer `gift_card_codes`. Class-pass cards are rejected for courses." gift_card_codes: type: array items: type: string description: Balance gift card codes to apply (multiple stack). Class-pass cards are rejected for courses. referral_link_token: type: string description: The token from the `?fc_ref=` referral invite link the customer arrived on (see `GET /referrals/link/{token}`). Prices the referee discount into the quote when no promo code discount applies. A stale, revoked, unknown or used-up token quotes no referral discount rather than an error. purchaser_id: type: string description: The buyer's customer account id, when known. Used only with `referral_link_token`, so a referrer quoting on their own link gets no discount. email: type: string format: email description: The buyer's email, for a guest checkout. Used only with `referral_link_token`, so a referrer quoting on their own link gets no discount. required: [seat_count] responses: "200": description: OK content: application/json: schema: $ref: "#/components/schemas/CourseQuote" "401": $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/Forbidden" "429": $ref: "#/components/responses/RateLimited" "404": description: Course not found content: application/json: schema: $ref: "#/components/schemas/Error" "422": description: Validation error (invalid `seat_count`, promo code, or gift card) content: application/json: schema: $ref: "#/components/schemas/Error" /courses/{id}/checkout-attempt: post: summary: Record a course checkout attempt description: | Records that a buyer finished the details step and is on the way to payment, so a checkout they abandon still leaves the business a name on its checkout-recovery report. Requires the `bookings:write` scope. Call it when the buyer leaves the details step, and again if they come back and change their email address (pass the old one as `supersedes_email`). When the buyer pays, the booking call records the outcome over this attempt; there is nothing to delete. Rate limited per IP like the other checkout writes, and to 300 a minute per business. The same as `POST /events/{id}/checkout-attempt`, for a course. operationId: recordCourseCheckoutAttempt tags: [Courses] security: - ApiKey: [bookings:write] parameters: - name: id in: path required: true schema: type: string requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/CheckoutAttemptRequest" responses: "204": description: Recorded "400": description: "`invalid_json`: the request body is not valid JSON." content: application/json: schema: $ref: "#/components/schemas/Error" "401": $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/Forbidden" "404": description: "`course_not_found`: no such course." content: application/json: schema: $ref: "#/components/schemas/Error" "422": description: | `validation_error`: `email` is not a valid address, `first_name` or `last_name` is missing or holds something other than letters, spaces, hyphens and apostrophes, or `phone` is not a phone number. content: application/json: schema: $ref: "#/components/schemas/Error" "429": $ref: "#/components/responses/RateLimited" /courses/{id}/bookings: post: summary: Create course booking description: | Books `seat_count` seats across every session of a course as a single purchase sharing one payment. Requires the `bookings:write` scope. **Idempotency:** Supply a unique `Idempotency-Key` header on every request. Retrying the same request with the same key returns the original booking instead of creating a duplicate. The replay omits `cancellation_token`; see that field's description. A key names one request: reusing it for a different one (another event or course, another route, another seat count, another buyer) answers `422 idempotency_key_mismatch` and books nothing. Fields a retry legitimately changes, such as a payment token or `payment_intent_id`, are not compared. **Purchaser types:** - `guest` — provide `email`, `first_name`, `last_name` (and optionally `phone`). A customer account is automatically created, or linked if one already exists with that email. - `account` — provide `purchaser_id` (an existing customer account ID). **Requires a secret (`sk_`) key**: it books in that customer's name and sends them the confirmation, so a public key (which ships inside your website) and the hosted booking page get `403 secret_key_required`. A `purchaser_id` this business does not have is `422 validation_error`, answered before any charge. Class-pass gift cards cannot be applied to courses — use the single-event booking flow for those. operationId: createCourseBooking tags: [Courses] security: - ApiKey: [bookings:write] parameters: - name: Idempotency-Key in: header required: true description: Unique string to prevent duplicate bookings on retry schema: type: string example: "order_abc123" - name: id in: path required: true schema: type: string requestBody: required: true content: application/json: schema: type: object properties: seat_count: type: integer minimum: 1 example: 2 description: Spots to book. For a course with price options, omit it (it is their total) or send that total. price_options: type: array description: For a course with price options, how many spots at each. Each option's price is the whole course, per spot; the price comes from the stored option, never the request. A course with options is refused without them (`422 price_option_required`), never priced at its one price. Refused with `price_option_invalid` for an option this course does not have (another course's or another business's looks the same as an unknown id), the same option twice, a quantity below 1, or a `seat_count` that disagrees; with `price_options_not_offered` for a course without options. items: type: object required: [price_option_id, quantity] properties: price_option_id: type: string quantity: type: integer minimum: 1 purchaser_type: type: string enum: [guest, account] default: guest purchaser_id: type: string description: Customer account ID (required when purchaser_type is `account`) email: type: string format: email description: Required when purchaser_type is `guest`. first_name: type: string description: Required when purchaser_type is `guest`. last_name: type: string description: Required when purchaser_type is `guest`. phone: type: string marketing_opt_in: type: boolean default: false customer_notes: type: string description: Optional free-text notes about this booking visit_address: type: string maxLength: 500 description: | The customer's address for the visits. Required when the course's `location_kind` is `customer_address` (refused with `422 visit_address_required` before any charge) and written to every session; ignored for every other location. Longer than 500 characters is refused with `422 visit_address_too_long`. waivers_accepted: type: boolean description: Set to `true` to confirm the customer accepted all required course waivers and terms & conditions. Required when the course has active waivers or terms. default: false waiver_signature: type: string maxLength: 200 description: The full name the customer typed to sign the waivers, stored with each waiver acceptance as their signature. Show the waiver text to the customer and collect this from them; do not fill it in on their behalf. Optional, but an acceptance without it carries no signature. When the request names participants (`booker_attending` or `participants`) and the booker signs, it must be the booker's name, meaning the account's name for `purchaser_type` `account` and otherwise the `first_name` and `last_name` sent, which may not then both be blank. Anything else is refused with `participants_invalid` on field `waiver_signature`, before any charge. booker_price_option_id: type: string description: For a course with price options, the option of the booker's own spot when they attend. Optional; without it the booker takes the next spot left over from `price_options`. booker_attending: type: boolean description: On a booking whose class or course has a waiver, whether the booker takes one of the spots. Send it (with `participants`) to name everyone on the booking; omit both and the booking records only the booker, as before. participants: type: array description: Everyone on a waiver booking other than the booker, one per remaining spot. A child (under 18 on the day of the class; for a course, the next session not yet started) gives `age` and is accepted for by the booker as parent or guardian. Every other adult gives `email` and is emailed a link to sign the waiver themselves, because the booker's signature does not bind them. Refused with `participants_invalid` when the count, names, ages or emails do not add up. items: type: object required: [first_name, last_name] properties: first_name: type: string last_name: type: string email: type: string description: Required for an adult. age: type: integer minimum: 0 maximum: 120 description: A child's age on the day of the class (for a course, the first session they attend, which is the next one not yet started and so the course's first unless late sign-up let them buy it part-way through). Required for a child; under 18 makes them a child, 18 or more an adult who needs `email`. Only a child's age is stored. guardian_name: type: string description: For a child the booker is not the parent or guardian of, that parent or guardian's full name. They are emailed a link to sign for the child, and `guardian_attested` does not cover this child. guardian_email: type: string description: Required with `guardian_name`. price_option_id: type: string description: For a course with price options, the option this person is booked at. Optional; must be one of `price_options`, with a spot left. signer_attested_adult: type: boolean description: The booker confirms they are an adult. Required with `participants`. guardian_attested: type: boolean description: The booker confirms they are the parent or legal guardian of every child in `participants` without a `guardian_name`, and accepts the waiver for them. Required when any such child is named. cancellation_policy_acknowledged: type: boolean default: false referral_link_token: type: string description: The token from the `?fc_ref=` referral invite link the customer arrived on (see `GET /referrals/link/{token}`). Applies the referee discount when no promo code discount applies, and links the purchasing account to the referrer. A stale, revoked or unknown token is ignored, not rejected. heard_about_us: type: string description: Optional marketing-attribution answer. promo_code: type: string description: Promo code to apply a discount. gift_card_code: type: string description: "Deprecated: a single balance gift card code. Prefer `gift_card_codes`. Class-pass cards are rejected for courses." gift_card_codes: type: array items: type: string description: Balance gift card codes to apply (multiple stack). Class-pass cards are rejected for courses. payment_timing: type: string enum: [pay_on_site] description: | Set to `pay_on_site` to reserve seats without paying online; the booking is created `pending` and settled in person later. Only honored when the tenant has enabled pay-on-site (`booking_payment_timing = pay_on_site` on `GET /tenant/config`); otherwise the request is rejected with `422 pay_on_site_not_allowed`. Omit for normal online payment. payment_source_id: type: string description: | Single-use payment token — a Square Web Payments nonce or Stripe PaymentMethod ID. Required for paid bookings. The provider is determined by the tenant's payment configuration. payment_intent_id: type: string description: | Stripe PaymentIntent ID. Provide this instead of `payment_source_id` after completing a Stripe 3DS authentication challenge (see `payment_action_required` response). required: [seat_count] responses: "201": description: Course booking created content: application/json: schema: $ref: "#/components/schemas/CourseBooking" "400": description: Bad request (missing Idempotency-Key, invalid JSON) content: application/json: schema: $ref: "#/components/schemas/Error" "401": $ref: "#/components/responses/Unauthorized" "403": description: | `insufficient_scope` — the key lacks `bookings:write`, or `secret_key_required` — `purchaser_type` `account` was sent by anything other than a secret (`sk_`) key. content: application/json: schema: $ref: "#/components/schemas/Error" "429": $ref: "#/components/responses/RateLimited" "402": description: | Payment required. Either the charge failed (`payment_failed`) or Stripe requires 3DS authentication (`payment_action_required`). For `payment_action_required`, use the `payment_action.client_secret` with Stripe.js to complete authentication, then retry with the `payment_intent_id`. content: application/json: schema: type: object properties: error: type: object properties: code: type: string enum: [payment_failed, payment_action_required] message: type: string decline_reason: type: string description: Provider-neutral decline reason. Present only on `payment_failed`; see the `Error` schema for the value list. payment_action: type: object description: Only present for `payment_action_required`. properties: provider: type: string example: stripe type: type: string example: confirm_payment client_secret: type: string payment_intent_id: type: string "404": description: Course not found or not available content: application/json: schema: $ref: "#/components/schemas/Error" "409": description: | `seats_unavailable` — the seats went while you were checking out. Or `charge_amount_mismatch` — the amount actually charged did not match the quoted charge. content: application/json: schema: $ref: "#/components/schemas/Error" "422": description: | Validation error. One of `validation_error`, `idempotency_key_mismatch` (see Idempotency above), `course_empty`, `seats_exceed_limit`, `pay_on_site_not_allowed`, `waivers_required`, `terms_required`, `promo_code_invalid`, `gift_card_not_found`, `gift_card_invalid` (including a class-pass card, which cannot be applied to a course), `gift_card_insufficient`, `gift_card_stacking_not_allowed` and `promo_gift_card_stacking_not_allowed` (the tenant's `single_gift_card_only` / `block_promo_with_gift_card` settings), `payment_required`, or `payment_amount_mismatch`. content: application/json: schema: $ref: "#/components/schemas/Error" "503": description: Payment provider not configured or unavailable for this tenant content: application/json: schema: $ref: "#/components/schemas/Error" /courses/{id}/waitlist: post: summary: Join course waitlist description: | Adds a customer to a sold-out course's waitlist. Requires the `bookings:write` scope, and the business must have waitlists enabled (`waitlist_enabled` on `GET /tenant/config`; one switch covers events and courses, and it is off by default). The course must be scheduled and still on sale, and one purchase must be unable to take a seat: either the course has no seats left, or a session still ahead is full. When a seat frees up, the first waiting customer receives an email with a claim window that never runs past the course's sale cutoff; claiming is a normal course purchase. Joining again with the same email returns `200` with the existing position instead of creating a duplicate entry. operationId: joinCourseWaitlist tags: [Courses] security: - ApiKey: [bookings:write] parameters: - name: id in: path required: true schema: type: string requestBody: required: true content: application/json: schema: type: object properties: email: type: string format: email first_name: type: string last_name: type: string phone: type: string description: Optional phone number required: [email, first_name, last_name] responses: "201": description: Joined the waitlist content: application/json: schema: type: object properties: position: type: integer description: 1-based place in line already_joined: type: boolean "200": description: Already on the waitlist; returns the existing position content: application/json: schema: type: object properties: position: type: integer already_joined: type: boolean "401": $ref: "#/components/responses/Unauthorized" "403": description: | Either the API key lacks the `bookings:write` scope (`insufficient_scope`), or waitlists are not enabled for this business (`feature_disabled`). content: application/json: schema: $ref: "#/components/schemas/Error" "404": description: Course not found, or not on sale (archived or past its sale cutoff) content: application/json: schema: $ref: "#/components/schemas/Error" "409": description: The course still has seats available; book instead content: application/json: schema: $ref: "#/components/schemas/Error" "422": description: Validation error content: application/json: schema: $ref: "#/components/schemas/Error" "429": $ref: "#/components/responses/RateLimited" /course-purchases/{id}: get: summary: Get a course purchase description: | One course purchase with every session under it. Take the id from a booking's `course_purchase.id` (for example after looking a booking up with `GET /v1/bookings?booking_number=`), from `course_purchase_id` on a booking webhook, or from the course checkout's response. **Requires a secret key** (`sk_...`) on top of the `bookings:read` scope, like `GET /v1/bookings`: it is a read of a customer's details and money keyed by an id, and that scope can be granted to a public key. operationId: getCoursePurchase tags: [Bookings] security: - ApiKey: [bookings:read] parameters: - name: id in: path required: true schema: type: string example: cpc_01HXYZ responses: "200": description: OK content: application/json: schema: $ref: "#/components/schemas/CoursePurchase" "401": $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/Forbidden" "404": description: No course purchase with that id at this business content: application/json: schema: $ref: "#/components/schemas/Error" "429": $ref: "#/components/responses/RateLimited" /course-purchases: get: summary: List course purchases description: | The business's course purchases, newest first (by creation time, then id), one page at a time. Each item is exactly what `GET /v1/course-purchases/{id}` returns for that purchase: the whole course's money, its customer, and every session under it. Canceled purchases are included. **Paging.** Each page carries `next_cursor` and `has_more`, exactly as `GET /v1/bookings` does. Pass `next_cursor` back as `cursor`, with the same filters, to get the next (older) page; stop when `has_more` is `false`. **Requires a secret key** (`sk_...`) on top of the `bookings:read` scope, like `GET /v1/bookings`: it lists customers' details and money, and that scope can be granted to a public key. Unknown query parameters are rejected with a 400 rather than ignored, and so is a parameter that is present but blank. operationId: listCoursePurchases tags: [Bookings] security: - ApiKey: [bookings:read] parameters: - name: course_id in: query description: Narrow to the purchases of one course. A blank value is refused with 400. schema: type: string example: crs_01HXYZ - name: status in: query description: | Narrow to one purchase status: `confirmed` while any session is still booked, `canceled` once every session is. A blank value is refused with 400. schema: type: string enum: [confirmed, canceled] - name: limit in: query description: Results per page (1–100, default 25). A blank value is refused with 400. schema: type: integer minimum: 1 maximum: 100 default: 25 - name: cursor in: query description: Pagination cursor from the previous response's `next_cursor` schema: type: string responses: "200": description: OK content: application/json: schema: type: object properties: data: type: array items: $ref: "#/components/schemas/CoursePurchase" next_cursor: type: string description: Cursor for the next page. An empty string (not `null`) on the last page. has_more: type: boolean "400": description: | `invalid_param`: an invalid `limit`, an unknown `status`, an unknown query parameter, a parameter that is present but blank, or a `cursor` this server did not issue. content: application/json: schema: $ref: "#/components/schemas/Error" "401": $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/Forbidden" "429": $ref: "#/components/responses/RateLimited" /bookings: get: summary: List bookings description: | The tenant's bookings, newest first (by creation time, then id), one page at a time. Unlike the dashboard's recent-bookings list this includes canceled ones, because an integration mirroring a studio's bookings needs the cancellations as much as the confirmations. **Paging.** Each page carries `next_cursor` and `has_more`, exactly as `GET /events` does. Pass `next_cursor` back as `cursor`, with the same filters, to get the next (older) page; stop when `has_more` is `false`. Every booking appears on exactly one page, including bookings created in the same instant (a course purchase writes all its sessions at once). **Requires a secret key** (`sk_...`) on top of the `bookings:read` scope, unlike `GET /v1/bookings/{id}`. That scope can be granted to a public key, which is the key embedded in your website, and this route answers "who has booked lately" for the whole business rather than needing an id you already hold. The response omits `cancellation_token`. See `BookingListItem`. **Looking up a booking by its number.** The widget's thank-you redirect passes `{booking_number}`, not the booking id. To confirm that booking from your server, call `GET /v1/bookings?booking_number=SHG8GS`: it returns that one booking, or an empty `data` if this business has no booking with that number. Unknown query parameters are rejected with a 400 rather than ignored, so a misspelled filter never returns the unfiltered list. So is a parameter that is present but blank (`?status=`, `?limit=`, `?cursor=`, and the two id filters): read as "no filter" it would answer a question you did not ask. operationId: listBookings tags: [Bookings] security: - ApiKey: [bookings:read] parameters: - name: status in: query description: Narrow to one booking status. Note that `pending` is a payment status, not a booking status, and is rejected. A blank value is refused with 400. schema: type: string enum: [confirmed, canceled, attended] - name: booking_number in: query description: | Exact booking number, as shown to the customer and passed by the widget's thank-you redirect. Case-insensitive. For a course, this is the number checkout returned, which belongs to one session booking; its `course_purchase` carries the whole course. A blank value is refused with 400 rather than read as "no filter". schema: type: string example: SHG8GS - name: course_purchase_id in: query description: | Every session booking of one course purchase (`course_purchase.id`). A blank value is refused with 400 rather than read as "no filter". schema: type: string - name: limit in: query description: Results per page (1–100, default 25). A blank value is refused with 400. schema: type: integer minimum: 1 maximum: 100 default: 25 - name: cursor in: query description: Pagination cursor from the previous response's `next_cursor` schema: type: string responses: "200": description: OK content: application/json: schema: type: object properties: data: type: array items: $ref: "#/components/schemas/BookingListItem" next_cursor: type: string description: Cursor for the next page. An empty string (not `null`) on the last page. has_more: type: boolean "400": description: | `invalid_param`: an invalid `limit`, an unknown `status`, an unknown query parameter, a parameter that is present but blank, or a `cursor` this server did not issue. content: application/json: schema: $ref: "#/components/schemas/Error" "401": $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/Forbidden" "429": $ref: "#/components/responses/RateLimited" post: summary: Create booking description: | Books seats on an event. Requires the `bookings:write` scope. **Idempotency:** Supply a unique `Idempotency-Key` header on every request. Retrying the same request with the same key returns the original booking instead of creating a duplicate. The replay omits `cancellation_token`; see that field's description. A key names one request: reusing it for a different one (another event or course, another route, another seat count, another buyer) answers `422 idempotency_key_mismatch` and books nothing. Fields a retry legitimately changes, such as a payment token or `payment_intent_id`, are not compared. **Purchaser types:** - `guest` — provide `email`, `first_name`, `last_name` (and optionally `phone`). A customer account is automatically created, or linked if one already exists with that email. - `account` — provide `purchaser_id` (an existing customer account ID). **Requires a secret (`sk_`) key**: it books in that customer's name and sends them the confirmation, so a public key (which ships inside your website) and the hosted booking page get `403 secret_key_required`. A `purchaser_id` this business does not have is `422 validation_error`, answered before any charge. operationId: createBooking tags: [Bookings] security: - ApiKey: [bookings:write] parameters: - name: Idempotency-Key in: header required: true description: Unique string to prevent duplicate bookings on retry schema: type: string example: "order_abc123" requestBody: required: true content: application/json: schema: type: object properties: event_id: type: string description: The event ID to book purchaser_type: type: string enum: [guest, account] default: guest purchaser_id: type: string description: Customer account ID (required when purchaser_type is `account`) email: type: string format: email description: Customer email. Required when purchaser_type is `guest`. first_name: type: string description: Required when purchaser_type is `guest`. last_name: type: string description: Required when purchaser_type is `guest`. phone: type: string marketing_opt_in: type: boolean description: Whether the customer consents to marketing communications. Applied to the guest's new or matched account. default: false seat_count: type: integer minimum: 1 example: 2 description: Spots to book. For an event with `price_options`, omit it (it is their total) or send that total. price_options: type: array description: For an event with price options, how many spots at each. The price comes from the stored option, never the request. Refused with `price_option_invalid` for an option this event does not have, the same option twice, a quantity below 1, or a `seat_count` that disagrees; with `price_options_not_offered` for an event without options. items: type: object required: [price_option_id, quantity] properties: price_option_id: type: string quantity: type: integer minimum: 1 hold_id: type: string description: | The checkout seat hold from `POST /events/{id}/hold`, when you reserved seats on entering checkout. Creating the booking consumes it, so your own hold stops counting against offered availability. Omit when the business has not enabled checkout holds. attendees: type: array description: Individual attendee details (optional) items: $ref: "#/components/schemas/Attendee" customer_notes: type: string description: Optional free-text notes about this booking visit_address: type: string maxLength: 500 description: | The customer's address for the visit. Required when the event's location `kind` is `customer_address` (refused with `422 visit_address_required` before any charge), and ignored for every other location. Longer than 500 characters is refused with `422 visit_address_too_long`. waivers_accepted: type: boolean description: Set to `true` to confirm the customer accepted all required class waivers and terms & conditions. Required when the event's class has active waivers or terms. default: false waiver_signature: type: string maxLength: 200 description: The full name the customer typed to sign the waivers, stored with each waiver acceptance as their signature. Show the waiver text to the customer and collect this from them; do not fill it in on their behalf. Optional, but an acceptance without it carries no signature. When the request names participants (`booker_attending` or `participants`) and the booker signs, it must be the booker's name, meaning the account's name for `purchaser_type` `account` and otherwise the `first_name` and `last_name` sent, which may not then both be blank. Anything else is refused with `participants_invalid` on field `waiver_signature`, before any charge. booker_price_option_id: type: string description: For an event with price options, the option of the booker's own spot when they attend. Optional; without it the booker takes the next spot left over from `price_options`. booker_attending: type: boolean description: On a booking whose class or course has a waiver, whether the booker takes one of the spots. Send it (with `participants`) to name everyone on the booking; omit both and the booking records only the booker, as before. participants: type: array description: Everyone on a waiver booking other than the booker, one per remaining spot. A child (under 18 on the day of the class; for a course, the next session not yet started) gives `age` and is accepted for by the booker as parent or guardian. Every other adult gives `email` and is emailed a link to sign the waiver themselves, because the booker's signature does not bind them. Refused with `participants_invalid` when the count, names, ages or emails do not add up. items: type: object required: [first_name, last_name] properties: first_name: type: string last_name: type: string email: type: string description: Required for an adult. age: type: integer minimum: 0 maximum: 120 description: A child's age on the day of the class (for a course, the first session they attend, which is the next one not yet started and so the course's first unless late sign-up let them buy it part-way through). Required for a child; under 18 makes them a child, 18 or more an adult who needs `email`. Only a child's age is stored. guardian_name: type: string description: For a child the booker is not the parent or guardian of, that parent or guardian's full name. They are emailed a link to sign for the child, and `guardian_attested` does not cover this child. guardian_email: type: string description: Required with `guardian_name`. price_option_id: type: string description: For an event with price options, the option this person is booked at. Optional; must be one of `price_options`, with a spot left. signer_attested_adult: type: boolean description: The booker confirms they are an adult. Required with `participants`. guardian_attested: type: boolean description: The booker confirms they are the parent or legal guardian of every child in `participants` without a `guardian_name`, and accepts the waiver for them. Required when any such child is named. referral_link_token: type: string description: The token from the `?fc_ref=` referral invite link the customer arrived on (see `GET /referrals/link/{token}`). Applies the referee discount when no promo code discount applies, and links the purchasing account to the referrer. A stale, revoked or unknown token is ignored, not rejected. heard_about_us: type: string description: Optional marketing-attribution answer (e.g. "Google", "Friend", or free text). addons: type: array description: Add-ons to include with this booking items: type: object properties: addon_id: type: string name: type: string price: type: integer currency: type: string quantity: type: integer gift_card_code: type: string description: "Deprecated: a single gift card code to apply. Prefer `gift_card_codes`. Any amount not covered by gift cards is charged via `payment_source_id`." gift_card_codes: type: array items: type: string description: Gift card codes to apply, in priority order. Any mix of balance and class-pass cards can be stacked — class passes cover seats first, then balance cards cover the remaining total. Any uncovered amount is charged via `payment_source_id`. membership_token: type: string description: | The `member_token` from `POST /memberships/verify/confirm` or `/verify/device`. With `membership_id`, the booking is made as that member and the seat is covered by the membership: one seat, priced at 0. Add-ons are charged as usual. Refused with `401 member_token_invalid`, `422 membership_with_discount` beside a promo code or gift card, or one of the `422` codes `POST /memberships/cover` lists. membership_id: type: string description: One of the member's memberships, from the verify response. promo_code: type: string description: Promo code to apply a discount. payment_timing: type: string enum: [pay_on_site] description: | Set to `pay_on_site` to reserve seats without paying online; the booking is created `pending` and settled in person later. Only honored when the tenant has enabled pay-on-site (`booking_payment_timing = pay_on_site` on `GET /tenant/config`); otherwise the request is rejected with `422 pay_on_site_not_allowed`. Omit for normal online payment. payment_source_id: type: string description: | Single-use payment token — a Square Web Payments nonce or Stripe PaymentMethod ID. Required for paid bookings. The provider is determined by the tenant's payment configuration. payment_intent_id: type: string description: | Stripe PaymentIntent ID. Provide this instead of `payment_source_id` after completing a Stripe 3DS authentication challenge (see `payment_action_required` response). required: [event_id, seat_count] responses: "201": description: Booking created content: application/json: schema: $ref: "#/components/schemas/Booking" "400": description: Bad request (missing Idempotency-Key, invalid JSON) content: application/json: schema: $ref: "#/components/schemas/Error" "402": description: | Payment required. Either the charge failed (`payment_failed`) or Stripe requires 3DS authentication (`payment_action_required`). For `payment_action_required`, use the `payment_action.client_secret` with Stripe.js to complete authentication, then retry with the `payment_intent_id`. content: application/json: schema: type: object properties: error: type: object properties: code: type: string enum: [payment_failed, payment_action_required] message: type: string decline_reason: type: string description: Provider-neutral decline reason. Present only on `payment_failed`; see the `Error` schema for the value list. payment_action: type: object description: Only present for `payment_action_required`. properties: provider: type: string example: stripe type: type: string example: confirm_payment client_secret: type: string payment_intent_id: type: string "401": $ref: "#/components/responses/Unauthorized" "403": description: | `insufficient_scope` — the key lacks `bookings:write`, or `secret_key_required` — `purchaser_type` `account` was sent by anything other than a secret (`sk_`) key. content: application/json: schema: $ref: "#/components/schemas/Error" "404": description: | `class_not_found` — no such event, or it is not available for booking. A private (bought-out) event also 404s from `GET /events/{id}`, but booking one returns `422 private_class` instead. content: application/json: schema: $ref: "#/components/schemas/Error" "409": description: | `seats_unavailable` — the seats went while you were checking out. Or `idempotency_key_reused` — this `Idempotency-Key` was already used with different parameters; the provider will not confirm whether the earlier attempt took money, so start a fresh checkout rather than retrying. content: application/json: schema: $ref: "#/components/schemas/Error" "422": description: | Validation error. One of `validation_error`, `idempotency_key_mismatch` (see Idempotency above), `private_class`, `booking_paused`, `seats_exceed_limit`, `booking_window_closed`, `pay_on_site_not_allowed`, `waivers_required`, `terms_required`, `promo_code_invalid`, `gift_card_not_found`, `gift_card_invalid`, `gift_card_insufficient`, `gift_card_stacking_not_allowed` and `promo_gift_card_stacking_not_allowed` (the tenant's `single_gift_card_only` / `block_promo_with_gift_card` settings), `payment_required` (a paid booking with no payment token), or `payment_amount_mismatch`. content: application/json: schema: $ref: "#/components/schemas/Error" "429": $ref: "#/components/responses/RateLimited" "503": description: Payment provider not configured or unavailable for this tenant content: application/json: schema: $ref: "#/components/schemas/Error" /bookings/{id}: get: summary: Get booking description: | Requires the `bookings:read` scope. A session of a course purchase also carries `course_id`, `course_purchase_id` and `course_purchase`, the whole purchase as the bookings list reports it. A standalone booking omits the two ids and has `course_purchase: null`. operationId: getBooking tags: [Bookings] security: - ApiKey: [bookings:read] parameters: - name: id in: path required: true schema: type: string responses: "200": description: OK content: application/json: schema: $ref: "#/components/schemas/BookingDetail" "401": $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/Forbidden" "429": $ref: "#/components/responses/RateLimited" "404": description: Booking not found content: application/json: schema: $ref: "#/components/schemas/Error" /bookings/{id}/cancel: post: summary: Cancel booking description: | Cancels a booking and refunds what the event's cancellation policy allows. Requires the `bookings:cancel` scope. **With a secret (`sk_`) key** the booking id is enough. **With a public key** the request must also prove it comes from the buyer, by sending the booking's own `cancellation_token` in the body: `POST /bookings` returns it to the buyer, and the confirmation email's manage link carries it. `bookings:cancel` can be granted to a public key, which ships inside your website, so the id alone is not enough. A public key with no token gets `403 cancellation_token_required`; a token that belongs to another booking gets `404 not_found`, the same answer as an unknown id. The `booking.canceled` webhook says who canceled in `canceled_by`: `api` for a secret key (your own server), `customer` for a public key with the buyer's `cancellation_token`. operationId: cancelBooking tags: [Bookings] security: - ApiKey: [bookings:cancel] parameters: - name: id in: path required: true schema: type: string requestBody: required: false content: application/json: schema: type: object properties: cancellation_token: type: string description: The booking's cancellation token. Required with a public key; ignored with a secret key. responses: "204": description: Booking canceled "400": description: "`invalid_json` — the body sent with a public key is not valid JSON." content: application/json: schema: $ref: "#/components/schemas/Error" "401": $ref: "#/components/responses/Unauthorized" "403": description: | `insufficient_scope` — the key lacks `bookings:cancel`, or `cancellation_token_required` — a public key sent no `cancellation_token`. content: application/json: schema: $ref: "#/components/schemas/Error" "429": $ref: "#/components/responses/RateLimited" "404": description: Booking not found, or the `cancellation_token` is not this booking's content: application/json: schema: $ref: "#/components/schemas/Error" "409": description: | The booking can no longer be canceled: `already_canceled` when it was canceled before, `already_attended` when the customer has already attended. Nothing is refunded and nothing changes. content: application/json: schema: $ref: "#/components/schemas/Error" /giftcards: post: summary: Create gift card description: | Issues a new gift card. Requires the `giftcards:write` scope. Two types are supported: - `balance` — a monetary value (in cents). Specify `balance_amount`. - `class_pass` — a fixed number of classes. Specify `class_pass_count`. **This endpoint charges unless you tell it the sale is already paid for.** By default a gift card is never activated without a captured payment, so every request must carry one of `payment_source_id` or `payment_intent_id` plus an `idempotency_key`. The amount charged is computed server-side from the tenant's configuration (`balance_amount` for balance cards; the class-pass price × count, plus gift card tax, for class passes) — the client cannot name its own price, and any `external_purchase_id` sent is ignored. **Sales you collected yourself** — through your own Stripe payment link, an in-person terminal, a bank transfer — send an `external_purchase` object instead of a payment token. Nothing is charged; the card is issued and the sale is recorded against the payment reference you supply. That mode **requires a secret (`sk_`) key**: issuing without a charge creates prepaid value, so a public key (which ships inside your website) and the hosted booking page both get `403 secret_key_required`. Your `external_purchase.amount_cents` is what the buyer paid, tax included; it is recorded as the sale value and is deliberately not checked against the tenant's class-pass price, so one pass can be sold at more than one price point. operationId: createGiftCard tags: [Gift Cards] security: - ApiKey: [giftcards:write] requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/GiftCardPurchaseRequest" responses: "201": description: Gift card created content: application/json: schema: $ref: "#/components/schemas/GiftCardPurchaseResponse" "400": description: | Validation error: `invalid_request` (malformed JSON, unknown `type`, a custom `code` under 8 characters, a non-positive amount or count, class-pass cards not configured for this tenant, or an `external_purchase` missing its `reference`/`amount_cents` or sent alongside a payment token), `invalid_amount` (`total_amount_cents` disagrees with the server-computed total), or `missing_idempotency_key`. content: application/json: schema: $ref: "#/components/schemas/Error" "402": description: | Payment required. One of: - `payment_required` — neither `payment_source_id` nor `payment_intent_id` was supplied. Gift cards are never issued without a captured payment. - `payment_failed` — the provider declined the charge. Carries `decline_reason`. - `payment_action_required` — Stripe requires 3DS authentication. Use `payment_action.client_secret` with Stripe.js to complete it, then retry with the resulting `payment_intent_id`. content: application/json: schema: type: object properties: error: type: object properties: code: type: string enum: [ payment_required, payment_failed, payment_action_required, ] message: type: string decline_reason: type: string description: Provider-neutral decline reason. Present only on `payment_failed`; see the `Error` schema for the value list. payment_action: type: object description: Only present for `payment_action_required`. properties: provider: type: string example: stripe type: type: string example: confirm_payment client_secret: type: string payment_intent_id: type: string "401": $ref: "#/components/responses/Unauthorized" "403": description: | `insufficient_scope` — the key lacks `giftcards:write`, or `secret_key_required` — an `external_purchase` was sent by anything other than a secret (`sk_`) key. content: application/json: schema: $ref: "#/components/schemas/Error" "409": description: | `idempotency_key_reused` — this key was already used with different parameters. The provider will not confirm whether the earlier attempt took money, so start a fresh purchase rather than retrying. `payment_already_used` — a card has already been issued against this payment. For `external_purchase` this is the safe answer to a redelivered webhook or a rerun script: the reference is unique per tenant, so retrying issues nothing new. content: application/json: schema: $ref: "#/components/schemas/Error" "422": description: | `payment_amount_mismatch` — the supplied Stripe PaymentIntent did not pay the server-computed amount and currency. content: application/json: schema: $ref: "#/components/schemas/Error" "429": $ref: "#/components/responses/RateLimited" "503": description: | `payment_gateway_unavailable` — the tenant has no payment provider configured, or the configured one (Square or Stripe) is not usable. Gift card purchase is only supported for Square and Stripe tenants. content: application/json: schema: $ref: "#/components/schemas/Error" /giftcards/pricing: get: summary: Get gift card pricing description: | Returns the tenant's gift card pricing configuration. Requires the `bookings:write` scope. Use this before presenting a gift card purchase form to determine the class pass price, applicable tax rates, and the tenant's currency. operationId: getGiftCardPricing tags: [Gift Cards] security: - ApiKey: [bookings:write] responses: "200": description: OK content: application/json: schema: $ref: "#/components/schemas/GiftCardPricingResponse" "401": $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/Forbidden" "429": $ref: "#/components/responses/RateLimited" /giftcards/passes/{id}: get: summary: Get a class pass description: | Returns one class pass the business currently sells, by id. Requires the `bookings:write` scope. This is how a `?fc_pass=` link on the business's own website is resolved: the link keeps working whether or not the business lists its passes in the booking widget. Buy the pass with `POST /giftcards` (`type: class_pass` and `class_pass_product_id`). operationId: getClassPassProduct tags: [Gift Cards] security: - ApiKey: [bookings:write] parameters: - name: id in: path required: true schema: type: string responses: "200": description: OK content: application/json: schema: $ref: "#/components/schemas/ClassPassProduct" "401": $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/Forbidden" "404": description: | `pass_not_found`: no such pass, it is no longer on sale, or it belongs to another business. The three are indistinguishable. content: application/json: schema: $ref: "#/components/schemas/Error" "429": $ref: "#/components/responses/RateLimited" /giftcards/{code}: get: summary: Look up gift card description: Returns the current status and remaining value of a gift card. Requires the `giftcards:read` scope. operationId: getGiftCard tags: [Gift Cards] security: - ApiKey: [giftcards:read] parameters: - name: code in: path required: true schema: type: string example: GC-ABCD1234 responses: "200": description: OK content: application/json: schema: $ref: "#/components/schemas/GiftCardLookupResponse" "401": $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/Forbidden" "429": $ref: "#/components/responses/RateLimited" "404": description: Gift card not found content: application/json: schema: $ref: "#/components/schemas/Error" /giftcards/by-id/{id}: get: summary: Look up gift card by id description: | Returns the same card `GET /giftcards/{code}` returns, addressed by the id rather than the code. It exists for the `payment.succeeded` webhook, which names the gift card it minted by id while every other read here is keyed by the code. Without this, an integration holding one of those events had no route from the id to the card. **Requires a SECRET key**, unlike the by-code lookup, and this is not an oversight to be "fixed" by matching them. The response carries `code`, which is spendable at checkout. Asking by code gives nothing away because you had to know the code already; asking by id does, because ids travel in events that deliberately may not carry the code (a webhook endpoint receives the code only if its owner opted in). The `giftcards:read` scope is no barrier there, since the booking widget's public key carries it on any tenant selling gift cards and a public key is readable by anyone who views the studio's website. operationId: getGiftCardById tags: [Gift Cards] security: - ApiKey: [giftcards:read] parameters: - name: id in: path required: true schema: type: string example: gc_01J8XQ2W7C9K3M5N7P9R1T3V5X responses: "200": description: OK content: application/json: schema: $ref: "#/components/schemas/GiftCardLookupResponse" "400": description: Gift card id is required content: application/json: schema: $ref: "#/components/schemas/Error" "401": $ref: "#/components/responses/Unauthorized" "403": description: | `secret_key_required` — a public key was used. `insufficient_scope` — the key lacks `giftcards:read`. content: application/json: schema: $ref: "#/components/schemas/Error" "429": $ref: "#/components/responses/RateLimited" "404": description: | Gift card not found. A card belonging to another tenant reads the same as one that does not exist. content: application/json: schema: $ref: "#/components/schemas/Error" /giftcards/{code}/validate: get: summary: Validate gift card description: | Checks whether a gift card is active and can be applied to a booking. Requires the `bookings:write` scope. Always returns `200` — check the `valid` field to determine usability, and `reason.code` for why an invalid card was refused. An unknown code answers exactly like a known-but-unusable one, deliberately: gift-card codes are short and carry money, so a distinguishable status would let them be enumerated. Note this differs from `GET /promocodes/{code}`, which answers a `4xx` for the equivalent situation. Handle the two separately. Pass `class_id` to also check class restrictions on the card. operationId: validateGiftCard tags: [Gift Cards] security: - ApiKey: [bookings:write] parameters: - name: code in: path required: true schema: type: string - name: class_id in: query description: Check whether the gift card is valid for this class. schema: type: string responses: "200": description: OK content: application/json: schema: $ref: "#/components/schemas/GiftCardValidateResponse" "401": $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/Forbidden" "429": $ref: "#/components/responses/RateLimited" /memberships/plans: get: summary: List membership plans on sale description: | Returns the studio's auto-renewing membership plans that are on sale, with what buying each one charges today and what each renewal charges after. Requires the `events:read` scope. Amounts are in the smallest currency unit. Tax rates are in millipercent (5% is `5000`). When `prices_include_tax` is true the tax lines are the part of each price that is tax; otherwise they are added on top. Send `mandate_version` back unchanged on `POST /memberships/purchase`: it names the automatic-renewal wording the buyer is agreeing to. `403 memberships_unavailable` means the studio is not selling memberships. operationId: listMembershipPlans tags: [Memberships] security: - ApiKey: [events:read] responses: "200": description: OK content: application/json: schema: $ref: "#/components/schemas/MembershipPlansResponse" "401": $ref: "#/components/responses/Unauthorized" "403": description: | `insufficient_scope`, or `memberships_unavailable` (the studio is not selling memberships). content: application/json: schema: $ref: "#/components/schemas/Error" "429": $ref: "#/components/responses/RateLimited" /memberships/purchase: post: summary: Buy a membership description: | Charges the first period (or, for a free trial, only saves the card), saves the card for automatic renewals and starts the membership. Requires the `bookings:write` scope. The amount is computed server-side from the plan; the client cannot name its own price. `accept_auto_renew` must be true and `mandate_version` must be the one `GET /memberships/plans` returned. When the card needs 3-D Secure the response is `402 payment_action_required` with a `payment_action.client_secret`. Run the challenge with Stripe.js (`confirmCardPayment`, or `confirmCardSetup` for a free trial), then send the same request again with the same `idempotency_key` plus `payment_intent_id` or `setup_intent_id`. operationId: purchaseMembership tags: [Memberships] security: - ApiKey: [bookings:write] requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/MembershipPurchaseRequest" responses: "201": description: Membership started content: application/json: schema: $ref: "#/components/schemas/MembershipPurchaseResponse" "400": description: | `invalid_request` (malformed JSON, or a missing `plan_id`, email or `first_name`), `consent_required` (`accept_auto_renew` not true, or no `mandate_version`) or `missing_idempotency_key`. content: application/json: schema: $ref: "#/components/schemas/Error" "402": description: | `payment_required` (no card was sent), `payment_failed` (the provider declined; carries `decline_reason`) or `payment_action_required` (run 3-D Secure with `payment_action.client_secret`, then resend). content: application/json: schema: $ref: "#/components/schemas/Error" "401": $ref: "#/components/responses/Unauthorized" "403": description: | `insufficient_scope`, or `memberships_unavailable` (the studio is not selling memberships). content: application/json: schema: $ref: "#/components/schemas/Error" "404": description: "`plan_not_found`: the plan is not on sale." content: application/json: schema: $ref: "#/components/schemas/Error" "409": description: | `already_member` (this person already holds the plan), `payment_already_used` (a membership was already bought with this payment) or `idempotency_key_reused`. content: application/json: schema: $ref: "#/components/schemas/Error" "422": description: "`payment_amount_mismatch`: the PaymentIntent did not pay the plan's amount and currency." content: application/json: schema: $ref: "#/components/schemas/Error" "429": $ref: "#/components/responses/RateLimited" "503": description: "`payment_gateway_unavailable`: the studio has no usable Stripe or Square connection." content: application/json: schema: $ref: "#/components/schemas/Error" /memberships/verify/start: post: summary: Email a member their booking code description: | Emails a six-digit code to the address when it belongs to a customer holding a live membership. Always answers 202, member or not, so the endpoint cannot be used to find out who is a member. At most five codes an hour per customer. Requires the `bookings:write` scope. operationId: startMembershipVerify tags: [Memberships] security: - ApiKey: [bookings:write] requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/MembershipVerifyStartRequest" responses: "202": description: Accepted. A code was sent if the address is a member. "400": description: "`invalid_request`: malformed JSON or no email." content: application/json: schema: $ref: "#/components/schemas/Error" "401": $ref: "#/components/responses/Unauthorized" "403": description: "`insufficient_scope`, or `memberships_unavailable`." content: application/json: schema: $ref: "#/components/schemas/Error" /memberships/verify/confirm: post: summary: Trade a member's code for a member token description: | Checks the code against the newest one sent. Returns a member token (30 minutes) to book with, and the member's memberships. With `remember_device` it also returns a device token for `POST /memberships/verify/device`. Five wrong codes lock that code. Requires the `bookings:write` scope. operationId: confirmMembershipVerify tags: [Memberships] security: - ApiKey: [bookings:write] requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/MembershipVerifyConfirmRequest" responses: "200": description: The member is proved. content: application/json: schema: $ref: "#/components/schemas/MemberSessionResponse" "401": $ref: "#/components/responses/Unauthorized" "403": description: "`insufficient_scope`, or `memberships_unavailable`." content: application/json: schema: $ref: "#/components/schemas/Error" "422": description: "`code_invalid`: wrong, expired or already used." content: application/json: schema: $ref: "#/components/schemas/Error" "429": description: "`code_locked`: too many wrong codes; ask for a new one." content: application/json: schema: $ref: "#/components/schemas/Error" /memberships/verify/device: post: summary: Resume a remembered member description: | Trades a device token for a fresh member token and a new device token. The old device token stops working. Requires the `bookings:write` scope. operationId: resumeMembershipDevice tags: [Memberships] security: - ApiKey: [bookings:write] requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/MembershipVerifyDeviceRequest" responses: "200": description: The member is proved. content: application/json: schema: $ref: "#/components/schemas/MemberSessionResponse" "401": description: "`device_token_invalid` (unknown, expired or used), or an invalid API key." content: application/json: schema: $ref: "#/components/schemas/Error" "403": description: "`insufficient_scope`, or `memberships_unavailable`." content: application/json: schema: $ref: "#/components/schemas/Error" /memberships/cover: post: summary: Check a membership covers a class description: | Whether the membership covers one seat of the event, and the classes left after it. A preview: the booking checks again. Requires the `bookings:write` scope. operationId: coverMembership tags: [Memberships] security: - ApiKey: [bookings:write] requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/MembershipCoverRequest" responses: "200": description: Covered. content: application/json: schema: $ref: "#/components/schemas/MembershipCoverResponse" "401": description: "`member_token_invalid` (expired or for another studio), or an invalid API key." content: application/json: schema: $ref: "#/components/schemas/Error" "403": description: "`insufficient_scope`, or `memberships_unavailable`." content: application/json: schema: $ref: "#/components/schemas/Error" "422": description: | Not covered. The code says why: `membership_not_active`, `membership_course`, `membership_class_not_accepted`, `membership_class_not_in_plan`, `membership_one_seat`, `membership_outside_period`, `membership_allowance_used`, `membership_daily_cap`, `membership_overlap` or `membership_not_found`. content: application/json: schema: $ref: "#/components/schemas/Error" /promocodes/{code}: get: summary: Validate promo code description: | Checks whether a promo code is valid and returns its discount value. Requires the `bookings:write` scope. Unlike `GET /giftcards/{code}/validate`, which answers `200` with a verdict in the body, this endpoint answers a `4xx`: `404 not_found` for a code that does not exist, and `422` with a `promo_code_*` code for one that exists but cannot be applied. Both are correctly tenant-scoped, but a client written against both has to handle them differently. Rate limited to 10 requests per minute to prevent code enumeration. That per-tenant limit, rather than the response shape, is what bounds enumeration here. operationId: validatePromoCode tags: [Promo Codes] security: - ApiKey: [bookings:write] parameters: - name: code in: path required: true schema: type: string example: SUMMER20 - name: event_id in: query description: Validate the code against a specific event (checks class restrictions) schema: type: string responses: "200": description: Valid promo code content: application/json: schema: $ref: "#/components/schemas/PromoCodeValidateResponse" "401": $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/Forbidden" "429": $ref: "#/components/responses/RateLimited" "404": description: Promo code not found content: application/json: schema: $ref: "#/components/schemas/Error" "422": description: >- Code exists but cannot be applied. `error.reason` says why, and `error.valid_from` gives the start date of a code not active yet. content: application/json: schema: $ref: "#/components/schemas/Error" /referrals/link/{token}: get: summary: Resolve a referral invite link description: | Resolves the opaque token from a referral invite link and reports whether it is still usable. Powers the booking widget's referral feedback, so a link that has been withdrawn or fully used is surfaced instead of silently ignored. Requires the `bookings:write` scope. Rate limited to deter token enumeration. The referee discount and the referrer's credit are applied server-side at booking creation — pass the same token as `referral_link_token` on `POST /bookings`. This replaced the earlier `GET /referrals/{code}`, which was removed when typed referral codes were retired. Entitlement lives on the link, not on a code a customer types. operationId: validateReferralLink tags: [Referrals] security: - ApiKey: [bookings:write] parameters: - name: token in: path required: true description: The opaque token from the invite link, not a human-typed code. schema: type: string responses: "200": description: The link resolves and can still be used content: application/json: schema: type: object properties: valid: type: boolean example: true token: type: string description: The resolved link id. Pass this as `referral_link_token` when creating the booking. referrer_first_name: type: string description: | First name of the customer whose link this is, for a line like "Alex invited you". No other referrer detail is returned. discount_usable: type: boolean description: | False once the link's discount cap is spent. The link is still followed when false — the referral is recorded when the friend books, because the referrer still brought a customer — but no referee discount applies. discount_type: type: string enum: [percentage, fixed] description: Omitted when the tenant has no referee discount, or the link carries none. discount_value: type: integer description: | Percentage points when `discount_type` is `percentage`, otherwise an amount in the smallest currency unit. Omitted with `discount_type`. required: [valid, token, referrer_first_name, discount_usable] "401": $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/Forbidden" "429": $ref: "#/components/responses/RateLimited" "404": description: Unknown token, or the referral program is not active content: application/json: schema: $ref: "#/components/schemas/Error" /customers: get: summary: List or find customers description: | Returns the tenant's most recently created customers, newest first, or the single customer matching `email` when that is given. Anonymized (erased) customers never appear. **Requires a secret key** (`sk_...`) and the `customers:read` scope. `customers:read` cannot be granted to a public key at all: a public key is the one embedded in your website, and this returns every customer you have. An `email` that matches nobody is an empty list, not a 404 — a lookup that found nothing is still a successful lookup. operationId: listCustomers tags: [Customers] security: - ApiKey: [customers:read] parameters: - name: email in: query description: | Return only the customer with this address. Case-insensitive. A blank value (`?email=`) is refused with `400 invalid_param` rather than read as "no filter", which would list every customer. schema: type: string format: email - name: limit in: query description: Results to return (1–100, default 25). Ignored when `email` is given. schema: type: integer minimum: 1 maximum: 100 default: 25 responses: "200": description: OK content: application/json: schema: type: object properties: data: type: array items: $ref: "#/components/schemas/Customer" "400": description: Invalid `limit`, or a blank `email` content: application/json: schema: $ref: "#/components/schemas/Error" "401": $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/Forbidden" "429": $ref: "#/components/responses/RateLimited" post: summary: Register customer description: | Creates a new customer account. Requires the `customers:write` scope. No email is sent on registration. Customers authenticate via magic link through the portal login flow. Names and email are trimmed; a name that is only spaces counts as missing. **The answer depends on the key type.** With your server's `sk_` key you get `201` and the account, or `409 email_taken` with the existing account's id in `error.customer_id`. A publishable `pk_` key is readable by anyone who views your website, so it never learns whether an address already has an account: it gets `202 {"status":"accepted"}` either way, and an existing account is left exactly as it was (no name, phone or marketing consent from the request reaches it). operationId: registerCustomer tags: [Customers] security: - ApiKey: [customers:write] requestBody: required: true content: application/json: schema: type: object properties: email: type: string format: email first_name: type: string last_name: type: string phone: type: string marketing_opt_in: type: boolean description: Whether the customer has consented to marketing communications default: false required: [email, first_name, last_name] responses: "201": description: Customer created (`sk_` key only) content: application/json: schema: $ref: "#/components/schemas/Account" "202": description: Accepted (`pk_` key, whether or not the address already had an account) content: application/json: schema: type: object properties: status: type: string enum: [accepted] required: [status] "401": $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/Forbidden" "429": $ref: "#/components/responses/RateLimited" "409": description: | `email_taken` (`sk_` key only). `error.customer_id` names the account that already holds the address. content: application/json: schema: $ref: "#/components/schemas/Error" "422": description: Validation error content: application/json: schema: $ref: "#/components/schemas/Error" /customers/{id}: get: summary: Get customer description: | One customer by id. This is what the `customer_id` on every booking and payment webhook is for. **Requires a secret key** (`sk_...`) and the `customers:read` scope. An id belonging to another tenant, an id that never existed, and a customer who has since been erased all answer the same 404. operationId: getCustomer tags: [Customers] security: - ApiKey: [customers:read] parameters: - name: id in: path required: true schema: type: string responses: "200": description: OK content: application/json: schema: $ref: "#/components/schemas/Customer" "401": $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/Forbidden" "404": description: Customer not found content: application/json: schema: $ref: "#/components/schemas/Error" "429": $ref: "#/components/responses/RateLimited" /waitlist-offers: get: summary: List outstanding waitlist offers description: | Returns the seats currently on offer to people on a waitlist, newest offer first. An offer is made by a sweep when a seat frees up on a sold-out event, and it lapses if nobody claims it in the window. Only entries still at `offered` are returned. One that converted (they booked) or expired is no longer a seat anyone can claim. **Requires a secret key** (`sk_...`) and the `waitlist:read` scope. `waitlist:read` cannot be granted to a public key: a public key is the one embedded in your website, and these rows carry names and email addresses. The fields match the `waitlist.offered` webhook payload exactly, so a mapping built against one works against the other. Nothing is delivered here — the webhook is how you receive offers as they happen; this is for reading the current state. An empty list is a 200, not a 404. A studio with waitlists on and nothing currently offered is the ordinary case. operationId: listWaitlistOffers tags: [Waitlists] security: - ApiKey: [waitlist:read] parameters: - name: limit in: query description: Results to return (1–100, default 25). schema: type: integer minimum: 1 maximum: 100 default: 25 responses: "200": description: OK content: application/json: schema: type: object properties: data: type: array items: $ref: "#/components/schemas/WaitlistOffer" "400": description: Invalid `limit` content: application/json: schema: $ref: "#/components/schemas/Error" "401": $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/Forbidden" "429": $ref: "#/components/responses/RateLimited" /webhooks: get: summary: List your subscriptions description: | The subscriptions your integrations made through `POST /v1/webhooks`, newest first, in the shape that call returns. Use it to reconcile what you hold: a subscribe whose response was lost, or one you forgot, keeps receiving events until you find it and delete it. **Requires a secret key** (`sk_...`) carrying the `webhooks:manage` scope. `active` is `false` for a subscription paused after repeated delivery failures. Endpoints registered in **Settings → Webhooks** are not listed, for the same reason `DELETE /v1/webhooks/{id}` will not remove them, and neither is anything belonging to another studio. operationId: listWebhookSubscriptions tags: [Webhooks] security: - ApiKey: [webhooks:manage] responses: "200": description: OK content: application/json: schema: type: object properties: data: type: array items: $ref: "#/components/schemas/WebhookSubscription" required: [data] "401": $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/Forbidden" post: summary: Subscribe to an event description: | Registers one callback URL against one event type, the REST-hook pattern Zapier and similar tools use. Turning an automation on subscribes; turning it off calls `DELETE /v1/webhooks/{id}`. **Requires a secret key** (`sk_...`) carrying the `webhooks:manage` scope. A full-access secret key has it; a Restricted one needs it ticked. Re-posting the same `target_url` and `event` returns the existing subscription with 201 rather than creating a second one, so a retried subscribe cannot end up delivering every event twice. This holds for concurrent identical requests too: they all get the same subscription. A subscription that was switched off after 20 consecutive failed deliveries is switched back on by re-posting it, with a fresh failure count, and the 201 says `active: true`. Fix the receiving end first: it has 20 more failures before it is switched off again. The URL must be public https. FullClass validates it on submit, re-checks the resolved address at connect time, and does not follow redirects. The studio sees the subscription in **Settings → Webhooks**, named `Zapier: ` and badged "Managed by Zapier" when the URL is a `hooks.zapier.com` REST hook, and `API: `, badged "Managed by a connected app", otherwise. operationId: createWebhookSubscription tags: [Webhooks] security: - ApiKey: [webhooks:manage] requestBody: required: true content: application/json: schema: type: object properties: target_url: type: string format: uri description: The https URL to POST events to. `url` is accepted as an alias. event: type: string description: One event type, from `GET /v1/webhooks/events`. example: booking.created required: [target_url, event] responses: "201": description: Subscribed content: application/json: schema: $ref: "#/components/schemas/WebhookSubscription" "401": $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/Forbidden" "422": description: Unusable URL, or an event type that does not exist content: application/json: schema: $ref: "#/components/schemas/Error" "429": description: Subscription limit reached, or rate limited content: application/json: schema: $ref: "#/components/schemas/Error" /webhooks/events: get: summary: List subscribable event types description: | Every event type a subscription may name. Wider than what any one integration uses, so read it rather than hard-coding the list. **Requires a secret key** (`sk_...`) carrying the `webhooks:manage` scope. It is also the cheapest call for confirming a key can manage subscriptions. operationId: listWebhookEventTypes tags: [Webhooks] security: - ApiKey: [webhooks:manage] responses: "200": description: OK content: application/json: schema: type: object properties: data: type: array items: type: string example: [booking.created, booking.updated, booking.canceled] "401": $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/Forbidden" /webhooks/{id}: delete: summary: Unsubscribe description: | Removes a subscription created through `POST /v1/webhooks`, and its delivery history with it. **Requires a secret key** (`sk_...`) carrying the `webhooks:manage` scope. It will not touch an endpoint registered in **Settings → Webhooks**: an id naming one of those answers 404, exactly as an unknown id does. The secret key a studio pastes into a third-party app must not be able to unhook the endpoint feeding its accountant. operationId: deleteWebhookSubscription tags: [Webhooks] security: - ApiKey: [webhooks:manage] parameters: - name: id in: path required: true schema: type: string responses: "204": description: Unsubscribed "401": $ref: "#/components/responses/Unauthorized" "403": $ref: "#/components/responses/Forbidden" "404": description: No such subscription, or it is not one this API created content: application/json: schema: $ref: "#/components/schemas/Error" /client-error: post: summary: Report a widget client error description: | Sink for runtime errors from the embedded booking widget. The widget POSTs here when it runs on your own site, where FullClass's browser error reporting is not loaded, so a broken embed is visible to us instead of failing silently. **Unauthenticated on purpose**, and the only `/v1` endpoint that is: a widget whose API key or initialization is what broke must still be able to report. Send no API key. Abuse is bounded by the per-IP rate limit and an 8 KB body cap. Always replies `204`, including for a malformed body, an oversized body, or an empty `message` (which is dropped as unactionable). It never reports its own errors, so a failed report cannot cascade. Nothing is returned to the caller and there is no way to read reports back. Every field is treated as untrusted opaque text and truncated before logging. Do not put personal data in them. operationId: reportClientError tags: [Diagnostics] security: [] requestBody: required: true content: application/json: schema: type: object properties: message: type: string description: The error message. Required in practice — a report without one is discarded. Truncated to 300 characters. example: "TypeError: Cannot read properties of undefined" stack: type: string description: Stack trace. Truncated to 2000 characters. source: type: string description: Where in the widget the error came from. Truncated to 300 characters. url: type: string description: Page URL the widget was running on. Truncated to 500 characters. widget_version: type: string description: SDK version string. Truncated to 40 characters. page_load: type: string description: | Identifier the widget generates once per page load and repeats on every call it makes, so a report can be tied to the request log lines behind it. Advisory only: it is never used to authorize, rate-limit or count anything. example: "pl_204b3de00cbe3e05" attempt: type: integer description: Which try of the failing call this was. Ignored outside 1-20. example: 1 api_key_prefix: type: string description: | The first 11 characters of the API key the widget was configured with (the `pk_` marker plus the key's 8-character display prefix) — never the whole key, and never anything usable as a credential. It lets a failure on a key we issued be told apart from one on a key we did not, which otherwise produce identical reports. Omitted when the embed has no key. example: "pk_3f9c2a71" responses: "204": description: Report accepted, or silently discarded. No body either way. "429": $ref: "#/components/responses/RateLimited" tags: - name: Events description: Scheduled class sessions, plus the checkout seat holds taken against them. `/v1/classes` is a backward-compatible alias for all `/v1/events` endpoints. - name: Courses description: Multi-session courses booked as a single purchase that fans out across all sessions. - name: Configuration description: Public tenant configuration used by browser widgets and SDK integrations. - name: Bookings description: Create and manage bookings for events. - name: Payments description: Helper endpoints for tenant-configured payment providers. - name: Gift Cards description: | Issue and look up gift cards. There is no endpoint that spends one: a card is redeemed inside `POST /bookings` (`gift_card_codes`), or by staff with **Apply gift card** on the booking page. `POST /giftcards/{code}/redeem` was removed on 2026-09-30 and now answers 404. - name: Memberships description: Auto-renewing memberships. List the plans on sale and buy one; the card is saved and charged each period. - name: Promo Codes description: Validate promo codes before applying them to a booking. - name: Customers description: Customer account registration, and reading customers back. The read endpoints need a secret key. - name: Waitlists description: Reading the seats currently offered to people waiting on a sold-out event. Secret key only. - name: Webhooks description: REST-hook subscriptions — how an integration registers its own callback URL instead of a studio typing one into Settings. Secret key only. - name: Diagnostics description: Error reporting from the embedded booking widget. Unauthenticated.