openapi: 3.1.0
info:
  title: Datastream API
  version: 1.0.0
  description: |
    The single versioned path into the Datastream data platform.

    Datastream still flows one direction (MBO → mirror → enriched → consumers):
    reads come from the mirror, and the write endpoints are proxied to Mindbody
    rather than written to Datastream. Every request is scoped to the sites
    bound to its API key; a request can never widen its own scope.

    Covers the schedule and operational endpoints, client reads (profile,
    memberships, passes/services, credits, transactions, enrollments),
    enriched sales, and the sellable catalog (packages, contracts, promo codes).

    **Profile composition.** A full Mindbody person view is several reads:
    `GET /v1/clients?client_id=`, `…/contracts`, `…/services`, `…/credits`,
    `…/transactions`, plus `…/enrollments` and `/v1/sales?client_id=` for
    history.

    ## Data freshness

    Freshness is the mirror's, not MBO's, and it is **per-site, not uniform** —
    every site is tagged `realtime` or `daily`/`paused` in
    `ds_config.site_configuration`, and the tag decides which of the two
    mechanisms below actually updates it. As of 2026-08-05:

    - **`realtime` sites (Flow, Flow Yoga Georgetown today)** — a webhook
      consumer (`itflow_datastream`) drains Mindbody's webhook stream and
      re-fetches only the entity a webhook actually touched, after a short hold
      window per family (roster bookings 60s, client/membership changes 300s,
      sales/contracts 900s). Typical event-to-mirror lag is **1–3 minutes**.
      This is **event-driven, not polled** — a class or client with no new
      webhook event since the last scheduled pass still shows that pass's
      data, however old, until something actually changes on it. There is no
      way to tell from a response alone whether a given row is
      webhook-fresh or pass-stale; assume the latter unless you know the site
      is `realtime` and the entity was recently touched.
    - **`daily`/`paused` sites (every other site today)** — the scheduled pass
      is the only mechanism: three fetches a day (08:00 / 18:00 / 23:00 UTC),
      so a change is invisible to read endpoints for **up to 10 hours** — the
      widest gap is 08:00→18:00.

    Visits also have a 7-day forward horizon regardless of tier. The sync
    pulls a future class's roster only when it starts within 7 days and
    already has bookings (`itflow_datastream` ClassFamily
    `VISIT_FUTURE_WINDOW_DAYS`). So a roster two weeks out is empty because it
    was never pulled, not because nobody booked — while `total_booked` on the
    class itself stays correct at any horizon, since it comes from the class
    record rather than the visit pull.

    **Writes made through this API are a third path, separate from both.** A
    `POST`/`PATCH`/`DELETE` write endpoint (bookings, purchases, contract
    purchases, teacher assignment) is proxied straight to Mindbody and its own
    response is live immediately — Mindbody, not the mirror, answered it. For
    up to 24 hours afterward, `GET /v1/schedule/{class_id}/roster` and the
    sales endpoints layer a **read-after-write overlay**
    (`ds_api.booking_write` / `ds_api.purchase_write`) on top of the mirror
    read, so the write shows up in those reads immediately too, without
    waiting on either the scheduled pass or a webhook. **The one write not yet
    covered by an overlay is check-in** (`POST
    .../roster/{visit_id}/check-in`): its own response is live, but a roster
    read right after still shows the mirror's stale `signed_in` for anyone but
    the caller who just made the change — see that endpoint's own note.

    **`GET /v1/reports/*`** (except `mbo-usage`) read from `ds_enriched`, a
    separate incremental transform run outside this repo
    (`CALL ds_enriched.run_all_sites(0)`) on top of the mirror above — so a
    report inherits the mirror's own freshness plus however far behind that
    transform is, which this repo does not track. If a report looks stale
    while the raw mirror does not, the enriched transform is where to look,
    not here.

    Fields marked *not yet populated* resolve to `null` because the underlying
    value is absent from the MBO mirror — the legacy API sourced them from the
    warehouse. See `docs/VERIFY.md` in the repo.
servers:
  - url: https://datastream.fvmgt.com/
    description: |
      Test deployment. The product domain is still an open item (PLAN.md §9) —
      this host is temporary and not meant for external consumers.

security:
  - bearerAuth: []

tags:
  - name: schedule
    description: Classes, rosters, and the rooms they run in.
  - name: staff
    description: Teachers and other staff.
  - name: locations
    description: Studios and their bookable rooms.
  - name: events
    description: Enrollments — events, trainings and retreats.
  - name: packages
    description: What is for sale — packages, contracts, promo codes.
  - name: clients
    description: One client's profile, bookings, passes, contracts, transactions.
  - name: sales
    description: Completed sales from the enriched layer, with items and payments.
  - name: config
    description: The tenant registry.
  - name: reports
    description: >
      Dashboard-shaped aggregate rollups replacing the legacy dw_flow
      ai_*_snapshot_v2 tables. Hand-written GROUP BY queries, not schema-map
      resources — see src/routes/v1/reports.ts.
  - name: ops
    description: Health and the spec itself.

