openapi: 3.1.0
info:
  title: SchedulerCX API
  version: "1.0.0"
  description: |
    Multi-tenant scheduling API. Every credential is bound to one tenant (user);
    all resources are implicitly scoped to that tenant — there is no way to read
    or write another tenant's data.

    ### Conventions
    - **Timestamps** are RFC 3339 / ISO 8601 with an explicit UTC offset
      (e.g. `2026-07-10T14:00:00-04:00`). Stored in UTC server-side.
    - **Errors** are RFC 7807 Problem Details (`application/problem+json`) with a
      machine-readable `type` URI per error class.
    - **Pagination** is cursor-based: pass `limit` and `cursor`, read `next_cursor`.
    - **Idempotency**: all mutating endpoints accept an `Idempotency-Key` header;
      retries with the same key and body return the original response.
    - **Rate limits**: per credential; `429` with `Retry-After` when exceeded.

    ### Authentication
    Two machine-auth options, equivalent in capability:
    1. **API key** (Bearer, RFC 6750): `Authorization: Bearer sk_live_...`
    2. **OAuth 2.0 client credentials** (RFC 6749): exchange `client_id` /
       `client_secret` at `/oauth/token` for a short-lived access token.

    An MCP server exposing these operations as agent tools lives at `/api/mcp`,
    authenticated with the same credentials.

    Human-readable documentation lives at `/docs`; this spec renders as an
    interactive Swagger UI at `/docs/api`.
  contact:
    name: SchedulerCX
servers:
  - url: /api/v1

security:
  - bearerAuth: []

tags:
  - name: Profile
  - name: Event Types
  - name: Availability
  - name: Bookings
  - name: Webhooks
  - name: OAuth

