openapi: 3.1.0
info:
  title: Tatiya Platform API
  version: 1.0.0-draft
  summary: >-
    Build booking, membership and community applications on top of a Tatiya
    space.
  description: |
    The Tatiya Platform API lets an application read a space's public offering,
    take bookings on its behalf, and react to what happens inside it.

    ## Two surfaces

    **Public** (`/v1/public/...`) needs no credential. It serves exactly what a
    visitor to the space's own website can already see: profile, booking types,
    availability, published posts. Cacheable, and safe to call from a browser.

    **Authenticated** (everything else) needs an API key. A key belongs to one
    space and carries a fixed set of scopes, so the space is never a parameter —
    the key decides which tenant a call reaches.

    ## What is not here

    No cross-space endpoints. A key that could name a tenant is a key that can
    name the wrong one; an integration spanning several spaces holds several
    keys. OAuth for "an app acting on behalf of any host" is a later phase.
  contact:
    name: Tatiya Platform
    url: https://help.tatiya.space
  license:
    name: Proprietary
servers:
  - url: https://api.tatiya.space/v1
    description: Production
  - url: http://localhost:4000/v1
    description: Local development
tags:
  - name: Public
    description: Unauthenticated reads of a space's published offering.
  - name: Bookings
    description: Read and write bookings for the key's space.
  - name: Booking types
    description: The sessions, classes and appointments a space offers.
  - name: Members
    description: The space's roster and each member's history.
  - name: Memberships
    description: Plans and the memberships held against them.
  - name: Webhooks
    description: |
      🚧 Not built yet. Every operation under this tag is a design, not a
      live route — calling any of them today 404s. See
      docs/api/README.md's "What exists right now" for the current state,
      and docs/api/README.md's "Webhooks" section for the design itself.
  - name: Meta
    description: Key introspection and service health.
security:
  - ApiKeyAuth: []
