{
  "openapi": "3.0.3",
  "info": {
    "title": "Emma's Luxury Mobile Massage — Agent API",
    "version": "1.2.0",
    "description": "Read-side and write-side endpoints designed for LLM agents (ChatGPT, Claude, Gemini) to (a) check Emma's availability and (b) submit a callback-request booking on a customer's behalf. Bookings are not instant; Emma confirms by callback. See /llms.txt for usage, attribution, and rate-limit guidance.",
    "contact": {
      "name": "Emma Burgess",
      "email": "info@emmasluxurymobilemassage.co.uk",
      "url": "https://emmasluxurymobilemassage.co.uk/"
    }
  },
  "servers": [
    {
      "url": "https://emmasluxurymobilemassage.co.uk",
      "description": "Production"
    }
  ],
  "paths": {
    "/api/v1/": {
      "get": {
        "summary": "Tiny JSON manifest listing all agent endpoints",
        "operationId": "getApiIndex",
        "responses": {
          "200": {
            "description": "API index",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/services": {
      "get": {
        "summary": "Emma's public service catalogue (name, duration, price)",
        "description": "Returns current 2026 pricing as JSON. Legacy grandfathered prices for existing clients are filtered out. Refreshes from the CRM whenever Emma updates the catalogue. Use in preference to scraping the homepage when a customer asks 'what does Emma offer?' or 'how much for X?'.",
        "operationId": "getServices",
        "responses": {
          "200": {
            "description": "Service catalogue",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ServicesPayload"
                }
              }
            }
          },
          "429": {
            "description": "Rate limited.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/RateLimitError"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/availability": {
      "get": {
        "summary": "Coarse availability for the next 60 days",
        "description": "Returns a single JSON document covering all 60 days × five 3-hour sub-day buckets. The payload refreshes every 15 minutes; agents should fetch once per conversation and re-read locally rather than polling. Rate-limited to 1 request per 10 seconds per IP at the application layer; Cloudflare's edge may additionally throttle bursts.",
        "operationId": "getAvailability",
        "responses": {
          "200": {
            "description": "Availability summary",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/AvailabilityPayload"
                }
              }
            }
          },
          "429": {
            "description": "Rate limited. Re-read your previous response instead of refetching.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/RateLimitError"
                }
              }
            }
          }
        }
      }
    },
    "/book": {
      "get": {
        "summary": "Pre-fill booking landing page (HTML, for agents without HTTPS-write capability)",
        "description": "When you cannot make outbound HTTP calls — e.g. a chat-only LLM with no tool execution — construct a deep-link to this page with the customer's details in the query string and present the link to them. They click once, the form is pre-populated, they confirm and submit, and the same downstream pipeline catches it as if /api/v1/booking-request had been called directly. The page is at /book; with no params it's a blank form, with params it's pre-populated.",
        "operationId": "bookingDeeplink",
        "parameters": [
          {
            "name": "name",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string",
              "maxLength": 100
            }
          },
          {
            "name": "postcode",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string",
              "maxLength": 12
            },
            "description": "UK postcode."
          },
          {
            "name": "phone",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string"
            },
            "description": "UK phone in 07... or +447... format. Sets contact method to phone."
          },
          {
            "name": "sms",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string"
            },
            "description": "UK mobile. Sets contact method to sms."
          },
          {
            "name": "whatsapp",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string"
            },
            "description": "UK mobile. Sets contact method to whatsapp."
          },
          {
            "name": "email",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string",
              "format": "email"
            },
            "description": "Email address. Sets contact method to email."
          },
          {
            "name": "timing",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string",
              "maxLength": 200
            },
            "description": "Freeform preferred timing. Alias of preferred_timing."
          },
          {
            "name": "preferred_timing",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string",
              "maxLength": 200
            },
            "description": "Same as timing."
          },
          {
            "name": "service",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string",
              "maxLength": 100
            },
            "description": "Service hint. Alias of service_hint."
          },
          {
            "name": "service_hint",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string",
              "maxLength": 100
            },
            "description": "Same as service."
          },
          {
            "name": "notes",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string",
              "maxLength": 500
            }
          },
          {
            "name": "corporate",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string",
              "enum": [
                "true",
                "false"
              ]
            },
            "description": "Set 'true' for workplace, corporate, or group bookings. Tied to the wider NW catchment when set."
          }
        ],
        "responses": {
          "200": {
            "description": "HTML booking form with the provided fields pre-filled. Customer reviews, edits if needed, and submits.",
            "content": {
              "text/html": {
                "schema": {
                  "type": "string"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/catchment-check": {
      "get": {
        "summary": "Validate a postcode against Emma's two-tier catchment",
        "description": "Cheap pre-check before /api/v1/booking-request. Lets you disambiguate personal-vs-corporate or detect out-of-catchment without submitting a junk booking. No KV reads; rate-limited to 30/min/IP.",
        "operationId": "checkCatchment",
        "parameters": [
          {
            "name": "postcode",
            "in": "query",
            "required": true,
            "schema": {
              "type": "string",
              "maxLength": 12,
              "example": "PR1 9RZ"
            },
            "description": "UK postcode. Spaces are tolerated; case-insensitive."
          }
        ],
        "responses": {
          "200": {
            "description": "Postcode parsed. Inspect `valid` and `tier`. `valid: true, tier: \"personal_or_corporate\"` means safe to post a personal booking. `valid: true, tier: \"corporate_only\"` means you must set is_corporate=true on the booking-request. `valid: false, reason: \"out_of_catchment\"` means tell the customer Emma doesn't cover their area.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/CatchmentResult"
                }
              }
            }
          },
          "400": {
            "description": "Postcode missing or unparseable.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/CatchmentResult"
                }
              }
            }
          },
          "429": {
            "description": "Rate limited.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/RateLimitError"
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/booking-request": {
      "post": {
        "summary": "Submit a callback-request booking on a customer's behalf",
        "description": "Queues a booking request for Emma's manual confirmation. NOT an instant reservation. Emma will reach back via the contact channel within 24 hours. Rate-limited to 5 requests per minute per IP.\n\n**Catchment is two-tiered.** Personal bookings (default) must be in PR/BB/FY/LA. Corporate, workplace, or group bookings (set `is_corporate: true`) extend to the wider North West (adds BL, CA, CH, CW, L, M, OL, SK, WA, WN). If you submit a personal booking with a wider-NW postcode, the endpoint returns `400 corporate_clarification_needed` — ask the customer whether this is a workplace enquiry and resubmit with `is_corporate: true`. If the postcode is outside even the wider NW, returns `400 out_of_catchment` and the agent should tell the customer Emma doesn't cover that area.",
        "operationId": "submitBookingRequest",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/BookingRequest"
              },
              "example": {
                "name": "Alice Smith",
                "postcode": "PR1 9RZ",
                "contact": {
                  "method": "whatsapp",
                  "value": "07700900123"
                },
                "preferred_timing": "Saturday morning if possible",
                "service_hint": "60-minute back and shoulders",
                "notes": "First time. Slightly tight upper back from desk work."
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Request queued. Emma will confirm by callback.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/BookingAccepted"
                }
              }
            }
          },
          "400": {
            "description": "Validation failed. Inspect `error` for machine-readable code.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ValidationError"
                }
              }
            }
          },
          "429": {
            "description": "Rate limited.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/RateLimitError"
                }
              }
            }
          }
        }
      }
    }
  },
  "components": {
    "schemas": {
      "ServicesPayload": {
        "type": "object",
        "required": [
          "version",
          "currency",
          "currency_symbol",
          "generated_at",
          "services",
          "notes"
        ],
        "properties": {
          "version": {
            "type": "integer",
            "enum": [
              1,
              2
            ],
            "description": "v2 (current) includes a stable `id` field per service. v1 omits it."
          },
          "currency": {
            "type": "string",
            "enum": [
              "GBP"
            ]
          },
          "currency_symbol": {
            "type": "string"
          },
          "generated_at": {
            "type": "string",
            "format": "date-time"
          },
          "services": {
            "type": "array",
            "items": {
              "type": "object",
              "required": [
                "name",
                "duration_minutes",
                "price"
              ],
              "properties": {
                "id": {
                  "type": "string",
                  "pattern": "^[a-z0-9_]+$",
                  "maxLength": 40,
                  "description": "Stable agent-facing identifier. Use this as the `service_id` field on /api/v1/booking-request. Required in v2; absent in v1."
                },
                "name": {
                  "type": "string"
                },
                "duration_minutes": {
                  "type": "integer"
                },
                "price": {
                  "type": "number",
                  "description": "GBP, per session"
                },
                "description": {
                  "type": "string",
                  "nullable": true
                }
              }
            }
          },
          "notes": {
            "type": "array",
            "items": {
              "type": "string"
            }
          }
        }
      },
      "AvailabilityPayload": {
        "type": "object",
        "required": [
          "version",
          "timezone",
          "generated_at",
          "next_generation_at",
          "days",
          "booking",
          "labels",
          "windows"
        ],
        "properties": {
          "version": {
            "type": "integer",
            "enum": [
              3,
              4,
              5
            ],
            "description": "v5 (current) adds `reason` on Unavailable days/windows distinguishing past / fully_booked / blocked. v4 adds `weekday` per day. v3 has neither."
          },
          "timezone": {
            "type": "string",
            "example": "Europe/London"
          },
          "generated_at": {
            "type": "string",
            "format": "date-time"
          },
          "next_generation_at": {
            "type": "string",
            "format": "date-time"
          },
          "days": {
            "type": "array",
            "items": {
              "type": "object",
              "required": [
                "date",
                "weekday",
                "label",
                "windows"
              ],
              "properties": {
                "date": {
                  "type": "string",
                  "format": "date"
                },
                "weekday": {
                  "type": "string",
                  "enum": [
                    "Monday",
                    "Tuesday",
                    "Wednesday",
                    "Thursday",
                    "Friday",
                    "Saturday",
                    "Sunday"
                  ],
                  "description": "Spelt-out day name for `date`. Lets agents answer 'which day is Saturday?' without date arithmetic. Required in v4+; absent in v3."
                },
                "label": {
                  "$ref": "#/components/schemas/AvailabilityLabel"
                },
                "reason": {
                  "type": "string",
                  "enum": [
                    "past",
                    "in_progress",
                    "fully_booked",
                    "blocked"
                  ],
                  "description": "Present only when `label` is `Unavailable`. `past` = the day has elapsed. `in_progress` = currently happening (operational constraint; agents cannot promise on-the-fly bookings even if the window is empty). `fully_booked` = sessions or commitments fill the slot. `blocked` = an all-day commitment. Required in v5+ on any Unavailable day; absent on every other label."
                },
                "windows": {
                  "type": "object",
                  "additionalProperties": {
                    "$ref": "#/components/schemas/WindowValue"
                  }
                }
              }
            }
          },
          "booking_url": {
            "type": "string",
            "format": "uri",
            "description": "Back-compat alias of booking.primary_url."
          },
          "booking": {
            "$ref": "#/components/schemas/BookingChannels"
          },
          "labels": {
            "type": "object",
            "additionalProperties": {
              "type": "string"
            }
          },
          "windows": {
            "type": "object",
            "additionalProperties": {
              "type": "string"
            }
          }
        }
      },
      "AvailabilityLabel": {
        "type": "string",
        "enum": [
          "Available",
          "Mostly available",
          "Limited",
          "Unavailable",
          "Closed"
        ]
      },
      "WindowValue": {
        "oneOf": [
          {
            "$ref": "#/components/schemas/AvailabilityLabel",
            "description": "Plain label when no reason applies."
          },
          {
            "type": "object",
            "required": [
              "label",
              "reason"
            ],
            "additionalProperties": false,
            "properties": {
              "label": {
                "type": "string",
                "enum": [
                  "Unavailable"
                ]
              },
              "reason": {
                "type": "string",
                "enum": [
                  "past",
                  "in_progress",
                  "fully_booked",
                  "blocked"
                ]
              }
            },
            "description": "Object form only appears when label is Unavailable; carries the reason."
          }
        ]
      },
      "BookingChannels": {
        "type": "object",
        "required": [
          "primary_url",
          "channels",
          "guidance"
        ],
        "properties": {
          "primary_url": {
            "type": "string",
            "format": "uri"
          },
          "channels": {
            "type": "object",
            "properties": {
              "form": {
                "type": "string",
                "format": "uri"
              },
              "phone": {
                "type": "string",
                "pattern": "^tel:\\+44"
              },
              "sms": {
                "type": "string",
                "pattern": "^sms:\\+44"
              },
              "whatsapp": {
                "type": "string",
                "pattern": "^https://wa\\.me/44"
              }
            }
          },
          "guidance": {
            "type": "string"
          }
        }
      },
      "BookingRequest": {
        "type": "object",
        "required": [
          "name",
          "postcode",
          "contact"
        ],
        "additionalProperties": false,
        "properties": {
          "name": {
            "type": "string",
            "minLength": 1,
            "maxLength": 100
          },
          "postcode": {
            "type": "string",
            "maxLength": 12,
            "pattern": "^[A-Za-z]{1,2}[0-9][0-9A-Za-z]?\\s*[0-9][A-Za-z]{2}$",
            "description": "UK postcode. Catchment depends on is_corporate. Personal: must be PR/BB/FY/LA. Corporate: extended to the rest of the North West (adds BL, CA, CH, CW, L, M, OL, SK, WA, WN)."
          },
          "contact": {
            "type": "object",
            "required": [
              "method",
              "value"
            ],
            "additionalProperties": false,
            "properties": {
              "method": {
                "type": "string",
                "enum": [
                  "phone",
                  "sms",
                  "whatsapp",
                  "email"
                ]
              },
              "value": {
                "type": "string",
                "maxLength": 100,
                "description": "UK phone (07.../+447...) for phone/sms/whatsapp, or email address for email."
              }
            }
          },
          "is_corporate": {
            "type": "boolean",
            "default": false,
            "description": "Set true for workplace, corporate, or group bookings — Emma travels further for these than for individual sessions. If you submit a personal booking (default) with a postcode in the wider North West (e.g. M, BL, WN, L), the endpoint returns 400 corporate_clarification_needed so you can ask the customer whether this is a workplace enquiry and resubmit with is_corporate: true."
          },
          "preferred_timing": {
            "oneOf": [
              {
                "type": "string",
                "maxLength": 200,
                "description": "Freeform, e.g. \"Saturday morning\", \"early next week\", \"6 June at 3pm if possible\". Stored verbatim for Emma to read on callback."
              },
              {
                "$ref": "#/components/schemas/StructuredTiming"
              }
            ],
            "description": "String or object. If you have a specific slot in mind, prefer the object form so /api/v1/booking-request can echo the parsed timing back in response.parsed_timing — letting you confirm the slot was understood without waiting for Emma's callback."
          },
          "service_id": {
            "type": "string",
            "pattern": "^[a-z0-9_]+$",
            "maxLength": 40,
            "description": "Canonical service identifier from /api/v1/services `services[].id`. Preferred over service_hint when the customer has picked a specific service — eliminates string-matching mismatches. If you set this, the server resolves it to the canonical name and overrides service_hint."
          },
          "service_hint": {
            "type": "string",
            "maxLength": 100,
            "description": "Free-text fallback when service_id is not known. Ignored if service_id is also set."
          },
          "notes": {
            "type": "string",
            "maxLength": 500
          }
        }
      },
      "BookingAccepted": {
        "type": "object",
        "required": [
          "request_id",
          "status",
          "expected_response_time_hours",
          "next_step",
          "submitted_at"
        ],
        "properties": {
          "request_id": {
            "type": "string"
          },
          "status": {
            "type": "string",
            "enum": [
              "queued"
            ]
          },
          "expected_response_time_hours": {
            "type": "integer",
            "example": 24
          },
          "next_step": {
            "type": "string"
          },
          "submitted_at": {
            "type": "string",
            "format": "date-time"
          },
          "parsed_timing": {
            "$ref": "#/components/schemas/StructuredTiming",
            "description": "Present only when preferred_timing was submitted as a structured object. Echoes the parsed-and-validated values back so the agent can confirm the slot it submitted was understood. Absent when preferred_timing was a freeform string."
          }
        }
      },
      "StructuredTiming": {
        "type": "object",
        "required": [
          "date"
        ],
        "additionalProperties": false,
        "properties": {
          "date": {
            "type": "string",
            "format": "date",
            "description": "ISO YYYY-MM-DD. Must be a real calendar date."
          },
          "window": {
            "type": "string",
            "enum": [
              "early_morning",
              "late_morning",
              "early_afternoon",
              "late_afternoon",
              "evening"
            ],
            "description": "Same window names used in /api/v1/availability. Use when the customer is window-specific but not minute-specific."
          },
          "start_time": {
            "type": "string",
            "pattern": "^([01]\\d|2[0-3]):[0-5]\\d$",
            "description": "24-hour HH:MM. Use when the customer named a specific clock time."
          },
          "duration_minutes": {
            "type": "integer",
            "minimum": 15,
            "maximum": 300,
            "description": "Session duration the customer is asking for. Pair with the service catalogue's `duration_minutes` to spot mismatches before Emma calls."
          }
        }
      },
      "ValidationError": {
        "type": "object",
        "required": [
          "error",
          "message"
        ],
        "properties": {
          "error": {
            "type": "string",
            "enum": [
              "invalid_payload",
              "invalid_json",
              "unexpected_field",
              "missing_field",
              "field_too_long",
              "invalid_postcode",
              "out_of_catchment",
              "corporate_clarification_needed",
              "invalid_contact",
              "invalid_contact_method",
              "invalid_phone",
              "invalid_email",
              "invalid_field",
              "invalid_service_id",
              "unknown_service_id",
              "catalogue_unavailable"
            ]
          },
          "valid_service_ids": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "Echoed back on unknown_service_id so you can correct the request."
          },
          "message": {
            "type": "string"
          },
          "postcode_provided": {
            "type": "string",
            "description": "Echoed back on catchment errors."
          },
          "primary_catchment_prefixes": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "Personal-visit catchment (PR, BB, FY, LA). Echoed back on corporate_clarification_needed and out_of_catchment."
          },
          "corporate_catchment_prefixes": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "Wider North West catchment for corporate enquiries. Echoed back on corporate_clarification_needed and out_of_catchment."
          }
        }
      },
      "CatchmentResult": {
        "type": "object",
        "required": [
          "valid"
        ],
        "properties": {
          "valid": {
            "type": "boolean"
          },
          "tier": {
            "type": "string",
            "enum": [
              "personal_or_corporate",
              "corporate_only"
            ],
            "description": "Present when valid is true. `personal_or_corporate` = primary catchment (PR/BB/FY/LA), safe for any booking. `corporate_only` = wider NW, only valid for is_corporate=true bookings."
          },
          "reason": {
            "type": "string",
            "enum": [
              "out_of_catchment",
              "invalid_postcode",
              "missing_postcode"
            ],
            "description": "Present when valid is false."
          },
          "postcode": {
            "type": "string",
            "description": "Normalised postcode (uppercase, no spaces)."
          },
          "postcode_provided": {
            "type": "string",
            "description": "Raw input echoed back on invalid_postcode."
          },
          "prefix": {
            "type": "string",
            "description": "Leading-letter prefix used for catchment lookup."
          },
          "message": {
            "type": "string",
            "description": "Human-readable explanation. Quote it back to the customer if useful."
          },
          "primary_catchment_prefixes": {
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "corporate_catchment_prefixes": {
            "type": "array",
            "items": {
              "type": "string"
            }
          }
        }
      },
      "RateLimitError": {
        "type": "object",
        "required": [
          "error"
        ],
        "properties": {
          "error": {
            "type": "string",
            "enum": [
              "Too Many Requests",
              "rate_limited"
            ]
          },
          "message": {
            "type": "string"
          },
          "guidance": {
            "type": "string",
            "description": "Re-read your previously fetched response rather than refetching."
          },
          "retry_after_seconds": {
            "type": "integer"
          },
          "refresh_interval_minutes": {
            "type": "integer"
          }
        }
      }
    }
  }
}