# Keptslot API -- OpenAPI 3.1 description.
#
# https://keptslot.com/docs

openapi: 3.1.0

info:
  title: Keptslot API
  version: '1'
  description: |
    Appointment booking for one workspace or many. Every endpoint uses an API
    key, except the customer-link routes under /v1/public, where the token in
    the path is the only credential.
  license:
    name: Proprietary

servers:
  - url: https://api.keptslot.com
    description: Production

tags:
  - name: Availability
    description: Open slots for a service.
  - name: Bookings
    description: Create, confirm, reschedule and cancel appointments.
  - name: Customer links
    description: Token-authenticated routes a customer opens from an email.
  - name: Services
    description: What a workspace sells.
  - name: Staff
    description: Bookable calendars. A calendar is not a person with a login.
  - name: Webhooks
    description: Endpoint registration and delivery history.
  - name: Workspace
    description: Workspace settings and usage.
  - name: Account
    description: Account-scoped routes. These use an account key.

components:
  securitySchemes:
    SecretKey:
      type: http
      scheme: bearer
      description: |
        A workspace secret key, prefix `sk_`. Use it from a server. Do not put
        it in a browser.
    PublishableKey:
      type: http
      scheme: bearer
      description: |
        A workspace publishable key, prefix `pk_`. Use it from a browser. It
        reaches three endpoints only: GET /v1/availability, POST /v1/bookings
        and POST /v1/bookings/{id}/verify. Every other route refuses it with a
        403 and `secret_key_required`.

        The API checks the Origin header when the request carries one. A request
        that sends no Origin is not origin-checked, so this key does work from a
        server. The origin list is an anti-abuse control and not a security
        boundary. See the keys guide.
    AccountKey:
      type: http
      scheme: bearer
      description: |
        An account key, prefix `ak_`. It reaches the four /v1/account routes
        and no others. It does not unlock workspace routes. Use it from a
        server.

  schemas:
    Error:
      type: object
      required: [error]
      properties:
        error:
          type: object
          required: [type, message]
          properties:
            type:
              type: string
              description: A stable machine-readable code. Match on this.
            message:
              type: string
              description: A sentence for a person. Do not match on this.
            param:
              type: string
              description: The field that caused the refusal, if one field did.
            dimension:
              type: string
              description: |
                The plan limit that refused the request. It is present only on
                a 402, and only for a secret key or an account key. See the
                errors guide.
            used:
              type: integer
              format: int64
              description: |
                The current count for that dimension. It is present only on a
                402, and only for an account key. It is absent when the refusal
                has no count to report. Absent and zero are different answers.
            limit:
              type: integer
              format: int64
              description: |
                The cap for that dimension. It is present only on a 402, and
                only for an account key. A cap of zero is a real value.

    Slot:
      type: object
      required: [start, end]
      properties:
        start:
          type: string
          format: date-time
          description: |
            The start of the slot, in the workspace timezone. The appointment
            happens at the business, so the offset is the business's and not
            the customer's.
        end: { type: string, format: date-time }

    StaffSlots:
      type: object
      required: [staff_id, slots]
      properties:
        staff_id:
          type: string
          format: uuid
          description: The calendar these slots belong to.
        slots:
          type: array
          items: { $ref: '#/components/schemas/Slot' }

    Service:
      type: object
      required: [id, name, duration_minutes]
      properties:
        id: { type: string, format: uuid }
        name: { type: string }
        duration_minutes: { type: integer }
        price_cents:
          type: [integer, 'null']
          description: |
            The price in the smallest currency unit. It is null when the
            business does not publish a price.
        description:
          type: [string, 'null']
          description: |
            Plain text written by the business. Render it as text. Do not
            render it as markup.
        payment_mode:
          type: string
          description: |
            Whether this service takes a payment when it is booked, and of what
            kind. One of `none`, `full`, `deposit_fixed` or `deposit_percent`.
            Show the requirement before the customer fills in the form. A card
            is asked for after the booking is created, which is too late to be
            the first the customer hears of it.
        payment_amount_cents:
          type: [integer, 'null']
          description: |
            What the customer pays at booking time, in the smallest currency
            unit. It is computed for you: do not work it out from a percentage
            yourself, because the rounding rule lives on the server and two
            implementations of it will disagree.

            It is null when `payment_mode` is `none`, and also when the service
            is misconfigured such that no amount can be computed. In that
            second case the service cannot be booked at all, so show it as
            unavailable rather than asking for a payment that will be refused.
        category_id:
          type: [string, 'null']
          description: |
            The heading this service groups under, matching an `id` in the
            bootstrap's `categories` array. Null means uncategorised: put it
            in a final "Other" group, or in no group at all when that is the
            only group.

            On `PUT /v1/services/{id}` this field is IGNORED. That endpoint is
            a full replace, and honouring it would mean a client that has
            never heard of categories strips a business's grouping every time
            it syncs. Categories are set from the dashboard.

    Staff:
      type: object
      required: [id, name]
      properties:
        id: { type: string, format: uuid }
        name: { type: string }
        title:
          type: [string, 'null']
          description: |
            What this person does, such as "Senior stylist". Plain text.
            Render it as text, never as markup. Null when the business has not
            written one -- show the name alone and reserve no space.
        bio:
          type: [string, 'null']
          description: |
            A short line about this person. Same rules as `title`.
      description: |
        A bookable calendar. A calendar is not a person with a login. See the
        glossary.

    Question:
      type: object
      required: [id, label, type, options, required, applies_to, service_ids, position]
      properties:
        id: { type: string, format: uuid }
        label:
          type: string
          description: Plain text written by the business. Render it as text.
        help_text: { type: [string, 'null'] }
        type: { type: string }
        options:
          type: array
          items: { type: string }
        required: { type: boolean }
        applies_to:
          type: string
          description: |
            The scope of the question. If the value is `selected_services`,
            then `service_ids` lists the services it applies to.
        service_ids:
          type: array
          items: { type: string, format: uuid }
        position: { type: integer }

    Booking:
      type: object
      required: [id, staff_id, service_id, customer_id, starts_at, ends_at, status]
      properties:
        id: { type: string, format: uuid }
        staff_id: { type: string, format: uuid }
        service_id: { type: string, format: uuid }
        customer_id: { type: string, format: uuid }
        starts_at: { type: string, format: date-time }
        ends_at: { type: string, format: date-time }
        status: { type: string }
        access_token:
          type: string
          description: |
            The customer's manage link token. It is present only on the
            response that creates the booking. Store it then. No later
            response repeats it.
        hold_expires_at:
          type: string
          format: date-time
          description: |
            When this booking stops holding its slot. It is present only while
            the booking is on a hold, and absent otherwise.

            Show this time rather than counting a fixed number of minutes from
            now. The window differs between a booking waiting on a code and one
            waiting on a card, and it is longer for a card because a 3DS
            redirect goes to the customer's bank and back.
        payment:
          $ref: '#/components/schemas/BookingPaymentIntent'

    BookingPaymentIntent:
      type: object
      description: |
        Present only when the service takes a payment, and only on the response
        that creates the booking. The key is absent for a service that takes no
        payment. It is never null, so a client can test for the key itself.

        Use these fields to mount Stripe's Payment Element on the connected
        account and confirm the payment. The client secret works once, for this
        booking.
      required: [client_secret, amount_cents, currency, connected_account_id]
      properties:
        client_secret: { type: string }
        amount_cents:
          type: integer
          description: |
            What this booking costs, in the smallest unit of the currency. It
            is a snapshot taken when the booking was made. Changing the
            service's price later never changes it.
        currency: { type: string }
        connected_account_id:
          type: string
          description: |
            The business's Stripe account. Pass it as `stripeAccount` when you
            construct Stripe.js.

    BookingPaymentState:
      type: object
      description: |
        What has happened to the money for a booking that has already been
        paid for. It is absent when the booking took no payment.

        This is the customer's view. It carries no Stripe identifiers and no
        client secret: the payment is already made, and there is nothing left
        to confirm.
      required: [status, amount_cents, currency, amount_refunded_cents]
      properties:
        status:
          type: string
          description: |
            One of `initializing`, `requires_payment`, `processing`,
            `succeeded`, `partially_refunded`, `refunded`, `refund_pending`
            or `canceled`. A disputed payment stays `succeeded`: the money was
            taken, and a dispute is not a status.
        amount_cents:
          type: integer
          description: |
            A snapshot taken when the booking was made. Changing the service's
            price later never changes it.
        currency: { type: string }
        amount_refunded_cents:
          type: integer
          description: Zero when nothing has been refunded.
        refund_policy:
          type: string
          description: |
            The business's own refund policy, word for word. We never parse it
            and we never compute a refund from it. Show it as written.
        client_secret:
          type: string
          description: |
            Present only while `status` is `requires_payment`, so the customer
            can finish a payment they have not completed. Use it to mount
            Stripe's Payment Element on `connected_account_id`.

            This is what makes a payment resumable. The secret returned when
            the booking was created works once and is held only in that browser
            tab, so a refresh, a closed tab or a return from the customer's bank
            would otherwise leave the customer unable to pay for a slot their
            own booking is still holding.
        connected_account_id:
          type: string
          description: Present under the same condition as `client_secret`.
        payment_unavailable:
          type: boolean
          description: |
            True when the payment could be resumed but Stripe could not be
            reached to resume it. Absent otherwise.

            Treat it as "try again shortly", not as a failed payment and not as
            a lost booking. The slot is still held. It exists so this case is
            distinguishable from the other reasons no secret is present:
            `initializing` has no payment set up yet, and `processing` means the
            customer has already paid and must not be asked to pay again.
        hold_expires_at:
          type: string
          format: date-time
          description: |
            When the slot stops being held, present while the payment is still
            unsettled. Show the time. Do not compute it from a fixed number of
            minutes.

    ServiceDetail:
      type: object
      description: |
        The full service, with the rules that drive availability. The `Service`
        schema is the smaller customer-facing view of the same row.
      required: [id, name, duration_minutes, buffer_before_minutes, buffer_after_minutes, min_lead_minutes, max_advance_days, min_cancel_notice_minutes, active]
      properties:
        id: { type: string, format: uuid }
        name: { type: string }
        duration_minutes: { type: integer }
        buffer_before_minutes:
          type: integer
          description: Free time held before the appointment.
        buffer_after_minutes:
          type: integer
          description: Free time held after the appointment.
        min_lead_minutes:
          type: integer
          description: How far ahead a customer must book.
        max_advance_days:
          type: integer
          description: |
            How far ahead a customer can book. If you send 0 on a write, the
            API stores 60.
        min_cancel_notice_minutes:
          type: integer
          description: |
            How long before the start time cancellation closes. It binds
            customers only. A secret key cancels at any time. A value of 0
            keeps cancellation open until the start time.
        active: { type: boolean }
        price_cents: { type: [integer, 'null'] }
        description: { type: [string, 'null'] }
        payment_mode: { type: string }
        deposit_amount_cents: { type: [integer, 'null'] }
        deposit_percent: { type: [integer, 'null'] }

    ServiceWrite:
      type: object
      description: |
        The writable fields of a service. Create and replace use the same
        shape. `active` is not here: use the activate and deactivate endpoints.
      required: [name, duration_minutes]
      properties:
        name: { type: string }
        duration_minutes: { type: integer }
        buffer_before_minutes: { type: integer }
        buffer_after_minutes: { type: integer }
        min_lead_minutes: { type: integer }
        max_advance_days:
          type: integer
          description: If you send 0, the API stores 60.
        min_cancel_notice_minutes: { type: integer }
        price_cents: { type: [integer, 'null'] }
        description:
          type: [string, 'null']
          description: |
            Plain text. It renders on a public page. Render it as text. Do not
            render it as markup.
        payment_mode:
          type: string
          description: |
            Whether booking this service takes a payment. One of `none`,
            `full`, `deposit_fixed` or `deposit_percent`. Omitting it is not the
            same as sending `none`, and is rejected.

            The other three fields must match the mode. `full` needs
            `price_cents` and neither deposit field. `deposit_fixed` needs
            `deposit_amount_cents` and no percent. `deposit_percent` needs
            `deposit_percent` and `price_cents`. `none` needs neither deposit
            field.

            Whatever the mode charges must come to more than zero. A price or
            deposit of 0 is rejected: it would save a service that no customer
            could ever book, because the payment would be refused at booking
            time rather than here.
        deposit_amount_cents:
          type: [integer, 'null']
          description: |
            The fixed deposit, in the smallest currency unit. Required by
            `deposit_fixed` and rejected by every other mode.
        deposit_percent:
          type: [integer, 'null']
          description: |
            The deposit as a whole-number percentage of `price_cents`, from 1 to
            100. Required by `deposit_percent` and rejected by every other mode.
            We round the resulting amount half up.

    StaffDetail:
      type: object
      description: |
        A bookable calendar, with its status. A calendar is not a person with a
        login. See the glossary.
      required: [id, name, active]
      properties:
        id: { type: string, format: uuid }
        name: { type: string }
        active: { type: boolean }

    ApiKey:
      type: object
      required: [id, type, display_prefix, revoked, created_at, allowed_origins]
      properties:
        id: { type: string, format: uuid }
        type:
          type: string
          enum: [sk, pk]
        name:
          type: [string, 'null']
          description: |
            Null for a key made before names existed. "No name" and "made
            before names" are the same answer here.
        key:
          type: [string, 'null']
          description: |
            The full key value. A create response carries it for both key
            types, because that is the only moment the value exists.

            A read gives it for a publishable key only, and gives null for a
            secret key. It is also null for an old publishable key whose value
            was never stored.
        display_prefix:
          type: string
          description: The first characters, to tell keys apart in a list.
        revoked: { type: boolean }
        created_at: { type: string, format: date-time }
        last_used_at: { type: [string, 'null'], format: date-time }
        allowed_origins:
          type: array
          items: { type: string }
          description: |
            The browser origins a publishable key works from. An empty list
            rejects every browser Origin.

    WorkspaceSettings:
      type: object
      required: [slug, name, timezone, currency, require_booking_verification, sms_enabled, staff_alerts, staff_reminders_enabled, staff_reminder_offset_minutes, reminder_offset_minutes, slot_interval_minutes, vertical]
      properties:
        slug:
          type: string
          description: Read only. It is the workspace address on the booking page.
        name: { type: string }
        timezone:
          type: string
          description: |
            An IANA zone name. Every time the API gives back uses this zone.
        currency: { type: string }
        require_booking_verification:
          type: boolean
          description: |
            True when a new booking waits on a 6-digit code. Read the status on
            the create response to decide what happens next. Do not decide it
            from this field.
        sms_enabled: { type: boolean }
        staff_alerts:
          type: string
          description: Who gets the alert when a booking changes.
        staff_reminders_enabled: { type: boolean }
        staff_reminder_offset_minutes:
          type: integer
          description: How long before the start time the staff reminder is sent.
        reminder_offset_minutes:
          type: integer
          description: How long before the start time the customer reminder is sent.
        slot_interval_minutes:
          type: integer
          description: The step between the slots that availability offers.
        booking_policy:
          type: [string, 'null']
          description: |
            Plain text shown to customers. Render it as text. Do not render it
            as markup.
        vertical:
          type: string
          description: |
            Selects the customer-facing words. It changes copy only. It never
            changes booking mechanics.
        contact_email: { type: [string, 'null'] }
        contact_phone: { type: [string, 'null'] }
        address:
          type: [string, 'null']
          description: |
            Used in the email footer. The booking page does not show it.
        blurb:
          type: [string, 'null']
          description: |
            A short line about the business, under its name on the booking
            page. At most 200 characters. Send `""` to clear it.
        refund_policy:
          type: [string, 'null']
          description: |
            The refund terms, shown where a customer pays. At most 2000
            characters. Separate from `booking_policy`: one governs whether an
            appointment can be moved, the other governs money.
        terms_url:
          type: [string, 'null']
          description: |
            The business's own terms, shown alongside ours. Must be `http` or
            `https` -- anything else is refused. A bare domain is accepted and
            stored as `https://`.
        website_url: { type: [string, 'null'] }
        instagram_url:
          type: [string, 'null']
          description: |
            A handle such as `@riverwood` is accepted and stored as the full
            profile URL.
        maps_url: { type: [string, 'null'] }
        confirmation_note:
          type: [string, 'null']
          description: |
            Shown on the confirmation screen after a booking succeeds. What to
            bring, where to park, the door code. At most 500 characters.
        notification_note:
          type: [string, 'null']
          description: |
            Stored for the notification emails. Nothing renders it yet. At
            most 500 characters.
        social_title:
          type: [string, 'null']
          description: At most 70 characters.
        social_description:
          type: [string, 'null']
          description: At most 160 characters.
        allow_staff_choice:
          type: boolean
          description: |
            Whether customers pick who they see. True by default, so a
            workspace that never touches it keeps today's behaviour.

    WebhookEndpoint:
      type: object
      required: [id, url, events, active, consecutive_failures, created_at]
      properties:
        id: { type: string, format: uuid }
        url: { type: string }
        events:
          type: array
          items: { type: string }
          description: The event types this endpoint receives.
        active: { type: boolean }
        consecutive_failures:
          type: integer
          description: |
            How many deliveries failed in a row. It returns to 0 after a
            success.
        last_success_at: { type: [string, 'null'], format: date-time }
        last_failure_at: { type: [string, 'null'], format: date-time }
        created_at: { type: string, format: date-time }
        secret:
          type: string
          description: |
            The signing secret. It is present ONLY on the create response. The
            stored form is sealed and no read path can reveal it again. Store
            it now. If you lose it, rotate it.

    WebhookDelivery:
      type: object
      required: [id, event_type, status, attempts, created_at, payload]
      properties:
        id: { type: string, format: uuid }
        event_type: { type: string }
        status: { type: string }
        attempts: { type: integer }
        response_status:
          type: [integer, 'null']
          description: The HTTP status your endpoint gave. Null before a reply.
        last_error: { type: [string, 'null'] }
        next_retry_at: { type: [string, 'null'], format: date-time }
        created_at: { type: string, format: date-time }
        completed_at: { type: [string, 'null'], format: date-time }
        payload:
          type: object
          description: The body that was sent, or that will be sent.

    WorkingHour:
      type: object
      required: [weekday, start_minute, end_minute]
      properties:
        weekday:
          type: integer
          description: The day of the week. 0 is Sunday.
        start_minute:
          type: integer
          description: Minutes after midnight, in the workspace timezone.
        end_minute:
          type: integer
          description: Minutes after midnight, in the workspace timezone.

    TimeOff:
      type: object
      required: [id, staff_id, starts_at, ends_at, reason]
      properties:
        id: { type: string, format: uuid }
        staff_id: { type: string, format: uuid }
        starts_at: { type: string, format: date-time }
        ends_at: { type: string, format: date-time }
        reason: { type: string }

  parameters:
    BookingID:
      name: id
      in: path
      required: true
      schema: { type: string, format: uuid }
      description: The booking id.
    ServiceID:
      name: id
      in: path
      required: true
      schema: { type: string, format: uuid }
      description: The service id.
    StaffID:
      name: id
      in: path
      required: true
      schema: { type: string, format: uuid }
      description: The calendar id.
    WebhookEndpointID:
      name: id
      in: path
      required: true
      schema: { type: string, format: uuid }
      description: The webhook endpoint id.

  responses:
    Unauthorized:
      description: The key is missing, malformed or revoked.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    NotFound:
      description: There is no such resource in this workspace.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    ValidationFailed:
      description: The request body or the query is not valid.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    PlanLimit:
      description: |
        A plan limit refused the request. The fields in the body change with
        the key type. See the errors guide.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }

