openapi: 3.1.0
info:
  title: HomeMasters Public API
  version: "1.0.0"
  description: |
    The HomeMasters Public API lets approved integrators check service availability,
    create bookings, generate a payment link, and check booking status for our
    Singapore home-services platform.

    ## Authentication
    Every request must include your secret API key as a bearer token:

    ```
    Authorization: Bearer hm_live_xxxxxxxx
    ```

    (The `X-API-Key: hm_live_xxxxxxxx` header is also accepted.) Keys are issued by
    HomeMasters. Test keys (`hm_test_...`) are fully isolated: bookings AND their
    customer are flagged test, so they never block real availability or dispatch a
    real provider - integrate safely, then switch to a live key. Keep keys secret; never
    embed them in browser or mobile clients.

    ## Conventions
    - All timestamps are ISO-8601; dates are `YYYY-MM-DD`; times are `HH:MM` (24h, SGT).
    - Money is a decimal number of Singapore Dollars (`currency: "SGD"`).
    - Successful responses are wrapped as `{ "data": ..., "request_id": "..." }`.
    - Errors use RFC 9457 problem details (`application/problem+json`).

    ## Rate limiting
    Each key has an hourly request quota, advertised on every response via the
    `RateLimit-Policy` header. Exceeding it returns `429` with `Retry-After`.

    ## Idempotency
    `POST /v1/bookings` accepts an `Idempotency-Key` header (a UUIDv4 you generate).
    Retrying with the same key replays the original response; reusing a key with a
    different body returns `409`.
  contact:
    name: HomeMasters
    url: https://homemaster.co
servers:
  - url: https://dogluauhvuljswsvbuac.supabase.co/functions/v1/public-api
    description: Production

security:
  - BearerAuth: []
  - ApiKeyHeader: []

tags:
  - name: Catalog
  - name: Availability
  - name: Bookings
  - name: Support

