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.

    **Building an AI assistant or agent?** Connect it to our Model Context Protocol
    (MCP) server instead: `https://homemaster.co/mcp` (remote, Streamable HTTP, no key:
    services, coverage, open times, exact prices, a filled-in booking link, and booking
    requests the customer confirms on WhatsApp). How it works: https://homemaster.co/agents

    ## Authentication
    Every request, except the three lookups listed under "Lookups without a key",
    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.

    ### What a key opens
    A key carries scopes. A new key opens what any caller already has without one
    (the three lookups and booking requests). Creating and checking bookings
    (`/v1/bookings`) and `POST /v1/qna` are granted separately, on request. A route
    your key does not cover answers `403 insufficient-scope`.

    ### What we say back about your key
    Every response, a refusal too, carries a `HomeMaster-Access-Key` header that says
    what we saw: `recognised` (a key we issued), `none` (no key reached us) or
    `not-recognised` (the key is mistyped, expired or switched off; such a request is
    refused with `401` or `403`). For a key we issued, `HomeMaster-Source` is your
    public name, the same one the MCP server answers with. So one lookup tells you
    your key is being read:

    ```
    curl -s -D - -o /dev/null https://homemaster.co/api/v1/services -H "X-API-Key: hm_live_..." | grep -i "^homemaster-"
    ```

    The headers change no answer: a lookup returns the same data with a key and with
    none.

    ### The same key on the MCP server
    Your key also names you on our MCP server, `https://homemaster.co/mcp`. There it is
    optional and never a permission: every tool gives the same answer with a key, with
    no key and with a key we do not recognise, and no call is refused over a key. Send
    it as `Authorization: Bearer`, as `X-API-Key`, or as `?key=` in the server's address
    for a client that takes only a URL. When you connect, the `initialize` result says
    who we think is calling under `_meta["co.homemaster/access"]`, and every response
    carries a `HomeMaster-Access-Key` header (`recognised`, `none`, `not-recognised` or
    `not-checked`). A booking link, a booking request or a call back made on that
    connection is recorded as yours. Details: https://homemaster.co/mcp/docs

    ### Lookups without a key
    `GET /v1/services`, `GET /v1/coverage` and `GET /v1/availability` also answer
    with NO key, so an AI assistant can check our prices, coverage and open times
    before it sends a customer to book. Without a key: 300 requests per hour per
    caller, a shared hourly cap across all keyless callers, and a dates range
    (`end_date`) of at most 14 days per request. A key that is sent must be valid.
    Creating a booking, checking a booking and `POST /v1/qna` still need a key.
    A booking REQUEST does not (see "Booking requests" below).
    They are plain web addresses, so an assistant can simply open one, for example
    `https://homemaster.co/api/v1/availability?service_code=cleaning&postal_code=238823&duration=3&frequency=one-time&date=YYYY-MM-DD&end_date=YYYY-MM-DD`
    for open dates, or the same without `end_date` for one day's start times.
    Prices here are list prices; the checkout can show a lower price when an offer applies.

    ## Booking links (no key needed)
    To hand a customer a booking without a key, link them to our checkout with the
    visit filled in. They confirm the address, pick a time, verify their phone by
    WhatsApp and pay.

    Why we need the customer's phone number, to tell them when you ask for it:
    HomeMaster needs the customer's number to confirm the booking and send the receipt on WhatsApp, and to reach them about the visit: if the expert is running late or cannot find the unit or get in, if the expert falls ill and another time is needed, or if the job turns out different from what was booked.

    A link looks like this:

    ```
    https://homemaster.co/book/clean?postal=238823&hours=3&frequency=weekly
    ```

    `postal` is a 6-digit Singapore postal code; `hours` is one of the cleaning
    service's `allowed_durations` from GET /v1/services; `frequency` is `one-time`,
    `weekly` or `bi-weekly`. Each is optional; a value we do not sell is ignored
    and the customer chooses instead. Add `utm_source=<your assistant>` and
    `utm_medium=ai_agent` so we know where the booking came from.

    ## 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`.
    Keyless lookups have their own per-caller quota (see above).

    ## 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://homemaster.co/api
    description: Production
  - url: https://dogluauhvuljswsvbuac.supabase.co/functions/v1/public-api
    description: Production (direct function address, same API)