paths:
  /v1/availability:
    get:
      tags: [Availability]
      summary: List open slots
      operationId: getAvailability
      description: |
        This endpoint gives the times a customer can book for one service. The
        response groups the slots by calendar.

        Give a range of 62 days or less. If the range is longer, the endpoint
        refuses the request with a 422.

        All times in the response use the workspace timezone.
      security:
        - SecretKey: []
        - PublishableKey: []
      parameters:
        - name: service_id
          in: query
          required: true
          schema: { type: string, format: uuid }
        - name: from
          in: query
          required: true
          schema: { type: string, format: date-time }
          description: An RFC3339 timestamp. The start of the range.
        - name: to
          in: query
          required: true
          schema: { type: string, format: date-time }
          description: |
            An RFC3339 timestamp. It must be after `from`, and no more than 62
            days after it.
        - name: exclude_booking_id
          in: query
          required: false
          schema: { type: string, format: uuid }
          description: |
            Removes one booking from the busy times. If you move a booking,
            use this parameter. Then the slot that the booking holds now stays
            available to it.

            If you send this parameter with an empty value, the endpoint
            refuses the request with a 422. An empty value is not the same as
            an absent one.
      responses:
        '200':
          description: The open slots in the range, grouped by calendar.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/StaffSlots' }
              example:
                data:
                  - staff_id: 6f1c0b52-9a3e-4f77-8d21-0b5a2c9e4d10
                    slots:
                      - start: '2026-09-01T09:00:00+01:00'
                        end: '2026-09-01T09:30:00+01:00'
                      - start: '2026-09-01T09:30:00+01:00'
                        end: '2026-09-01T10:00:00+01:00'
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '422': { $ref: '#/components/responses/ValidationFailed' }

  /v1/bookings:
    get:
      tags: [Bookings]
      summary: List bookings
      operationId: listBookings
      description: |
        This endpoint lists the bookings of the workspace, newest first. It
        pages with a cursor.

        If `next_cursor` is not null, more bookings exist. Send that value back
        as `cursor` to read the next page.
      security:
        - SecretKey: []
      parameters:
        - name: limit
          in: query
          required: false
          schema: { type: integer, default: 50, maximum: 200 }
          description: |
            The page size. The default is 50 and the maximum is 200. A value
            that is not a positive number gives the default.
        - name: cursor
          in: query
          required: false
          schema: { type: string }
          description: The `next_cursor` value from the previous page.
      responses:
        '200':
          description: One page of 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']
                    description: Null on the last page.
        '401': { $ref: '#/components/responses/Unauthorized' }
        '422': { $ref: '#/components/responses/ValidationFailed' }

    post:
      tags: [Bookings]
      summary: Create a booking
      operationId: createBooking
      description: |
        This endpoint books an appointment. Both key types can call it, because
        the publishable key drives the public booking widget.

        Read the status on the response to decide what happens next. If the
        status is `pending`, the customer must enter a 6-digit code, and you
        send that code to `POST /v1/bookings/{id}/verify`. Do not decide this
        from `require_verification` on the bootstrap response.

        `access_token` is on this response only. It is the customer manage
        link. Store it now, because no later response repeats it.

        If `staff_id` is absent, the API picks an available calendar.
      security:
        - SecretKey: []
        - PublishableKey: []
      parameters:
        - name: Idempotency-Key
          in: header
          required: false
          schema: { type: string }
          description: |
            Makes a retry safe. The first request stores its response. A repeat
            with the same key and the same body gives that stored response
            again, with the same status code.

            If you send the same key with a different body, the endpoint
            refuses the request with a 422.

            Only a 2xx response is stored. Two requests that arrive at the same
            time do not replay: the loser gets its own error.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [service_id, starts_at, customer]
              properties:
                service_id: { type: string, format: uuid }
                staff_id:
                  type: [string, 'null']
                  format: uuid
                  description: |
                    The calendar to book. If it is absent, the API picks an
                    available calendar.
                starts_at:
                  type: string
                  format: date-time
                  description: An RFC3339 timestamp. It must be an open slot.
                customer:
                  type: object
                  required: [name, email]
                  properties:
                    name: { type: string }
                    email: { type: string }
                    phone:
                      type: string
                      description: |
                        E.164 format. It is optional for every workspace. If it
                        is absent, the customer is notified by email only.
                answers:
                  type: array
                  description: |
                    Answers to the custom questions of the booking form. It is
                    optional. A caller that does not use questions omits it.
                  items:
                    type: object
                    required: [question_id, value]
                    properties:
                      question_id: { type: string, format: uuid }
                      value: { type: string }
            example:
              service_id: 3a7b1e90-2c4d-4f81-9b02-7e5c1a6d8f44
              starts_at: '2026-09-01T09:00:00+01:00'
              customer:
                name: Sam Okafor
                email: sam@example.com
                phone: '+447700900123'
      responses:
        '201':
          description: The booking is created.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Booking' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '402': { $ref: '#/components/responses/PlanLimit' }
        '409':
          description: The time is no longer available.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '422': { $ref: '#/components/responses/ValidationFailed' }
        '429':
          description: |
            Two different refusals share this status, and they are told apart
            by `error.type`. Do NOT treat them the same way.

            `rate_limited` is the request limiter. It counts per calendar
            minute, a refused request still counts, and retrying after the
            minute turns over succeeds. See the rate-limit guide.

            `booking_volume_refused` means this account has created an
            implausible number of bookings in its current billing period. It
            is a platform safety limit, not a plan allowance: no plan raises
            it, no plan is exempt from it, and it is not something an upgrade
            resolves. **Retrying does not help.** The limit resets when the
            account's billing period does, which may be weeks away, so a
            client that retries on a backoff will simply keep being refused.
            There is deliberately no `Retry-After`, no number in the body, and
            no `dimension`/`used`/`limit` — an account reaching this has a
            runaway integration, and the fix is to stop it. If you believe
            you have reached this legitimately, contact support.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  /v1/bookings/{id}/cancel:
    post:
      tags: [Bookings]
      summary: Cancel a booking
      operationId: cancelBooking
      description: |
        This endpoint cancels a booking. The cancel notice of the service binds
        customers only, so an operator key cancels at any time.

        A cancellation clears the customer manage token. The customer link then
        gives a 404.
      security:
        - SecretKey: []
      parameters:
        - $ref: '#/components/parameters/BookingID'
      responses:
        '204': { description: The booking is cancelled. No body. }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '422': { $ref: '#/components/responses/ValidationFailed' }

  /v1/bookings/{id}/confirm:
    post:
      tags: [Bookings]
      summary: Confirm a booking
      operationId: confirmBooking
      description: |
        This endpoint confirms a booking that waits on verification, without a
        code. It is the operator override for a customer who cannot receive the
        code.

        A confirmation counts against the plan allowance, so this endpoint can
        answer 402.
      security:
        - SecretKey: []
      parameters:
        - $ref: '#/components/parameters/BookingID'
      responses:
        '204': { description: The booking is confirmed. No body. }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '402': { $ref: '#/components/responses/PlanLimit' }
        '404': { $ref: '#/components/responses/NotFound' }
        '422': { $ref: '#/components/responses/ValidationFailed' }

  /v1/bookings/{id}/no-show:
    post:
      tags: [Bookings]
      summary: Mark a no-show
      operationId: markBookingNoShow
      description: |
        This endpoint records that the customer did not arrive. The booking
        keeps its time and its calendar.
      security:
        - SecretKey: []
      parameters:
        - $ref: '#/components/parameters/BookingID'
      responses:
        '204': { description: The booking is marked. No body. }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '422': { $ref: '#/components/responses/ValidationFailed' }
    delete:
      tags: [Bookings]
      summary: Clear a no-show
      operationId: clearBookingNoShow
      description: |
        This endpoint removes the no-show mark. It does not cancel or erase the
        booking.
      security:
        - SecretKey: []
      parameters:
        - $ref: '#/components/parameters/BookingID'
      responses:
        '204': { description: The mark is removed. No body. }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '422': { $ref: '#/components/responses/ValidationFailed' }

  /v1/bookings/{id}/reschedule:
    post:
      tags: [Bookings]
      summary: Move a booking
      operationId: rescheduleBooking
      description: |
        This endpoint moves a booking to a new time, and to a different
        calendar when you give one.

        The new time must be an open slot. Read the open slots from
        `GET /v1/availability`. Pass the id of this booking as
        `exclude_booking_id`. If you do not, the slot that the booking holds
        now counts as busy.

        A booking that is not confirmed cannot move.
      security:
        - SecretKey: []
      parameters:
        - $ref: '#/components/parameters/BookingID'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [starts_at]
              properties:
                starts_at:
                  type: string
                  format: date-time
                  description: An RFC3339 timestamp. The new start time.
                staff_id:
                  type: [string, 'null']
                  format: uuid
                  description: |
                    Moves the booking to this calendar. If it is absent, the
                    booking keeps its calendar.
            example:
              starts_at: '2026-09-02T14:00:00+01:00'
      responses:
        '200':
          description: The booking is moved.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Booking' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409':
          description: The new time is not available.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '422': { $ref: '#/components/responses/ValidationFailed' }

  /v1/bookings/{id}/verify:
    post:
      tags: [Bookings]
      summary: Verify a booking code
      operationId: verifyBooking
      description: |
        This endpoint promotes a pending booking to confirmed when the code is
        correct. Both key types can call it, because the publishable key that
        created the hold must be able to complete it.

        Every refusal gives the same error: a wrong code, an expired hold, too
        many attempts, and an unknown booking are not told apart. The response
        is deliberately not an oracle.

        A confirmation counts against the plan allowance, so this endpoint can
        answer 402.
      security:
        - SecretKey: []
        - PublishableKey: []
      parameters:
        - $ref: '#/components/parameters/BookingID'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [code]
              properties:
                code:
                  type: string
                  description: The 6-digit code from the customer.
            example:
              code: '482915'
      responses:
        '200':
          description: The booking is confirmed.
          content:
            application/json:
              schema:
                type: object
                required: [status]
                properties:
                  status: { type: string }
              example:
                status: confirmed
        '401': { $ref: '#/components/responses/Unauthorized' }
        '402': { $ref: '#/components/responses/PlanLimit' }
        '422': { $ref: '#/components/responses/ValidationFailed' }
        '429':
          description: |
            Too many requests. The limiter counts per calendar minute. A
            refused request still counts. See the rate-limit guide.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  /v1/services:
    get:
      tags: [Services]
      summary: List services
      operationId: listServices
      description: |
        This endpoint lists every service of the workspace, active and
        inactive. Read `active` to tell them apart.
      security:
        - SecretKey: []
      responses:
        '200':
          description: The services of the workspace.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/ServiceDetail' }
        '401': { $ref: '#/components/responses/Unauthorized' }
    post:
      tags: [Services]
      summary: Create a service
      operationId: createService
      description: |
        This endpoint adds a service. A new service is active.

        If you send `max_advance_days` as 0, the API stores 60.
      security:
        - SecretKey: []
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/ServiceWrite' }
            example:
              name: Cut and finish
              duration_minutes: 45
              buffer_after_minutes: 10
              min_lead_minutes: 120
              max_advance_days: 60
              min_cancel_notice_minutes: 1440
              price_cents: 4000
      responses:
        '201':
          description: The service is created.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ServiceDetail' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '402': { $ref: '#/components/responses/PlanLimit' }
        '422': { $ref: '#/components/responses/ValidationFailed' }

  /v1/services/{id}:
    put:
      tags: [Services]
      summary: Replace a service
      operationId: updateService
      description: |
        This endpoint replaces every field of a service. Send the whole object.
        A field you omit takes its zero value.

        This endpoint does not change `active`. Use the activate and deactivate
        endpoints for that.
      security:
        - SecretKey: []
      parameters:
        - $ref: '#/components/parameters/ServiceID'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/ServiceWrite' }
      responses:
        '200':
          description: The service is replaced.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ServiceDetail' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '422': { $ref: '#/components/responses/ValidationFailed' }

  /v1/services/{id}/activate:
    post:
      tags: [Services]
      summary: Activate a service
      operationId: activateService
      description: |
        This endpoint makes a service bookable again. Existing bookings are not
        changed.
      security:
        - SecretKey: []
      parameters:
        - $ref: '#/components/parameters/ServiceID'
      responses:
        '200':
          description: The service is active.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ServiceDetail' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '422': { $ref: '#/components/responses/ValidationFailed' }

  /v1/services/{id}/deactivate:
    post:
      tags: [Services]
      summary: Deactivate a service
      operationId: deactivateService
      description: |
        This endpoint stops new bookings for a service. It does not erase the
        service, and it does not cancel the bookings it already has.
      security:
        - SecretKey: []
      parameters:
        - $ref: '#/components/parameters/ServiceID'
      responses:
        '200':
          description: The service is inactive.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ServiceDetail' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '422': { $ref: '#/components/responses/ValidationFailed' }

  /v1/services/{id}/staff:
    put:
      tags: [Services]
      summary: Set service calendars
      operationId: assignServiceStaff
      description: |
        This endpoint replaces the whole set of calendars that offer a service.
        Send every calendar id you want. An id you omit no longer offers the
        service.

        Send an empty array to make the service unbookable. Availability then
        returns no slots for it.

        The response echoes the ids you sent.
      security:
        - SecretKey: []
      parameters:
        - $ref: '#/components/parameters/ServiceID'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [staff_ids]
              properties:
                staff_ids:
                  type: array
                  items: { type: string, format: uuid }
      responses:
        '200':
          description: The set is replaced.
          content:
            application/json:
              schema:
                type: object
                required: [staff_ids]
                properties:
                  staff_ids:
                    type: array
                    items: { type: string, format: uuid }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '422': { $ref: '#/components/responses/ValidationFailed' }

  /v1/staff:
    get:
      tags: [Staff]
      summary: List calendars
      operationId: listStaff
      description: |
        This endpoint lists the bookable calendars of the workspace. A calendar
        is not a person with a login. See the glossary.
      security:
        - SecretKey: []
      responses:
        '200':
          description: The calendars of the workspace.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/StaffDetail' }
        '401': { $ref: '#/components/responses/Unauthorized' }
    post:
      tags: [Staff]
      summary: Create a calendar
      operationId: createStaff
      description: |
        This endpoint adds a bookable calendar. A new calendar is active and
        offers no service. Use `PUT /v1/services/{id}/staff` to attach it to a
        service.
      security:
        - SecretKey: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name: { type: string }
            example:
              name: Chair 2
      responses:
        '201':
          description: The calendar is created.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/StaffDetail' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '402': { $ref: '#/components/responses/PlanLimit' }
        '422': { $ref: '#/components/responses/ValidationFailed' }

  /v1/staff/{id}:
    delete:
      tags: [Staff]
      summary: Deactivate a calendar
      operationId: deactivateStaff
      description: |
        CAUTION: The method is DELETE, and this endpoint DEACTIVATES. It does
        not erase the calendar and it does not cancel the bookings on it. The
        calendar stops taking new bookings.
      security:
        - SecretKey: []
      parameters:
        - $ref: '#/components/parameters/StaffID'
      responses:
        '204': { description: The calendar is inactive. No body. }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '422': { $ref: '#/components/responses/ValidationFailed' }

  /v1/staff/working-hours:
    get:
      tags: [Staff]
      summary: List weekly hours
      operationId: listWorkingHours
      description: |
        This endpoint gives the weekly hours of every ACTIVE calendar in one
        call. An inactive calendar is not in the response.

        Use it instead of one request per calendar.
      security:
        - SecretKey: []
      responses:
        '200':
          description: The weekly hours, grouped by calendar.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      required: [staff_id, working_hours]
                      properties:
                        staff_id: { type: string, format: uuid }
                        working_hours:
                          type: array
                          items: { $ref: '#/components/schemas/WorkingHour' }
        '401': { $ref: '#/components/responses/Unauthorized' }

  /v1/staff/{id}/working-hours:
    get:
      tags: [Staff]
      summary: Read weekly hours
      operationId: getWorkingHours
      description: This endpoint gives the weekly hours of one calendar.
      security:
        - SecretKey: []
      parameters:
        - $ref: '#/components/parameters/StaffID'
      responses:
        '200':
          description: The weekly hours of the calendar.
          content:
            application/json:
              schema:
                type: object
                required: [working_hours]
                properties:
                  working_hours:
                    type: array
                    items: { $ref: '#/components/schemas/WorkingHour' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '422': { $ref: '#/components/responses/ValidationFailed' }
    put:
      tags: [Staff]
      summary: Replace weekly hours
      operationId: replaceWorkingHours
      description: |
        This endpoint replaces the whole weekly schedule in one transaction.
        Send every block you want. A block you omit is erased.

        Send an empty array to clear the schedule. The calendar then offers no
        slots.

        The response echoes what you sent.
      security:
        - SecretKey: []
      parameters:
        - $ref: '#/components/parameters/StaffID'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [working_hours]
              properties:
                working_hours:
                  type: array
                  items: { $ref: '#/components/schemas/WorkingHour' }
            example:
              working_hours:
                - weekday: 1
                  start_minute: 540
                  end_minute: 1020
                - weekday: 2
                  start_minute: 540
                  end_minute: 1020
      responses:
        '200':
          description: The schedule is replaced.
          content:
            application/json:
              schema:
                type: object
                required: [working_hours]
                properties:
                  working_hours:
                    type: array
                    items: { $ref: '#/components/schemas/WorkingHour' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '422': { $ref: '#/components/responses/ValidationFailed' }

  /v1/staff/{id}/time-off:
    get:
      tags: [Staff]
      summary: List time off
      operationId: listTimeOff
      description: This endpoint lists the time off blocks of one calendar.
      security:
        - SecretKey: []
      parameters:
        - $ref: '#/components/parameters/StaffID'
      responses:
        '200':
          description: The time off blocks of the calendar.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/TimeOff' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '422': { $ref: '#/components/responses/ValidationFailed' }
    post:
      tags: [Staff]
      summary: Add time off
      operationId: createTimeOff
      description: |
        This endpoint blocks a period on one calendar. Availability removes the
        period.

        It does not cancel a booking that already sits inside the period.
      security:
        - SecretKey: []
      parameters:
        - $ref: '#/components/parameters/StaffID'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [starts_at, ends_at]
              properties:
                starts_at: { type: string, format: date-time }
                ends_at: { type: string, format: date-time }
                reason: { type: string }
            example:
              starts_at: '2026-09-10T00:00:00+01:00'
              ends_at: '2026-09-17T00:00:00+01:00'
              reason: Annual leave
      responses:
        '201':
          description: The time off is added.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/TimeOff' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '422': { $ref: '#/components/responses/ValidationFailed' }

  /v1/staff/{id}/time-off/{timeOffID}:
    delete:
      tags: [Staff]
      summary: Remove time off
      operationId: deleteTimeOff
      description: |
        This endpoint removes one time off block. Availability gives the period
        back.
      security:
        - SecretKey: []
      parameters:
        - $ref: '#/components/parameters/StaffID'
        - name: timeOffID
          in: path
          required: true
          schema: { type: string, format: uuid }
          description: The time off block id.
      responses:
        '204': { description: The block is removed. No body. }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '422': { $ref: '#/components/responses/ValidationFailed' }

  /v1/webhook-endpoints:
    get:
      tags: [Webhooks]
      summary: List endpoints
      operationId: listWebhookEndpoints
      description: |
        This endpoint lists the webhook endpoints of the workspace. No response
        here carries the signing secret.
      security:
        - SecretKey: []
      responses:
        '200':
          description: The webhook endpoints of the workspace.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/WebhookEndpoint' }
        '401': { $ref: '#/components/responses/Unauthorized' }
    post:
      tags: [Webhooks]
      summary: Create an endpoint
      operationId: createWebhookEndpoint
      description: |
        This endpoint registers a URL to receive events.

        CAUTION: The response carries `secret`, and it is the only response
        that ever does. Store it now. The stored form is sealed, so no later
        call can show it to you. If you lose it, use the rotate endpoint and
        store the new one.
      security:
        - SecretKey: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [url, events]
              properties:
                url: { type: string }
                events:
                  type: array
                  items: { type: string }
            example:
              url: https://example.com/hooks/keptslot
              events: [booking.created, booking.cancelled]
      responses:
        '201':
          description: The endpoint is created. This response carries `secret`.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/WebhookEndpoint' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '422': { $ref: '#/components/responses/ValidationFailed' }

  /v1/webhook-endpoints/{id}:
    patch:
      tags: [Webhooks]
      summary: Change an endpoint
      operationId: patchWebhookEndpoint
      description: |
        This endpoint changes one webhook endpoint. Send only the fields you
        want to change. A field you omit keeps its value.

        Set `active` to false to stop deliveries and keep the endpoint.
      security:
        - SecretKey: []
      parameters:
        - $ref: '#/components/parameters/WebhookEndpointID'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                url: { type: string }
                events:
                  type: array
                  items: { type: string }
                active: { type: boolean }
      responses:
        '200':
          description: The endpoint is changed. No secret is in this response.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/WebhookEndpoint' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '422': { $ref: '#/components/responses/ValidationFailed' }
    delete:
      tags: [Webhooks]
      summary: Deactivate endpoint
      operationId: deleteWebhookEndpoint
      description: |
        CAUTION: The method is DELETE, and this endpoint DEACTIVATES. It erases
        nothing. The endpoint stays readable and its delivery history stays
        readable.

        The response is the endpoint, with `active` false. It is a 200 and not
        a 204.
      security:
        - SecretKey: []
      parameters:
        - $ref: '#/components/parameters/WebhookEndpointID'
      responses:
        '200':
          description: The endpoint is inactive.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/WebhookEndpoint' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '422': { $ref: '#/components/responses/ValidationFailed' }

  /v1/webhook-endpoints/{id}/rotate-secret:
    post:
      tags: [Webhooks]
      summary: Rotate the secret
      operationId: rotateWebhookSecret
      description: |
        This endpoint makes a new signing secret and returns it once.

        CAUTION: The old secret stops working immediately. Deploy the new
        secret before you rotate, or signature checks fail between the rotate
        call and your deploy.

        The response carries the id and the secret only. It is not the whole
        endpoint object.
      security:
        - SecretKey: []
      parameters:
        - $ref: '#/components/parameters/WebhookEndpointID'
      responses:
        '200':
          description: The new secret. This is the only time it is shown.
          content:
            application/json:
              schema:
                type: object
                required: [id, secret]
                properties:
                  id: { type: string, format: uuid }
                  secret: { type: string }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '422': { $ref: '#/components/responses/ValidationFailed' }

  /v1/webhook-endpoints/{id}/deliveries:
    get:
      tags: [Webhooks]
      summary: List delivery attempts
      operationId: listWebhookDeliveries
      description: |
        This endpoint lists what was sent to one endpoint, newest first. Use it
        to see why a delivery failed.

        `next_cursor` is ABSENT on the last page. It is not null. Test whether
        the key exists.
      security:
        - SecretKey: []
      parameters:
        - $ref: '#/components/parameters/WebhookEndpointID'
        - name: limit
          in: query
          required: false
          schema: { type: integer, default: 50, maximum: 200 }
        - name: cursor
          in: query
          required: false
          schema: { type: string }
        - name: status
          in: query
          required: false
          schema: { type: string }
          description: Limits the result to one delivery status.
      responses:
        '200':
          description: One page of delivery attempts.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/WebhookDelivery' }
                  next_cursor:
                    type: string
                    description: Absent on the last page.
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '422': { $ref: '#/components/responses/ValidationFailed' }

  /v1/workspace:
    get:
      tags: [Workspace]
      summary: Read the settings
      operationId: getWorkspaceSettings
      description: This endpoint gives the settings of the workspace the key belongs to.
      security:
        - SecretKey: []
      responses:
        '200':
          description: The workspace settings.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/WorkspaceSettings' }
        '401': { $ref: '#/components/responses/Unauthorized' }
    patch:
      tags: [Workspace]
      summary: Change the settings
      operationId: patchWorkspaceSettings
      description: |
        This endpoint changes the settings of the workspace. Send only the
        fields you want to change. A field you omit keeps its value.
      security:
        - SecretKey: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                name: { type: string }
                timezone:
                  type: string
                  description: An IANA zone name, such as `Europe/London`.
                currency: { type: string }
                require_booking_verification: { type: boolean }
                sms_enabled: { type: boolean }
                staff_alerts: { type: string }
                staff_reminders_enabled: { type: boolean }
                staff_reminder_offset_minutes: { type: integer }
                reminder_offset_minutes: { type: integer }
                slot_interval_minutes: { type: integer }
                booking_policy: { type: [string, 'null'] }
                vertical: { type: string }
                contact_email: { type: [string, 'null'] }
                contact_phone: { type: [string, 'null'] }
                address: { type: [string, 'null'] }
      responses:
        '200':
          description: The settings are changed.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/WorkspaceSettings' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '422': { $ref: '#/components/responses/ValidationFailed' }

  /v1/usage:
    get:
      tags: [Workspace]
      summary: Read usage
      operationId: getWorkspaceUsage
      description: |
        This endpoint gives what THIS workspace used in the current billing
        period.

        There is no maximum beside either number, and that is deliberate. The
        caps belong to the account and are shared across its workspaces, so a
        per-workspace maximum would be a number that nothing enforces. A
        workspace key must not learn about its sibling workspaces. Read the
        caps with an account key, from `GET /v1/account/usage`.

        The period is given as two dates, not as a month name. An account
        billed on the 20th has a period that no month names.
      security:
        - SecretKey: []
      responses:
        '200':
          description: This workspace's usage for the current period.
          content:
            application/json:
              schema:
                type: object
                required: [period_start, period_end, bookings_this_month, sms_credits_used]
                properties:
                  period_start: { type: string, format: date }
                  period_end: { type: string, format: date }
                  bookings_this_month:
                    type: integer
                    description: |
                      What this workspace contributed to the account total.
                  sms_credits_used:
                    type: integer
                    description: |
                      SMS credits this workspace spent. It sums both pots: the
                      monthly allowance and any top-up.
        '401': { $ref: '#/components/responses/Unauthorized' }

  /v1/account/workspaces:
    get:
      tags: [Account]
      summary: List workspaces
      operationId: listAccountWorkspaces
      description: |
        This endpoint lists the live workspaces of the account. A deleted
        workspace is not in the response.
      security:
        - AccountKey: []
      responses:
        '200':
          description: The live workspaces of the account.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      required: [id, name, timezone]
                      properties:
                        id: { type: string, format: uuid }
                        name: { type: string }
                        slug:
                          type: [string, 'null']
                          description: |
                            The address of the booking page. It is null for a
                            workspace that has no slug yet.
                        timezone: { type: string }
        '401': { $ref: '#/components/responses/Unauthorized' }
    post:
      tags: [Account]
      summary: Create a workspace
      operationId: createAccountWorkspace
      description: |
        This endpoint creates a workspace on the account, with one calendar.

        CAUTION: If `with_secret_key` is true and `secret_key` is ABSENT from
        the response, the workspace still exists. The key mint failed after the
        create committed. Mint a key with
        `POST /v1/account/workspaces/{id}/keys`. Do not read a missing
        `secret_key` as a failed create, and do not create the workspace again.
      security:
        - AccountKey: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name, timezone]
              properties:
                name: { type: string }
                timezone:
                  type: string
                  description: An IANA zone name, such as `Europe/London`.
                slot_interval_minutes:
                  type: integer
                  description: The step between the slots that availability offers.
                staff_name:
                  type: string
                  description: The name of the first calendar.
                with_secret_key:
                  type: boolean
                  description: |
                    If it is true, the response carries a new secret key. That
                    is the only time the key is shown.
            example:
              name: Bella Hair, Leeds
              timezone: Europe/London
              slot_interval_minutes: 15
              staff_name: Chair 1
              with_secret_key: true
      responses:
        '201':
          description: The workspace is created.
          content:
            application/json:
              schema:
                type: object
                required: [id, name, slug, timezone]
                properties:
                  id: { type: string, format: uuid }
                  name: { type: string }
                  slug: { type: string }
                  timezone: { type: string }
                  secret_key:
                    type: string
                    description: |
                      Present only when the request asked for it, and only on
                      this response. Store it now. Read the CAUTION above when
                      it is absent.
        '401': { $ref: '#/components/responses/Unauthorized' }
        '402': { $ref: '#/components/responses/PlanLimit' }
        '422': { $ref: '#/components/responses/ValidationFailed' }

  /v1/account/workspaces/{id}/keys:
    post:
      tags: [Account]
      summary: Mint a workspace key
      operationId: mintAccountWorkspaceKey
      description: |
        This endpoint makes a new API key for a workspace the account owns. A
        workspace on another account gives a 404, and nothing is created.

        CAUTION: `key` carries the full value, and this is the only response
        that shows it for a secret key. Store it now. A later read gives null.
      security:
        - AccountKey: []
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
          description: The workspace id.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [type]
              properties:
                type:
                  type: string
                  enum: [sk, pk]
                  description: |
                    `sk` for a secret key, used from a server. `pk` for a
                    publishable key, used from a browser.
                name:
                  type: string
                  description: A label for the key. 100 characters at most.
            example:
              type: sk
              name: Production server
      responses:
        '201':
          description: The key is created.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ApiKey' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '422': { $ref: '#/components/responses/ValidationFailed' }

  /v1/account/usage:
    get:
      tags: [Account]
      summary: Read account usage
      operationId: getAccountAPIUsage
      description: |
        This endpoint gives what the whole account used in the current billing
        period, and the caps it is counted against. The caps belong to the
        account and are shared across its workspaces.

        Use this endpoint for the caps. `GET /v1/usage` reports one workspace's
        contribution and has no caps beside it.

        The period is given as two dates, not as a month name. An account
        billed on the 20th has a period that no month names.
      security:
        - AccountKey: []
      responses:
        '200':
          description: The account usage and its caps.
          content:
            application/json:
              schema:
                type: object
                required: [plan, plan_step, period_start, period_end, bookings_this_month, active_workspaces, max_active_workspaces, staff, max_staff, monthly_sms_credits, sms_credits_remaining, sms_topup_balance, extra_locations]
                properties:
                  plan: { type: string }
                  plan_step:
                    type: integer
                    description: |
                      The volume step inside the plan. The caps are keyed by
                      plan and step together, so the two travel as a pair.
                  period_start: { type: string, format: date }
                  period_end: { type: string, format: date }
                  bookings_this_month:
                    type: integer
                    description: |
                      Bookings created in this billing period. There is no
                      maximum beside it and no maximum anywhere else: bookings
                      are counted and are not capped on any plan.
                  active_workspaces: { type: integer }
                  max_active_workspaces: { type: integer }
                  staff:
                    type: integer
                    description: |
                      Active calendars across every live workspace of the
                      account.
                  max_staff: { type: integer }
                  monthly_sms_credits: { type: integer }
                  sms_credits_remaining:
                    type: integer
                    description: |
                      What is left of the monthly credits. It has a floor of 0.
                      A downgrade in the middle of a period can leave the used
                      figure above the new allowance.
                  sms_topup_balance:
                    type: integer
                    description: Bought credits. They are separate from the monthly ones.
                  extra_locations: { type: integer }
        '401': { $ref: '#/components/responses/Unauthorized' }

  /v1/public/workspaces/{slug}:
    get:
      tags: [Customer links]
      summary: Read a booking page
      operationId: getWorkspaceBootstrap
      description: |
        This endpoint gives one slug's workspace, its bookable services and
        calendars, and the questions its booking form asks.

        It carries no API key. The response includes the workspace publishable
        key, and every later call carries that key.

        `require_verification` warns the customer that a 6-digit code follows.
        Do not use it to decide the next screen. Use the status on the create response instead. A business can change this setting at any time.
      security: []
      parameters:
        - name: slug
          in: path
          required: true
          schema: { type: string }
      responses:
        '200':
          description: The workspace and its bookable content.
          content:
            application/json:
              schema:
                type: object
                required: [workspace, services, staff, questions]
                properties:
                  workspace:
                    type: object
                    required: [slug, name, timezone, currency, publishable_key, require_verification, sms_enabled, vertical]
                    properties:
                      slug: { type: string }
                      name: { type: string }
                      timezone:
                        type: string
                        description: An IANA zone name, such as `Europe/London`.
                      currency: { type: string }
                      publishable_key:
                        type: string
                        description: |
                          The `pk_` key for later calls. It is public by
                          design. It is not a secret.
                      require_verification: { type: boolean }
                      sms_enabled:
                        type: boolean
                        description: |
                          True when the business sends text messages as well as
                          email. The phone field is optional for every
                          business, whatever this value is.
                      vertical:
                        type: string
                        description: |
                          Selects the customer-facing words, such as
                          `appointment` or `meeting`. It changes copy only. It
                          never changes booking mechanics.
                      booking_policy: { type: [string, 'null'] }
                      contact_email: { type: [string, 'null'] }
                      contact_phone: { type: [string, 'null'] }
                      blurb:
                        type: [string, 'null']
                        description: |
                          A short line about the business, shown under its
                          name. Plain text. Render it as text, never as
                          markup. Null when the business has not written one,
                          which is the usual case.
                      terms_url:
                        type: [string, 'null']
                        description: |
                          The BUSINESS's own terms, not the platform's. Show
                          it alongside ours and label it so a customer can
                          tell whose is whose. Always `http` or `https`.
                      website_url: { type: [string, 'null'] }
                      instagram_url: { type: [string, 'null'] }
                      maps_url: { type: [string, 'null'] }
                      confirmation_note:
                        type: [string, 'null']
                        description: |
                          Shown on the confirmation screen after the booking
                          succeeds. What to bring, where to park, the door
                          code. Plain text -- render it as text, never as
                          markup. Null is the usual case and renders nothing
                          at all.
                      social_title:
                        type: [string, 'null']
                        description: |
                          For the link card only, when someone pastes the
                          booking link into a message. Crawlers do not run
                          JavaScript, so nothing rendered in a browser can use
                          this. Fall back to the business name when it is null.
                      social_description: { type: [string, 'null'] }
                      allow_staff_choice:
                        type: boolean
                        description: |
                          False when the business does not let customers pick
                          who they see. Omit the staff step and send no
                          `staff_id`. The create endpoint already treats
                          `staff_id` as optional, so nothing else changes.
                      refund_policy:
                        type: [string, 'null']
                        description: |
                          The business's refund terms, for disclosure BEFORE a
                          customer picks a service that takes a deposit. Plain
                          text, passed through verbatim. We never parse it and
                          never compute a refund from it.
                  services:
                    type: array
                    items: { $ref: '#/components/schemas/Service' }
                  staff:
                    type: array
                    items: { $ref: '#/components/schemas/Staff' }
                  categories:
                    type: array
                    description: |
                      The headings services group under, in the order the
                      business set. Always an array, never null. Empty when
                      the business has created none, which renders as no
                      grouping at all. Do not re-sort it: the order is the
                      business's and is decided on the server.

                      A service whose `category_id` is null, or names no
                      category here, belongs in a final "Other" group -- or in
                      no group at all when that is the only group.
                    items:
                      type: object
                      required: [id, name, position]
                      properties:
                        id: { type: string, format: uuid }
                        name: { type: string }
                        position: { type: integer }
                  questions:
                    type: array
                    items: { $ref: '#/components/schemas/Question' }
        '404': { $ref: '#/components/responses/NotFound' }

  /v1/public/bookings/{token}:
    get:
      tags: [Customer links]
      summary: Read a booking
      operationId: getBookingBootstrap
      description: |
        This endpoint gives one booking and the business that holds it. The
        token in the path is the only credential. There is no login on this
        surface.

        An unknown token, an expired token, and the token of a cancelled booking give the same 404. A cancellation clears the token. Then the booking resolves to no row, the same as a token that never existed.

        Use `can_cancel` and `can_reschedule` to enable the buttons. Each one
        is true exactly when that call succeeds. They are not copies of each other. You can cancel a pending booking inside the notice window. You cannot move it.
      security: []
      parameters:
        - name: token
          in: path
          required: true
          schema: { type: string }
      responses:
        '200':
          description: The booking and its business.
          content:
            application/json:
              schema:
                type: object
                required: [booking, workspace]
                properties:
                  booking:
                    type: object
                    required: [id, status, starts_at, ends_at, service, staff, customer, can_cancel, cancel_cutoff_passed, cancel_cutoff_minutes, can_reschedule]
                    properties:
                      id: { type: string, format: uuid }
                      status: { type: string }
                      starts_at:
                        type: string
                        format: date-time
                        description: |
                          In the business's timezone. The appointment happens at the business.
                      ends_at: { type: string, format: date-time }
                      service: { $ref: '#/components/schemas/Service' }
                      staff: { $ref: '#/components/schemas/Staff' }
                      customer:
                        type: object
                        required: [name, email, phone]
                        properties:
                          name: { type: string }
                          email: { type: string }
                          phone: { type: string }
                      can_cancel: { type: boolean }
                      cancel_cutoff_passed:
                        type: boolean
                        description: |
                          True when the notice window is the reason
                          `can_cancel` is false. For a past booking it is
                          false, because the status is the reason instead.
                      cancel_cutoff_minutes:
                        type: integer
                        description: |
                          How long before the start time cancellation closes. A
                          value of 0 means cancellation stays open until the
                          start time.
                      can_reschedule: { type: boolean }
                      payment:
                        $ref: '#/components/schemas/BookingPaymentState'
                  workspace:
                    type: object
                    required: [slug, name, timezone, currency, vertical]
                    properties:
                      slug: { type: string }
                      name: { type: string }
                      timezone: { type: string }
                      currency: { type: string }
                      vertical: { type: string }
                      publishable_key:
                        type: [string, 'null']
                        description: |
                          Null when the workspace has no usable key. A customer can still view and cancel the booking. A reschedule needs the key, so it is not available.
                      blurb: { type: [string, 'null'] }
                      terms_url: { type: [string, 'null'] }
                      website_url: { type: [string, 'null'] }
                      instagram_url: { type: [string, 'null'] }
                      maps_url: { type: [string, 'null'] }
                      confirmation_note:
                        type: [string, 'null']
                        description: |
                          The same note the bootstrap carries. Show it once the
                          booking is confirmed. A booking that takes a deposit
                          redirects here to pay and never sees the booking
                          flow's confirmation screen, so this is the only place
                          those customers can read it. Plain text -- render it
                          as text, never as markup.
        '404': { $ref: '#/components/responses/NotFound' }

  /v1/public/bookings/{token}/cancel:
    post:
      tags: [Customer links]
      summary: Cancel a booking
      operationId: publicCancelBooking
      description: |
        This endpoint cancels the booking the token identifies. The token is
        the only credential.

        The service's cancel notice applies. If the notice window has closed,
        the endpoint refuses the request. Read `can_cancel` first to avoid a
        failed call.

        A cancellation clears the token. Then the same token gives a 404.
      security: []
      parameters:
        - name: token
          in: path
          required: true
          schema: { type: string }
      responses:
        '200':
          description: The booking is cancelled.
          content:
            application/json:
              schema:
                type: object
                required: [status]
                properties:
                  status: { type: string }
              example:
                status: cancelled
        '404': { $ref: '#/components/responses/NotFound' }
        '422': { $ref: '#/components/responses/ValidationFailed' }

  /v1/public/bookings/{token}/reschedule:
    post:
      tags: [Customer links]
      summary: Move a booking
      operationId: publicRescheduleBooking
      description: |
        This endpoint moves the booking the token identifies to a new time. The
        token is the only credential.

        The service's cancel notice applies here too. A booking that is not confirmed cannot move. Read `can_reschedule` first.

        The new time must be an open slot. Read the open slots from `GET /v1/availability`. Pass the id of the booking as `exclude_booking_id`. If you do not, the slot that the booking holds now counts as busy.
      security: []
      parameters:
        - name: token
          in: path
          required: true
          schema: { type: string }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [starts_at]
              properties:
                starts_at:
                  type: string
                  format: date-time
                  description: An RFC3339 timestamp. The new start time.
            example:
              starts_at: '2026-09-02T14:00:00+01:00'
      responses:
        '200':
          description: The booking is moved.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Booking' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409':
          description: The new time is not available.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '422': { $ref: '#/components/responses/ValidationFailed' }