paths:
  /public/{spaceHandle}:
    get:
      tags:
        - Public
      summary: Read a space's public profile
      operationId: getPublicSpace
      security: []
      parameters:
        - $ref: '#/components/parameters/SpaceHandle'
        - $ref: '#/components/parameters/Locale'
      responses:
        '200':
          description: The space, as its own website presents it.
          headers:
            Cache-Control:
              schema:
                type: string
              description: '`public, max-age=60, stale-while-revalidate=300`'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PublicSpace'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/RateLimited'
  /public/{spaceHandle}/booking-types:
    get:
      tags:
        - Public
      summary: List the space's bookable session types
      operationId: listPublicBookingTypes
      security: []
      parameters:
        - $ref: '#/components/parameters/SpaceHandle'
        - $ref: '#/components/parameters/Locale'
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Cursor'
      responses:
        '200':
          description: Published, non-archived booking types.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/Page'
                  - type: object
                    properties:
                      data:
                        type: array
                        items:
                          $ref: '#/components/schemas/PublicBookingType'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/RateLimited'
  /public/{spaceHandle}/booking-types/{bookingTypeId}/availability:
    get:
      tags:
        - Public
      summary: Compute bookable slots for a date range
      operationId: getPublicAvailability
      security: []
      description: |
        Wraps `computeSlots` (`@tatiya/shared/availability`), which today has no
        HTTP surface at all. Times are wall-clock in the space's own timezone;
        the zone is returned alongside so a client never has to guess it.

        A slot's presence means the window is open and unbooked at compute time.
        It is not a hold — the authoritative capacity check happens inside the
        booking transaction, and a slot can fill between this call and that one.
      parameters:
        - $ref: '#/components/parameters/SpaceHandle'
        - name: bookingTypeId
          in: path
          required: true
          schema:
            type: string
            format: uuid
        - name: from
          in: query
          required: true
          schema:
            type: string
            format: date
          description: Inclusive start date, `YYYY-MM-DD`, in the space's timezone.
        - name: to
          in: query
          required: true
          schema:
            type: string
            format: date
          description: Inclusive end date. At most 62 days after `from`.
      responses:
        '200':
          description: Computed slots.
          content:
            application/json:
              schema:
                type: object
                required:
                  - timezone
                  - slots
                properties:
                  timezone:
                    type: string
                    example: Asia/Bangkok
                    description: >-
                      IANA zone every `startTime`/`endTime` below is expressed
                      in.
                  slots:
                    type: array
                    items:
                      $ref: '#/components/schemas/Slot'
        '400':
          $ref: '#/components/responses/BadRequest'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/RateLimited'
  /public/{spaceHandle}/bookings:
    post:
      tags:
        - Public
      summary: Book a session as a customer
      operationId: createPublicBooking
      description: |
        The customer-facing write path. Distinct from `POST /bookings`, which is
        the host acting on someone's behalf and can carry policy overrides —
        this one cannot, by construction.

        Identity resolves the way the rest of the platform resolves a guest:
        email lowercased, then digits-only phone. A returning customer is
        recognised without an account; someone who gives neither is a
        first-timer every visit.

        Requires the space to have opted this integration in. An unauthenticated
        caller may create a booking only when the space allows public booking;
        otherwise present an API key with `bookings:write`.
      security:
        - {}
        - ApiKeyAuth: []
      parameters:
        - $ref: '#/components/parameters/SpaceHandle'
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateCustomerBookingRequest'
      responses:
        '201':
          description: Booking created.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Booking'
        '400':
          $ref: '#/components/responses/BadRequest'
        '409':
          description: |
            Refused for a reason the caller can act on — a full session, a
            closed booking window, a duplicate. The `booking_failure` member
            names which, mirroring the `bookingFailure` extension the tRPC
            error formatter already returns.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/BookingConflictError'
        '429':
          $ref: '#/components/responses/RateLimited'
  /public/{spaceHandle}/posts:
    get:
      tags:
        - Public
      summary: List published posts
      operationId: listPublicPosts
      security: []
      description: |
        Locale is an exact match, not a negotiated fallback — a post absent in
        the requested locale is absent. Post slugs are per-locale, so a URL
        built for one locale is not valid in another.
      parameters:
        - $ref: '#/components/parameters/SpaceHandle'
        - $ref: '#/components/parameters/Locale'
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Cursor'
      responses:
        '200':
          description: Published posts, newest first.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/Page'
                  - type: object
                    properties:
                      data:
                        type: array
                        items:
                          $ref: '#/components/schemas/Post'
        '429':
          $ref: '#/components/responses/RateLimited'
  /bookings:
    get:
      tags:
        - Bookings
      summary: List bookings
      operationId: listBookings
      description: |
        Scoped to the key's space. Always paginated — there is no unbounded
        form, deliberately: the tRPC procedure this replaces reads the whole
        table twice per call and that is not a contract worth publishing.
      parameters:
        - name: status
          in: query
          schema:
            $ref: '#/components/schemas/BookingStatus'
        - name: source
          in: query
          schema:
            $ref: '#/components/schemas/BookingSource'
        - name: from
          in: query
          schema:
            type: string
            format: date
          description: Inclusive lower bound on `scheduled_date`.
        - name: to
          in: query
          schema:
            type: string
            format: date
        - name: awaiting_payment
          in: query
          schema:
            type: boolean
          description: Priced, unpaid, and neither cancelled nor no-showed.
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Cursor'
      responses:
        '200':
          description: A page of bookings.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/Page'
                  - type: object
                    properties:
                      data:
                        type: array
                        items:
                          $ref: '#/components/schemas/Booking'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
    post:
      tags:
        - Bookings
      summary: Create a booking on a customer's behalf
      operationId: createBooking
      description: |
        The host-side write path — a booking taken over LINE, WhatsApp, the
        phone, or through a partner's own checkout. Requires `bookings:write`.

        `allow_over_capacity` and `allow_policy_bypass` are deliberate
        overrides, not conveniences: send them only after a human has been told
        what they are overriding. They are refused unless the key carries
        `bookings:override`.
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateBookingRequest'
      responses:
        '201':
          description: Booking created.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Booking'
        '400':
          $ref: '#/components/responses/BadRequest'
        '403':
          $ref: '#/components/responses/Forbidden'
        '409':
          description: Refused — see `booking_failure`.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/BookingConflictError'
  /bookings/{bookingId}:
    parameters:
      - $ref: '#/components/parameters/BookingId'
    get:
      tags:
        - Bookings
      summary: Read one booking
      operationId: getBooking
      responses:
        '200':
          description: The booking.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Booking'
        '404':
          $ref: '#/components/responses/NotFound'
    patch:
      tags:
        - Bookings
      summary: Update a booking's notes or source
      operationId: updateBooking
      description: |
        Status is not settable here. A booking's lifecycle moves through the
        named transitions below, each of which publishes its own domain event —
        a generic status write would let a caller skip the event and leave the
        analytics, notification and community subscribers unfired.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              minProperties: 1
              properties:
                notes:
                  type: string
                  maxLength: 1000
                  nullable: true
                source:
                  $ref: '#/components/schemas/BookingSource'
      responses:
        '200':
          description: Updated.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Booking'
        '404':
          $ref: '#/components/responses/NotFound'
  /bookings/{bookingId}/cancel:
    post:
      tags:
        - Bookings
      summary: Cancel a booking
      operationId: cancelBooking
      description: |
        Publishes `booking.cancelled`. A cancelled booking still counts against
        an intro offer — forgiving them would make cancel-and-rebook an
        unlimited free-session loop.
      parameters:
        - $ref: '#/components/parameters/BookingId'
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                reason:
                  type: string
                  maxLength: 500
      responses:
        '200':
          description: Cancelled.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Booking'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          description: Already cancelled, or outside the space's cancellation window.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
  /bookings/{bookingId}/check-in:
    post:
      tags:
        - Bookings
      summary: Check a customer in
      operationId: checkInBooking
      description: |
        Requires the space's tier to include attendance tracking; a Free-tier
        space gets `403 feature_not_available`. Publishes `booking.checked_in`.
      parameters:
        - $ref: '#/components/parameters/BookingId'
        - $ref: '#/components/parameters/IdempotencyKey'
      responses:
        '200':
          description: Checked in.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Booking'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
  /bookings/{bookingId}/payment:
    post:
      tags:
        - Bookings
      summary: Record a payment against a booking
      operationId: recordBookingPayment
      description: Publishes `booking.paid`. Requires `bookings:write`.
      parameters:
        - $ref: '#/components/parameters/BookingId'
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - method
              properties:
                method:
                  type: string
                  enum:
                    - cash
                    - bank_transfer
                    - card
                    - promptpay
                    - other
                amount_minor:
                  type: integer
                  minimum: 0
                  description: Minor units. Omit to record the booking's own price.
                reference:
                  type: string
                  maxLength: 200
      responses:
        '200':
          description: Recorded.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Booking'
        '404':
          $ref: '#/components/responses/NotFound'
  /booking-types:
    get:
      tags:
        - Booking types
      summary: List booking types
      operationId: listBookingTypes
      parameters:
        - name: include_archived
          in: query
          schema:
            type: boolean
            default: false
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Cursor'
      responses:
        '200':
          description: A page of booking types.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/Page'
                  - type: object
                    properties:
                      data:
                        type: array
                        items:
                          $ref: '#/components/schemas/BookingType'
  /booking-types/{bookingTypeId}:
    get:
      tags:
        - Booking types
      summary: Read one booking type
      operationId: getBookingType
      parameters:
        - name: bookingTypeId
          in: path
          required: true
          schema:
            type: string
            format: uuid
        - name: member_id
          in: query
          required: false
          schema:
            type: string
            format: uuid
          description: >-
            Ask whether this member still has the booking type's intro offer,
            returned as `intro_offer.applies_to_member`. Omit it and that field
            is `null` — "not asked", which is NOT the same as `false`.

            Requires `members:read` IN ADDITION to `booking_types:read`, because
            the answer is a readout of one person's booking history at this
            space rather than a property of the offering. A key without it gets
            403, not a null answer.

            Advisory: `createBooking` re-resolves eligibility inside its
            transaction under an advisory lock, and that is the only verdict
            that can price a booking.
      responses:
        '200':
          description: >-
            The booking type. It carries no schedules or pricing tiers — this
            said it did, for as long as the endpoint has existed, and the
            handler has never returned them. Caught when `BookingType` was added
            to `check:api-spec`'s gated components.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/BookingType'
        '404':
          $ref: '#/components/responses/NotFound'
  /members:
    get:
      tags:
        - Members
      summary: List the space's roster
      operationId: listMembers
      description: Requires `members:read`. Never returns a credential field of any kind.
      parameters:
        - name: lifecycle_stage
          in: query
          schema:
            type: string
            enum:
              - lead
              - first_timer
              - returning
              - regular
              - at_risk
              - lapsed
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Cursor'
      responses:
        '200':
          description: A page of members.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/Page'
                  - type: object
                    properties:
                      data:
                        type: array
                        items:
                          $ref: '#/components/schemas/Member'
    post:
      tags:
        - Members
      summary: Add a member by email
      operationId: createMember
      description: |
        Requires `members:write`. Always creates a plain roster entry — the
        same identity resolution the admin UI's own "add member" flow uses.
        409s if the email is already registered anywhere on the platform,
        not only in this space.
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateMemberRequest'
      responses:
        '201':
          description: Added.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/MemberDetail'
        '400':
          $ref: '#/components/responses/BadRequest'
        '403':
          $ref: '#/components/responses/Forbidden'
        '409':
          description: Email already registered.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
  /members/{memberId}:
    get:
      tags:
        - Members
      summary: Read one member
      operationId: getMember
      parameters:
        - name: memberId
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '200':
          description: The member, with visit history and consent state.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/MemberDetail'
        '404':
          $ref: '#/components/responses/NotFound'
    patch:
      tags:
        - Members
      summary: Update one role's profile fields
      operationId: updateMember
      description: |
        Requires `members:write`. `role` is required, not inferred — a
        member can hold several roles (participant AND facilitator, say),
        each with its own bio/progression/certifications/specialties, so
        there is no single "the" profile to patch without naming which
        role's row it is.
      parameters:
        - name: memberId
          in: path
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - role
              properties:
                role:
                  type: string
                  enum:
                    - participant
                    - facilitator
                    - guide
                    - artist
                    - host
                bio:
                  type: string
                progression_level:
                  type: string
                certifications:
                  type: array
                  items:
                    type: string
                specialties:
                  type: array
                  items:
                    type: string
      responses:
        '200':
          description: Updated.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/MemberDetail'
        '400':
          $ref: '#/components/responses/BadRequest'
        '404':
          description: |
            Either the member doesn't exist, or holds no row for the named
            `role`.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
  /membership-plans:
    get:
      tags:
        - Memberships
      summary: List membership plans
      operationId: listMembershipPlans
      parameters:
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Cursor'
      responses:
        '200':
          description: A page of plans.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/Page'
                  - type: object
                    properties:
                      data:
                        type: array
                        items:
                          $ref: '#/components/schemas/MembershipPlan'
  /memberships:
    get:
      tags:
        - Memberships
      summary: List memberships
      operationId: listMemberships
      parameters:
        - name: status
          in: query
          schema:
            type: string
            enum:
              - active
              - cancelled
              - expired
              - paused
        - name: member_id
          in: query
          schema:
            type: string
            format: uuid
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Cursor'
      responses:
        '200':
          description: A page of memberships.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/Page'
                  - type: object
                    properties:
                      data:
                        type: array
                        items:
                          $ref: '#/components/schemas/Membership'
    post:
      tags:
        - Memberships
      summary: Assign a plan to a member
      operationId: createMembership
      description: |
        Requires `memberships:write`. Refuses a plan that is inactive,
        archived, or belongs to a different space than the key's.
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateMembershipRequest'
      responses:
        '201':
          description: Assigned.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Membership'
        '400':
          $ref: '#/components/responses/BadRequest'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
  /memberships/{membershipId}:
    parameters:
      - $ref: '#/components/parameters/MembershipId'
    patch:
      tags:
        - Memberships
      summary: Update a membership's notes
      operationId: updateMembership
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - notes
              properties:
                notes:
                  type: string
                  maxLength: 2000
      responses:
        '200':
          description: Updated.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Membership'
        '404':
          $ref: '#/components/responses/NotFound'
  /memberships/{membershipId}/cancel:
    post:
      tags:
        - Memberships
      summary: Cancel a membership
      operationId: cancelMembership
      description: Publishes `membership.cancelled`. Requires `memberships:write`.
      parameters:
        - $ref: '#/components/parameters/MembershipId'
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                reason:
                  type: string
                  maxLength: 1000
                immediate:
                  type: boolean
                  default: false
                  description: |
                    End access now instead of at the current period's end.
      responses:
        '200':
          description: Cancelled.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Membership'
        '400':
          description: Already cancelled.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '404':
          $ref: '#/components/responses/NotFound'
  /memberships/{membershipId}/pause:
    post:
      tags:
        - Memberships
      summary: Pause a membership
      operationId: pauseMembership
      description: |
        Only an `active` membership can be paused. Publishes
        `membership.paused`. Requires `memberships:write`.
      parameters:
        - $ref: '#/components/parameters/MembershipId'
        - $ref: '#/components/parameters/IdempotencyKey'
      responses:
        '200':
          description: Paused.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Membership'
        '400':
          description: Not currently active.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '404':
          $ref: '#/components/responses/NotFound'
  /memberships/{membershipId}/resume:
    post:
      tags:
        - Memberships
      summary: Resume a paused membership
      operationId: resumeMembership
      description: |
        Only a `paused` membership can be resumed. Publishes
        `membership.resumed`. Requires `memberships:write`.
      parameters:
        - $ref: '#/components/parameters/MembershipId'
        - $ref: '#/components/parameters/IdempotencyKey'
      responses:
        '200':
          description: Resumed.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Membership'
        '400':
          description: Not currently paused.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '404':
          $ref: '#/components/responses/NotFound'
  /memberships/{membershipId}/payment:
    post:
      tags:
        - Memberships
      summary: Record a payment against a membership
      operationId: recordMembershipPayment
      description: |
        Publishes `membership.paid`, and reactivates an expired membership.
        Requires `memberships:write`. `method` is narrower than a booking
        payment's — `promptpay`, `bank_transfer`, `cash` or `other` only.
      parameters:
        - $ref: '#/components/parameters/MembershipId'
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - method
                - amount_minor
              properties:
                method:
                  type: string
                  enum:
                    - promptpay
                    - bank_transfer
                    - cash
                    - other
                amount_minor:
                  type: integer
                  minimum: 0
                reference:
                  type: string
                  maxLength: 200
      responses:
        '200':
          description: Recorded.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Membership'
        '400':
          $ref: '#/components/responses/BadRequest'
        '404':
          $ref: '#/components/responses/NotFound'
  /webhooks/endpoints:
    get:
      tags:
        - Webhooks
      summary: 🚧 Not built yet — List registered endpoints
      operationId: listWebhookEndpoints
      responses:
        '200':
          description: The space's endpoints.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/WebhookEndpoint'
    post:
      tags:
        - Webhooks
      summary: 🚧 Not built yet — Register an endpoint
      operationId: createWebhookEndpoint
      description: |
        🚧 Not built yet — this operation does not exist in `apps/api` today;
        calling it 404s. Documented here as the design.

        Once shipped: requires the space's tier to include webhooks. The
        signing secret is in this response and nowhere else — the same rule
        the agent grant follows. Lose it and you rotate rather than look it up.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - url
                - event_types
              properties:
                url:
                  type: string
                  format: uri
                  description: Must be `https`. Redirects are not followed.
                event_types:
                  type: array
                  minItems: 1
                  items:
                    $ref: '#/components/schemas/EventType'
                description:
                  type: string
                  maxLength: 200
      responses:
        '201':
          description: Registered.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/WebhookEndpoint'
                  - type: object
                    required:
                      - signing_secret
                    properties:
                      signing_secret:
                        type: string
                        description: Shown once. Used to verify `Tatiya-Signature`.
        '403':
          $ref: '#/components/responses/Forbidden'
  /webhooks/endpoints/{endpointId}:
    delete:
      tags:
        - Webhooks
      summary: 🚧 Not built yet — Delete an endpoint
      operationId: deleteWebhookEndpoint
      parameters:
        - name: endpointId
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '204':
          description: Deleted.
        '404':
          $ref: '#/components/responses/NotFound'
  /webhooks/deliveries:
    get:
      tags:
        - Webhooks
      summary: 🚧 Not built yet — Inspect delivery attempts
      operationId: listWebhookDeliveries
      description: >-
        The last 30 days. This is the debugging surface — without it, a failing
        integration is a black box.
      parameters:
        - name: endpoint_id
          in: query
          schema:
            type: string
            format: uuid
        - name: status
          in: query
          schema:
            type: string
            enum:
              - pending
              - succeeded
              - failed
              - exhausted
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Cursor'
      responses:
        '200':
          description: A page of delivery attempts.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/Page'
                  - type: object
                    properties:
                      data:
                        type: array
                        items:
                          $ref: '#/components/schemas/WebhookDelivery'
  /webhooks/deliveries/{deliveryId}/replay:
    post:
      tags:
        - Webhooks
      summary: 🚧 Not built yet — Replay one delivery
      operationId: replayWebhookDelivery
      parameters:
        - name: deliveryId
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '202':
          description: Queued for redelivery.
        '404':
          $ref: '#/components/responses/NotFound'
  /me:
    get:
      tags:
        - Meta
      summary: Describe the calling key
      operationId: getCurrentKey
      description: |
        What this key is and what it may do. Call it first — it is how a client
        discovers its own scopes rather than discovering them from a 403.
      responses:
        '200':
          description: The key.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/KeyIntrospection'
        '401':
          $ref: '#/components/responses/Unauthorized'
  /health:
    get:
      tags:
        - Meta
      summary: Service health
      operationId: getHealth
      description: >
        The real route is unversioned — `GET /health`, not `GET /v1/health`.

        It's documented under this spec's `/v1` server for discoverability

        alongside the rest of the API, but this path segment is the one

        exception to that base URL: strip `/v1` when actually calling it.

        Static today (`{"status":"ok"}`, no dependency probe) —
        apps/api/src/index.ts.
      security: []
      responses:
        '200':
          description: Healthy.
          content:
            application/json:
              schema:
                type: object
                properties:
                  status:
                    type: string
                    enum:
                      - ok
                  database:
                    type: string
                    enum:
                      - ok
                  timestamp:
                    type: string
                    format: date-time
        '503':
          description: A dependency is unreachable.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