paths:
  /v1/services:
    get:
      tags: [Catalog]
      summary: List bookable services
      description: >-
        Returns active, customer-bookable services and their SKUs (valid
        start-time / duration / price combinations). Response shape is stable,
        but the SKU set is a moving catalog: slots marked admin-only are
        withheld here and are rejected by POST /v1/bookings, so integrators
        must treat this list (or /v1/availability) as the only source of valid
        sku_id values rather than caching one indefinitely.
      operationId: listServices
      responses:
        "200":
          description: Services list
          headers:
            RateLimit-Policy: { $ref: "#/components/headers/RateLimitPolicy" }
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      services:
                        type: array
                        items: { $ref: "#/components/schemas/Service" }
                  request_id: { type: string }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /v1/coverage:
    get:
      tags: [Availability]
      summary: Check service coverage
      description: |
        The lightweight first step of a booking: is a service serviceable at a postal
        code at all, before choosing a date. Geocodes the postal code and reports
        whether any provider covers it for the given service. Use this to gate the
        address step, then call GET /v1/availability for concrete dates and slots.
      operationId: getCoverage
      parameters:
        - { name: service_code, in: query, required: true, schema: { type: string }, example: cleaning }
        - { name: postal_code, in: query, required: true, schema: { type: string, pattern: "^\\d{6}$" }, example: "238823" }
      responses:
        "200":
          description: Coverage result
          headers:
            RateLimit-Policy: { $ref: "#/components/headers/RateLimitPolicy" }
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/Coverage" }
                  request_id: { type: string }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationError" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /v1/availability:
    get:
      tags: [Availability]
      summary: Check availability
      description: |
        Returns available start times for a service at a postal code on a given date.
        Supply `end_date` (with `date`) to instead return the list of available dates
        in that range. Services with `booking_mode: duration` require `duration`;
        services with `booking_mode: units` (e.g. aircon packs) require `job_type` and
        `unit_count`. Check `booking_mode` from GET /v1/services.
      operationId: getAvailability
      parameters:
        - { name: service_code, in: query, required: true, schema: { type: string }, example: cleaning }
        - { name: postal_code, in: query, required: true, schema: { type: string, pattern: "^\\d{6}$" }, example: "238823" }
        - { name: date, in: query, required: true, schema: { type: string, format: date }, example: "2026-07-10" }
        - { name: end_date, in: query, required: false, schema: { type: string, format: date }, description: "If set, returns available_dates instead of slots." }
        - { name: duration, in: query, required: false, schema: { type: number }, description: "Hours. Required when booking_mode is duration.", example: 3 }
        - { name: frequency, in: query, required: false, schema: { type: string, enum: [one-time, weekly, bi-weekly], default: one-time } }
        - { name: job_type, in: query, required: false, schema: { type: string }, description: "Required when booking_mode is units." }
        - { name: unit_count, in: query, required: false, schema: { type: integer }, description: "Required when booking_mode is units." }
      responses:
        "200":
          description: Availability
          headers:
            RateLimit-Policy: { $ref: "#/components/headers/RateLimitPolicy" }
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    oneOf:
                      - $ref: "#/components/schemas/SlotAvailability"
                      - $ref: "#/components/schemas/DateAvailability"
                  request_id: { type: string }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/ValidationError" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /v1/bookings:
    post:
      tags: [Bookings]
      summary: Create a booking and get a payment link
      description: |
        Creates a booking (status `pending_payment`) and returns a branded payment
        page URL (`/pay/b/{bookingId}`). When the customer pays there, the booking
        proceeds to assignment automatically. Booking management (reschedule/cancel)
        is not part of this API.
      operationId: createBooking
      parameters:
        - name: Idempotency-Key
          in: header
          required: false
          schema: { type: string, format: uuid }
          description: A UUIDv4 to make this create safely retryable.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/BookingCreateRequest" }
      responses:
        "201":
          description: Booking created
          headers:
            RateLimit-Policy: { $ref: "#/components/headers/RateLimitPolicy" }
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/BookingCreated" }
                  request_id: { type: string }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "422": { $ref: "#/components/responses/ValidationError" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /v1/bookings/{id}:
    get:
      tags: [Bookings]
      summary: Get booking status
      description: Returns the current status and payment state of a booking created by your key.
      operationId: getBooking
      parameters:
        - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
      responses:
        "200":
          description: Booking status
          headers:
            RateLimit-Policy: { $ref: "#/components/headers/RateLimitPolicy" }
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/BookingStatus" }
                  request_id: { type: string }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /v1/qna:
    post:
      tags: [Support]
      summary: Ask a question answered from our SOP
      description: |
        Answers a free-text question strictly from our documented standard operating
        procedures (pricing, scheduling, cancellation, packs/renewals, the affiliate
        program, and service policy). Retrieval-grounded: the answer is drawn only from
        matching SOP sections, never from outside knowledge. When the SOP does not cover
        the question, `answered_from_sop` is `false` and the answer says so.

        This is an informational endpoint - it takes no action on any booking or account
        and holds no conversation state (each request is answered independently). Each
        call runs two LLM steps, so it is more expensive and higher-latency than the
        data endpoints; cache answers where you can.
      operationId: askQna
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/QnaRequest" }
      responses:
        "200":
          description: Answer
          headers:
            RateLimit-Policy: { $ref: "#/components/headers/RateLimitPolicy" }
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/SopAnswer" }
                  request_id: { type: string }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "422": { $ref: "#/components/responses/ValidationError" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "502": { $ref: "#/components/responses/UpstreamError" }

components:
  securitySchemes:
    BearerAuth:
      type: http
      scheme: bearer
      description: "Authorization: Bearer hm_live_xxx"
    ApiKeyHeader:
      type: apiKey
      in: header
      name: X-API-Key

  headers:
    RateLimitPolicy:
      description: IETF rate-limit policy advertisement, e.g. '"hourly";q=1000;w=3600'.
      schema: { type: string }

  schemas:
    Sku:
      type: object
      properties:
        sku_id: { type: string, format: uuid }
        start_time: { type: string, example: "14:00" }
        duration_hrs: { type: number, example: 3 }
        price: { type: number, example: 84 }
        job_type: { type: [string, "null"] }
        unit_count: { type: [integer, "null"] }
        pack_size: { type: [integer, "null"] }
    Service:
      type: object
      properties:
        code: { type: [string, "null"], example: cleaning }
        name: { type: string, example: Home Cleaning }
        description: { type: [string, "null"] }
        pricing_model: { type: string, enum: [hourly, per_unit] }
        booking_mode:
          type: string
          enum: [duration, units]
          description: "Which params availability/booking need: 'duration' (send duration; e.g. cleaning) or 'units' (send job_type + unit_count; e.g. aircon packs)."
        allowed_durations: { type: [array, "null"], items: { type: number } }
        unit_label: { type: [string, "null"] }
        unit_label_plural: { type: [string, "null"] }
        skus:
          type: array
          items: { $ref: "#/components/schemas/Sku" }
    Coverage:
      type: object
      properties:
        service_code: { type: [string, "null"] }
        postal_code: { type: string }
        serviceable: { type: boolean, description: "True if at least one provider covers this postal code for the service." }
        provider_count: { type: integer, description: "How many providers cover this postal code for the service." }
    Slot:
      type: object
      properties:
        start_time: { type: string, example: "14:00" }
        sku_id: { type: string, format: uuid }
        price: { type: number }
        duration_hrs: { type: [number, "null"] }
        available_provider_count: { type: [integer, "null"] }
    SlotAvailability:
      type: object
      properties:
        service_code: { type: [string, "null"] }
        postal_code: { type: string }
        date: { type: string, format: date }
        slots:
          type: array
          items: { $ref: "#/components/schemas/Slot" }
    DateAvailability:
      type: object
      properties:
        service_code: { type: [string, "null"] }
        postal_code: { type: string }
        available_dates:
          type: array
          items: { type: string, format: date }
    BookingCreateRequest:
      type: object
      required: [service_code, sku_id, date, start_time, postal_code, customer]
      properties:
        service_code: { type: string, example: cleaning }
        sku_id: { type: string, format: uuid, description: "A sku_id from /v1/availability or /v1/services." }
        date: { type: string, format: date, example: "2026-07-10" }
        start_time: { type: string, example: "14:00" }
        duration: { type: number, description: "Hours. Required when booking_mode is duration.", example: 3 }
        frequency: { type: string, enum: [one-time, weekly, bi-weekly], default: one-time }
        job_type: { type: string, description: "Required when booking_mode is units." }
        unit_count: { type: integer, description: "Required when booking_mode is units." }
        postal_code: { type: string, pattern: "^\\d{6}$", example: "238823", description: "Block, street and building are resolved from this via OneMap; send unit_number/floor_number separately." }
        unit_number: { type: string, example: "12-34" }
        floor_number: { type: string }
        notes: { type: string }
        customer:
          type: object
          required: [name, email, phone]
          properties:
            name: { type: string }
            email: { type: string, format: email }
            phone: { type: string, example: "+6591234567" }
    BookingCreated:
      type: object
      properties:
        booking_id: { type: string, format: uuid }
        status: { type: string, example: pending_payment }
        payment_status: { type: string, example: pending }
        is_test: { type: boolean }
        service_code: { type: [string, "null"] }
        start_date: { type: string, format: date }
        payment:
          type: object
          properties:
            url: { type: [string, "null"], format: uri, description: "Branded payment page (/pay/b/{bookingId}) to send to the customer." }
            amount: { type: number }
            currency: { type: string, example: SGD }
        price:
          type: object
          properties:
            total: { type: number }
            currency: { type: string, example: SGD }
    BookingStatus:
      type: object
      properties:
        booking_id: { type: string, format: uuid }
        status: { type: string, example: pending_payment }
        payment_status: { type: string, enum: [pending, completed, failed, unpaid] }
        paid: { type: boolean }
        is_test: { type: boolean }
        frequency: { type: [string, "null"] }
        service:
          type: [object, "null"]
          properties:
            code: { type: [string, "null"] }
            name: { type: string }
        start_date: { type: [string, "null"], format: date }
        price:
          type: object
          properties:
            total: { type: number }
            paid: { type: number }
            currency: { type: string }
        payment_url:
          type: [string, "null"]
          format: uri
          description: >-
            Hosted checkout page for this booking. The path is always
            /pay/b/{booking_id}; the origin is the deployment's configured
            payment page base URL and defaults to https://homemaster.co.
            Treat it as an opaque absolute URL rather than assuming the host.
          example: https://homemaster.co/pay/b/7c9e6679-7425-40de-944b-e07fc1f90ae7
        sessions:
          type: array
          items:
            type: object
            properties:
              date: { type: string, format: date }
              status: { type: string }
              payment_status: { type: string }
    QnaRequest:
      type: object
      required: [question]
      properties:
        question:
          type: string
          maxLength: 1000
          description: The question to answer from our SOP.
          example: How does the affiliate referral program work?
    SopAnswer:
      type: object
      properties:
        answer:
          type: string
          description: The answer, drawn only from our SOP.
          example: "Affiliates earn a commission on each referred booking, with tiered rates..."
        topics:
          type: array
          items: { type: string }
          description: SOP topics matched for retrieval (empty when the full SOP was used).
          example: [affiliate]
        answered_from_sop:
          type: boolean
          description: False when the question isn't covered by our documented SOP.
          example: true
    Problem:
      type: object
      description: RFC 9457 problem details.
      properties:
        type: { type: string, format: uri }
        title: { type: string }
        status: { type: integer }
        detail: { type: string }
        code: { type: string, description: "Stable machine-readable error code." }
        request_id: { type: string }
        errors:
          type: object
          description: "Present on 422; maps each invalid field to a reason."
          additionalProperties: { type: string }

  responses:
    BadRequest:
      description: Malformed request (e.g. invalid JSON)
      content:
        application/problem+json:
          schema: { $ref: "#/components/schemas/Problem" }
    Unauthorized:
      description: Missing or invalid API key
      content:
        application/problem+json:
          schema: { $ref: "#/components/schemas/Problem" }
    Forbidden:
      description: Key lacks the required scope, or is expired
      content:
        application/problem+json:
          schema: { $ref: "#/components/schemas/Problem" }
    NotFound:
      description: Resource not found
      content:
        application/problem+json:
          schema: { $ref: "#/components/schemas/Problem" }
    Conflict:
      description: Slot no longer available, or idempotency key reused with a different body
      content:
        application/problem+json:
          schema: { $ref: "#/components/schemas/Problem" }
    ValidationError:
      description: Field validation failed (see `errors`)
      content:
        application/problem+json:
          schema: { $ref: "#/components/schemas/Problem" }
    RateLimited:
      description: Hourly quota exceeded
      headers:
        Retry-After: { schema: { type: integer }, description: Seconds to wait. }
        RateLimit-Policy: { $ref: "#/components/headers/RateLimitPolicy" }
      content:
        application/problem+json:
          schema: { $ref: "#/components/schemas/Problem" }
    UpstreamError:
      description: A dependency was temporarily unavailable; the request can be retried
      content:
        application/problem+json:
          schema: { $ref: "#/components/schemas/Problem" }