paths:
  /status:
    get:
      tags: [ops]
      summary: Health check
      description: |
        Unauthenticated. Runs a single trivial query against the mirror to
        confirm the pool can reach the database — not a check of sync
        freshness or any individual site. A 200 means "the API can talk to
        Postgres," nothing more.
      security: []
      responses:
        '200':
          description: Service healthy.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/StatusResponse' }
        '503':
          description: Database unreachable.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/StatusResponse' }

  /status/schema-assumptions:
    get:
      tags: [ops]
      summary: Fields resting on unverified JSON paths
      description: |
        The reconciliation checklist. Each entry is a mapped field whose
        `_document` path was not observed in the schema dump.
      security: []
      responses:
        '200':
          description: The current assumption list.

  /v1/docs:
    get:
      tags: [ops]
      summary: This document
      description: |
        The live spec, generated from this same file — served as YAML so it
        stays diffable and matches what's committed to the repo. api-fvmgt's
        combined API docs page (/) fetches this alongside api-funnel's /v1/docs
        and renders both as one operation list.
      security: []
      responses:
        '200':
          description: The OpenAPI spec as YAML.
          content:
            text/yaml:
              schema: { type: string }

  /v1/stream:
    get:
      tags: [ops]
      summary: Live change stream (SSE)
      description: |
        Server-Sent Events, not a normal request/response call — the
        connection stays open and pushes an event each time something this
        key can read changes (bookings, check-ins, roster updates). Read
        scope only: subscribing tells a caller what changed, it never lets
        them change anything, so a `raw:read`-only check-in screen and a
        `booking:write` widget can both listen on the same key.

        Long-lived by design, so none of the usual response machinery
        applies here — no envelope, no ETag, no pagination, no Cache-Control.
        The rate limiter in `authenticate` still runs once at connect time,
        which is what stops one caller from opening hundreds of these.
      parameters:
        - $ref: '#/components/parameters/site_id'
      responses:
        '200':
          description: |
            `text/event-stream`. Never closes on its own; the client (or a
            proxy timeout) ends the connection.

  /v1/schedule:
    get:
      tags: [schedule]
      summary: Class schedule
      description: |
        Backed by `ds_mbo.class`. Defaults to a single day (today) — an unbounded
        default over 196k rows is not useful. Use either `date`+`days` (the legacy
        pair) or `start_date`+`end_date`, not both.
      parameters:
        - $ref: '#/components/parameters/site_id'
        - name: date
          in: query
          description: Window start. Defaults to today (UTC).
          schema: { type: string, format: date }
        - name: days
          in: query
          description: Window length in days from `date`.
          schema: { type: integer, minimum: 1, maximum: 90, default: 1 }
        - name: start_date
          in: query
          schema: { type: string, format: date }
        - name: end_date
          in: query
          description: Inclusive.
          schema: { type: string, format: date }
        - name: location_id
          in: query
          schema: { type: string }
        - name: location
          in: query
          description: |
            Name-based sibling of `location_id`, for a caller that only has
            the studio's display name on hand. Exact match against
            `location_name`.
          schema: { type: string, example: West Gate }
        - name: staff_id
          in: query
          schema: { type: string }
        - name: teacher_id
          in: query
          description: Deprecated alias for `staff_id`, retained from the legacy API.
          deprecated: true
          schema: { type: string }
        - name: teacher
          in: query
          description: |
            Name-based sibling of `staff_id`/`teacher_id`, for a caller that
            only has the teacher's display name on hand. Exact match against
            `staff_name`.
          schema: { type: string, example: Ronli Sokol }
        - name: category
          in: query
          schema: { type: string }
        - name: class_type
          in: query
          description: |
            Matches the class description's `sessionType`. Common values
            include `Class`, `Workshop`, `Event`, `Community`, `Retreat`,
            `Teacher Trainings` — see `session_type` on `/v1/events` for the
            full set actually in use at a site.
          schema: { type: string, example: Workshop }
        - name: include_cancelled
          in: query
          description: |
            Cancelled classes are excluded by default — MBO records a
            discontinued recurring schedule the same way as a one-off
            cancellation, so unfiltered results include classes the studio no
            longer offers. Pass `true` to include them.
          schema: { type: boolean, default: false }
        - $ref: '#/components/parameters/modified_since'
        - $ref: '#/components/parameters/limit_batch'
        - $ref: '#/components/parameters/offset'
        - $ref: '#/components/parameters/include_inactive'
      responses:
        '200':
          description: A page of classes.
          headers:
            ETag: { $ref: '#/components/headers/ETag' }
            Last-Modified: { $ref: '#/components/headers/LastModified' }
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/ListEnvelope'
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: '#/components/schemas/ClassSummary' }
        '304': { $ref: '#/components/responses/NotModified' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '503': { $ref: '#/components/responses/Unavailable' }

  /v1/schedule/{class_id}:
    get:
      tags: [schedule]
      summary: One class
      description: |
        A single scheduled class instance by its MBO `class_id`, joined the
        same way `/v1/schedule` is (room, staff, description). `class_id`s
        are per-site sequential and collide across tenants — this key's
        `SiteScope` is what keeps a lookup from crossing into another studio.
      parameters:
        - name: class_id
          in: path
          required: true
          schema: { type: string }
        - $ref: '#/components/parameters/site_id'
      responses:
        '200':
          description: The class.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/ItemEnvelope'
                  - type: object
                    properties:
                      data: { $ref: '#/components/schemas/ClassSummary' }
        '304': { $ref: '#/components/responses/NotModified' }
        '404': { $ref: '#/components/responses/NotFound' }

  /v1/class-descriptions/{class_schedule_id}:
    get:
      tags: [schedule]
      summary: One class type's description
      description: |
        The class name, category, and full HTML description for a class
        *type* (e.g. "Flow"), keyed by `class_schedule_id` — the same field
        every `/v1/schedule` row carries. Split out of `/v1/schedule` because
        the description text repeats verbatim across every instance of a
        class type and was ~52% of that endpoint's payload by weight; this
        route is meant to be fetched on demand (e.g. when a user expands a
        class for details) and cached long by the client, not called once per
        schedule row.
      parameters:
        - name: class_schedule_id
          in: path
          required: true
          schema: { type: string }
        - $ref: '#/components/parameters/site_id'
      responses:
        '200':
          description: The class type's description.
          headers:
            ETag: { $ref: '#/components/headers/ETag' }
            Cache-Control:
              schema: { type: string, example: 'private, max-age=3600' }
              description: >
                Static reference content — cached far longer than
                `/v1/schedule`'s 60s so repeat lookups of the same
                `class_schedule_id` are served from the browser's HTTP cache.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/ItemEnvelope'
                  - type: object
                    properties:
                      data: { $ref: '#/components/schemas/ClassDescription' }
        '304': { $ref: '#/components/responses/NotModified' }
        '404': { $ref: '#/components/responses/NotFound' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /v1/schedule/class:
    get:
      tags: [schedule]
      summary: One class (legacy query-param form)
      deprecated: true
      description: |
        Identical response to `/v1/schedule/{class_id}` — same lookup, same
        join, same site scoping — just `class_id` as a query param instead of
        a path segment. Retained so legacy consumers repoint without a path
        change; new integrations should use the path form.
      parameters:
        - name: class_id
          in: query
          required: true
          schema: { type: string }
        - $ref: '#/components/parameters/site_id'
      responses:
        '200':
          description: The class.
        '404': { $ref: '#/components/responses/NotFound' }

  /v1/schedule/{class_id}/roster:
    get:
      tags: [schedule]
      summary: Class roster
      description: |
        Backed by `ds_mbo.class_visit`, joined to `ds_mbo.client` on
        `(client_id, site_id)` — never on id alone, since MBO ids collide across
        sites.

        Freshness caveat: as of 2026-08-05, `realtime`-tier sites (currently Flow and
        Flow Yoga Georgetown) get roster changes pushed via webhook — a booking,
        cancellation, or membership change typically lands in the mirror within
        1–3 minutes. That is **event-driven, not polled**: a class with no new
        activity since the switch still reflects whatever the last scheduled pass
        (08:00/18:00/23:00 UTC) wrote, which can be hours old even for a class
        starting soon. `daily`/`paused` sites still rely solely on the scheduled
        passes. Confirm a class has a recent `sync_dirty_entity` fetch before
        trusting it as live.
      parameters:
        - name: class_id
          in: path
          required: true
          schema: { type: string }
        - $ref: '#/components/parameters/site_id'
        - $ref: '#/components/parameters/modified_since'
        - $ref: '#/components/parameters/limit_batch'
        - $ref: '#/components/parameters/offset'
      responses:
        '200':
          description: A page of roster entries.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/ListEnvelope'
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: '#/components/schemas/RosterEntry' }
        '304': { $ref: '#/components/responses/NotModified' }

  /v1/schedule/{class_id}/bookings:
    post:
      tags: [schedule]
      summary: Book a client into a class
      description: |
        Adds a client to a class. Requires the `booking:write` scope, and is
        served only where the deployment has writes enabled.

        **This does not write to Datastream.** The booking is proxied to
        Mindbody, which is the system of record; `ds_mbo.class_visit` picks it
        up on the next sync, so a roster read straight after a booking will not
        show it yet. Datastream still flows one direction.

        The class is resolved inside the key's site scope first — a class the
        key cannot read is a `404`, not a `403`, so the endpoint never confirms
        a class it will not serve. The Mindbody site and staff login used are
        the ones registered for that class's site.

        Business rules are Mindbody's: capacity, eligibility, late-booking
        windows and payment are its call, and its own message comes back
        verbatim on a `422`. The single precondition checked here is a
        cancelled class, which is a `409`.

        Send `test: true` to have Mindbody validate the booking without
        creating one. The response echoes `test` — a consumer that ignores it
        will read a dry run as a real booking. A dry run returns `visit_id: 0`,
        since Mindbody answers with a placeholder visit rather than none.

        **Mindbody does not dedupe.** The same client booked into the same
        class twice gets two distinct visits — verified in production, and the
        mirror cannot be used to pre-check because it syncs three times a day.
        Send an `Idempotency-Key` and a repeat replays the first response
        instead of booking again. Without one, a retry or a double-click
        creates a duplicate.
      parameters:
        - name: class_id
          in: path
          required: true
          schema: { type: string }
        - $ref: '#/components/parameters/idempotency_key'
        - $ref: '#/components/parameters/site_id'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/BookingRequest' }
      responses:
        '201':
          description: The booking, as Mindbody created it.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/ItemEnvelope'
                  - type: object
                    properties:
                      data: { $ref: '#/components/schemas/Booking' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }
        '422': { $ref: '#/components/responses/UpstreamRejected' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '503': { $ref: '#/components/responses/Unavailable' }

    delete:
      tags: [schedule]
      summary: Remove a client from a class
      description: |
        Cancels a client's place. Requires `booking:write`.

        Scoped to the class, not a visit id, because Mindbody's
        `removeclientfromclass` takes `(client_id, class_id)` and has no
        per-visit form. One call clears **every** visit that client holds in the
        class, so it is the cleanup for the duplicates `POST` can create.

        Not a no-op when there is nothing to remove: a second call returns
        `422` with "No class visit found…". Useful as a way to confirm a client
        is clear.

        `late_cancel` defaults off, so the forgiving outcome — credit returned,
        no penalty — is the one you get by accident.
      parameters:
        - name: class_id
          in: path
          required: true
          schema: { type: string }
        - name: client_id
          in: query
          required: true
          schema: { type: string }
        - name: late_cancel
          in: query
          description: Apply the studio's late-cancel penalty instead of returning the credit.
          schema: { type: boolean, default: false }
        - name: send_email
          in: query
          schema: { type: boolean, default: false }
        - name: test
          in: query
          description: Validate against Mindbody without removing anything.
          schema: { type: boolean, default: false }
        - $ref: '#/components/parameters/site_id'
      responses:
        '200':
          description: The client was removed.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/ItemEnvelope'
                  - type: object
                    properties:
                      data:
                        type: object
                        properties:
                          class_id: { type: integer }
                          client_id: { type: string }
                          class_name: { type: [string, 'null'] }
                          start_time: { type: [string, 'null'] }
                          site_id: { type: string }
                          removed: { type: boolean }
                          test: { type: boolean }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '422': { $ref: '#/components/responses/UpstreamRejected' }
        '503': { $ref: '#/components/responses/Unavailable' }

  /v1/schedule/{class_id}/roster/{visit_id}/check-in:
    post:
      tags: [schedule]
      summary: Mark a booked client signed in (or undo it)
      description: |
        Flips `signed_in` on a visit the client already holds. Requires the
        `checkin:write` scope — deliberately separate from `booking:write`,
        since this cannot add, remove, or move anyone's booking, only mark
        arrival on a booking that already exists.

        `visit_id` is the roster row's `booking_id`, from a prior `GET
        /v1/schedule/{class_id}/roster`. It is not independently re-verified
        against the class — the same trust model `DELETE .../bookings` already
        uses for a caller-supplied `client_id` — but the Mindbody session used
        is scoped to the class's site, so a visit id from another site fails
        there, not here.

        **Known gap:** unlike book/cancel, this write is not yet reflected by
        the read-after-write overlay. A roster read immediately after still
        shows the mirror's stale `signed_in` for anyone but the caller, who
        should trust this response instead.
      parameters:
        - name: class_id
          in: path
          required: true
          schema: { type: string }
        - name: visit_id
          in: path
          required: true
          schema: { type: string }
        - $ref: '#/components/parameters/idempotency_key'
        - $ref: '#/components/parameters/site_id'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/CheckInRequest' }
      responses:
        '200':
          description: Mindbody's updated visit.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/ItemEnvelope'
                  - type: object
                    properties:
                      data: { $ref: '#/components/schemas/CheckIn' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '422': { $ref: '#/components/responses/UpstreamRejected' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '503': { $ref: '#/components/responses/Unavailable' }

  /v1/schedule/{class_id}/teacher:
    patch:
      tags: [schedule]
      summary: Assign a teacher or substitute to one class instance
      description: |
        Sets the `staff_id` on a single scheduled class instance via
        Mindbody's `updateclass`. Requires the `schedule:write` scope, and is
        served only where the deployment has writes enabled.

        This is the "sub" verb: Mindbody itself decides whether the result
        reads as a substitute by comparing the instance's staff to its
        recurring schedule's own default — this endpoint does not set that
        flag directly. To change the schedule's *permanent* teacher instead
        (the default every future-generated instance inherits), use `PATCH
        /v1/schedule-definitions/{class_schedule_id}/teacher`.

        The class is resolved inside the key's site scope first, same as
        `POST .../bookings` — a class the key cannot read is a `404`. A
        cancelled class is a `409`.

        **Does not write to Datastream and is not yet reflected by the
        read-after-write overlay** — a schedule read immediately after still
        shows the mirror's stale teacher until the next sync.
      parameters:
        - name: class_id
          in: path
          required: true
          schema: { type: string }
        - $ref: '#/components/parameters/idempotency_key'
        - $ref: '#/components/parameters/site_id'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/AssignTeacherRequest' }
      responses:
        '200':
          description: Mindbody's updated class.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/ItemEnvelope'
                  - type: object
                    properties:
                      data: { $ref: '#/components/schemas/ClassInstance' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }
        '422': { $ref: '#/components/responses/UpstreamRejected' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '503': { $ref: '#/components/responses/Unavailable' }

  /v1/schedule-definitions:
    post:
      tags: [schedule]
      summary: Publish a new recurring class
      description: |
        Creates a recurring class schedule via Mindbody's
        `addclassschedule` — the class type, location, days/times, and its
        **permanent teacher** in one call. Mindbody generates the individual
        instances; the mirror picks them up on its next sync. Requires
        `schedule:write`, and is served only where the deployment has writes
        enabled.

        `site_id` is required here (not just a narrowing filter): there is no
        existing mirror row to read tenancy off before the class exists, so
        the caller's own site scope is checked directly against it. It also
        supplies Mindbody's `LocationId`.
      parameters:
        - $ref: '#/components/parameters/idempotency_key'
        - name: site_id
          in: query
          required: true
          description: |
            The 32-character Datastream site id to publish into. Must be one
            of the key's own sites — anything else is a 403.
          schema: { type: string }
          example: 194b29112cf18b58d8a387198cfdc0db
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/NewClassScheduleRequest' }
      responses:
        '201':
          description: The recurring schedule, as Mindbody created it.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/ItemEnvelope'
                  - type: object
                    properties:
                      data: { $ref: '#/components/schemas/ClassSchedule' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '422': { $ref: '#/components/responses/UpstreamRejected' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '503': { $ref: '#/components/responses/Unavailable' }

  /v1/schedule-definitions/{class_schedule_id}/teacher:
    patch:
      tags: [schedule]
      summary: Change a recurring schedule's permanent teacher
      description: |
        Sets `staff_id` on the recurring class schedule itself via
        Mindbody's `updateclassschedule` — the default every
        future-generated instance inherits, not just one occurrence (see
        `PATCH /v1/schedule/{class_id}/teacher` for that). Requires
        `schedule:write`, and is served only where the deployment has writes
        enabled.
      parameters:
        - name: class_schedule_id
          in: path
          required: true
          schema: { type: string }
        - $ref: '#/components/parameters/idempotency_key'
        - name: site_id
          in: query
          required: true
          description: |
            The 32-character Datastream site id the schedule belongs to.
            Must be one of the key's own sites — anything else is a 403.
          schema: { type: string }
          example: 194b29112cf18b58d8a387198cfdc0db
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/AssignTeacherRequest' }
      responses:
        '200':
          description: Mindbody's updated recurring schedule.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/ItemEnvelope'
                  - type: object
                    properties:
                      data: { $ref: '#/components/schemas/ClassSchedule' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '422': { $ref: '#/components/responses/UpstreamRejected' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '503': { $ref: '#/components/responses/Unavailable' }

  /v1/purchases:
    post:
      tags: [sales]
      summary: Check out a catalog item for a client
      description: |
        Sells one service (package/drop-in) or retail product to a client via
        Mindbody's `checkoutshoppingcart`. Requires the `purchase:write` scope,
        and is served only where the deployment has writes enabled.

        **This does not write to Datastream.** The cart is proxied to Mindbody,
        the system of record; the sale reaches `ds_enriched.sales` on the next
        sync. Datastream still flows one direction.

        The item is resolved inside the key's site scope first — an item the
        key cannot read is a `404`, never a `403`.

        **`test` defaults TRUE**, the opposite of a booking: this endpoint
        moves money on a client's account, so a real sale must say
        `test: false` out loud. A dry run has Mindbody validate and price the
        whole cart (tax included) and returns the totals with `sale_id: null`.

        Mindbody owns the total, tax included, and refuses a payment off by a
        cent. Omit `payment_amount` and the endpoint resolves the authoritative
        total itself (one retry with Mindbody's own calculated figure). An
        explicit `payment_amount` is never corrected — a mismatch comes back as
        a `422` carrying Mindbody's message with the real total in it.

        **No card is charged.** The payment is recorded against a Mindbody
        custom payment method (e.g. 19 = "Square" on the Flow site), which is a
        label, not a processor. Whatever collects the actual money does so
        before calling this.

        Mindbody does not dedupe sales. Send an `Idempotency-Key` and a repeat
        replays the first response instead of charging the account again.
      parameters:
        - $ref: '#/components/parameters/idempotency_key'
        - $ref: '#/components/parameters/site_id'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/PurchaseRequest' }
      responses:
        '200':
          description: "Dry run (`test: true`) — Mindbody's priced cart, no sale created."
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/ItemEnvelope'
                  - type: object
                    properties:
                      data: { $ref: '#/components/schemas/Purchase' }
        '201':
          description: The sale, as Mindbody created it.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/ItemEnvelope'
                  - type: object
                    properties:
                      data: { $ref: '#/components/schemas/Purchase' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '422': { $ref: '#/components/responses/UpstreamRejected' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '503': { $ref: '#/components/responses/Unavailable' }

  /v1/payments/square/config:
    get:
      tags: [sales]
      summary: Square Web Payments SDK bootstrap values for one Mindbody location
      description: |
        Application id, environment, the SDK script URL, and the Square
        location that `location_id` (Mindbody's) maps to — a console page
        needs all of it to render a card form and call
        `POST /v1/payments/square/charge`. Requires only a valid key — none
        of this is secret; the Web Payments SDK ships the application id to
        the browser by design.

        **`location_id` is required.** One Square merchant account (one
        application id, one access token) serves every Flow studio, but
        Mindbody's `location_id` and Square's own location id are different
        id spaces with no natural correspondence, so the mapping lives in
        `ds_api.square_location_map` (`src/db/square-locations.ts`), one row
        per (site, Mindbody location). Tenancy is proven the same way
        `/v1/purchases` proves it for an item: the location is read inside
        the key's scope first, a `404` for one it cannot see, never a `403`
        that confirms it exists.

        Returns `503` when the account itself isn't configured
        (`SQUARE_APPLICATION_ID` / `SQUARE_ACCESS_TOKEN`), or when this
        specific location has no Square location mapped yet.
      parameters:
        - name: location_id
          in: query
          required: true
          schema: { type: string }
          description: Mindbody's location id (from `GET /v1/locations`).
        - $ref: '#/components/parameters/site_id'
      responses:
        '200':
          description: Bootstrap values for the Web Payments SDK, for this location.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/ItemEnvelope'
                  - type: object
                    properties:
                      data: { $ref: '#/components/schemas/SquareConfig' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '503': { $ref: '#/components/responses/Unavailable' }

  /v1/payments/square/charge:
    post:
      tags: [sales]
      summary: Charge a card via Square and check out the cart in Mindbody
      description: |
        The endpoint `/v1/purchases` deliberately does not have: this one
        actually moves money. Requires the `payment:write` scope, kept
        separate from `purchase:write` because it can debit a card, and is
        served only where the deployment has writes enabled and Square is
        configured.

        Runs three steps, in order, per the square-mbo-payments design
        (verified against production Mindbody and the Square sandbox,
        2026-07-30):

          1. A `test: true` Mindbody checkout to learn the authoritative total
             — tax included — **before Square is touched**.
          2. `POST /v2/payments` at Square for exactly that figure, using
             `source_id` (the Web Payments SDK token from the browser; a card
             number is never seen server-side).
          3. A `test: false` Mindbody checkout at that same figure, recorded
             against the deployment's Square custom payment method
             (`SQUARE_MBO_PAYMENT_METHOD_ID`, 19 on the Flow site).

        **If step 3 fails after step 2 succeeded**, the card has already been
        charged. This endpoint refunds it automatically, alerts through
        flow-notify, and returns `422` naming what happened either way — it
        never leaves a charged-but-unfulfilled payment silent.

        Mindbody discards any payment reference it is given (`Payments[].
        Metadata.Notes` verified stored `NULL`), so the Square payment id and
        the Mindbody sale id are linked only in this service's own ledger
        (`ds_api.square_payment`) — not automation for refunds issued in the
        Mindbody admin panel; that is a separate, unbuilt design.

        **`Idempotency-Key` is required, not optional** — unlike `/v1/purchases`,
        where the worst case of a dropped retry is a duplicate sale. Here it
        is a duplicate real charge.
      parameters:
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string, minLength: 8, maxLength: 255 }
        - $ref: '#/components/parameters/site_id'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/SquareChargeRequest' }
      responses:
        '201':
          description: The sale, as Mindbody created it, plus the Square payment.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/ItemEnvelope'
                  - type: object
                    properties:
                      data: { $ref: '#/components/schemas/SquarePurchase' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '422': { $ref: '#/components/responses/UpstreamRejected' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '503': { $ref: '#/components/responses/Unavailable' }

  /v1/payments/square/plans:
    get:
      tags: [sales]
      summary: Square subscription plan variations on this merchant account
      description: |
        Every `SUBSCRIPTION_PLAN_VARIATION` in the Square Catalog, flattened
        with its parent plan's name. `amount` is **dollars**, not Square's
        cents, matching every other money field in this API.

        Not site-filtered, and it cannot be: a Square Catalog belongs to the
        merchant account, not to a Datastream site. Behind `payment:write`
        rather than a read scope because it calls Square with the merchant
        credential — this is not a Datastream read, and `raw:read` keys are
        minted freely for developers.
      responses:
        '200':
          description: Plan variations, most recently returned by Square first.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/ListEnvelope'
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: '#/components/schemas/SquarePlanVariation' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '503': { $ref: '#/components/responses/Unavailable' }
    post:
      tags: [sales]
      summary: Create a Square subscription plan with one variation
      description: |
        Creates a `SUBSCRIPTION_PLAN` and its first variation in one call.

        **A Square Catalog object cannot be deleted through this API**, only
        ignored, which is why `Idempotency-Key` is required and why this sits
        behind `ENABLE_WRITES` like every other upstream state change.

        Omit `periods` for a plan that renews indefinitely — that is what 18 of
        Flow's 25 Mindbody contracts actually are
        (`ContractAutomaticallyRenews`). The 6 that stop after a year are
        `periods: 12`. Prices are tax inclusive, matching Mindbody's own
        contract totals.
      parameters:
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string, minLength: 8, maxLength: 255 }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/SquarePlanRequest' }
      responses:
        '201':
          description: The plan variation Square created.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/ItemEnvelope'
                  - type: object
                    properties:
                      data: { $ref: '#/components/schemas/SquarePlanVariation' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '503': { $ref: '#/components/responses/Unavailable' }

  /v1/payments/square/subscriptions:
    get:
      tags: [sales]
      summary: A client's Square subscriptions, live status joined to the Mindbody link
      description: |
        Square is authoritative for `square_status`; this service's own
        `ds_api.square_subscription` is authoritative for which Mindbody
        contract a subscription stands for, because Square's Subscription
        object has no `reference_id` and no metadata to hold one.

        A row recorded here but absent at Square comes back with
        `square_status: null`, and a subscription at Square with no local row
        comes back with `recorded_status: "not_recorded"`. Neither is dropped —
        that divergence is exactly what a reconciliation job looks for.

        `is_winding_down` is the field to read, not `status`: Square keeps a
        cancelled subscription `ACTIVE` until the period already paid for ends.
      parameters:
        - name: client_id
          in: query
          required: true
          schema: { type: string }
          description: Mindbody client id.
        - $ref: '#/components/parameters/site_id'
      responses:
        '200':
          description: Subscriptions for this client.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/ListEnvelope'
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: '#/components/schemas/SquareSubscription' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '503': { $ref: '#/components/responses/Unavailable' }
    post:
      tags: [sales]
      summary: Enrol a client on a Square subscription (starts real recurring billing)
      description: |
        Find-or-create the Square Customer, store the card against it, create
        the subscription — in that order. Square charges the first period
        immediately unless `start_date` is in the future, so **a 201 means real
        money has already moved**.

        **Nothing is written to Mindbody.** The response carries
        `mbo_entitlement: "none"` rather than letting a caller infer from a 201
        that the member can book a class. The per-cycle Mindbody write hangs
        off the `invoice.payment_made` webhook and is not built: Mindbody's
        `/sale/purchasecontract` accepts no custom payment method (only
        `CreditCardInfo`, `StoredCardInfo`, `UseDirectDebit`,
        `UseAccountCredit`), so there is no way to create a Mindbody contract
        that Mindbody will not also try to bill, and which write replaces it is
        an open decision.

        Send **exactly one** of `source_id` (a fresh Web Payments SDK token to
        save) or `card_id` (a card already on file for this client; it is
        checked against the customer's own cards before use). Only credit and
        debit cards can be stored — Apple Pay, Google Pay, Cash App Pay and ACH
        cannot, so a token from those flows will be rejected by Square.

        A client with neither a name nor an email address in Mindbody is
        refused with `409`: Square silently deactivates such a subscription at
        the next billing cycle.
      parameters:
        - name: Idempotency-Key
          in: header
          required: true
          schema: { type: string, minLength: 8, maxLength: 255 }
        - $ref: '#/components/parameters/site_id'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/SquareSubscribeRequest' }
      responses:
        '201':
          description: The subscription Square created. Billing has started.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/ItemEnvelope'
                  - type: object
                    properties:
                      data: { $ref: '#/components/schemas/SquareSubscription' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }
        '422': { $ref: '#/components/responses/UpstreamRejected' }
        '503': { $ref: '#/components/responses/Unavailable' }

  /v1/payments/square/subscriptions/{subscription_id}/cancel:
    post:
      tags: [sales]
      summary: Cancel a Square subscription at the end of the paid period
      description: |
        Square's only cancel semantics, and the right ones: the member keeps
        what they have already paid for. The subscription stays `ACTIVE` with a
        `canceled_date` until that date passes, so read `is_winding_down`, not
        `status`.

        Tenancy comes from this service's own record of the subscription, so a
        subscription created outside this service — straight in the Square
        dashboard, say — cannot be cancelled here. That is deliberate: the row
        is the only thing tying a Square subscription to a Flow site, and
        cancelling on a caller-supplied id alone would let any `payment:write`
        key cancel anything on the merchant account.

        Changes nothing in Mindbody, because nothing was created there.
      parameters:
        - name: subscription_id
          in: path
          required: true
          schema: { type: string }
        - $ref: '#/components/parameters/site_id'
      responses:
        '200':
          description: The subscription, now winding down.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/ItemEnvelope'
                  - type: object
                    properties:
                      data: { $ref: '#/components/schemas/SquareSubscription' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '503': { $ref: '#/components/responses/Unavailable' }

  /v1/payments/square/webhook-events:
    get:
      tags: [sales]
      summary: Square webhook deliveries this service has received
      description: |
        The audit trail behind `POST /webhooks/square`, most recent first —
        including deliveries whose signature failed to verify, flagged by
        `signature_verified`, because a forged or misconfigured delivery is
        exactly what you want a record of.

        Not site-scoped: a Square webhook envelope carries a merchant id and a
        Square location id, neither of which maps to a Datastream site without
        first reading the payload.
      parameters:
        - name: limit
          in: query
          schema: { type: integer, minimum: 1, maximum: 200, default: 25 }
      responses:
        '200':
          description: Recent webhook deliveries.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/ListEnvelope'
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: '#/components/schemas/SquareWebhookEvent' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }

  /v1/contracts/{contract_id}/purchases:
    post:
      tags: [packages]
      summary: Sell a membership to a client
      description: |
        Buys a contract (membership/autopay) for a client via Mindbody's
        `purchasecontract`. Requires the `contract:write` scope — deliberately
        separate from `purchase:write`, because a membership creates a recurring
        billing obligation rather than a one-off charge.

        **Mindbody has no dry run for this endpoint**, and it silently ignores
        unknown fields — verified against production on 2026-08-02: `Test: true`
        and a deliberately bogus field produced byte-identical responses. So
        `test` is never forwarded. `test: true` (**the default**) runs a local
        preflight instead: it reads the contract, checks it is purchasable at the
        location Mindbody would check against, and returns the terms and the
        amounts that would be charged, with `validated_locally_only: true`. It
        cannot tell you whether the card will authorise or whether the studio's
        own rules allow the sale — only a real purchase does that.

        A real purchase (`test: false`) additionally requires **`confirm_amount`**,
        the first payment the caller expects to charge. It must match the catalog
        or the request is a `409`. There is no dry run and the amount is otherwise
        absent from the request, so this is what stops a consumer whose catalog has
        drifted from silently charging the wrong figure.

        **Payment sources:** `use_account_credit` (draws on the client's Mindbody
        account balance) or `stored_card` (charges the card on file). Raw card
        details are refused with a `400` — accepting a PAN would place this
        service in PCI DSS scope. `use_direct_debit` is refused too: Mindbody
        answers `Direct debit not enabled` for this site.

        Only a stale staff token is retried. The purchase itself never is — a
        retry Mindbody accepted the first time would enrol the client twice.
        Send an `Idempotency-Key`.
      parameters:
        - name: contract_id
          in: path
          required: true
          schema: { type: string }
        - $ref: '#/components/parameters/idempotency_key'
        - $ref: '#/components/parameters/site_id'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/ContractPurchaseRequest' }
      responses:
        '200':
          description: "Local preflight (`test: true`) — nothing was sent to Mindbody."
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/ItemEnvelope'
                  - type: object
                    properties:
                      data: { $ref: '#/components/schemas/ContractPreflight' }
        '201':
          description: The membership, as Mindbody created it.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/ItemEnvelope'
                  - type: object
                    properties:
                      data: { $ref: '#/components/schemas/ContractPurchase' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }
        '422': { $ref: '#/components/responses/UpstreamRejected' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '503': { $ref: '#/components/responses/Unavailable' }

  /v1/schedule/roster:
    get:
      tags: [schedule]
      summary: Class roster (legacy query-param form)
      deprecated: true
      description: |
        Everyone booked into one class — name, booking status, whether they
        checked in. `class_id` as a query param rather than a path segment;
        kept for callers that haven't repointed to the equivalent path-form
        endpoint.
      parameters:
        - name: class_id
          in: query
          required: true
          schema: { type: string }
        - $ref: '#/components/parameters/site_id'
      responses:
        '200':
          description: A page of roster entries.

  /v1/staff:
    get:
      tags: [staff]
      summary: Staff and instructors
      description: |
        Backed by `ds_mbo.staff`.

        **Not yet populated:** `slug`. The mirror's staff entity does not carry
        it — the legacy API read it from `mb_flow_staff` on the warehouse.

        **`bio` is not returned here.** It is a long free-text biography that
        was ~62% of this endpoint's payload by weight at `limit=1000`; fetch it
        on demand from `GET /v1/staff/{staff_id}/bio` instead.
      parameters:
        - $ref: '#/components/parameters/site_id'
        - name: location_id
          in: query
          schema: { type: string }
        - name: active
          in: query
          schema: { type: boolean }
        - name: class_teacher
          in: query
          description: Restrict to staff who teach classes.
          schema: { type: boolean }
        - $ref: '#/components/parameters/modified_since'
        - $ref: '#/components/parameters/limit_batch'
        - $ref: '#/components/parameters/offset'
      responses:
        '200':
          description: A page of staff.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/ListEnvelope'
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: '#/components/schemas/StaffMember' }
        '304': { $ref: '#/components/responses/NotModified' }

  /v1/staff/{staff_id}/bio:
    get:
      tags: [staff]
      summary: One staff member's biography
      description: |
        A teacher's full-text biography, keyed by `staff_id` — the same field
        every `/v1/staff` row carries. Split out of `/v1/staff` because `bio`
        was ~62% of that endpoint's payload by weight at `limit=1000`; this
        route is meant to be fetched on demand (e.g. when a user expands a
        teacher profile) and cached long by the client, not called once per
        staff row.
      parameters:
        - name: staff_id
          in: path
          required: true
          schema: { type: string }
        - $ref: '#/components/parameters/site_id'
      responses:
        '200':
          description: The staff member's bio.
          headers:
            ETag: { $ref: '#/components/headers/ETag' }
            Cache-Control:
              schema: { type: string, example: 'private, max-age=3600' }
              description: >
                Static reference content — cached far longer than a single
                `/v1/staff` page refresh so repeat lookups of the same
                `staff_id` are served from the browser's HTTP cache.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/ItemEnvelope'
                  - type: object
                    properties:
                      data: { $ref: '#/components/schemas/StaffBio' }
        '304': { $ref: '#/components/responses/NotModified' }
        '404': { $ref: '#/components/responses/NotFound' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /v1/locations:
    get:
      tags: [locations]
      summary: Studio locations
      description: |
        Backed by `ds_mbo.location`. PLAN.md §6 listed `ds_config` as the source,
        but `ds_config.sites` is the tenant registry and has no addresses; the
        mirror holds the data matching this shape. Requires `raw:read`.

        **Not yet populated:** `slug`, `image_url`. `amenities` passes through MBO's
        array of objects rather than the array of strings the legacy schema
        advertised.
      parameters:
        - $ref: '#/components/parameters/site_id'
        - name: has_classes
          in: query
          schema: { type: boolean }
        - $ref: '#/components/parameters/modified_since'
        - $ref: '#/components/parameters/limit'
        - $ref: '#/components/parameters/offset'
      responses:
        '200':
          description: A page of locations.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/ListEnvelope'
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: '#/components/schemas/Location' }
        '304': { $ref: '#/components/responses/NotModified' }

  /v1/events:
    get:
      tags: [events]
      summary: Enrollments, workshops, and events
      description: |
        Backed by `ds_mbo.enrollment`.

        **Not yet populated:** `max_capacity`, `total_booked` — the enrollment
        entity has no counts, and the legacy API aggregated them warehouse-side.
        `pricing` is not served at all: it came from `cms_pricing` on the Portal,
        which is another product's schema (CLAUDE.md rule 5).
      parameters:
        - $ref: '#/components/parameters/site_id'
        - name: start_date
          in: query
          description: Events overlapping on or after this date.
          schema: { type: string, format: date }
        - name: end_date
          in: query
          schema: { type: string, format: date }
        - name: location_id
          in: query
          schema: { type: string }
        - name: category
          in: query
          description: Style of the description ("Yoga", "Meditation"), not the kind of event.
          schema: { type: string }
        - name: class_description_id
          in: query
          description: >
            All scheduled dates for one class description — i.e. every occurrence
            of a single Publisher post. Note `flow_cms_posts.mbo_enrollment_id`
            stores this id, not `event_id`.
          schema: { type: string }
        - name: session_type
          in: query
          description: MBO's own label — Event, Community, Workshop, Retreat, Teacher Trainings, ...
          schema: { type: string }
        - name: program
          in: query
          description: MBO program the description is filed under — Event Single-Day, Workshops, Retreats, Teacher Trainings, ...
          schema: { type: string }
        - name: event_type
          in: query
          description: >
            Retreats and trainings folded out of `session_type` OR `program`;
            everything else is `event`. Either signal is enough — a Continuing
            Education session under the Teacher Trainings program is a training.
            Program IDs are not consulted: MBO reuses id 23 for both
            "Event Single-Day" and "Workshops".
          schema: { type: string, enum: [event, training, retreat] }
        - $ref: '#/components/parameters/modified_since'
        - $ref: '#/components/parameters/limit'
        - $ref: '#/components/parameters/offset'
      responses:
        '200':
          description: A page of events.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/ListEnvelope'
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: '#/components/schemas/EventSummary' }
        '304': { $ref: '#/components/responses/NotModified' }

  /v1/events/{event_id}:
    get:
      tags: [events]
      summary: One event
      description: |
        A single enrollment — a training, retreat, or workshop by MBO
        `event_id`. An event is a date RANGE, not one class instance: a
        mentorship can run May → January as one row, so this shows the span
        rather than pretending it's a single session.
      parameters:
        - name: event_id
          in: path
          required: true
          schema: { type: string }
        - $ref: '#/components/parameters/site_id'
      responses:
        '200':
          description: The event.
        '404': { $ref: '#/components/responses/NotFound' }

  /v1/events/detail:
    get:
      tags: [events]
      summary: One event (legacy query-param form)
      deprecated: true
      description: |
        Same event lookup as `/v1/events/{event_id}`, with `event_id` as a
        query param instead of a path segment. Kept for callers that haven't
        repointed yet.
      parameters:
        - name: event_id
          in: query
          required: true
          schema: { type: string }
        - $ref: '#/components/parameters/site_id'
      responses:
        '200':
          description: The event.

  /v1/packages:
    get:
      tags: [packages]
      summary: Purchasable services and packages
      description: |
        Backed by `ds_mbo.service`. Hides discontinued packages unless
        `discontinued=true`.

        The legacy `location_id` filter is deliberately not implemented — it would
        require matching inside the `sellAtLocationIds` JSON array, whose element
        type is unverified, and a filter that quietly matches nothing is worse than
        an absent one. `location_ids` is returned on every row for client-side
        filtering.

        **Not yet populated:** `description`, `is_auto_renewing`.
      parameters:
        - $ref: '#/components/parameters/site_id'
        - name: sell_online
          in: query
          schema: { type: boolean }
        - name: is_intro_offer
          in: query
          schema: { type: boolean }
        - name: discontinued
          in: query
          schema: { type: boolean, default: false }
        - name: type
          in: query
          schema: { type: string }
        - $ref: '#/components/parameters/modified_since'
        - $ref: '#/components/parameters/limit_batch'
        - $ref: '#/components/parameters/offset'
      responses:
        '200':
          description: A page of packages.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/ListEnvelope'
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: '#/components/schemas/Package' }
        '304': { $ref: '#/components/responses/NotModified' }

  /v1/contracts:
    get:
      tags: [packages]
      summary: The contract catalog — memberships and autopays a client can buy
      description: >
        ds_mbo.sale_contract: what is FOR SALE, as opposed to
        /v1/clients/{client_id}/contracts, which is what a client HOLDS.


        MBO returns contracts per location, so a contract sold at three studios
        appears three times, each row carrying its own `location_id`. Pricing is
        quoted as `first_payment_total`, `recurring_payment_total` and
        `total_contract_amount`; `contract_items` carries the line items the
        contract grants.


        `description` is not included in this list response — it was 69.5KB of
        a 214KB payload (31%) across 174 rows. Fetch it on demand from
        `GET /v1/contracts/{contract_id}/description` rather than expecting it
        inline.

        Carries `source` as upstream provenance, `"mbo"` today (see
        docs/DS-ENRICHED-V2.md).
      parameters:
        - {name: sold_online, in: query, schema: {type: boolean}}
        - {name: is_intro_offer, in: query, schema: {type: boolean}}
        - {name: autopay_enabled, in: query, schema: {type: boolean}}
        - {name: location_id, in: query, schema: {type: string}}
        - {name: modified_since, in: query, schema: {type: string, format: date-time}}
      responses:
        '200': {description: List envelope of contracts}
  /v1/contracts/{contract_id}:
    get:
      tags: [packages]
      summary: One contract from the catalog
      description: >
        The sellable membership definition itself (terms, pricing, autopay
        schedule) — not a client's holding of it. For what a specific client
        actually has, see `/v1/clients/{client_id}/contracts`. Includes
        `description`, unlike the list response.
      parameters: [{name: contract_id, in: path, required: true, schema: {type: string}}]
      responses:
        '200': {description: Contract}
        '404': {description: Not found within the key's site scope}
  /v1/contracts/{contract_id}/description:
    get:
      tags: [packages]
      summary: One contract's description
      description: |
        The contract name and full HTML description, keyed by `contract_id` —
        the same field every `/v1/contracts` row carries. Split out of
        `/v1/contracts` because the description text was ~31% of that
        endpoint's payload by weight; this route is meant to be fetched on
        demand (e.g. when a user expands a contract for details) and cached
        long by the client, not called once per list row.
      parameters:
        - name: contract_id
          in: path
          required: true
          schema: { type: string }
        - $ref: '#/components/parameters/site_id'
      responses:
        '200':
          description: The contract's description.
          headers:
            ETag: { $ref: '#/components/headers/ETag' }
            Cache-Control:
              schema: { type: string, example: 'private, max-age=3600' }
              description: >
                Static reference content — cached far longer than
                `/v1/contracts`'s 900s so repeat lookups of the same
                `contract_id` are served from the browser's HTTP cache.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/ItemEnvelope'
                  - type: object
                    properties:
                      data: { $ref: '#/components/schemas/ContractDescription' }
        '304': { $ref: '#/components/responses/NotModified' }
        '404': { $ref: '#/components/responses/NotFound' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/RateLimited' }
  /v1/rooms:
    get:
      tags: [locations]
      summary: Bookable rooms at the site
      description: >
        `ds_mbo.resource` — `{room_id, name}`. MBO returns nothing else on
        `/site/resources`: **no capacity and no location**. Room capacity lives in
        the Portal's own `cms_rooms_capacity`, and the room↔location pairing is
        only observable through classes, which carry both.

        Carries `source` as upstream provenance, `"mbo"` today (see
        docs/DS-ENRICHED-V2.md).
      parameters:
        - {name: modified_since, in: query, schema: {type: string, format: date-time}}
      responses:
        '200': {description: List envelope of rooms}
  /v1/products:
    get:
      tags: [packages]
      summary: The retail catalog — merchandise, not class packs
      description: >
        `ds_mbo.product`: water bottles, apparel, cacao, gift cards. Distinct from
        `/v1/packages`, which is `ds_mbo.service` (class packs and drop-ins).


        MBO's `/sale/products` was not pulled until 2026-08-01, so this is empty
        until the sync's next SalesFamily run — an empty list here means "not
        synced yet", not "no retail". Every field except `product_id` and
        `site_id` is currently unverified against real rows; see
        `/status/schema-assumptions`.

        Carries `source` as upstream provenance, `"mbo"` today (see
        docs/DS-ENRICHED-V2.md).
      parameters:
        - {name: category_id, in: query, schema: {type: string}}
        - {name: sell_online, in: query, schema: {type: boolean}}
        - {name: discontinued, in: query, schema: {type: boolean}}
        - {name: modified_since, in: query, schema: {type: string, format: date-time}}
      responses:
        '200': {description: List envelope of retail products}
  /v1/products/{product_id}:
    get:
      tags: [packages]
      summary: One retail product
      description: A single physical/retail item from the catalog (mats, apparel, gift cards) — not a class package or membership contract.
      parameters: [{name: product_id, in: path, required: true, schema: {type: string}}]
      responses:
        '200': {description: Product}
        '404': {description: Not found within the key's site scope}
  /v1/promo-codes:
    get:
      tags: [packages]
      summary: Promo code definitions and their terms
      description: >
        ds_mbo.promo_code — the codes that exist and what they do:
        `discount_type` ("Percent" or "FlatRate") with `discount_amount`, the
        `activation_date`/`expiration_date` window, `days_valid`, `max_uses`,
        and `applicable_items`.


        Definitions only. Datastream records the discount a sale received
        (`sale_items.discount_amount`) but not which code produced it, and MBO's
        sale payload has no promo field — so redemption counts are not
        available from this API at any endpoint.


        `current=true` is the practical filter: active AND inside its date
        window. Most expired codes are still flagged active in MBO, so
        `active=true` alone over-reports what a student could actually redeem.
        Open-ended codes carry an expiration of 2099-12-31.

        Carries `source` as upstream provenance, `"mbo"` today (see
        docs/DS-ENRICHED-V2.md).
      parameters:
        - {name: code, in: query, schema: {type: string}, description: Exact match}
        - {name: current, in: query, schema: {type: boolean}, description: Active and within its date window}
        - {name: active, in: query, schema: {type: boolean}}
        - {name: allow_online, in: query, schema: {type: boolean}}
        - {name: discount_type, in: query, schema: {type: string, enum: [Percent, FlatRate]}}
        - {name: modified_since, in: query, schema: {type: string, format: date-time}}
      responses:
        '200': {description: List envelope of promo codes}
  /v1/promo-codes/{promo_code_id}:
    get:
      tags: [packages]
      summary: One promo code
      description: Discount code definition — percent/amount off, validity window, applicable services. Redemption is tracked on the sale, not here.
      parameters: [{name: promo_code_id, in: path, required: true, schema: {type: string}}]
      responses:
        '200': {description: Promo code}
        '404': {description: Not found within the key's site scope}
  /v1/reports/sales-daily:
    get:
      tags: [reports]
      summary: Daily revenue by location and category (cash-accounting adjusted)
      description: >
        Replaces ai_sales_snapshot_v2 — specifically dw_flow's
        vw__dm_sales_adjusted shape, which is what the old Redash dashboards
        actually read. Sums ds_enriched.sale_items (excluding line items
        literally named "Tip") and unions a negated slice of
        ds_enriched.sale_payments for non-cash payment types (Groupon,
        ClassPass, Debt Write Off, LivingSocial, Comp/Guest, Flow Yoga, Gift
        Card, Account) — those sales show full retail price on the purchased
        item but no real cash came in, so the payment leg offsets it back out.
        Excludes ds_enriched.config_excluded_clients (test/demo accounts).

        Requires `date_from`/`date_to` in practice — an unbounded call can
        time out over the full sales history.
      parameters:
        - {name: date_from, in: query, schema: {type: string, format: date}}
        - {name: date_to, in: query, schema: {type: string, format: date}}
        - {name: location_id, in: query, schema: {type: string}}
      responses:
        '200': {description: List envelope of daily revenue rows}
  /v1/reports/membership-changes:
    get:
      tags: [reports]
      summary: Point-in-time active membership counts by type and location
      description: >
        Replaces ai_membership_snapshot_v2's membership-by-type chart
        (dw_flow.pc_memberByType). For each client, ranks their
        ds_enriched.client_contracts rows by start_date/agreement_date and
        keeps only the most recent contract that is active/unexpired/
        non-terminated as of `as_of` (default today) — a true point-in-time
        snapshot, not a raw count of every contract row started in a date
        range. Excludes config_excluded_clients.

        `changeInMembership` (new/renewed/cancelled/expired, diffed
        day-over-day by the old cron against yesterday's snapshot) has no
        equivalent — there is no daily snapshot history table here to diff
        against — and is intentionally omitted.
      parameters:
        - {name: as_of, in: query, schema: {type: string, format: date}, description: Snapshot date, default today}
        - {name: location_id, in: query, schema: {type: string}}
      responses:
        '200': {description: List envelope of membership-type/status counts}
  /v1/reports/class-performance:
    get:
      tags: [reports]
      summary: Class attendance and capacity rolled up by class/teacher/day-time
      description: >
        Replaces ai_class_performance_snapshot_v2 + ai_class_detail_snapshot_v2
        in one shape, from ds_enriched.classes. "Best"/"worst" rankings (best
        class times, top candidates for replacement) are this same rollup
        sorted ascending instead of descending on avgClassAttendance — there
        is nothing further to compute.

        Not excluded: config_excluded_clients. classes.total_signed_in is a
        count at the class-occurrence grain (no client_id column on this
        table), so a test account's visit can't be excluded here without a
        per-class fan-out join down to class_visit.
      parameters:
        - {name: date_from, in: query, schema: {type: string, format: date}}
        - {name: date_to, in: query, schema: {type: string, format: date}}
        - {name: location_id, in: query, schema: {type: string}}
      responses:
        '200': {description: List envelope of class rollup rows}
  /v1/reports/new-students:
    get:
      tags: [reports]
      summary: New students by first signed-in visit within a required date window
      description: >
        Replaces the dw_flow first-visit marts (vw_first_visits / the v1
        client_first_visits table), which were removed in the v2 enriched
        rebuild (docs/DS-ENRICHED-V2.md, "Gone in v2"). Computed live from the
        raw mirror ds_mbo.class_visit: ranks every signed-in visit per client
        by start time within the requested window, keeps rank 1, groups by
        the date of that first visit. Excludes config_excluded_clients.

        `date_from`/`date_to` are REQUIRED — 400 without both. "New student"
        means *first signed-in visit within the queried window*, not an
        absolute lifetime-first-visit-ever: a client whose true first visit
        predates date_from but who visits again inside the window will show
        up as a "new student" on that later date. An earlier version ranked
        each client's entire visit history to preserve a true lifetime-first
        answer and filtered the date window only after ranking; that 503'd in
        production (class_visit is 897K+ rows) even with a supporting index,
        because the volume of history scanned per client — not just the sort
        — was the actual cost. This is a deliberate, documented tradeoff, and
        matches how comparable "new users in period" reports work elsewhere.

        `location_id`, if given, narrows the ranking window further — a
        client's first signed-in visit *at that location, within the date
        window*.
      parameters:
        - {name: date_from, in: query, required: true, schema: {type: string, format: date}}
        - {name: date_to, in: query, required: true, schema: {type: string, format: date}}
        - {name: location_id, in: query, schema: {type: string}}
      responses:
        '200': {description: List envelope of new-student counts by date and location}
  /v1/reports/promo-detail:
    get:
      tags: [reports]
      summary: Promo code catalog and discounted sale line items (unjoined)
      description: >
        Replaces ai_promo_detail_snapshot_v2. Datastream does not tie a sale
        to the promo code that discounted it — sale_items only carries
        discount_amount, no code reference, and MBO's sale payload itself has
        no promo field to mirror. So this returns two independent lists: the
        promo code catalog (ds_mbo.promo_code) and sale_items rows with a
        discount in the date window (config_excluded_clients-filtered) — not
        a fabricated join. A caller who needs "how much did FREEWEEK cost us"
        cannot get that from Datastream today.
      parameters:
        - {name: date_from, in: query, schema: {type: string, format: date}}
        - {name: date_to, in: query, schema: {type: string, format: date}}
      responses:
        '200':
          description: Two-list envelope — promo_codes and discounted_sale_items
  /v1/reports/mbo-usage:
    get:
      tags: [reports]
      summary: Mindbody API call volume — sync side and write side
      description: >
        Requires `config:read` (ops data, not client data). Two sources,
        tagged by `source`, one row per (date, site, family|endpoint):


        - `sync` — itflow_datastream's scheduled and webhook-driven pulls,
        read from `ds_config.sync_control_execution` (that service's own
        schema, read-only here). `family` is a sync family (`ClassFamily`,
        `TransactionFamily`, …); `endpoint` is null.

        - `write_api` — this service's own writes (bookings, cancels,
        check-ins, schedule/teacher edits), counted at the single choke
        point in `mboFetch()` and stored in `ds_api.mbo_call_log`. `endpoint`
        is the MBO path (e.g. `/class/addclienttoclass`); `family` is null.


        Defaults to the last 7 days if neither `date_from` nor `date_to` is
        given — this is a spend dashboard, not a full-history report.


        **Not covered:** the legacy Java sync service on AWS, which also
        calls Mindbody and is not instrumented anywhere this API can read.
        That spend has to be read from Mindbody's own account dashboard, not
        this endpoint — the response `note` field repeats this so a chart
        never implies otherwise.


        **Degrades gracefully** if `ds_api.mbo_call_log` (sql/019) hasn't
        been applied yet: the response falls back to sync-only rows rather
        than erroring.
      parameters:
        - {name: date_from, in: query, schema: {type: string, format: date}}
        - {name: date_to, in: query, schema: {type: string, format: date}}
      responses:
        '200':
          description: >
            List envelope. Each row: source (sync|write_api), date, siteId,
            family (nullable), endpoint (nullable), requests, retries
            (sync only), failed (write_api only).
        '403': { $ref: '#/components/responses/Forbidden' }
  /v1/sites:
    get:
      tags: [config]
      summary: Tenant registry for this key
      description: |
        Backed by `ds_config.sites`, scoped to the key's own sites. Requires
        `config:read`.

        An empty response does not mean "no such site": two data-bearing site ids
        are absent from the registry entirely, so a key can be bound to a site that
        has data but no registry row.
      parameters:
        - $ref: '#/components/parameters/limit'
        - $ref: '#/components/parameters/offset'
      responses:
        '200':
          description: A page of registry rows.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/ListEnvelope'
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: '#/components/schemas/Site' }
        '403': { $ref: '#/components/responses/Forbidden' }

  /v1/clients:
    get:
      tags: [clients]
      summary: Look up clients by id, unique id, email, status, or modified_since
      description: >
        Client profile rows: `client_id`, name, `email`, `mobile_phone`,
        `status` / `active`, `account_balance`, `creation_date`,
        `home_location_id`, `address`, `address2`, `city`, `state`,
        `postal_code`, `country`, etc.

        Requires at least one narrowing filter — an unfiltered call is a full
        per-site dump and returns 400. `email` resolves through the enriched
        layer's indexed email column, then serves the fresh mirror row; an email
        registered since the last enriched refresh will not resolve.

        Carries `source` as upstream provenance, `"mbo"` today (see
        docs/DS-ENRICHED-V2.md).
      parameters:
        - {name: client_id, in: query, schema: {type: string}}
        - {name: client_unique_id, in: query, schema: {type: string}}
        - {name: email, in: query, schema: {type: string}}
        - {name: status, in: query, schema: {type: string}}
        - {name: modified_since, in: query, schema: {type: string, format: date-time}}
        - {name: site_id, in: query, schema: {type: string}, description: Narrow within the key's site scope}
        - {name: limit, in: query, schema: {type: integer, default: 500}}
        - {name: offset, in: query, schema: {type: integer, default: 0}}
      responses:
        '200': {description: List envelope of client profiles}
        '400': {description: No narrowing filter supplied}
  /v1/clients/search:
    get:
      tags: [clients]
      summary: Typeahead search — name, email, phone, or client_id in one `q` param
      description: >
        For a search-as-you-type box, not a list page: capped small (default
        and effective limit 8) and always ordered by name. `q` matches against
        name, email, mobile_phone, client_unique_id, and client_id together, so
        the caller doesn't need to know which field the user typed into.

        Reads the enriched mirror (real columns, not the raw MBO JSON), so it
        carries the same freshness caveat as `/v1/clients?email=`: refreshes
        on the enriched transform's schedule, not live.
      parameters:
        - {name: q, in: query, required: true, schema: {type: string, minLength: 2}}
        - {name: site_id, in: query, schema: {type: string}, description: Narrow within the key's site scope}
        - {name: limit, in: query, schema: {type: integer, default: 8}}
      responses:
        '200': {description: List envelope of matching client profiles}
        '400': {description: q missing or under 2 characters}
  /v1/clients/{client_id}/enrollments:
    get:
      tags: [clients]
      summary: Bookings — upcoming, or a window of visit history
      description: >
        Defaults to start_time >= now. `include_past=true` lifts the floor;
        `date_from`/`date_to` set an explicit window instead and suppress the
        default. Joined to class/description/staff/location for display names.


        For visit history, prefer a window: results are ordered by start time
        ASC and capped at limit=1000, so `include_past=true` on a long-standing
        member returns their OLDEST page of visits, not their most recent.

        Carries `source` as upstream provenance, `"mbo"` today (see
        docs/DS-ENRICHED-V2.md).
      parameters:
        - {name: client_id, in: path, required: true, schema: {type: string}}
        - {name: include_past, in: query, schema: {type: boolean, default: false}}
        - {name: date_from, in: query, schema: {type: string, format: date}}
        - {name: date_to, in: query, schema: {type: string, format: date}}
      responses:
        '200': {description: List envelope of bookings with booking_id for cancels}
  /v1/clients/{client_id}/credits:
    get:
      tags: [clients]
      summary: Active passes with remaining balance ("My Passes & Credits")
      description: >
        Current services with an unexpired window; `remaining_count` /
        `total_count` answer "7 of 10 classes left, expires June 15". Subset of
        `/services` useful for a profile "active credits" strip.
      parameters:
        - {name: client_id, in: path, required: true, schema: {type: string}}
        - {name: expires_after, in: query, schema: {type: string, format: date}}
      responses:
        '200': {description: List envelope of active services}
  /v1/clients/{client_id}/services:
    get:
      tags: [clients]
      summary: All service/pass records for a client (raw, incl. expired)
      description: >
        Every client_service row: packs, unlimited periods, intro offers.
        `is_current` splits active vs past; includes `service_name`,
        `remaining_count`, `total_count`, `active_date`, `expiration_date`,
        `program_name`. Reads from `ds_enriched.client_services` (cut over
        2026-08-04); carries `source` as upstream provenance, `"mbo"` today
        (see docs/DS-ENRICHED-V2.md).
      parameters:
        - {name: client_id, in: path, required: true, schema: {type: string}}
        - {name: is_current, in: query, schema: {type: boolean}}
        - {name: active_from, in: query, schema: {type: string, format: date}}
      responses:
        '200': {description: List envelope of client services}
  /v1/clients/{client_id}/contracts:
    get:
      tags: [clients]
      summary: Contracts / autopay memberships for a client
      description: >
        What the client **holds** (not the sellable catalog at `/v1/contracts`).
        Fields include `contract_name`, `autopay_status`, `agreement_date`,
        `start_date`, `end_date`. Use autopay_status / end_date to separate
        active memberships from past ones. Reads from
        `ds_enriched.client_contracts` (cut over 2026-08-04; memberships are
        merged into this table upstream); carries `source` as upstream
        provenance, `"mbo"` today (see docs/DS-ENRICHED-V2.md).
      parameters:
        - {name: client_id, in: path, required: true, schema: {type: string}}
        - {name: autopay_status, in: query, schema: {type: string}}
        - {name: start_from, in: query, schema: {type: string, format: date}}
      responses:
        '200': {description: List envelope of client contracts}
  /v1/clients/{client_id}/transactions:
    get:
      tags: [clients]
      summary: Payment transaction history for a client
      description: >
        Card charges for the client: `amount`, `status`, `transaction_time`,
        `card_type`, `cc_last_four`, `is_settled`. Card expiration month/year
        are deliberately NOT mapped anywhere in this service and can never
        appear. Reads from `ds_mbo.client_transaction`; carries `source` as
        upstream provenance, `"mbo"` today (see docs/DS-ENRICHED-V2.md).
      parameters:
        - {name: client_id, in: path, required: true, schema: {type: string}}
        - {name: auth_from, in: query, schema: {type: string, format: date-time}}
        - {name: auth_to, in: query, schema: {type: string, format: date-time}}
      responses:
        '200': {description: List envelope}
  /v1/sales:
    get:
      tags: [sales]
      summary: Sale headers from the enriched layer (scope enriched:read)
      description: >
        One row per sale with item_count / payment_count / sale_total /
        payment_total rollups — most reads never need the line tables.
        `sale_date` is date-only; `transacted_at` is the datetime when known
        (prefer it for clocks/sorting). Requires at least one narrowing filter.
        `adjusted=true` returns 400 until sale_adjusted exists (PLAN.md §9):
        refusing loudly beats silently serving unadjusted numbers.


        A sale made through `POST /v1/purchases` appears here before the mirror
        has it, carrying `source: "pending_write"` and counted in `total_count`.
        It retires the moment the sync carries the same sale id. Such a response
        is `Cache-Control: no-store`. The same applies to `/v1/sales/{id}`,
        `/{id}/items` and `/{id}/payments`.

        Every row also carries `source` as upstream provenance — `"mbo"` today
        for every synced row (ds_enriched v2's provenance column, see
        docs/DS-ENRICHED-V2.md). It exists so a future second raw source (e.g.
        a Momence/Arketa mirror) can land in the same tables without growing a
        parallel set of prefixed columns; `"pending_write"` is this API's own
        overlay value layered on top of that column, not a competing source.
      parameters:
        - {name: client_id, in: query, schema: {type: string}}
        - {name: date_from, in: query, schema: {type: string, format: date}}
        - {name: date_to, in: query, schema: {type: string, format: date}}
        - {name: location_id, in: query, schema: {type: string}}
        - {name: modified_since, in: query, schema: {type: string, format: date-time}}
        - {name: adjusted, in: query, schema: {type: boolean}, description: NOT IMPLEMENTED — 400s}
      responses:
        '200': {description: List envelope of sale headers}
        '400': {description: No narrowing filter, or adjusted=true}
  /v1/sales/items:
    get:
      tags: [sales]
      summary: Line items for multiple sales in one call (batch)
      description: >
        Batched form of `/v1/sales/{sale_id}/items` — pass `sale_ids` as a
        comma-separated list (same convention as every other multi-value
        param) and get every matching line back in one flat list, one
        `WHERE sale_id IN (...)` query instead of one request per sale.
        Built for purchase-history style pages that otherwise fire two
        requests per sale after listing them. Registered ahead of
        `/v1/sales/{sale_id}` so the literal path `items` is not swallowed
        by that route's `sale_id` parameter.
      parameters:
        - {name: sale_ids, in: query, required: true, schema: {type: string}, description: Comma-separated sale ids, max 200 per call.}
      responses:
        '200': {description: List envelope of line items across all requested sales}
        '400': {description: Missing/empty sale_ids, or more than 200 ids}
  /v1/sales/payments:
    get:
      tags: [sales]
      summary: Payments for multiple sales in one call (batch)
      description: >
        Batched form of `/v1/sales/{sale_id}/payments` — same `sale_ids`
        convention and same IN (...) shape as `/v1/sales/items`.
      parameters:
        - {name: sale_ids, in: query, required: true, schema: {type: string}, description: Comma-separated sale ids, max 200 per call.}
      responses:
        '200': {description: List envelope of payments across all requested sales}
        '400': {description: Missing/empty sale_ids, or more than 200 ids}
  /v1/sales/{sale_id}:
    get:
      tags: [sales]
      summary: One sale header
      description: >
        The transaction record itself (date, client, location, totals) from
        the enriched layer — line items and payments are separate reads
        (`/v1/sales/{sale_id}/items`, `/payments`), joined by this same
        `sale_id`.
      parameters: [{name: sale_id, in: path, required: true, schema: {type: string}}]
      responses:
        '200': {description: Sale header}
        '404': {description: Not found within the key's site scope}
  /v1/sales/{sale_id}/items:
    get:
      tags: [sales]
      summary: Line items of a sale (revenue earned)
      description: >
        Never sum together with /payments — the same money appears in both.


        Returns carry a negative `unit_price` AND a negative `item_quantity`:
        MBO puts the sign in both columns, so the naive `unit_price *
        item_quantity` flips a refund back to positive. Extended list price is
        `unit_price * ABS(item_quantity)`. `total_amount` is already signed and
        net of discount, so a purchase-history total is simply
        `SUM(total_amount)`; the discount is the gap between that and the
        extended list price. Verified against MBO's ALL Purchases PDF.


        Each row carries `source` (`ds_enriched.sale_items.source`, default
        `"mbo"`) — provenance, same convention as `/v1/sales`.
      parameters: [{name: sale_id, in: path, required: true, schema: {type: string}}]
      responses:
        '200': {description: List envelope}
  /v1/sales/{sale_id}/payments:
    get:
      tags: [sales]
      summary: Payments of a sale (money received)
      description: >
        Never sum together with /items — the same money appears in both.


        The two method fields read backwards from what the names suggest, and
        this is MBO's naming carried through verbatim: `payment_type` is the
        human label MBO prints on its reports ("Square", "Account", "Credit
        Card", "Comp/Guest"); `payment_method` is the numeric MBO code behind
        it ("19", "16", "4", "7"). Display `payment_type`; match on
        `payment_method`.


        Each row carries `source` (`ds_enriched.sale_payments.source`, default
        `"mbo"`) — provenance, same convention as `/v1/sales`.
      parameters: [{name: sale_id, in: path, required: true, schema: {type: string}}]
      responses:
        '200': {description: List envelope}

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      description: |
        `Authorization: Bearer <key>`. Keys are stored hashed in
        `ds_config.api_key` and carry their own site binding, scopes
        (`raw:read`, `enriched:read`, `config:read`), and rate limit.

  headers:
    ETag:
      description: Weak validator over the response body. Send back as `If-None-Match`.
      schema: { type: string }
    LastModified:
      description: Newest `_updated_at` across the matched rows.
      schema: { type: string }

  parameters:
    site_id:
      name: site_id
      in: query
      description: |
        Comma-separated 32-character Datastream site ids. Narrows *within* the
        key's own sites; anything outside them is a 403. This is not the MBO
        numeric site id.
      schema: { type: string }
      example: 194b29112cf18b58d8a387198cfdc0db
    modified_since:
      name: modified_since
      in: query
      description: Return only rows the mirror updated at or after this instant.
      schema: { type: string, format: date-time }
    limit:
      name: limit
      in: query
      schema: { type: integer, minimum: 1, maximum: 1000, default: 50 }
    limit_batch:
      name: limit
      in: query
      schema: { type: integer, minimum: 1, maximum: 1000, default: 500 }
    offset:
      name: offset
      in: query
      schema: { type: integer, minimum: 0, default: 0 }
    include_inactive:
      name: include_inactive
      in: query
      description: Include rows the mirror has flagged inactive or removed.
      schema: { type: boolean, default: false }
    idempotency_key:
      name: Idempotency-Key
      in: header
      required: false
      description: |
        Opaque, caller-chosen, unique per intended booking. A repeat within 24
        hours replays the first response and sets `Idempotency-Replayed: true`
        rather than booking again. Failures are not recorded, so a key frees up
        for a genuine retry. Scoped to the API key.
      schema: { type: string, pattern: '^[\w.:-]{8,255}$' }

  responses:
    NotModified:
      description: The client's cached copy is current.
    BadRequest:
      description: Malformed or unrecognised parameters.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorEnvelope' }
    Unauthorized:
      description: Missing or invalid API key.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorEnvelope' }
    Forbidden:
      description: Missing scope, or a site outside the key's scope.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorEnvelope' }
    NotFound:
      description: No such record within the key's site scope.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorEnvelope' }
    RateLimited:
      description: Per-key rate limit exceeded. See `Retry-After`.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorEnvelope' }
    Conflict:
      description: A precondition this API checks itself — e.g. a cancelled class.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorEnvelope' }
    UpstreamRejected:
      description: |
        Mindbody understood the request and declined it — class full, no
        eligible service, already booked. `error.message` is Mindbody's own
        wording, suitable for showing to a client.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorEnvelope' }
    Unavailable:
      description: The database or Mindbody is unreachable or busy.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorEnvelope' }

  schemas:
    ListEnvelope:
      type: object
      required: [status, data, total_count, limit, offset]
      properties:
        status: { type: string, enum: [success] }
        data: { type: array, items: {} }
        total_count: { type: integer }
        limit: { type: integer }
        offset: { type: integer }

    BookingRequest:
      type: object
      required: [client_id]
      additionalProperties: false
      properties:
        client_id:
          type: string
          description: The Mindbody client id to book.
        client_service_id:
          type: string
          description: |
            Pay with this specific package. Omit to let Mindbody pick an
            eligible one, which is what a normal online booking does.
        require_payment:
          type: boolean
          default: true
          description: |
            Defaults on. An unpaid visit is a real hole in a studio's books, so
            booking without payment has to be asked for.
        send_email:
          type: boolean
          default: false
          description: |
            Defaults off, unlike Mindbody's own default — a platform API should
            not email a studio's client as a side effect the caller did not ask
            for.
        waitlist:
          type: boolean
          default: false
        cross_regional_booking:
          type: boolean
          default: false
        cross_regional_booking_client_service_site_id:
          type: string
        test:
          type: boolean
          default: false
          description: Validate against Mindbody without creating the booking.

    CheckInRequest:
      type: object
      required: [client_id]
      additionalProperties: false
      properties:
        client_id:
          type: string
          description: |
            The Mindbody client this visit belongs to. Not sent to Mindbody
            (which needs only the visit id) — carried for the audit log.
        signed_in:
          type: boolean
          default: true
          description: |
            Defaults to true: this endpoint's whole reason to exist is
            checking someone IN. Send false to undo a mistaken check-in.

    AssignTeacherRequest:
      type: object
      required: [staff_id]
      additionalProperties: false
      properties:
        staff_id:
          type: string
          description: The Mindbody staff id to assign.
        substitute:
          type: boolean
          default: true
          description: |
            Documentation of intent only, on the per-instance route — Mindbody
            itself decides sub-vs-not by comparing to the schedule's own
            default teacher. Required so a caller cannot reach for the wrong
            endpoint by accident when they meant a permanent change. Not
            accepted on the schedule-definitions route, where every change is
            permanent by definition.

    NewClassScheduleRequest:
      type: object
      required: [class_description_id, staff_id, days_of_week, start_date, end_date, start_time, end_time]
      additionalProperties: false
      properties:
        class_description_id:
          type: string
          description: The Mindbody class type to schedule.
        staff_id:
          type: string
          description: The permanent teacher — every generated instance defaults to this.
        days_of_week:
          type: array
          minItems: 1
          items: { type: string }
          description: 'Mindbody day names, e.g. ["Monday", "Wednesday"].'
        start_date: { type: string, format: date }
        end_date:
          type: string
          format: date
          description: |
            Required — Mindbody has no "forever" recurring schedule, and a
            caller who wants one has to pick a real, far-out date rather than
            omitting this.
        start_time: { type: string, description: 'HH:MM, 24-hour.' }
        end_time: { type: string, description: 'HH:MM, 24-hour.' }
        max_capacity: { type: integer, minimum: 1 }

    ContractPurchaseRequest:
      type: object
      required: [client_id, location_id]
      additionalProperties: false
      properties:
        client_id:
          type: string
          description: The Mindbody client the membership is for.
        location_id:
          type: string
          description: |
            Must be one of the contract's `location_restriction_ids` — Mindbody
            refuses anything else, and so does this endpoint, with the allowed
            list in the message.
        use_account_credit:
          type: boolean
          default: false
          description: Draw the first payment from the client's Mindbody account balance.
        stored_card:
          type: boolean
          default: false
          description: Charge the client's card on file.
        stored_card_last_four:
          type: string
          description: Picks which card on file, when the client has more than one.
        confirm_amount:
          type: number
          description: |
            **Required when `test: false`.** The first payment you expect to
            charge; a mismatch with the catalog is a `409`. There is no dry run
            at Mindbody, so this is the only guard against a stale catalog
            charging the wrong amount.
        promo_code:
          type: string
        start_date:
          type: string
          format: date
          description: Defaults to Mindbody's own (today).
        send_email:
          type: boolean
          default: false
        test:
          type: boolean
          default: true
          description: |
            Defaults ON, and is **never forwarded to Mindbody** — it has no dry
            run here. `true` returns a local preflight; `false` sells the
            membership for real.

    ContractPreflight:
      type: object
      description: "What `test: true` returns. Nothing was sent to Mindbody."
      properties:
        contract_id: { type: string }
        contract_name: { type: [string, 'null'] }
        client_id: { type: string }
        location_id: { type: integer }
        purchasable_at_location:
          type: boolean
          description: Checked against the contract's own location restrictions.
        sold_online: { type: boolean }
        first_payment_total: { type: [number, 'null'] }
        recurring_payment_total: { type: [number, 'null'] }
        total_contract_amount: { type: [number, 'null'] }
        autopay_enabled: { type: boolean }
        number_of_autopays: { type: [integer, 'null'] }
        autopay_frequency_unit: { type: [string, 'null'] }
        autopay_frequency_value: { type: [integer, 'null'] }
        payment_source: { type: string, enum: [account_credit, stored_card, none] }
        start_date: { type: [string, 'null'] }
        site_id: { type: string }
        validated_locally_only:
          const: true
          description: |
            Always true. A consumer that reads `test` as "Mindbody approved this"
            will sell a membership on a check that never reached Mindbody.
        validation_note: { type: string }
        test: { const: true }

    ContractPurchase:
      type: object
      properties:
        sale_id:
          type: [integer, 'null']
          description: |
            Mindbody's sale id when it returns one. The response envelope for this
            endpoint is unverified (see docs/VERIFY.md) — a null here means the
            membership was created but not identified, not that it failed.
        client_contract_id: { type: [integer, string, 'null'] }
        contract_id: { type: string }
        contract_name: { type: [string, 'null'] }
        client_id: { type: string }
        location_id: { type: integer }
        agreement_date: { type: [string, 'null'] }
        start_date: { type: [string, 'null'] }
        end_date: { type: [string, 'null'] }
        autopay_status: { type: [string, 'null'] }
        first_payment_total: { type: [number, 'null'] }
        recurring_payment_total: { type: [number, 'null'] }
        total_contract_amount: { type: [number, 'null'] }
        number_of_autopays: { type: [integer, 'null'] }
        payment_source: { type: string }
        site_id: { type: string }
        test: { const: false }

    PurchaseRequest:
      type: object
      required: [client_id, item_id, location_id, payment_method_id]
      additionalProperties: false
      properties:
        client_id:
          type: string
          description: The Mindbody client id buying the item.
        item_type:
          type: string
          enum: [service, product]
          default: service
          description: |
            `service` is a package/drop-in from `/v1/packages`; `product` is
            retail from `/v1/products`. Memberships (contracts) are not sold
            here — Mindbody uses a different endpoint for those.
        item_id:
          type: string
          description: The `package_id` or `product_id` from the catalog.
        location_id:
          type: string
          description: Mindbody location the sale is rung up at. Tax follows it.
        quantity:
          type: integer
          default: 1
          minimum: 1
        discount_amount:
          type: number
          default: 0
          description: |
            Dollar amount off. Mindbody derives the percent itself — only send
            the amount.
        payment_method_id:
          type: integer
          description: |
            The Mindbody **custom payment method** the sale is recorded against
            (e.g. 19 = "Square" on the Flow site). A label in Mindbody's books,
            not a processor — no card is charged.
        payment_amount:
          type: number
          description: |
            Must equal Mindbody's calculated total (tax included) to the cent.
            Omit to have the endpoint resolve the authoritative total itself.
        promo_code:
          type: string
        send_email:
          type: boolean
          default: false
        test:
          type: boolean
          default: true
          description: |
            Defaults ON. Mindbody validates and prices the cart without
            creating a sale; pass `false` to commit.

    Purchase:
      type: object
      properties:
        sale_id:
          type: [integer, 'null']
          description: Mindbody's sale id. `null` on a dry run.
        client_id: { type: string }
        item_type: { type: string, enum: [service, product] }
        item_id: { type: string }
        item_name: { type: [string, 'null'] }
        quantity: { type: integer }
        location_id: { type: integer }
        sub_total: { type: [number, 'null'] }
        discount_total: { type: [number, 'null'] }
        tax_total: { type: [number, 'null'] }
        grand_total:
          type: [number, 'null']
          description: Mindbody's calculated total, tax included.
        payment_method_id: { type: integer }
        payment_amount: { type: number }
        site_id: { type: string }
        test:
          type: boolean
          description: |
            Echoed loud — a consumer that ignores it will read a dry run as a
            sale.

    SquareConfig:
      type: object
      properties:
        environment: { type: string, enum: [sandbox, production] }
        application_id: { type: string }
        square_location_id:
          type: string
          description: Square's own location id — NOT the `location_id` query param, which is Mindbody's.
        square_location_name:
          type: [string, 'null']
          description: From `ds_api.square_location_map`; a human label for the mapping, not Square's own name.
        js_sdk_url:
          type: string
          description: '`<script src>` for the Web Payments SDK — sandbox or production, matching `environment`.'
        api_base_url:
          type: string
          description: For reference only; the browser never calls this directly.

    SquareChargeRequest:
      type: object
      required: [client_id, item_id, location_id, source_id]
      additionalProperties: false
      properties:
        client_id:
          type: string
          description: The Mindbody client id the sale — and the charge — is for.
        item_type:
          type: string
          enum: [service, product]
          default: service
        item_id:
          type: string
          description: The `package_id` or `product_id` from the catalog.
        location_id:
          type: string
          description: Mindbody location the sale is rung up at. Tax follows it.
        quantity:
          type: integer
          default: 1
          minimum: 1
        discount_amount:
          type: number
          default: 0
        promo_code:
          type: string
        source_id:
          type: string
          description: |
            The Web Payments SDK's single-use payment token, from
            `payments.card().tokenize()` in the browser. Never a raw card
            number — accepting one would place this service in PCI DSS scope.
        send_email:
          type: boolean
          default: false
          description: Applies to the Mindbody sale only; there is no dry run here to email.

    SquarePurchase:
      type: object
      properties:
        sale_id: { type: [integer, 'null'] }
        client_id: { type: string }
        item_type: { type: string, enum: [service, product] }
        item_id: { type: string }
        item_name: { type: [string, 'null'] }
        quantity: { type: integer }
        location_id: { type: integer }
        sub_total: { type: [number, 'null'] }
        discount_total: { type: [number, 'null'] }
        tax_total: { type: [number, 'null'] }
        grand_total:
          type: [number, 'null']
          description: Mindbody's calculated total, tax included — what the card was charged.
        site_id: { type: string }
        square_payment_id: { type: string }
        square_status: { type: string, description: 'Square''s own payment status, e.g. COMPLETED.' }
        square_receipt_url: { type: [string, 'null'] }

    SquarePlanVariation:
      type: object
      properties:
        plan_variation_id: { type: string }
        plan_id: { type: [string, 'null'] }
        plan_name: { type: [string, 'null'] }
        variation_name: { type: [string, 'null'] }
        cadence:
          type: [string, 'null']
          description: |
            Square's billing cadence — `MONTHLY`, `ANNUAL`, `EVERY_TWO_WEEKS`
            and so on. Flow's own contracts only need `MONTHLY` and `ANNUAL`.
        amount:
          type: [number, 'null']
          description: Dollars, tax inclusive. Square's own API uses cents; this does not.
        currency: { type: [string, 'null'] }
        periods:
          type: [integer, 'null']
          description: Billing periods before the plan ends. `null` means it renews indefinitely.

    SquarePlanRequest:
      type: object
      required: [plan_name, amount]
      properties:
        plan_name: { type: string }
        variation_name:
          type: string
          description: Defaults to `plan_name` when omitted.
        cadence:
          type: string
          default: MONTHLY
          enum: [DAILY, WEEKLY, EVERY_TWO_WEEKS, THIRTY_DAYS, SIXTY_DAYS, NINETY_DAYS, MONTHLY, EVERY_TWO_MONTHS, QUARTERLY, EVERY_FOUR_MONTHS, EVERY_SIX_MONTHS, ANNUAL, EVERY_TWO_YEARS]
        amount:
          type: number
          description: Dollars per period, tax inclusive.
        periods:
          type: integer
          minimum: 1
          description: Omit for a plan that never ends.

    SquareSubscribeRequest:
      type: object
      required: [client_id, location_id, plan_variation_id]
      properties:
        client_id: { type: string }
        location_id: { type: string, description: Mindbody's location id, not Square's. }
        plan_variation_id: { type: string }
        source_id:
          type: string
          description: |
            A Web Payments SDK card token to save. Mutually exclusive with
            `card_id`; exactly one is required. Wallet and ACH tokens are
            rejected by Square — only cards can be stored on file.
        card_id:
          type: string
          description: A card already on file for this client. Verified against the customer's own cards.
        cardholder_name:
          type: string
          description: Required with `source_id` — Square's CreateCard demands it.
        postal_code:
          type: string
          description: Required with `source_id` — the card's billing postal code.
        verification_token:
          type: string
          description: |
            From the Web Payments SDK's `payments.verifyBuyer(token, …)` with a
            `STORE` intent. Optional in the US, required for SCA markets, and it
            measurably reduces declines either way.
        start_date:
          type: string
          format: date
          description: |
            `YYYY-MM-DD`. Omitted or today means Square charges the first
            period immediately.
        mbo_contract_id:
          type: string
          description: |
            Which Mindbody catalog contract this subscription stands in for,
            recorded for later reconciliation. Nothing is created in Mindbody.

    SquareSubscription:
      type: object
      properties:
        square_subscription_id: { type: string }
        status:
          type: string
          description: 'Square''s own status: PENDING, ACTIVE, CANCELED, DEACTIVATED, PAUSED.'
        square_status:
          type: [string, 'null']
          description: Live status from Square on the list endpoint. `null` means Square has no such subscription.
        recorded_status:
          type: [string, 'null']
          description: Status recorded at enrolment. `not_recorded` means Square has it and this service does not.
        plan_variation_id: { type: [string, 'null'] }
        square_customer_id: { type: [string, 'null'] }
        square_card_id: { type: [string, 'null'] }
        square_location_id: { type: [string, 'null'] }
        square_location_name: { type: [string, 'null'] }
        client_id: { type: [string, 'null'] }
        location_id: { type: [integer, 'null'] }
        mbo_contract_id: { type: [string, 'null'] }
        site_id: { type: string }
        amount: { type: [number, 'null'], description: Dollars per period, tax inclusive. }
        cadence: { type: [string, 'null'] }
        start_date: { type: [string, 'null'] }
        charged_through_date: { type: [string, 'null'] }
        canceled_date: { type: [string, 'null'] }
        is_winding_down:
          type: [boolean, 'null']
          description: |
            `true` when Square still reports `ACTIVE` but a cancellation is
            pending at period end. Read this rather than `status`.
        mbo_entitlement:
          type: string
          enum: [none]
          description: |
            Always `none` today. Square is billing; nothing was written to
            Mindbody, so the member is not entitled to anything there yet.
        mbo_entitlement_note: { type: string }
        cancel_note: { type: string }

    SquareWebhookEvent:
      type: object
      properties:
        event_id: { type: string }
        event_type:
          type: string
          description: |
            Square's own name, e.g. `invoice.payment_made`,
            `invoice.scheduled_charge_failed`, `subscription.updated`.
        signature_verified:
          type: boolean
          description: False means the delivery was recorded but rejected and never acted on.
        occurred_at:
          type: [string, 'null']
          description: Square's own timestamp from the envelope, UTC. Unlike Datastream datetimes, this is not site-local wall clock.
        received_at: { type: [string, 'null'] }
        processed_at: { type: [string, 'null'] }
        error: { type: [string, 'null'] }
        payload: { type: [string, 'null'], description: The raw delivery body, truncated at 65,000 characters. }

    Booking:
      type: object
      properties:
        visit_id:
          type: [integer, 'null']
          description: |
            Mindbody's visit id. `0` on a dry run — check `test`, not this, to
            tell a dry run from a booking.
        class_id: { type: integer }
        client_id: { type: [string, 'null'] }
        class_name: { type: [string, 'null'] }
        start_time: { type: [string, 'null'] }
        end_time: { type: [string, 'null'] }
        service_id: { type: [integer, 'null'] }
        service_name: { type: [string, 'null'], description: The package the visit was paid from. }
        location_id: { type: [integer, 'null'] }
        staff_id: { type: [integer, 'null'] }
        signed_in: { type: boolean }
        late_cancelled: { type: boolean }
        web_signup: { type: boolean }
        site_id: { type: string }
        test:
          type: boolean
          description: True when nothing was created. Check it before treating this as a booking.

    CheckIn:
      type: object
      properties:
        visit_id: { type: integer }
        class_id: { type: integer }
        client_id: { type: [string, 'null'] }
        signed_in: { type: boolean }
        site_id: { type: string }

    ClassInstance:
      type: object
      description: Mindbody's updated single class instance, after a teacher/sub assignment.
      properties:
        class_id: { type: [integer, 'null'] }
        staff_id: { type: [integer, 'null'] }
        start_time: { type: [string, 'null'] }
        end_time: { type: [string, 'null'] }
        name: { type: [string, 'null'] }
        substitute: { type: boolean }
        site_id: { type: string }

    ClassSchedule:
      type: object
      description: Mindbody's recurring class schedule (the definition, not an instance).
      properties:
        class_schedule_id: { type: [integer, 'null'] }
        class_description_id: { type: [integer, 'null'] }
        location_id: { type: [integer, 'null'] }
        staff_id: { type: [integer, 'null'] }
        days_of_week: { type: [array, 'null'], items: { type: string } }
        start_date: { type: [string, 'null'] }
        end_date: { type: [string, 'null'] }
        start_time: { type: [string, 'null'] }
        end_time: { type: [string, 'null'] }
        max_capacity: { type: [integer, 'null'] }
        site_id: { type: string }

    ItemEnvelope:
      type: object
      required: [status, data]
      properties:
        status: { type: string, enum: [success] }
        data: { type: object }

    ErrorEnvelope:
      type: object
      required: [status, error]
      properties:
        status: { type: string, enum: [error] }
        error:
          type: object
          required: [code, message]
          properties:
            code:
              type: string
              enum:
                - bad_request
                - unauthorized
                - forbidden
                - not_found
                - site_scope_violation
                - rate_limited
                - upstream_unavailable
                - internal_error
            message: { type: string }

    StatusResponse:
      type: object
      properties:
        status: { type: string, enum: [success, error] }
        data:
          type: object
          properties:
            service: { type: string }
            version: { type: string }
            uptime_seconds: { type: integer }
            database:
              type: object
              properties:
                ok: { type: boolean }
                latency_ms: { type: number }
                error: { type: string }
            request_log_sink: { type: string, enum: [stdout, database, none] }
            unverified_field_count: { type: integer }

    ClassSummary:
      type: object
      properties:
        class_id: { type: integer }
        class_name: { type: [string, 'null'] }
        start_time: { type: [string, 'null'], format: date-time }
        end_time: { type: [string, 'null'], format: date-time }
        staff_id: { type: [integer, 'null'] }
        staff_name: { type: [string, 'null'] }
        staff_image_url:
          type: [string, 'null']
          description: Not yet populated — absent from the mirror.
        location_id: { type: [integer, 'null'] }
        location_name: { type: [string, 'null'] }
        room_id:
          type: [string, 'null']
          description: MBO resource id for the room the class runs in.
        room_name:
          type: [string, 'null']
          description: >
            Room within the studio ("SA", "Maha"). Carried inline on the class,
            unlike staff and location which hold ids only. Null where MBO has no
            resource assigned to the class.
        max_capacity: { type: [integer, 'null'] }
        web_capacity: { type: [integer, 'null'] }
        total_booked: { type: [integer, 'null'] }
        total_signed_in: { type: [integer, 'null'] }
        total_booked_waitlist: { type: [integer, 'null'] }
        is_waitlist_available: { type: [boolean, 'null'] }
        is_cancelled:
          type: [boolean, 'null']
          description: House spelling. MBO's source field is `isCanceled`.
        is_canceled:
          type: [boolean, 'null']
          deprecated: true
          description: Legacy alias of `is_cancelled`. Will be removed once no consumer reads it.
        is_available_online: { type: [boolean, 'null'] }
        category: { type: [string, 'null'] }
        class_schedule_id:
          type: [integer, 'null']
          description: >
            Key for the class type's description — fetch it on demand from
            `GET /v1/class-descriptions/{class_schedule_id}` rather than
            expecting it inline. The description text repeats verbatim across
            every instance of a class type and was ~52% of this endpoint's
            payload by weight; it was split out and is no longer returned here.
        is_substitute: { type: [boolean, 'null'] }
        source:
          type: string
          description: >
            Provenance of the row — `ds_enriched.classes.source`, default
            `"mbo"`. Datastream is source-agnostic by design (DS-ENRICHED-V2.md):
            a future non-MBO mirror (Momence/Arketa) would populate this rather
            than growing a parallel set of prefixed columns.
        site_id: { type: string }

    ClassDescription:
      type: object
      properties:
        class_schedule_id: { type: [integer, 'null'] }
        class_name: { type: [string, 'null'] }
        category: { type: [string, 'null'] }
        class_description: { type: [string, 'null'] }

    ContractDescription:
      type: object
      properties:
        contract_id: { type: [string, 'null'] }
        contract_name: { type: [string, 'null'] }
        description: { type: [string, 'null'] }

    RosterEntry:
      type: object
      properties:
        booking_id: { type: [string, 'null'] }
        class_id: { type: [integer, 'null'] }
        client_id: { type: [string, 'null'] }
        client_unique_id: { type: [string, 'null'] }
        client_name: { type: [string, 'null'] }
        client_email:
          type: [string, 'null']
          description: Unverified path — may read null pending reconciliation.
        client_phone:
          type: [string, 'null']
          description: Unverified path — may read null pending reconciliation.
        client_photo_url: { type: [string, 'null'] }
        signed_in: { type: [boolean, 'null'] }
        late_cancelled: { type: [boolean, 'null'] }
        service_name: { type: [string, 'null'] }
        web_signup: { type: [boolean, 'null'] }
        source:
          type: string
          description: >
            Provenance of the row — `ds_enriched.class_visit.source`, default
            `"mbo"`. See DS-ENRICHED-V2.md.
        site_id: { type: string }

    StaffMember:
      type: object
      properties:
        staff_id: { type: integer }
        first_name: { type: [string, 'null'] }
        last_name: { type: [string, 'null'] }
        full_name: { type: [string, 'null'] }
        image_url: { type: [string, 'null'] }
        email: { type: [string, 'null'] }
        slug: { type: [string, 'null'], description: Not yet populated. }
        is_class_teacher: { type: [boolean, 'null'] }
        is_active: { type: [boolean, 'null'] }
        sort_order: { type: [integer, 'null'] }
        source:
          type: string
          description: >
            Provenance of the row — `ds_mbo.staff.source`, default `"mbo"`.
            Datastream is source-agnostic by design (DS-ENRICHED-V2.md): a
            future non-MBO mirror (Momence/Arketa) would populate this rather
            than growing a parallel set of prefixed columns. Currently always
            `"mbo"`, since ds_mbo is exclusively written by the MBO sync.
        site_id: { type: string }

    StaffBio:
      type: object
      properties:
        staff_id: { type: [integer, 'null'] }
        full_name: { type: [string, 'null'] }
        bio: { type: [string, 'null'] }

    Location:
      type: object
      properties:
        location_id: { type: integer }
        name: { type: [string, 'null'] }
        slug: { type: [string, 'null'], description: Not yet populated. }
        address: { type: [string, 'null'] }
        address2: { type: [string, 'null'] }
        city: { type: [string, 'null'] }
        state: { type: [string, 'null'] }
        postal_code: { type: [string, 'null'] }
        latitude: { type: [number, 'null'] }
        longitude: { type: [number, 'null'] }
        phone: { type: [string, 'null'] }
        description: { type: [string, 'null'] }
        amenities:
          description: MBO's array of amenity objects, passed through verbatim.
          type: [array, 'null']
          items: { type: object }
        has_classes: { type: [boolean, 'null'] }
        average_rating: { type: [number, 'null'] }
        image_url: { type: [string, 'null'], description: Not yet populated. }
        source:
          type: string
          description: >
            Provenance of the row — `ds_mbo.location.source`, default `"mbo"`.
            Same reasoning as `ClassSummary.source` (DS-ENRICHED-V2.md).
            Currently always `"mbo"`, since ds_mbo is exclusively written by
            the MBO sync.
        site_id: { type: string }

    EventSummary:
      type: object
      properties:
        event_id: { type: integer }
        class_description_id:
          type: [string, 'null']
          description: Join key to CMS content; one description, many enrollments.
        event_name: { type: [string, 'null'] }
        event_description: { type: [string, 'null'] }
        start_date: { type: [string, 'null'], format: date }
        end_date: { type: [string, 'null'], format: date }
        start_time:
          type: [string, 'null']
          description: Time of day on the enrollment, not a full timestamp.
        end_time: { type: [string, 'null'] }
        staff_id: { type: [integer, 'null'] }
        staff_name: { type: [string, 'null'] }
        location_id: { type: [integer, 'null'] }
        location_name: { type: [string, 'null'] }
        category: { type: [string, 'null'] }
        session_type: { type: [string, 'null'], description: MBO's session type name — Event, Community, Workshop, Retreat, Teacher Trainings, ... }
        program: { type: [string, 'null'], description: MBO program the description is filed under. }
        event_type: { type: string, enum: [event, training, retreat], description: Retreats and trainings folded out of session_type. }
        is_available_online: { type: [boolean, 'null'] }
        allow_open_enrollment: { type: [boolean, 'null'] }
        max_capacity: { type: [integer, 'null'], description: Not yet populated. }
        total_booked: { type: [integer, 'null'], description: Not yet populated. }
        source:
          type: string
          description: >
            Provenance of the row — `ds_enriched.events.source`, default
            `"mbo"`. See DS-ENRICHED-V2.md.
        site_id: { type: string }

    Package:
      type: object
      properties:
        package_id: { type: integer }
        package_name: { type: [string, 'null'] }
        price: { type: [number, 'null'] }
        online_price: { type: [number, 'null'] }
        count: { type: [integer, 'null'] }
        expiration: { type: [integer, 'null'] }
        expiration_unit: { type: [string, 'null'] }
        type: { type: [string, 'null'] }
        program: { type: [string, 'null'] }
        description: { type: [string, 'null'], description: Not yet populated. }
        location_ids:
          type: [array, 'null']
          items: {}
          description: MBO `sellAtLocationIds`, verbatim.
        use_at_location_ids: { type: [array, 'null'], items: {} }
        sell_online: { type: [boolean, 'null'] }
        is_intro_offer: { type: [boolean, 'null'] }
        discontinued: { type: [boolean, 'null'] }
        is_auto_renewing: { type: [boolean, 'null'], description: Not yet populated. }
        source:
          type: string
          description: >
            Provenance of the row — `ds_mbo.service.source`, default `"mbo"`.
            Same reasoning as `ClassSummary.source` (DS-ENRICHED-V2.md).
            Currently always `"mbo"`, since ds_mbo is exclusively written by
            the MBO sync.
        site_id: { type: string }

    Site:
      type: object
      properties:
        site_id: { type: string }
        site_name: { type: [string, 'null'] }
        mbo_site_id:
          type: [string, 'null']
          description: MBO-side numeric site id. Populated on all 9 rows as of 2026-08-02.
        status: { type: [string, 'null'] }
        plan: { type: [string, 'null'] }
        timezone:
          type: [string, 'null']
          description: |
            IANA zone the studio keeps ("America/Chicago"). Every wall-clock
            datetime this API serves for the site is in this zone. Null means
            America/Chicago — the zone of every current tenant. Set at
            onboarding for studios outside Central.