webhooks:
  booking.created:
    post:
      summary: A booking was created
      operationId: onBookingCreated
      requestBody:
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/WebhookEvent'
      responses:
        2xx:
          description: |
            Any 2xx acknowledges. Anything else is retried with exponential
            backoff for 24 hours, then marked `exhausted`. Respond within 5
            seconds and do your work asynchronously.
  booking.cancelled:
    post:
      summary: A booking was cancelled
      operationId: onBookingCancelled
      requestBody:
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/WebhookEvent'
      responses:
        2xx:
          description: Acknowledged.
  membership.assigned:
    post:
      summary: A membership was assigned
      operationId: onMembershipAssigned
      requestBody:
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/WebhookEvent'
      responses:
        2xx:
          description: Acknowledged.
components:
  securitySchemes:
    ApiKeyAuth:
      type: http
      scheme: bearer
      bearerFormat: tat_live_...
      description: |
        `Authorization: Bearer tat_live_<opaque>`

        A key belongs to one space and carries a fixed scope set. It is stored
        as a SHA-256 hash — the plaintext appears once, at creation.

        Two properties are inherited from the existing agent-grant design and
        are not negotiable:

        - A key is **not a user**. It never populates a user session, so it can
          reach only the routes on this surface.
        - The granting leader's authority is re-checked **live** on every
          request. A leader removed from a space takes their integrations with
          them immediately, with no propagation delay.
  parameters:
    SpaceHandle:
      name: spaceHandle
      in: path
      required: true
      description: The space's handle — the `sunrise` in `sunrise.tatiya.space`.
      schema:
        type: string
        pattern: ^[a-z0-9][a-z0-9-]{1,62}$
      example: sunrise
    BookingId:
      name: bookingId
      in: path
      required: true
      schema:
        type: string
        format: uuid
    MembershipId:
      name: membershipId
      in: path
      required: true
      schema:
        type: string
        format: uuid
    Locale:
      name: locale
      in: query
      description: |
        BCP-47 tag. Exact match — content absent in this locale is absent, not
        substituted from another.
      schema:
        type: string
        default: en
      example: th
    Limit:
      name: limit
      in: query
      schema:
        type: integer
        minimum: 1
        maximum: 100
        default: 25
    Cursor:
      name: cursor
      in: query
      description: >-
        Opaque. Take it from the previous page's `next_cursor`; never construct
        one.
      schema:
        type: string
    IdempotencyKey:
      name: Idempotency-Key
      in: header
      required: false
      description: |
        A client-generated key, unique per logical operation. Replaying a
        request with the same key returns the original response instead of
        acting twice, for 24 hours. Send one on every write from a client that
        might retry — which is every client.
      schema:
        type: string
        maxLength: 255
  responses:
    BadRequest:
      description: The request did not validate.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ValidationError'
    Unauthorized:
      description: Missing, malformed, expired or revoked key.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
    Forbidden:
      description: |
        The key is valid but not allowed — a missing scope, a tier that does
        not include the feature, or a granting leader who no longer leads.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
    NotFound:
      description: No such resource in this key's space.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
    RateLimited:
      description: Too many requests.
      headers:
        Retry-After:
          schema:
            type: integer
          description: Seconds until the window resets.
        RateLimit-Remaining:
          schema:
            type: integer
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
  schemas:
    Error:
      type: object
      required:
        - error
      properties:
        error:
          type: object
          required:
            - type
            - message
          properties:
            type:
              type: string
              description: |
                Stable, machine-readable. Branch on this, never on `message`.
              enum:
                - invalid_request
                - unauthorized
                - forbidden
                - insufficient_scope
                - feature_not_available
                - not_found
                - conflict
                - rate_limited
                - internal_error
            message:
              type: string
              description: Human-readable. May change; not part of the contract.
            docs_url:
              type: string
              format: uri
            request_id:
              type: string
              description: Echoes `Tatiya-Request-Id`. Quote it in a support request.
    ValidationError:
      allOf:
        - $ref: '#/components/schemas/Error'
        - type: object
          properties:
            error:
              type: object
              properties:
                fields:
                  type: array
                  description: >-
                    One entry per failing field, so a form can bind errors
                    directly.
                  items:
                    type: object
                    required:
                      - path
                      - message
                    properties:
                      path:
                        type: string
                        example: customer.email
                      message:
                        type: string
                      code:
                        type: string
                        example: invalid_email
    BookingConflictError:
      allOf:
        - $ref: '#/components/schemas/Error'
        - type: object
          properties:
            error:
              type: object
              properties:
                booking_failure:
                  type: object
                  description: |
                    Why the booking was refused, and which override would let it
                    through. Mirrors the `bookingFailure` extension the tRPC
                    error formatter already returns today.
                  required:
                    - reason
                  properties:
                    reason:
                      type: string
                      enum:
                        - capacity_full
                        - policy_window
                        - duplicate_booking
                        - bookings_disabled
                        - booking_type_not_found
                        - space_unavailable
                        - tier_blocked
                    override:
                      type: string
                      nullable: true
                      enum:
                        - allow_over_capacity
                        - allow_policy_bypass
                      description: The flag that would permit it. Null when nothing would.
    Page:
      type: object
      required:
        - data
        - has_more
        - next_cursor
      properties:
        data:
          type: array
          items: {}
        has_more:
          type: boolean
        next_cursor:
          type: string
          nullable: true
          description: Pass as `cursor` for the next page. Null on the last page.
    Money:
      type: object
      required:
        - amount_minor
        - currency
      properties:
        amount_minor:
          type: integer
          description: Minor units — 35000 is ฿350.00. Never a float.
        currency:
          type: string
          minLength: 3
          maxLength: 3
          example: THB
    Slot:
      type: object
      required:
        - date
        - weekday
        - start_time
        - end_time
        - capacity
        - spots_left
      properties:
        date:
          type: string
          format: date
        weekday:
          type: string
          nullable: true
          enum:
            - monday
            - tuesday
            - wednesday
            - thursday
            - friday
            - saturday
            - sunday
        start_time:
          type: string
          pattern: ^([01][0-9]|2[0-3]):[0-5][0-9]$
          description: Wall-clock `HH:mm` in the space's timezone. Not an instant.
        end_time:
          type: string
          pattern: ^([01][0-9]|2[0-3]):[0-5][0-9]$
        capacity:
          type: integer
          nullable: true
          description: Places at this slot. Null means uncapped.
        spots_left:
          type: integer
          nullable: true
          description: |
            Null means NOT COUNTED, and is different from 0. Rendering an
            uncounted slot as "0 left" tells a customer the opposite of the
            truth.
    PublicSpace:
      type: object
      required:
        - handle
        - name
        - tagline
        - about
        - timezone
        - currency
        - locales
        - website_url
        - logo_url
        - location
        - open_hours
        - social_links
        - accepts_bookings
      properties:
        handle:
          type: string
          example: sunrise
        name:
          type: string
          example: Sunrise Studio
        tagline:
          type: string
          nullable: true
        about:
          type: string
          nullable: true
        timezone:
          type: string
          example: Asia/Bangkok
        currency:
          type: string
          example: THB
        locales:
          type: array
          items:
            type: string
        website_url:
          type: string
          format: uri
          nullable: true
        logo_url:
          type: string
          format: uri
          nullable: true
        location:
          type: object
          nullable: true
          required:
            - address
            - city
            - country
            - latitude
            - longitude
          properties:
            address:
              type: string
              nullable: true
            city:
              type: string
              nullable: true
            country:
              type: string
              nullable: true
            latitude:
              type: number
              nullable: true
            longitude:
              type: number
              nullable: true
        open_hours:
          type: array
          items:
            type: object
            required:
              - weekday
              - opens_at
              - closes_at
              - closed
            properties:
              weekday:
                type: string
              opens_at:
                type: string
                nullable: true
              closes_at:
                type: string
                nullable: true
              closed:
                type: boolean
        social_links:
          type: array
          items:
            type: object
            required:
              - channel
              - url
            properties:
              channel:
                type: string
                example: instagram
              url:
                type: string
                format: uri
        accepts_bookings:
          type: boolean
          description: >-
            False when the space has bookings switched off. Do not render a
            booking flow.
    PublicBookingType:
      type: object
      required:
        - id
        - slug
        - title
        - description
        - duration_minutes
        - price
        - capacity
        - categories
        - intro_offer
      properties:
        id:
          type: string
          format: uuid
        slug:
          type: string
          nullable: true
        title:
          type: string
          example: Vinyasa Flow
        description:
          type: string
          nullable: true
        duration_minutes:
          type: integer
          example: 60
        price:
          allOf:
            - $ref: '#/components/schemas/Money'
          nullable: true
        capacity:
          type: integer
          nullable: true
        categories:
          type: array
          items:
            type: object
            required:
              - id
              - name
            properties:
              id:
                type: string
                format: uuid
              name:
                type: string
        intro_offer:
          type: object
          nullable: true
          description: Present only when this booking type runs a first-session offer.
          required:
            - eligibility
            - price
          properties:
            eligibility:
              type: string
              enum:
                - first_time_only
                - anyone
            price:
              $ref: '#/components/schemas/Money'
    BookingType:
      type: object
      description: |
        The authenticated shape of a booking type — deliberately NOT the same
        as `PublicBookingType`. `GET /booking-types*` reuses the same
        `timeBlocks.listPublic` query the public surface calls, but shapes
        its price as a bare minor-units integer (pair it with the space's own
        currency from `GET /me`) rather than a `Money` object, and carries no
        `schedules`/`pricing_tiers` detail — a partner reading one booking
        type gets the same flat shape `LIST` already returns, not the admin
        editor's full detail.
      required:
        - id
        - slug
        - title
        - description
        - duration_minutes
        - price_amount
        - capacity
        - categories
        - intro_offer
        - archived
        - created_at
        - updated_at
      properties:
        id:
          type: string
          format: uuid
        slug:
          type: string
          nullable: true
        title:
          type: string
          example: Vinyasa Flow
        description:
          type: string
          nullable: true
        duration_minutes:
          type: integer
          example: 60
        price_amount:
          type: integer
          nullable: true
          description: >-
            Minor units — 35000 is ฿350.00. Pair with the space's own currency
            (`GET /me`).
        capacity:
          type: integer
          nullable: true
        categories:
          type: array
          items:
            type: object
            required:
              - id
              - name
            properties:
              id:
                type: string
                format: uuid
              name:
                type: string
        intro_offer:
          type: object
          nullable: true
          description: Present only when this booking type runs a first-session offer.
          required:
            - eligibility
            - intro_price_amount
            - applies_to_member
          properties:
            eligibility:
              type: string
              enum:
                - first_time_only
                - anyone
            intro_price_amount:
              type: integer
              description: Minor units, same currency as `price_amount`.
            applies_to_member:
              type: boolean
              nullable: true
              description: >-
                Whether this offer still applies to the member named by `GET
                /booking-types/{bookingTypeId}`'s own `?member_id=` query
                parameter — see that operation's own description. `null` means
                "not asked" (no `member_id` given, or the key lacks
                `members:read`), which is NOT the same as `false`. Always `null`
                on `GET /booking-types` (the list endpoint, which has no
                `member_id` to ask about) — the key is still present, its value
                just never resolves there.
        archived:
          type: boolean
        created_at:
          type: string
          description: |
            Postgres's own `timestamptz` text form (a space, not `T`, between
            date and time) — not necessarily strict ISO 8601. Parse leniently.
        updated_at:
          type: string
          description: |
            Postgres's own `timestamptz` text form (a space, not `T`, between
            date and time) — not necessarily strict ISO 8601. Parse leniently.
    BookingStatus:
      type: string
      enum:
        - pending
        - confirmed
        - paid
        - completed
        - cancelled
        - no_show
    BookingSource:
      type: string
      description: |
        Where the booking came from. `external_partner` and `discovery_app`
        already exist in the platform's own enum and are the values a
        third-party integration should send.
      enum:
        - native_app
        - discovery_app
        - classpass
        - external_partner
        - admin_walk_in
        - admin_line
        - admin_whatsapp
        - admin_messenger
        - admin_phone
        - admin_instagram
        - admin_other
        - website_enquiry
    Customer:
      type: object
      description: |
        The person on a booking. Deliberately narrow — id, name, email, phone
        and nothing else. It carries no credential field, and adding one would
        be a breaking change to this schema rather than a silent widening of a
        database relation.
      required:
        - id
        - name
        - email
        - phone
      properties:
        id:
          type: string
          format: uuid
          nullable: true
          description: Null for a walk-in who gave neither email nor phone.
        name:
          type: string
          nullable: true
          description: |
            Required on a create request. Nullable on a response — an older
            booking's stored customer name can be absent even though the
            create path itself always requires one now.
        email:
          type: string
          nullable: true
          description: |
            Not necessarily strict RFC email — the create path validates one,
            but this field reflects whatever a booking's customer email
            column already holds, including rows from before that validation
            existed. Parse leniently.
        phone:
          type: string
          nullable: true
    Booking:
      type: object
      required:
        - id
        - booking_type_id
        - booking_type_title
        - scheduled_date
        - scheduled_start_time
        - scheduled_end_time
        - status
        - source
        - customer
        - price
        - paid_at
        - checked_in_at
        - cancelled_at
        - was_intro_offer
        - covered_by_membership
        - notes
        - created_at
      properties:
        id:
          type: string
          format: uuid
        booking_type_id:
          type: string
          format: uuid
        booking_type_title:
          type: string
        scheduled_date:
          type: string
          format: date
        scheduled_start_time:
          type: string
          example: '07:00'
        scheduled_end_time:
          type: string
          nullable: true
        status:
          $ref: '#/components/schemas/BookingStatus'
        source:
          $ref: '#/components/schemas/BookingSource'
        customer:
          $ref: '#/components/schemas/Customer'
        price:
          allOf:
            - $ref: '#/components/schemas/Money'
          nullable: true
        paid_at:
          type: string
          nullable: true
          description: |
            Postgres's own `timestamptz` text form (a space, not `T`, between
            date and time) — not necessarily strict ISO 8601. Parse leniently.
        checked_in_at:
          type: string
          nullable: true
          description: |
            Postgres's own `timestamptz` text form (a space, not `T`, between
            date and time) — not necessarily strict ISO 8601. Parse leniently.
        cancelled_at:
          type: string
          nullable: true
          description: |
            Postgres's own `timestamptz` text form (a space, not `T`, between
            date and time) — not necessarily strict ISO 8601. Parse leniently.
        was_intro_offer:
          type: boolean
          description: Stamped at creation; never recomputed.
        covered_by_membership:
          type: object
          nullable: true
          required:
            - membership_id
            - plan_name
          properties:
            membership_id:
              type: string
              format: uuid
            plan_name:
              type: string
        notes:
          type: string
          nullable: true
        created_at:
          type: string
          description: |
            Postgres's own `timestamptz` text form (a space, not `T`, between
            date and time) — not necessarily strict ISO 8601. Parse leniently.
    CreateCustomerBookingRequest:
      type: object
      required:
        - booking_type_id
        - scheduled_date
        - scheduled_start_time
        - customer
      properties:
        booking_type_id:
          type: string
          format: uuid
        scheduled_date:
          type: string
          format: date
        scheduled_start_time:
          type: string
          example: '07:00'
        customer:
          type: object
          required:
            - name
          properties:
            name:
              type: string
              minLength: 1
            email:
              type: string
              format: email
            phone:
              type: string
              maxLength: 20
        source:
          allOf:
            - $ref: '#/components/schemas/BookingSource'
          default: external_partner
        marketing_consent:
          type: boolean
          default: false
          description: |
            Whether the customer agreed to marketing contact at the point of
            booking. Recorded with its own timestamp and the consent text shown.
            Never default this to true.
        notes:
          type: string
          maxLength: 1000
    CreateBookingRequest:
      allOf:
        - $ref: '#/components/schemas/CreateCustomerBookingRequest'
        - type: object
          properties:
            payment_status:
              type: string
              enum:
                - paid
                - pay_later
                - free
              default: pay_later
            check_in_now:
              type: boolean
              default: false
            allow_over_capacity:
              type: boolean
              default: false
              description: Requires `bookings:override`. Refused otherwise.
            allow_policy_bypass:
              type: boolean
              default: false
              description: Requires `bookings:override`. Refused otherwise.
    CreateMemberRequest:
      type: object
      required:
        - email
      properties:
        email:
          type: string
          format: email
        name:
          type: string
          maxLength: 200
    CreateMembershipRequest:
      type: object
      required:
        - member_id
        - plan_id
      properties:
        member_id:
          type: string
          format: uuid
        plan_id:
          type: string
          format: uuid
        start_date:
          type: string
          format: date
          description: Defaults to today.
        notes:
          type: string
          maxLength: 2000
    Member:
      type: object
      required:
        - id
        - name
        - email
        - phone
        - joined_at
        - lifecycle_stage
        - last_visit_at
        - total_bookings
      properties:
        id:
          type: string
          format: uuid
        name:
          type: string
          nullable: true
        email:
          type: string
          nullable: true
          description: Not necessarily strict RFC email — see `Customer.email`'s own note.
        phone:
          type: string
          nullable: true
        joined_at:
          type: string
          description: |
            Postgres's own `timestamptz` text form (a space, not `T`, between
            date and time) — not necessarily strict ISO 8601. Parse leniently.
        lifecycle_stage:
          type: string
          nullable: true
        last_visit_at:
          type: string
          nullable: true
          description: |
            Postgres's own `timestamptz` text form (a space, not `T`, between
            date and time) — not necessarily strict ISO 8601. Parse leniently.
        total_bookings:
          type: integer
          description: |
            Materialised, written asynchronously after a booking commits. Fine
            for display; never gate anything on it.
    MemberDetail:
      allOf:
        - $ref: '#/components/schemas/Member'
        - type: object
          required:
            - consent
            - active_memberships
            - roles
          properties:
            consent:
              type: object
              required:
                - marketing
                - updated_at
              properties:
                marketing:
                  type: boolean
                updated_at:
                  type: string
                  nullable: true
                  description: >
                    Postgres's own `timestamptz` text form (a space, not `T`,
                    between

                    date and time) — not necessarily strict ISO 8601. Parse
                    leniently.
            recent_visits:
              type: array
              description: |
                🚧 Never sent today — this surface has no per-visit list yet,
                `MemberDetail.recent_visits` documents the intended shape.
              items:
                type: object
                required:
                  - booking_id
                  - date
                  - booking_type_title
                properties:
                  booking_id:
                    type: string
                    format: uuid
                  date:
                    type: string
                    format: date
                    description: >-
                      A booking's `scheduled_date` — a real date column, not a
                      timestamp.
                  booking_type_title:
                    type: string
            active_memberships:
              type: array
              items:
                $ref: '#/components/schemas/Membership'
            roles:
              type: array
              description: |
                One entry per role this member holds — see `PATCH
                /members/{memberId}`'s own description for why this isn't
                flattened onto `Member` itself.
              items:
                type: object
                required:
                  - role
                  - bio
                  - progression_level
                  - certifications
                  - specialties
                  - created_at
                properties:
                  role:
                    type: string
                    enum:
                      - participant
                      - facilitator
                      - guide
                      - artist
                      - host
                  bio:
                    type: string
                    nullable: true
                  progression_level:
                    type: string
                    nullable: true
                  certifications:
                    type: array
                    items:
                      type: string
                    nullable: true
                  specialties:
                    type: array
                    items:
                      type: string
                    nullable: true
                  created_at:
                    type: string
                    description: >
                      Postgres's own `timestamptz` text form (a space, not `T`,
                      between

                      date and time) — not necessarily strict ISO 8601. Parse
                      leniently.
    MembershipPlan:
      type: object
      required:
        - id
        - name
        - description
        - price
        - billing_interval
        - archived
        - benefits
      properties:
        id:
          type: string
          format: uuid
        name:
          type: string
        description:
          type: string
          nullable: true
        price:
          $ref: '#/components/schemas/Money'
        billing_interval:
          type: string
          enum:
            - monthly
            - yearly
            - one_time
        archived:
          type: boolean
        benefits:
          type: array
          items:
            type: object
            required:
              - type
            properties:
              type:
                type: string
                enum:
                  - unlimited_access
                description: |
                  The only benefit type this API can return today — enforced
                  by `packages/domain-membership`'s own output schema
                  (`z.literal('unlimited_access')`), not just this facade.
                  Session credits, category credits, discounts and priority
                  booking are planned (see CLAUDE.md's Memberships Module
                  section); widen this enum in step with that schema when one
                  ships, not ahead of it.
              scope:
                type: string
                enum:
                  - all
                  - category
                  - specific
              target_ids:
                type: array
                items:
                  type: string
                  format: uuid
    Membership:
      type: object
      required:
        - id
        - plan_id
        - plan_name
        - member_id
        - status
        - started_at
        - current_period_end
        - cancelled_at
      properties:
        id:
          type: string
          format: uuid
        plan_id:
          type: string
          format: uuid
        plan_name:
          type: string
        member_id:
          type: string
          format: uuid
        status:
          type: string
          enum:
            - active
            - cancelled
            - expired
            - paused
        started_at:
          type: string
          description: |
            Postgres's own `timestamptz` text form (a space, not `T`, between
            date and time) — not necessarily strict ISO 8601. Parse leniently.
            Despite the name, this is a `timestamptz` column, not a date-only
            one.
        current_period_end:
          type: string
          nullable: true
          description: |
            Postgres's own `timestamptz` text form (a space, not `T`, between
            date and time) — not necessarily strict ISO 8601. Parse leniently.
        cancelled_at:
          type: string
          nullable: true
          description: |
            Postgres's own `timestamptz` text form (a space, not `T`, between
            date and time) — not necessarily strict ISO 8601. Parse leniently.
    Post:
      type: object
      required:
        - id
        - slug
        - locale
        - title
        - excerpt
        - body_html
        - cover_image_url
        - published_at
        - url
      properties:
        id:
          type: string
          format: uuid
        slug:
          type: string
          description: Per-locale. A slug from one locale is not valid in another.
        locale:
          type: string
        title:
          type: string
        excerpt:
          type: string
          nullable: true
        body_html:
          type: string
        cover_image_url:
          type: string
          format: uri
          nullable: true
        published_at:
          type: string
          nullable: true
          description: |
            Not necessarily strict ISO 8601 — this is Postgres's own
            `timestamptz` text form (space, not `T`, between date and time).
            Parse leniently.
        url:
          type: string
          format: uri
          nullable: true
          description: |
            Canonical public URL. Resolved from the page the host's blog block
            lives on — it is not necessarily `/blog`, so build links from this
            rather than assembling a path. Null when no published page
            currently claims the blog — the post itself still exists.
    EventType:
      type: string
      enum:
        - booking.created
        - booking.confirmed
        - booking.paid
        - booking.cancelled
        - booking.checked_in
        - booking.completed
        - booking.no_show
        - membership.assigned
        - membership.paid
        - membership.cancelled
        - membership.paused
        - membership.resumed
    WebhookEvent:
      type: object
      required:
        - id
        - type
        - created_at
        - space_handle
        - data
      properties:
        id:
          type: string
          format: uuid
          description: |
            The `domain_events` row id. Stable across retries — use it to
            deduplicate, because delivery is at-least-once.
        type:
          $ref: '#/components/schemas/EventType'
        created_at:
          type: string
          format: date-time
        space_handle:
          type: string
        data:
          type: object
          description: |
            The event payload. Carries the facts that cannot be cheaply
            re-derived; anything else (a space's current name, a booking type's
            current title) should be read fresh rather than trusted from an
            event that may be minutes old.
          additionalProperties: true
    WebhookEndpoint:
      type: object
      required:
        - id
        - url
        - event_types
        - status
      properties:
        id:
          type: string
          format: uuid
        url:
          type: string
          format: uri
        event_types:
          type: array
          items:
            $ref: '#/components/schemas/EventType'
        description:
          type: string
          nullable: true
        status:
          type: string
          enum:
            - active
            - disabled
        created_at:
          type: string
          format: date-time
    WebhookDelivery:
      type: object
      required:
        - id
        - endpoint_id
        - event_id
        - event_type
        - status
        - attempts
      properties:
        id:
          type: string
          format: uuid
        endpoint_id:
          type: string
          format: uuid
        event_id:
          type: string
          format: uuid
        event_type:
          $ref: '#/components/schemas/EventType'
        status:
          type: string
          enum:
            - pending
            - succeeded
            - failed
            - exhausted
        attempts:
          type: integer
        response_status:
          type: integer
          nullable: true
        last_attempted_at:
          type: string
          format: date-time
          nullable: true
        next_attempt_at:
          type: string
          format: date-time
          nullable: true
    KeyIntrospection:
      type: object
      required:
        - key_id
        - label
        - space
        - scopes
        - expires_at
      properties:
        key_id:
          type: string
          format: uuid
        label:
          type: string
        space:
          type: object
          required:
            - handle
            - name
            - timezone
            - currency
          properties:
            handle:
              type: string
            name:
              type: string
            timezone:
              type: string
            currency:
              type: string
        scopes:
          type: array
          items:
            type: string
            enum:
              - bookings:read
              - bookings:write
              - bookings:override
              - booking_types:read
              - booking_types:write
              - members:read
              - members:write
              - memberships:read
              - memberships:write
              - content:read
              - webhooks:manage
        expires_at:
          type: string
          nullable: true
          description: |
            Watch this and prompt for rotation — a key past this date starts
            returning 401 with no other warning. Postgres's own `timestamptz`
            text form (a space, not `T`, between date and time) — not
            necessarily strict ISO 8601. Parse leniently.