security:
  - BearerAuth: []
  - ApiKeyHeader: []

tags:
  - name: Catalog
  - name: Availability
  - name: Bookings
  - name: Booking requests
    description: |
      Book for a customer without a key. We send the customer Helen's booking card on
      WhatsApp (date, their address, the clean, their price) with "Yes, book it" and
      "No, cancel". Nothing is booked until they tap Yes within 60 minutes, and that tap
      is the check that the phone is theirs; they then pay by the payment link Helen
      sends, never by a saved card. The time is not held while they decide. You get a
      `reference` to follow the request; it is the only credential for its status,
      which never includes the customer's details. Cleaning only for now. Limits
      without a key: 10 requests an hour per caller; one waiting request per phone
      number and at most 3 a day.
  - name: Support

paths:
  /v1/services:
    get:
      tags: [Catalog]
      summary: List bookable services
      description: >-
        Returns active, customer-bookable services and every one of their
        SKUs (valid start-time / duration / price combinations; since 19 Oct
        2026 the whole set, where a set of more than 1,000 used to be cut
        short). 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. Works without a key
        (see "Lookups without a key"). `services` lists only active services.
        A service switched on only for AI assistants on the MCP server at
        homemaster.co/mcp (which has its own switch) is named under
        `more_services`: it has no SKU here and cannot be booked through this
        API, but it IS offered, and an assistant books it on the MCP server:
        online, through its checkout link (homemaster.co/book/<code>), for the
        job itself or, for a job our team quotes, a quotation visit at the
        quotation fee on its service card; or as a call back. Do not conclude
        a service is not offered from `services` alone.
      operationId: listServices
      security:
        - {}
        - BearerAuth: []
        - ApiKeyHeader: []
      responses:
        "200":
          description: Services list
          headers:
            RateLimit-Policy: { $ref: "#/components/headers/RateLimitPolicy" }
            HomeMaster-Access-Key: { $ref: "#/components/headers/AccessKey" }
            HomeMaster-Source: { $ref: "#/components/headers/Source" }
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      services:
                        type: array
                        items: { $ref: "#/components/schemas/Service" }
                      more_services:
                        type: array
                        description: >-
                          Services arranged by our team: offered, but with no SKU
                          and not bookable through this API. Booked by an AI
                          assistant on the MCP server (homemaster.co/mcp) as a
                          call back: free for a job on a price list, with a
                          quotation fee for a job our team quotes.
                        items:
                          type: object
                          properties:
                            code: { type: string, example: general_pest_control_ants_cockroaches_flies }
                            name: { type: string, example: "General Pest Control (Ants, Cockroaches, Flies)" }
                            description: { type: [string, "null"] }
                      more_services_note:
                        type: [string, "null"]
                        description: How the services under more_services are booked (online through the MCP server's checkout link, the job itself or a quotation visit). Null when there are none.
                      terms:
                        type: object
                        description: >-
                          Cancelling and moving a booking, to tell the customer before
                          they book: home cleaning's terms, and every other service's.
                          Added 22 Oct 2026.
                        properties:
                          home_cleaning: { $ref: "#/components/schemas/BookingTerms" }
                          every_other_service: { $ref: "#/components/schemas/BookingTerms" }
                  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.
        Works without a key (see "Lookups without a key").
      operationId: getCoverage
      security:
        - {}
        - BearerAuth: []
        - ApiKeyHeader: []
      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" }
            HomeMaster-Access-Key: { $ref: "#/components/headers/AccessKey" }
            HomeMaster-Source: { $ref: "#/components/headers/Source" }
          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. Works without a
        key (see "Lookups without a key"); without one, `end_date` may be at most
        14 days after `date`. A booking can only START inside the booking window
        (a setting, 8 weeks at the time of writing; Singapore date): a `date` past
        its last day returns `422` naming that day, and a range is cut there. Later
        sessions of a weekly or bi-weekly plan may run past it.

        A start time is listed only while at least one cleaner is free for it. A time
        whose free cleaners are all held by another customer's checkout is left out
        until the hold ends (a few minutes), because a booking request for it would be
        refused.
      operationId: getAvailability
      security:
        - {}
        - BearerAuth: []
        - ApiKeyHeader: []
      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. Without a key, at most 14 days after date." }
        - { 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" }
            HomeMaster-Access-Key: { $ref: "#/components/headers/AccessKey" }
            HomeMaster-Source: { $ref: "#/components/headers/Source" }
          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 (deprecated)
      deprecated: true
      description: |
        **Deprecated since 22 Oct 2026: use `POST /v1/booking-requests` with the same key.**
        It makes the same booking, takes the simpler fields of a booking request (no
        `sku_id`), and answers `outcome: payment_link`. This route keeps working.

        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.

        There is no phone check here: a key granted `bookings` is trusted to send the
        customer's own number. The same grant lets that partner book directly on the
        MCP server (`request_booking` answers with `outcome: payment_link`).

        Invite-only does not apply: when HomeMaster takes new homes by invitation, a
        booking made with a key (here or through `POST /v1/booking-requests`) is never
        asked who invited the customer.
      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" }
            HomeMaster-Access-Key: { $ref: "#/components/headers/AccessKey" }
            HomeMaster-Source: { $ref: "#/components/headers/Source" }
          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" }
            HomeMaster-Access-Key: { $ref: "#/components/headers/AccessKey" }
            HomeMaster-Source: { $ref: "#/components/headers/Source" }
          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/booking-requests:
    post:
      tags: [Booking requests]
      summary: Book for a customer (the one booking endpoint)
      description: |
        The one way to book through this API. What happens depends on your key, and the
        answer's `outcome` says which:

        - **`payment_link`**: your key holds the `bookings` grant (partners we have cleared
          to book directly). The booking is made at once, for any service booked online,
          and the answer carries `payment_url`, `pay_now` and a reference `hmb_...`. There
          is no phone check: send the customer's own number and read it back to them, as
          the receipt and every message about the visit go to it. The time is not held
          until they pay.
        - **`whatsapp_card`**: no key, or a key without that grant. Home cleaning only. We
          send the customer Helen's booking card on WhatsApp; nothing is booked until they
          tap "Yes, book it" before `expires_at`, then they pay by the link Helen sends.
          The reference is `hmr_...`. If you send your key, the request is recorded as yours.

        Use a start time from `GET /v1/availability`. Returns `201` with a `reference`; a
        retry with the same `Idempotency-Key` returns the same answer (`200`,
        `Idempotency-Replayed: true`) and makes nothing twice. Follow either reference at
        `GET /v1/booking-requests/{reference}`.
      operationId: createBookingRequest
      security:
        - {}
        - BearerAuth: []
        - ApiKeyHeader: []
      parameters:
        - name: Idempotency-Key
          in: header
          required: false
          schema: { type: string, maxLength: 200 }
          description: Any string of yours that makes this request safely retryable.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/BookingRequestCreate" }
      responses:
        "201":
          description: Booked (`payment_link`) or sent to the customer's WhatsApp (`whatsapp_card`)
          headers:
            RateLimit-Policy: { $ref: "#/components/headers/RateLimitPolicy" }
            HomeMaster-Access-Key: { $ref: "#/components/headers/AccessKey" }
            HomeMaster-Source: { $ref: "#/components/headers/Source" }
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    oneOf:
                      - $ref: "#/components/schemas/DirectBookingCreated"
                      - $ref: "#/components/schemas/BookingRequestCreated"
                    discriminator: { propertyName: outcome }
                  request_id: { type: string }
        "200":
          description: The same request again (idempotent replay); nothing was made or sent twice
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    oneOf:
                      - $ref: "#/components/schemas/DirectBookingCreated"
                      - $ref: "#/components/schemas/BookingRequestCreated"
                    discriminator: { propertyName: outcome }
                  request_id: { type: string }
        "400": { $ref: "#/components/responses/BadRequest" }
        "409":
          description: |
            `slot-unavailable` (that time is not open), `request-pending` (a request is
            already waiting for this customer), `customer-busy` (the customer is in a
            conversation with us), `customer-unavailable` (we can't message this number),
            or `idempotency-conflict`.

            A `slot-unavailable` refusal says why in `detail`: the time is held by another
            customer at checkout (try again in a few minutes), it has been taken, or it is
            not a start time we offer for that length (for a weekly or bi-weekly plan, the
            time must be free on every visit date). It also carries `open_times`: the start
            times open on that date right now, `HH:MM`, empty when the day is full.
          content:
            application/problem+json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/Problem"
                  - type: object
                    properties:
                      open_times:
                        type: array
                        items: { type: string, example: "18:30" }
                        description: "slot-unavailable only: the start times open on that date now."
        "422":
          description: |
            `validation-error` (fields to fix, in `errors`), `postal-not-found`, or
            `not-available` (no cleaner covers that postal code yet).
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "502": { $ref: "#/components/responses/UpstreamError" }
        "503":
          description: |
            `availability-failed`: we could not check the time just now. A fault on our
            side, not a sign the time is taken; nothing was sent. Try again in a minute.
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }

  /v1/booking-requests/{reference}:
    get:
      tags: [Booking requests]
      summary: Where a booking request stands
      description: |
        An `hmr_...` reference (a WhatsApp card): the step the request is at. The
        reference is the credential; the answer never includes the customer's name,
        phone, address or cleaning expert.

        An `hmb_...` reference (booked directly): the booking itself, in the shape of
        `GET /v1/bookings/{id}`. Only the key that made it can read it.
      operationId: getBookingRequest
      security:
        - {}
        - BearerAuth: []
        - ApiKeyHeader: []
      parameters:
        - { name: reference, in: path, required: true, schema: { type: string, pattern: "^(hmr_[A-Za-z0-9_-]{20,64}|hmb_[0-9a-f-]{36})$" } }
      responses:
        "200":
          description: The request's current step
          headers:
            RateLimit-Policy: { $ref: "#/components/headers/RateLimitPolicy" }
            HomeMaster-Access-Key: { $ref: "#/components/headers/AccessKey" }
            HomeMaster-Source: { $ref: "#/components/headers/Source" }
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    oneOf:
                      - $ref: "#/components/schemas/BookingRequestStatus"
                      - $ref: "#/components/schemas/BookingStatus"
                  request_id: { type: string }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /v1/booking-requests/{reference}/cancel:
    post:
      tags: [Booking requests]
      summary: Cancel a booking this key made
      description: |
        Cancels a booking, or one visit of it, under HomeMaster's usual rules (48 hours' notice, the
        free change each month for home cleaning, 50% for a late or extra change, free within 4 hours
        of booking, an unpaid visit always free). Only the key that made the booking may change it,
        and `phone` must be the customer's number on the booking.

        Two calls. Without `confirm_code` the answer is `outcome: confirm_needed`: what will be
        cancelled, the fee, the refund and why, with a `confirm_code` good for 15 minutes. Nothing
        changes. With the code and `customer_confirmed: true` the cancel is made (`outcome:
        cancelled`). A used code returns its first answer, never a second cancel; a code whose
        booking changed since the quote returns a new quote (`quote_changed`).

        A change the rules refuse is a 200 with `outcome: refused`, a `reason` and what to do instead
        (`manage_link`, `whatsapp`): a booking not made with this key, a paid booking still waiting for
        an expert, a prepaid pack, or a visit under 2 hours away.
      operationId: cancelBooking
      parameters:
        - { name: reference, in: path, required: true, schema: { type: string, pattern: "^(hmr_[A-Za-z0-9_-]{20,64}|hmb_[0-9a-f-]{36})$" } }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              properties:
                phone: { type: string, example: "9123 4567", description: "Optional: only the key that made the booking can change it. If sent, the customer's number as they booked with it; it must match." }
                visit_date: { type: string, format: date, description: "One visit of a plan. Without it, the whole booking." }
                confirm_code: { type: string, description: "Second call: the code the quote returned." }
                customer_confirmed: { type: boolean, description: "Second call: true when the customer heard the change, its cost and why, and said yes." }
      responses:
        "200":
          description: A quote, the change made, or why nothing changed
          headers:
            RateLimit-Policy: { $ref: "#/components/headers/RateLimitPolicy" }
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/BookingChange" }
                  request_id: { type: string }
        "400": { $ref: "#/components/responses/ValidationError" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "422": { $ref: "#/components/responses/ValidationError" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /v1/booking-requests/{reference}/move:
    post:
      tags: [Booking requests]
      summary: Move a visit of a booking this key made
      description: |
        Moves one visit to another time with the same expert, under the same rules and the same two
        calls as cancel. Without `new_date` and `new_start_time` the answer is `outcome: pick_a_time`
        with the times that visit's own expert is free. With them, `outcome: confirm_needed` and a
        `confirm_code`; with the code and `customer_confirmed: true`, `outcome: moved` (a fee, when
        one applies, comes with its `payment_url`). A booking with several upcoming visits needs
        `visit_date` (`outcome: pick_a_visit` lists them). A booking not paid yet with no visit
        arranged answers `outcome: rebook_needed`: book the new time, then cancel this one (free).
      operationId: moveBooking
      parameters:
        - { name: reference, in: path, required: true, schema: { type: string, pattern: "^(hmr_[A-Za-z0-9_-]{20,64}|hmb_[0-9a-f-]{36})$" } }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              properties:
                phone: { type: string, example: "9123 4567", description: "Optional: only the key that made the booking can change it. If sent, the customer's number as they booked with it; it must match." }
                visit_date: { type: string, format: date, description: "The visit to move. Needed when the booking has several." }
                new_date: { type: string, format: date }
                new_weekday: { type: string, enum: [Monday, Tuesday, Wednesday, Thursday, Friday, Saturday, Sunday], description: "Required with new_date: the weekday the customer agreed to." }
                new_start_time: { type: string, pattern: "^\\d{2}:\\d{2}$", example: "14:00" }
                confirm_code: { type: string, description: "Last call: the code the quote returned." }
                customer_confirmed: { type: boolean, description: "Last call: true when the customer heard the move, its cost and why, and said yes." }
      responses:
        "200":
          description: Open times, a quote, the move made, or why nothing changed
          headers:
            RateLimit-Policy: { $ref: "#/components/headers/RateLimitPolicy" }
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: "#/components/schemas/BookingChange" }
                  request_id: { type: string }
        "400": { $ref: "#/components/responses/ValidationError" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "422": { $ref: "#/components/responses/ValidationError" }
        "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" }
            HomeMaster-Access-Key: { $ref: "#/components/headers/AccessKey" }
            HomeMaster-Source: { $ref: "#/components/headers/Source" }
          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 }
    AccessKey:
      description: What we saw of the key this request carried. On every response.
      schema: { type: string, enum: [recognised, none, not-recognised] }
    Source:
      description: The public name of the key, when it is one we issued. The same name the MCP server answers with.
      schema: { type: string, example: acme }

  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, unit_number, 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. For booking_mode units leave it out: the length is the sku_id's own.", 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", description: "Required. The unit the cleaner goes to; for a landed house, the house number. An HDB block or condo also needs floor_number (or '12-34' here); an HDB floor must exist in that block. Otherwise 422 validation-error with the reason in errors.unit_number." }
        floor_number: { type: string, example: "12", description: "Required for an HDB block or condo unless unit_number carries it ('12-34')." }
        notes: { type: string }
        campaign_code:
          type: string
          description: "Optional. A public offer's code (its slug), for example the offer a quote named. Sent as a code only: HomeMaster re-checks the offer for this customer and booking and applies it only if it still applies (an offer for new customers only does not apply to someone who has booked before). Not a valid code shape: 422 validation-error in errors.campaign_code. The price in the answer is the one to tell the customer."
        customer:
          type: object
          required: [name, phone]
          properties:
            name: { type: string }
            email: { type: string, format: email, description: "Optional. The receipt goes to the phone on WhatsApp." }
            phone: { type: string, example: "+6591234567", description: "HomeMaster needs the customer's number to confirm the booking and send the receipt on WhatsApp, and to reach them about the visit: if the expert is running late or cannot find the unit or get in, if the expert falls ill and another time is needed, or if the job turns out different from what was booked." }
    BookingChange:
      type: object
      description: A cancel or move answer. `outcome` says which; `changed` says whether anything changed.
      required: [outcome, changed]
      properties:
        outcome: { type: string, enum: [confirm_needed, cancelled, moved, pick_a_time, no_open_times, pick_a_visit, rebook_needed, refused, refunded_not_cancelled], description: "refunded_not_cancelled: what was paid is refunded but the booking could not be marked cancelled; HomeMaster follows it up." }
        changed: { type: boolean }
        change: { type: string, description: "confirm_needed: the change, in words." }
        cost:
          type: object
          properties:
            fee: { type: number }
            refund: { type: number }
            currency: { type: string, example: SGD }
            paid_for_the_visit: { type: number }
            paid_for_the_booking: { type: number, description: "A paid booking our team has not placed an expert on yet: it cancels free and this is refunded in full." }
        why: { type: string, description: "Why it is free or what it costs." }
        uses_free_change: { type: boolean }
        confirm_code: { type: string }
        code_expires_at: { type: string, format: date-time }
        quote_changed: { type: string, description: "A new quote, and why the old code was not used." }
        reason: { type: string, description: "refused: a short code, e.g. not_this_key, within_two_hours, phone_mismatch, waiting_for_expert (a move of a booking our team has not placed yet), refund_failed (the refund did not go through, so nothing was cancelled: try again in a few minutes)." }
        said: { type: string }
        next_step: { type: string }
        manage_link: { type: string, format: uri }
        whatsapp: { type: string, format: uri }
        open_times:
          type: array
          items: { type: object, properties: { date: { type: string, format: date }, day: { type: string }, label: { type: string }, start_time: { type: string } } }
        visits:
          type: array
          items: { type: object, properties: { date: { type: string, format: date }, day: { type: string }, start_time: { type: string }, paid: { type: boolean } } }
        refunded: { type: object, properties: { amount: { type: number }, currency: { type: string } } }
        fee: { type: object, properties: { amount: { type: number }, currency: { type: string }, status: { type: string }, payment_url: { type: string, format: uri } } }
        repeat: { type: string, description: "The code was already used; this is the answer it gave." }
        money_problem: { type: string, description: "The change stands but its refund or fee did not go through; HomeMaster follows it up." }
    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, description: "What the payment page charges now, SGD, GST included: the first visit after any offer on a plan's first visits, less anything already paid." }
            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 }

    BookingRequestCreate:
      type: object
      required: [postal_code, unit, date, start_time, phone]
      properties:
        service_code:
          type: string
          default: cleaning
          description: |
            A service code from `GET /v1/services`. A WhatsApp card is for `cleaning` only; a
            key cleared to book directly may book any service booked online.
        hours: { type: number, example: 3, description: "Home cleaning: required, the visit length in hours." }
        job_type: { type: string, example: sofa_3_seater, description: "Any other service (booked directly): required, the job from GET /v1/services." }
        unit_count: { type: integer, minimum: 1, example: 1, description: "Any other service (booked directly): required, how many." }
        details: { type: string, maxLength: 600, description: "Optional (booked directly): the job in the customer's words, for the partner who does it." }
        postal_code: { type: string, pattern: "^\\d{6}$", example: "238823" }
        unit: { type: string, example: "#05-12", description: "Floor and unit number, for example #05-12. For a landed home, its house number, for example 44. A flat or condo given no floor is refused with what is missing." }
        date: { type: string, format: date }
        start_time: { type: string, pattern: "^\\d{2}:\\d{2}$", example: "13:30", description: Singapore time, one of the open start times. }
        frequency: { type: string, enum: [one-time, weekly, bi-weekly], default: one-time, description: Home cleaning only. }
        phone: { type: string, example: "+6591234567", description: "The customer's WhatsApp number. Why it is needed, to tell the customer: HomeMaster needs the customer's number to confirm the booking and send the receipt on WhatsApp, and to reach them about the visit: if the expert is running late or cannot find the unit or get in, if the expert falls ill and another time is needed, or if the job turns out different from what was booked." }
        customer_name:
          type: string
          maxLength: 60
          example: Alex Tan
          description: |
            Required when booked directly (the name the booking is under). For a WhatsApp card,
            recommended. The customer's name. Saved to their HomeMaster profile once they tap
            "Yes, book it", and only if we have no name for them yet, so their cleaning expert
            and receipt can greet them. Letters, spaces and . ' - only.
        agent_name:
          type: string
          maxLength: 30
          description: |
            Optional. Your name as the customer knows you (letters, digits and spaces),
            shown as "<name> asked us to book this for you". Otherwise "An AI assistant".
    DirectBookingCreated:
      type: object
      description: The answer to a key cleared to book directly.
      properties:
        outcome: { type: string, enum: [payment_link] }
        reference: { type: string, example: "hmb_7c9e6679-7425-40de-944b-e07fc1f90ae7", description: "Follow it at GET /v1/booking-requests/{reference}." }
        status: { type: string, example: pending_payment }
        payment_status: { type: string, example: pending }
        payment_url: { type: string, format: uri, description: Give it to the customer exactly as it is; the booking is paid there. }
        pay_now: { type: number, description: What the customer pays now, SGD, GST included. }
        total: { type: number, description: The booking's total, SGD, GST included. }
        currency: { type: string, enum: [SGD] }
        service_code: { type: string }
        when: { type: string, example: "2026-10-24 09:00" }
        is_test: { type: boolean }
        status_url: { type: string, format: uri }
        next: { type: string }
        terms: { $ref: "#/components/schemas/BookingTerms" }
    BookingRequestCreated:
      type: object
      description: The answer to a caller with no key, or a key without the bookings grant.
      properties:
        outcome: { type: string, enum: [whatsapp_card] }
        reference: { type: string, example: "hmr_2yqK...", description: Keep it; it is the only way to check this request. }
        status: { type: string, enum: [waiting_for_customer] }
        expires_at: { type: string, format: date-time, description: The customer must tap "Yes, book it" before this. }
        when: { type: string, example: "Friday, 2 October at 1:30pm" }
        price: { type: string, example: "$75 - $18 = $57", description: The price line on the customer's card (their own price). }
        total: { type: [number, "null"], description: What the customer pays now, SGD, GST included. }
        status_url: { type: string, format: uri }
        next: { type: string }
        terms: { $ref: "#/components/schemas/BookingTerms" }
    BookingTerms:
      type: object
      description: |
        The cancellation terms for this booking, to tell the customer before they book, with
        these figures (the ones a charge is worked out with). The full Terms of Service are at
        `full_terms`. `free_each_month`, `just_booked` and `prepaid_pack` are for home cleaning only.
      properties:
        cancel_or_move: { type: string, description: How to cancel or move, and the notice needed. }
        free_each_month: { type: string, description: "Home cleaning: the free cancel or move each calendar month." }
        just_booked: { type: string, description: "Home cleaning: a change soon after booking, and a visit not paid yet." }
        late_change: { type: string, description: What a late cancel or move is charged. }
        no_access: { type: string, description: When the expert arrives and cannot get in. }
        if_we_cannot_come: { type: string, description: When HomeMaster cannot send an expert. }
        prepaid_pack: { type: string, description: "Home cleaning: when a prepaid pack can be refunded." }
        full_terms: { type: string, format: uri, example: "https://homemaster.co/terms" }
    BookingRequestStatus:
      type: object
      properties:
        stage:
          type: string
          enum: [waiting_for_customer, declined, expired, failed, awaiting_payment, finding_cleaner, booked, done, cancelled]
        message: { type: string }
        requested:
          type: object
          properties:
            date: { type: string, format: date }
            start_time: { type: string, example: "13:30" }
            hours: { type: number }
            frequency: { type: string }
        expires_at: { type: string, format: date-time, description: While waiting for the customer. }
        payment_url: { type: string, format: uri, description: While awaiting payment. }
        next_visit:
          type: object
          description: Once booked.
          properties:
            date: { type: string, format: date }
            start_time: { 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" }
        HomeMaster-Access-Key: { $ref: "#/components/headers/AccessKey" }
        HomeMaster-Source: { $ref: "#/components/headers/Source" }
      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" }