paths:
  /me:
    get:
      operationId: getMe
      tags: [Profile]
      summary: Get the authenticated tenant's profile
      description: Requires scope `profile:read`.
      responses:
        "200":
          description: Tenant profile
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Profile" }
        "401": { $ref: "#/components/responses/Unauthorized" }
    patch:
      operationId: updateMe
      tags: [Profile]
      summary: Update the authenticated tenant's profile
      description: |
        Requires scope `profile:write`. Partial update of the same settings
        exposed on the dashboard Settings page: display name, booking handle,
        timezone and notification email.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ProfileUpdate" }
      responses:
        "200":
          description: Updated profile
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Profile" }
        "400": { $ref: "#/components/responses/ValidationError" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "409":
          description: The requested handle is already in use.
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }

  /event-types:
    get:
      operationId: listEventTypes
      tags: [Event Types]
      summary: List event types
      description: Requires scope `event-types:read`.
      parameters:
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Cursor"
        - name: active
          in: query
          description: Filter by active state.
          schema: { type: boolean }
      responses:
        "200":
          description: Paginated event types
          content:
            application/json:
              schema:
                type: object
                required: [data, next_cursor]
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/EventType" }
                  next_cursor:
                    type: [string, "null"]
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
    post:
      operationId: createEventType
      tags: [Event Types]
      summary: Create an event type
      description: Requires scope `event-types:write`.
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/EventTypeCreate" }
      responses:
        "201":
          description: Created
          content:
            application/json:
              schema: { $ref: "#/components/schemas/EventType" }
        "400": { $ref: "#/components/responses/ValidationError" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "409":
          description: An event type with this slug already exists.
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }

  /event-types/{id}:
    parameters:
      - $ref: "#/components/parameters/ResourceId"
    get:
      operationId: getEventType
      tags: [Event Types]
      summary: Get an event type
      responses:
        "200":
          description: Event type
          content:
            application/json:
              schema: { $ref: "#/components/schemas/EventType" }
        "404": { $ref: "#/components/responses/NotFound" }
    patch:
      operationId: updateEventType
      tags: [Event Types]
      summary: Update an event type
      description: Requires scope `event-types:write`. Partial update.
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/EventTypeUpdate" }
      responses:
        "200":
          description: Updated event type
          content:
            application/json:
              schema: { $ref: "#/components/schemas/EventType" }
        "400": { $ref: "#/components/responses/ValidationError" }
        "404": { $ref: "#/components/responses/NotFound" }
    delete:
      operationId: deleteEventType
      tags: [Event Types]
      summary: Delete an event type
      description: |
        Requires scope `event-types:write`. Fails with `409` if the event type
        has upcoming confirmed bookings (cancel them first, or deactivate the
        event type instead).
      responses:
        "204": { description: Deleted }
        "404": { $ref: "#/components/responses/NotFound" }
        "409":
          description: Upcoming bookings exist.
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }

  /availability:
    get:
      operationId: getAvailability
      tags: [Availability]
      summary: List bookable slots for an event type
      description: |
        Requires scope `availability:read`. Computes real availability:
        weekly rules + date overrides − existing bookings − buffers − minimum
        notice. Maximum range is 60 days per request.
      parameters:
        - name: event_type
          in: query
          required: true
          description: Event type id or slug.
          schema: { type: string }
        - name: start
          in: query
          required: true
          description: Range start (RFC 3339, or `YYYY-MM-DD` interpreted in `timezone`).
          schema: { type: string }
          example: "2026-07-10"
        - name: end
          in: query
          required: true
          description: Range end, inclusive when a bare date.
          schema: { type: string }
          example: "2026-07-17"
        - name: timezone
          in: query
          description: IANA zone to render slot times in. Defaults to the tenant's zone.
          schema: { type: string }
          example: America/Toronto
      responses:
        "200":
          description: Bookable slots
          content:
            application/json:
              schema:
                type: object
                required: [event_type_id, timezone, slots]
                properties:
                  event_type_id: { type: string }
                  timezone: { type: string }
                  slots:
                    type: array
                    items:
                      type: object
                      required: [start, end]
                      properties:
                        start: { type: string, format: date-time, examples: ["2026-07-10T14:00:00-04:00"] }
                        end: { type: string, format: date-time }
        "400": { $ref: "#/components/responses/ValidationError" }
        "404": { $ref: "#/components/responses/NotFound" }

  /bookings:
    get:
      operationId: listBookings
      tags: [Bookings]
      summary: List bookings
      description: Requires scope `bookings:read`.
      parameters:
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Cursor"
        - name: status
          in: query
          schema: { $ref: "#/components/schemas/BookingStatus" }
        - name: event_type
          in: query
          description: Filter by event type id or slug.
          schema: { type: string }
        - name: invitee_email
          in: query
          schema: { type: string, format: email }
        - name: invitee_phone
          in: query
          schema: { type: string }
        - name: from
          in: query
          description: Only bookings starting at/after this instant (RFC 3339).
          schema: { type: string, format: date-time }
        - name: to
          in: query
          description: Only bookings starting before this instant (RFC 3339).
          schema: { type: string, format: date-time }
        - name: sort
          in: query
          description: Sort by start time.
          schema: { type: string, enum: [start_asc, start_desc], default: start_asc }
      responses:
        "200":
          description: Paginated bookings
          content:
            application/json:
              schema:
                type: object
                required: [data, next_cursor]
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/Booking" }
                  next_cursor:
                    type: [string, "null"]
        "401": { $ref: "#/components/responses/Unauthorized" }
    post:
      operationId: createBooking
      tags: [Bookings]
      summary: Create a booking
      description: |
        Requires scope `bookings:write`. The slot must be one the availability
        endpoint would return. Double-booking is prevented by a database
        exclusion constraint; a lost race returns `409` with type
        `.../errors/slot-conflict` and `alternative_slots` to retry with.
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/BookingCreate" }
      responses:
        "201":
          description: Booking confirmed
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Booking" }
        "400": { $ref: "#/components/responses/ValidationError" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "409": { $ref: "#/components/responses/SlotConflict" }
        "422":
          description: Required custom answers missing.
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }

  /bookings/{id}:
    parameters:
      - $ref: "#/components/parameters/ResourceId"
    get:
      operationId: getBooking
      tags: [Bookings]
      summary: Get a booking
      responses:
        "200":
          description: Booking
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Booking" }
        "404": { $ref: "#/components/responses/NotFound" }
    delete:
      operationId: cancelBooking
      tags: [Bookings]
      summary: Cancel a booking
      description: |
        Requires scope `bookings:write`. Cancelling is a soft state change
        (status → `cancelled`); the record remains readable.

        The cancellation reason may be supplied either in the JSON body or as
        the `reason` query parameter (for clients that cannot attach a body to
        a DELETE). If both are present, the body wins.
      parameters:
        - name: reason
          in: query
          schema: { type: string, maxLength: 500 }
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                reason: { type: [string, "null"], maxLength: 500 }
      responses:
        "200":
          description: The cancelled booking
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Booking" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409":
          description: Booking is already cancelled.
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }

  /bookings/{id}/approve:
    parameters:
      - $ref: "#/components/parameters/ResourceId"
    post:
      operationId: approveBooking
      tags: [Bookings]
      summary: Approve a pending booking
      description: |
        Requires scope `bookings:write`. Moves a `pending` booking to
        `confirmed` and sends the invitee their calendar invite.

        No conflict check is needed: the pending booking already held its slot
        via the same exclusion constraint that guards confirmed ones, so the
        time cannot have been taken in the meantime. Idempotent via
        `Idempotency-Key`.
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      responses:
        "200":
          description: The now-confirmed booking
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Booking" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409":
          description: Booking is not awaiting approval.
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }

  /bookings/{id}/reschedule:
    parameters:
      - $ref: "#/components/parameters/ResourceId"
    post:
      operationId: rescheduleBooking
      tags: [Bookings]
      summary: Reschedule a booking
      description: |
        Requires scope `bookings:write`. Marks the original booking
        `rescheduled` and creates a new booking at `start_time`, linked via
        `rescheduled_from_id`. Same conflict semantics as create. Rescheduling
        a `pending` booking keeps the replacement pending.
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [start_time]
              properties:
                start_time:
                  type: string
                  format: date-time
                  description: New start (RFC 3339 with offset).
                timezone:
                  type: string
                  description: New display zone for the invitee; defaults to the previous one.
      responses:
        "201":
          description: The replacement booking
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Booking" }
        "400": { $ref: "#/components/responses/ValidationError" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/SlotConflict" }

  /webhooks:
    get:
      operationId: listWebhooks
      tags: [Webhooks]
      summary: List webhook endpoints
      description: Requires scope `webhooks:manage`.
      responses:
        "200":
          description: Webhook endpoints
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/WebhookEndpoint" }
    post:
      operationId: createWebhook
      tags: [Webhooks]
      summary: Create a webhook endpoint
      description: |
        Requires scope `webhooks:manage`. The signing `secret` is returned
        only on creation. Deliveries carry `X-Signature: sha256=<hex hmac>`
        computed over the raw request body with this secret, plus
        `X-Timestamp` (include it when verifying to prevent replay).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [url, events]
              properties:
                url: { type: string, format: uri }
                events:
                  type: array
                  items:
                    type: string
                    enum: [booking.created, booking.requested, booking.approved, booking.cancelled, booking.rescheduled]
      responses:
        "201":
          description: Created (includes secret — shown once)
          content:
            application/json:
              schema: { $ref: "#/components/schemas/WebhookEndpointWithSecret" }

  /webhooks/{id}:
    parameters:
      - $ref: "#/components/parameters/ResourceId"
    delete:
      operationId: deleteWebhook
      tags: [Webhooks]
      summary: Delete a webhook endpoint
      responses:
        "204": { description: Deleted }
        "404": { $ref: "#/components/responses/NotFound" }

  /oauth/token:
    post:
      operationId: issueToken
      tags: [OAuth]
      summary: OAuth 2.0 token endpoint (client credentials)
      security: []
      requestBody:
        required: true
        content:
          application/x-www-form-urlencoded:
            schema:
              type: object
              required: [grant_type, client_id, client_secret]
              properties:
                grant_type: { type: string, enum: [client_credentials] }
                client_id: { type: string }
                client_secret: { type: string }
                scope:
                  type: string
                  description: Space-separated subset of the client's granted scopes.
      responses:
        "200":
          description: Access token
          content:
            application/json:
              schema:
                type: object
                required: [access_token, token_type, expires_in]
                properties:
                  access_token: { type: string }
                  token_type: { type: string, enum: [Bearer] }
                  expires_in: { type: integer, examples: [3600] }
                  scope: { type: string }
        "400":
          description: OAuth error per RFC 6749 §5.2
          content:
            application/json:
              schema:
                type: object
                properties:
                  error: { type: string, enum: [invalid_request, invalid_client, invalid_scope, unsupported_grant_type] }
                  error_description: { type: string }

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      description: >
        API key (`sk_live_...` / `sk_test_...`) or an OAuth access token from
        /oauth/token.

  parameters:
    ResourceId:
      name: id
      in: path
      required: true
      schema: { type: string }
    Limit:
      name: limit
      in: query
      schema: { type: integer, minimum: 1, maximum: 100, default: 20 }
    Cursor:
      name: cursor
      in: query
      description: Opaque cursor from a previous response's `next_cursor`.
      schema: { type: string }
    IdempotencyKey:
      name: Idempotency-Key
      in: header
      description: |
        Unique client-generated key (e.g. UUIDv4). Replaying the same key with
        the same body returns the original response; a different body returns
        `409` type `.../errors/idempotency-conflict`.
      schema: { type: string, maxLength: 255 }

  responses:
    Unauthorized:
      description: Missing, invalid, or revoked credential.
      content:
        application/problem+json:
          schema: { $ref: "#/components/schemas/Problem" }
    Forbidden:
      description: Credential lacks the required scope.
      content:
        application/problem+json:
          schema: { $ref: "#/components/schemas/Problem" }
    NotFound:
      description: No such resource in this tenant.
      content:
        application/problem+json:
          schema: { $ref: "#/components/schemas/Problem" }
    ValidationError:
      description: Request failed validation; see `errors` extension member.
      content:
        application/problem+json:
          schema: { $ref: "#/components/schemas/Problem" }
    SlotConflict:
      description: >
        The requested time is not available (taken, outside availability, or
        below minimum notice). The problem document includes
        `conflicting_bookings` (when caused by an overlap) and
        `alternative_slots` (nearest bookable alternatives) so callers can
        disambiguate and retry.
      content:
        application/problem+json:
          schema:
            allOf:
              - $ref: "#/components/schemas/Problem"
              - type: object
                properties:
                  conflicting_bookings:
                    type: array
                    items:
                      type: object
                      properties:
                        id: { type: string }
                        start_time: { type: string, format: date-time }
                        end_time: { type: string, format: date-time }
                  alternative_slots:
                    type: array
                    items:
                      type: object
                      properties:
                        start: { type: string, format: date-time }
                        end: { type: string, format: date-time }

  schemas:
    Problem:
      type: object
      description: RFC 7807 Problem Details.
      required: [type, title, status]
      properties:
        type:
          type: string
          format: uri
          description: Stable error-class URI, e.g. `https://<app>/errors/slot-conflict`.
        title: { type: string }
        status: { type: integer }
        detail: { type: string }
        instance: { type: string }

    Profile:
      type: object
      required: [id, email, slug, timezone]
      properties:
        id: { type: string }
        email: { type: string, format: email }
        name: { type: [string, "null"] }
        slug: { type: string, description: "Public booking page: /{slug}" }
        timezone: { type: string, examples: [America/Toronto] }
        notification_email:
          type: string
          format: email
          description: |
            Where booking notifications are delivered. Mirrors `email` unless an
            override has been set.
        booking_page_url: { type: string, format: uri }
        mcp_url: { type: string, format: uri }

    ProfileUpdate:
      type: object
      minProperties: 1
      description: Partial update; omit a field to leave it unchanged.
      properties:
        name: { type: [string, "null"], maxLength: 120 }
        slug:
          type: string
          description: Public handle. Changing it invalidates shared booking links.
        timezone: { type: string, examples: [America/Toronto] }
        notification_email:
          type: [string, "null"]
          format: email
          description: Send `null` or `""` to clear the override and use the login email.

    CustomQuestion:
      type: object
      required: [label, type, required]
      properties:
        label: { type: string }
        type: { type: string, enum: [text, textarea, phone, select] }
        required: { type: boolean }
        options:
          type: array
          items: { type: string }
          description: For `select` questions.

    EventType:
      type: object
      required: [id, slug, title, duration_minutes, is_active]
      properties:
        id: { type: string }
        slug: { type: string }
        title: { type: string }
        description: { type: [string, "null"] }
        duration_minutes: { type: integer }
        location: { type: [string, "null"] }
        color: { type: string }
        is_active: { type: boolean }
        custom_questions:
          type: array
          items: { $ref: "#/components/schemas/CustomQuestion" }
        buffer_before_minutes: { type: integer }
        buffer_after_minutes: { type: integer }
        minimum_notice_minutes: { type: integer }
        requires_confirmation:
          type: boolean
          description: When true, bookings for this event type start as `pending`.
        booking_url: { type: string, format: uri }
        created_at: { type: string, format: date-time }

    EventTypeCreate:
      type: object
      required: [title, duration_minutes]
      properties:
        title: { type: string, minLength: 1, maxLength: 120 }
        slug:
          type: string
          pattern: "^[a-z0-9]+(-[a-z0-9]+)*$"
          description: Defaults to a slugified title.
        description: { type: [string, "null"], maxLength: 2000 }
        duration_minutes: { type: integer, minimum: 5, maximum: 720 }
        location: { type: [string, "null"], maxLength: 500 }
        color: { type: string, pattern: "^#[0-9a-fA-F]{6}$" }
        is_active: { type: boolean, default: true }
        custom_questions:
          type: array
          maxItems: 10
          items: { $ref: "#/components/schemas/CustomQuestion" }
        buffer_before_minutes: { type: integer, minimum: 0, maximum: 240, default: 0 }
        buffer_after_minutes: { type: integer, minimum: 0, maximum: 240, default: 0 }
        minimum_notice_minutes: { type: integer, minimum: 0, default: 0 }
        requires_confirmation:
          type: boolean
          default: false
          description: When true, new bookings land in `pending` and need approving.

    EventTypeUpdate:
      type: object
      description: All fields optional; only provided fields change.
      properties:
        title: { type: string, minLength: 1, maxLength: 120 }
        slug: { type: string, pattern: "^[a-z0-9]+(-[a-z0-9]+)*$" }
        description: { type: [string, "null"], maxLength: 2000 }
        duration_minutes: { type: integer, minimum: 5, maximum: 720 }
        location: { type: [string, "null"], maxLength: 500 }
        color: { type: string, pattern: "^#[0-9a-fA-F]{6}$" }
        is_active: { type: boolean }
        custom_questions:
          type: array
          maxItems: 10
          items: { $ref: "#/components/schemas/CustomQuestion" }
        buffer_before_minutes: { type: integer, minimum: 0, maximum: 240 }
        buffer_after_minutes: { type: integer, minimum: 0, maximum: 240 }
        minimum_notice_minutes: { type: integer, minimum: 0 }
        requires_confirmation: { type: boolean }

    BookingStatus:
      type: string
      enum: [pending, confirmed, cancelled, rescheduled]
      description: |
        `pending` means the event type has `requires_confirmation` set and the
        booking is awaiting owner approval. Pending bookings still hold their
        slot, so the time cannot be taken by anyone else in the meantime.

    Answer:
      type: object
      required: [label, value]
      properties:
        label: { type: string }
        value: { type: string }

    Booking:
      type: object
      required: [id, event_type_id, status, start_time, end_time, timezone, invitee_name, invitee_email]
      properties:
        id: { type: string }
        event_type_id: { type: string }
        event_type_slug: { type: string }
        event_type_title: { type: string }
        status: { $ref: "#/components/schemas/BookingStatus" }
        start_time:
          type: string
          format: date-time
          description: RFC 3339 with offset, rendered in `timezone`.
          examples: ["2026-07-10T14:00:00-04:00"]
        end_time: { type: string, format: date-time }
        timezone: { type: string }
        invitee_name: { type: string }
        invitee_email: { type: string, format: email }
        invitee_phone: { type: [string, "null"] }
        answers:
          type: array
          items: { $ref: "#/components/schemas/Answer" }
        created_via: { type: string, enum: [web, api, agent] }
        approved_at: { type: [string, "null"], format: date-time }
        cancel_reason: { type: [string, "null"] }
        rescheduled_from_id: { type: [string, "null"] }
        manage_url:
          type: string
          format: uri
          description: Invitee self-service reschedule/cancel link.
        created_at: { type: string, format: date-time }

    BookingCreate:
      type: object
      required: [start_time, invitee_name, invitee_email]
      description: |
        Exactly one of `event_type` or `event_type_id` is required. The latter
        is an alias matching the field name on `Booking` responses, so a
        returned booking can be fed back in without renaming.
      anyOf:
        - required: [event_type]
        - required: [event_type_id]
      properties:
        event_type:
          type: string
          description: Event type id or slug.
        event_type_id:
          type: string
          description: Alias for `event_type`; accepts an id or slug.
        start_time:
          type: string
          format: date-time
          description: Slot start, RFC 3339 with offset.
        timezone:
          type: string
          description: IANA zone for the invitee's confirmations. Defaults to the offset's zone or the tenant's.
        invitee_name: { type: string, minLength: 1, maxLength: 200 }
        invitee_email: { type: string, format: email }
        invitee_phone: { type: [string, "null"], maxLength: 40 }
        answers:
          type: array
          items: { $ref: "#/components/schemas/Answer" }

    WebhookEndpoint:
      type: object
      required: [id, url, events, is_active]
      properties:
        id: { type: string }
        url: { type: string, format: uri }
        events:
          type: array
          items: { type: string }
        is_active: { type: boolean }
        created_at: { type: string, format: date-time }

    WebhookEndpointWithSecret:
      allOf:
        - $ref: "#/components/schemas/WebhookEndpoint"
        - type: object
          required: [secret]
          properties:
            secret:
              type: string
              description: HMAC signing secret. Shown only once.
