openapi: 3.1.0
info:
  title: Mr Charles — public studio booking API
  version: "1.0.0"
  summary: Read a studio's rooms and availability, and book a room as a guest.
  description: |
    Public, unauthenticated endpoints used by studio websites and third-party
    frontends. A guest books without an account: a 12-minute hold, a one-time
    code to a phone or email, then confirmation (card deposit or pay on site).

    Prices, policy and conflicts are decided by the server. Writes are rate
    limited per IP and per contact, and require a Cloudflare Turnstile token
    when the site payload exposes `captchaSiteKey`.

    Guides: https://developers.mrcharles.app
  contact:
    name: Mr Charles developers
    email: developers@mrcharles.app
    url: https://developers.mrcharles.app
servers:
  - url: https://api.mrcharles.app/api/v1
    description: Production
  - url: https://api-pre.mrcharles.app/api/v1
    description: Pre-production
tags:
  - name: Site
  - name: Availability
  - name: Holds
  - name: Bookings

paths:
  /public/sites/{slug}:
    get:
      tags: [Site]
      summary: Site payload by slug
      parameters:
        - $ref: '#/components/parameters/Slug'
      responses:
        '200':
          description: The studio, its rooms and the site configuration
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/Envelope'
                  - type: object
                    properties:
                      data: { $ref: '#/components/schemas/PublicSite' }
        '404': { $ref: '#/components/responses/NotFound' }

  /public/sites/resolve:
    get:
      tags: [Site]
      summary: Site payload by hostname
      parameters:
        - name: host
          in: query
          required: true
          schema: { type: string, examples: ['studiouno.com'] }
      responses:
        '200':
          description: Same payload as by slug
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/Envelope'
                  - type: object
                    properties:
                      data: { $ref: '#/components/schemas/PublicSite' }
        '404': { $ref: '#/components/responses/NotFound' }

  /public/sites/{slug}/rooms/{roomId}/availability:
    get:
      tags: [Availability]
      summary: Free spans per day for a room
      parameters:
        - $ref: '#/components/parameters/Slug'
        - $ref: '#/components/parameters/RoomId'
        - name: from
          in: query
          schema: { type: string, format: date }
          description: Studio-local day. Defaults to today.
        - name: to
          in: query
          schema: { type: string, format: date }
          description: Inclusive. Windows longer than 31 days are clipped.
      responses:
        '200':
          description: Availability
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/Envelope'
                  - type: object
                    properties:
                      data: { $ref: '#/components/schemas/Availability' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }

  /public/sites/{slug}/holds:
    post:
      tags: [Holds]
      summary: Reserve a slot and send a one-time code
      parameters:
        - $ref: '#/components/parameters/Slug'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/CreateHold' }
      responses:
        '201':
          description: Hold created; a code was sent to the guest
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/Envelope'
                  - type: object
                    properties:
                      data: { $ref: '#/components/schemas/Hold' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '503': { $ref: '#/components/responses/Unavailable' }

  /public/holds/{holdId}/verify:
    post:
      tags: [Holds]
      summary: Verify the one-time code
      parameters:
        - $ref: '#/components/parameters/HoldId'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [code]
              properties:
                code: { type: string, pattern: '^[0-9]{6}$', examples: ['181989'] }
      responses:
        '200':
          description: Hold is now verified
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/Envelope'
                  - type: object
                    properties:
                      data: { $ref: '#/components/schemas/Hold' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '410': { $ref: '#/components/responses/Gone' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /public/holds/{holdId}/resend:
    post:
      tags: [Holds]
      summary: Send a fresh code
      description: Maximum 3 sends per hold, at least 60 seconds apart.
      parameters:
        - $ref: '#/components/parameters/HoldId'
      responses:
        '200':
          description: A new code was sent
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/Envelope'
                  - type: object
                    properties:
                      data: { $ref: '#/components/schemas/Hold' }
        '410': { $ref: '#/components/responses/Gone' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /public/holds/{holdId}/confirm:
    post:
      tags: [Holds]
      summary: Turn a verified hold into a booking
      parameters:
        - $ref: '#/components/parameters/HoldId'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [paymentMode]
              properties:
                paymentMode:
                  type: string
                  enum: [card, on_site]
                notes: { type: string, maxLength: 500 }
                email: { type: string, format: email }
      responses:
        '201':
          description: Booking created
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/Envelope'
                  - type: object
                    properties:
                      data: { $ref: '#/components/schemas/ConfirmResult' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '409': { $ref: '#/components/responses/Conflict' }
        '410': { $ref: '#/components/responses/Gone' }

  /public/bookings/manage/{token}:
    get:
      tags: [Bookings]
      summary: Read a booking with the guest's manage token
      parameters:
        - $ref: '#/components/parameters/ManageToken'
      responses:
        '200':
          description: Booking summary
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/Envelope'
                  - type: object
                    properties:
                      data: { $ref: '#/components/schemas/Booking' }
        '404': { $ref: '#/components/responses/NotFound' }

  /public/bookings/manage/{token}/cancel:
    post:
      tags: [Bookings]
      summary: Cancel a booking
      parameters:
        - $ref: '#/components/parameters/ManageToken'
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                reason: { type: string, maxLength: 500 }
      responses:
        '200':
          description: Booking cancelled
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/Envelope'
                  - type: object
                    properties:
                      data: { $ref: '#/components/schemas/Booking' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { $ref: '#/components/responses/Conflict' }

components:
  parameters:
    Slug:
      name: slug
      in: path
      required: true
      schema: { type: string, examples: ['studio-uno-madrid'] }
    RoomId:
      name: roomId
      in: path
      required: true
      schema: { type: string, format: uuid }
    HoldId:
      name: holdId
      in: path
      required: true
      schema: { type: string, format: uuid }
    ManageToken:
      name: token
      in: path
      required: true
      schema: { type: string, minLength: 64, maxLength: 64 }

  responses:
    BadRequest:
      description: VALIDATION_ERROR · CAPTCHA_FAILED · CODE_INVALID · PAYMENT_MODE_DENIED
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    NotFound:
      description: RESOURCE_NOT_FOUND · SITE_NOT_LIVE
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    Conflict:
      description: SLOT_TAKEN · ROOM_NOT_BOOKABLE · OUTSIDE_HOURS · OUTSIDE_BOOKING_WINDOW · HOLD_NOT_VERIFIED · HOLD_ALREADY_CONFIRMED · CANCEL_TOO_LATE · INVALID_STATE · PAYMENTS_UNAVAILABLE
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    Gone:
      description: HOLD_EXPIRED
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    RateLimited:
      description: RATE_LIMIT_EXCEEDED · TOO_MANY_ATTEMPTS
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    Unavailable:
      description: SMS_UNAVAILABLE
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }

  schemas:
    Envelope:
      type: object
      required: [success]
      properties:
        success: { type: boolean, const: true }

    Error:
      type: object
      required: [success, error]
      properties:
        success: { type: boolean, const: false }
        error:
          type: object
          required: [code, message]
          properties:
            code: { type: string, examples: ['SLOT_TAKEN'] }
            message: { type: string }

    PublicSite:
      type: object
      required: [studio, rooms, site, bookingOnline, cardPayments]
      properties:
        studio: { $ref: '#/components/schemas/Studio' }
        rooms:
          type: array
          items: { $ref: '#/components/schemas/Room' }
        site: { $ref: '#/components/schemas/SiteConfig' }
        bookingOnline: { type: boolean }
        cardPayments: { type: boolean }
        captchaSiteKey: { type: string }
        stripePublishableKey: { type: string }

    Studio:
      type: object
      properties:
        name: { type: string }
        slug: { type: string }
        description: { type: string }
        shortDescription: { type: [string, 'null'] }
        studioType: { type: [string, 'null'] }
        logoUrl: { type: [string, 'null'] }
        coverImageUrl: { type: [string, 'null'] }
        addressLine1: { type: string }
        addressLine2: { type: [string, 'null'] }
        city: { type: string }
        postalCode: { type: string }
        country: { type: string, description: ISO 3166-1 alpha-2 }
        latitude: { type: [number, 'null'] }
        longitude: { type: [number, 'null'] }
        phone: { type: [string, 'null'] }
        email: { type: string }
        website: { type: [string, 'null'] }
        timezone: { type: string, description: IANA name; every date and time is in this zone }
        isVerified: { type: boolean }
        averageRating: { type: [number, 'null'] }
        totalReviews: { type: integer }
        operatingHours:
          type: array
          items:
            type: object
            properties:
              dayOfWeek: { type: integer, minimum: 0, maximum: 6 }
              openTime: { type: string, examples: ['10:00'] }
              closeTime: { type: string, examples: ['22:00'] }
              isClosed: { type: boolean }

    Room:
      type: object
      properties:
        id: { type: string, format: uuid }
        name: { type: string }
        description: { type: [string, 'null'] }
        capacity: { type: integer }
        roomType: { type: [string, 'null'] }
        hourlyCents: { type: integer, description: Minor units }
        currency: { type: string, examples: ['EUR'] }
        bookable: { type: boolean }
        minMinutes: { type: integer }
        maxMinutes: { type: integer }
        stepMinutes: { type: integer }
        paymentModes:
          type: array
          items: { type: string, enum: [card, on_site] }
        depositPercent: { type: integer }
        cancelNoticeHours: { type: integer }
        instructions: { $ref: '#/components/schemas/Localized' }
        photos: { type: array, items: { type: string } }

    SiteConfig:
      type: object
      properties:
        template: { type: string, enum: [stage, daylight] }
        theme: { type: object }
        sections: { type: object }
        content: { type: object }
        legal: { type: object }
        seo: { type: object }
        defaultLocale: { type: string, enum: [es, ca, en] }
        locales: { type: array, items: { type: string, enum: [es, ca, en] } }
        status: { type: string, enum: [live] }
        customDomain: { type: [string, 'null'] }
        canonicalHost: { type: string }

    Localized:
      type: object
      additionalProperties: { type: string }
      examples:
        - { es: 'Llama al timbre 2.', en: 'Ring bell 2.' }

    Availability:
      type: object
      properties:
        roomId: { type: string, format: uuid }
        timezone: { type: string }
        hourlyCents: { type: integer }
        currency: { type: string }
        minMinutes: { type: integer }
        maxMinutes: { type: integer }
        stepMinutes: { type: integer }
        minNoticeMin: { type: integer }
        maxAdvanceDays: { type: integer }
        days:
          type: array
          items:
            type: object
            properties:
              date: { type: string, format: date }
              free:
                type: array
                description: Continuous open spans; `end` is exclusive.
                items:
                  type: object
                  properties:
                    start: { type: string, examples: ['09:00'] }
                    end: { type: string, examples: ['13:00'] }

    CreateHold:
      type: object
      required: [roomId, date, startTime, durationMinutes, guestName, channel, contact, idempotencyKey]
      properties:
        roomId: { type: string, format: uuid }
        date: { type: string, format: date, description: Studio-local day }
        startTime: { type: string, pattern: '^[0-2][0-9]:[0-5][0-9]$' }
        durationMinutes: { type: integer, description: Between minMinutes and maxMinutes, multiple of stepMinutes }
        guestName: { type: string, minLength: 2, maxLength: 120 }
        channel: { type: string, enum: [phone, email] }
        contact: { type: string, description: E.164 phone or email address }
        locale: { type: string, enum: [es, ca, en], default: es }
        idempotencyKey: { type: string, description: One per attempt; reuse when retrying }
        captchaToken: { type: string, description: Required when the site exposes captchaSiteKey }

    Hold:
      type: object
      properties:
        holdId: { type: string, format: uuid }
        status: { type: string, enum: [pending, verified] }
        roomId: { type: string, format: uuid }
        roomName: { type: string }
        date: { type: string, format: date }
        startTime: { type: string }
        endTime: { type: string }
        timezone: { type: string }
        price: { $ref: '#/components/schemas/Price' }
        channel: { type: string, enum: [phone, email] }
        contactMasked: { type: string }
        expiresAt: { type: string, format: date-time }
        resendsLeft: { type: integer }
        paymentModes: { type: array, items: { type: string, enum: [card, on_site] } }
        devCode: { type: string, description: Pre-production only, when no delivery provider is configured }

    Price:
      type: object
      properties:
        priceCents: { type: integer }
        depositCents: { type: integer, description: Charged now when paying by card }
        currency: { type: string }
        minutes: { type: integer }

    ConfirmResult:
      type: object
      properties:
        booking: { $ref: '#/components/schemas/Booking' }
        manageToken: { type: string, description: Shown once — cannot be recovered }
        manageUrl: { type: string, format: uri }
        clientSecret: { type: string, description: Stripe PaymentIntent secret, only when paymentMode is card }

    Booking:
      type: object
      properties:
        id: { type: string, format: uuid }
        reference: { type: string, examples: ['MC-TGRMY5'] }
        status: { type: string, enum: [pending_payment, confirmed, completed, cancelled, no_show] }
        paymentMode: { type: string, enum: [card, on_site] }
        paymentStatus: { type: string, enum: [unpaid, pending, deposit_paid, paid, refunded] }
        studioName: { type: string }
        roomName: { type: string }
        guestName: { type: string }
        date: { type: string, format: date }
        startTime: { type: string }
        endTime: { type: string }
        timezone: { type: string }
        startsAt: { type: string, format: date-time }
        endsAt: { type: string, format: date-time }
        minutes: { type: integer }
        priceCents: { type: integer }
        depositCents: { type: integer }
        currency: { type: string }
        cancelNoticeHours: { type: integer }
        mayCancel: { type: boolean }
        notes: { type: [string, 'null'] }
        paymentDueAt: { type: [string, 'null'], format: date-time }
        createdAt: { type: string, format: date-time }
