{
  "openapi": "3.1.0",
  "info": {
    "title": "BetterSTR User API",
    "version": "1.0.0",
    "summary": "Manage your BetterSTR guidebooks, reservations, map pins, SMS, WiFi and guest marketing list.",
    "description": "Public REST API for BetterSTR hosts. Authenticate with HTTP Basic using the client_id and client_secret created under API Credentials on https://gb.betterstr.com/members/profile/. Every success response is wrapped as {\"data\": ...} and every error as {\"error\": {\"code\", \"message\"}}. Full prose documentation with examples: https://api.betterstr.com/user-api/README.md. Rate limits are per credential per minute in three independent buckets (global, reservations, places); responses carry X-RateLimit-* headers and HTTP 429 carries Retry-After.",
    "termsOfService": "https://betterstr.com/terms/",
    "contact": {
      "name": "BetterSTR support",
      "email": "support@betterstr.com",
      "url": "https://betterstr.com/help/claude-mcp/"
    }
  },
  "externalDocs": {
    "description": "README with examples, error tables and rate limits",
    "url": "https://api.betterstr.com/user-api/README.md"
  },
  "servers": [
    { "url": "https://api.betterstr.com/user-api", "description": "Production" }
  ],
  "security": [ { "basicAuth": [] } ],
  "tags": [
    { "name": "Properties", "description": "Your active properties." },
    { "name": "Guidebook entries", "description": "Menus, sub-menus and content cards in a property's guidebook. Three-level rule: only depth-3 entries carry content." },
    { "name": "Reservations", "description": "Read-only bookings pulled from your connected PMS, plus a payment-link helper." },
    { "name": "Tags", "description": "User-defined groups of properties." },
    { "name": "Locations", "description": "Map pins scoped to one property, a tag, or every property." },
    { "name": "Places", "description": "Google Places proxy. Guidebook Premium only; separate rate-limit bucket." },
    { "name": "SMS", "description": "Two-stage quote-then-send SMS to guests of your reservations. Charged per segment per recipient." },
    { "name": "WiFi", "description": "Captured captive-portal guests (no plan needed) and live controller status (WiFi plan required)." }
  ],
  "paths": {
    "/v1/properties": {
      "get": {
        "tags": ["Properties"],
        "operationId": "listProperties",
        "summary": "List active properties",
        "parameters": [
          { "name": "q", "in": "query", "description": "Fuzzy match on name or display address.", "schema": { "type": "string" } },
          { "name": "city", "in": "query", "description": "Exact, case-insensitive city match.", "schema": { "type": "string" } },
          { "name": "country", "in": "query", "description": "2-letter ISO country code.", "schema": { "type": "string", "minLength": 2, "maxLength": 2 } }
        ],
        "responses": {
          "200": {
            "description": "Properties owned by the caller (demo property excluded).",
            "content": { "application/json": { "schema": { "type": "object", "properties": { "data": { "type": "object", "properties": {
              "properties": { "type": "array", "items": { "$ref": "#/components/schemas/Property" } },
              "total": { "type": "integer" }
            } } } } } }
          },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      }
    },
    "/v1/properties/{property_uuid}/entries": {
      "parameters": [ { "$ref": "#/components/parameters/PropertyUuid" } ],
      "get": {
        "tags": ["Guidebook entries"],
        "operationId": "listEntries",
        "summary": "List guidebook entries",
        "description": "Omit parent_id for top-level menus; pass an id to list that entry's children.",
        "parameters": [
          { "name": "parent_id", "in": "query", "schema": { "type": ["integer", "null"] } },
          { "name": "lang", "in": "query", "schema": { "type": "string", "default": "en" } }
        ],
        "responses": {
          "200": { "description": "Entries at the requested level.", "content": { "application/json": { "schema": { "type": "object", "properties": { "data": { "type": "object", "properties": {
            "entries": { "type": "array", "items": { "$ref": "#/components/schemas/Entry" } },
            "total": { "type": "integer" },
            "lang": { "type": "string" }
          } } } } } } },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "404": { "$ref": "#/components/responses/NotFound" },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      },
      "post": {
        "tags": ["Guidebook entries"],
        "operationId": "createEntry",
        "summary": "Create an entry",
        "description": "title is required. parent_id null creates a top-level menu. Non-empty content is only allowed on depth-3 entries (HTTP 422 content_requires_depth_3 otherwise).",
        "requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/EntryWrite" }, "example": { "title": "Parking", "content": "Driveway fits 2 cars.", "icon": "car", "parent_id": 1234 } } } },
        "responses": {
          "201": { "description": "Created.", "content": { "application/json": { "schema": { "type": "object", "properties": { "data": { "type": "object", "properties": { "entry": { "$ref": "#/components/schemas/Entry" } } } } } } } },
          "400": { "$ref": "#/components/responses/BadRequest" },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "404": { "$ref": "#/components/responses/NotFound" },
          "422": { "$ref": "#/components/responses/Unprocessable" },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      }
    },
    "/v1/properties/{property_uuid}/entries/{entry_id}": {
      "parameters": [ { "$ref": "#/components/parameters/PropertyUuid" }, { "$ref": "#/components/parameters/EntryId" } ],
      "get": {
        "tags": ["Guidebook entries"],
        "operationId": "getEntry",
        "summary": "Get one entry with its direct children",
        "responses": {
          "200": { "description": "The entry and its children.", "content": { "application/json": { "schema": { "type": "object", "properties": { "data": { "type": "object", "properties": {
            "entry": { "$ref": "#/components/schemas/Entry" },
            "children": { "type": "array", "items": { "$ref": "#/components/schemas/Entry" } }
          } } } } } } },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "404": { "$ref": "#/components/responses/NotFound" },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      },
      "patch": {
        "tags": ["Guidebook entries"],
        "operationId": "updateEntry",
        "summary": "Update an entry",
        "description": "Send only the fields to change. parent_id null promotes the entry to top level.",
        "requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/EntryWrite" } } } },
        "responses": {
          "200": { "description": "Updated entry.", "content": { "application/json": { "schema": { "type": "object", "properties": { "data": { "type": "object", "properties": { "entry": { "$ref": "#/components/schemas/Entry" } } } } } } } },
          "400": { "$ref": "#/components/responses/BadRequest" },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "404": { "$ref": "#/components/responses/NotFound" },
          "422": { "$ref": "#/components/responses/Unprocessable" },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      },
      "delete": {
        "tags": ["Guidebook entries"],
        "operationId": "deleteEntry",
        "summary": "Delete an entry and all descendants",
        "responses": {
          "200": { "description": "Rows removed.", "content": { "application/json": { "schema": { "type": "object", "properties": { "data": { "type": "object", "properties": { "deleted": { "type": "integer" } } } } } } } },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "404": { "$ref": "#/components/responses/NotFound" },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      }
    },
    "/v1/reservations": {
      "get": {
        "tags": ["Reservations"],
        "operationId": "listReservations",
        "summary": "List reservations",
        "description": "All dates are YYYY-MM-DD. data.total is the full match count, not the page size. Uses the reservations rate-limit bucket.",
        "parameters": [
          { "name": "checkin_from", "in": "query", "schema": { "type": "string", "format": "date" } },
          { "name": "checkin_to", "in": "query", "schema": { "type": "string", "format": "date" } },
          { "name": "checkout_from", "in": "query", "schema": { "type": "string", "format": "date" } },
          { "name": "checkout_to", "in": "query", "schema": { "type": "string", "format": "date" } },
          { "name": "booked_from", "in": "query", "schema": { "type": "string", "format": "date" } },
          { "name": "booked_to", "in": "query", "schema": { "type": "string", "format": "date" } },
          { "name": "today", "in": "query", "description": "1 = check-ins today.", "schema": { "type": "integer", "enum": [0, 1] } },
          { "name": "this_week", "in": "query", "description": "1 = check-ins Monday to Sunday this week.", "schema": { "type": "integer", "enum": [0, 1] } },
          { "name": "next_week", "in": "query", "schema": { "type": "integer", "enum": [0, 1] } },
          { "name": "this_month", "in": "query", "description": "1 = booked this calendar month.", "schema": { "type": "integer", "enum": [0, 1] } },
          { "name": "last_month", "in": "query", "schema": { "type": "integer", "enum": [0, 1] } },
          { "name": "status", "in": "query", "description": "Exact match, e.g. accepted or cancelled.", "schema": { "type": "string" } },
          { "name": "property_uuid", "in": "query", "schema": { "type": "string" } },
          { "name": "platform_id", "in": "query", "description": "Guest-facing booking code, e.g. an Airbnb confirmation code.", "schema": { "type": "string" } },
          { "name": "guest", "in": "query", "description": "Fragment of the guest name.", "schema": { "type": "string" } },
          { "name": "sort", "in": "query", "schema": { "type": "string", "enum": ["checkin_asc", "checkin_desc", "booked_asc", "booked_desc"], "default": "checkin_asc" } },
          { "name": "limit", "in": "query", "schema": { "type": "integer", "minimum": 1, "maximum": 200, "default": 50 } },
          { "name": "offset", "in": "query", "schema": { "type": "integer", "minimum": 0, "default": 0 } }
        ],
        "responses": {
          "200": { "description": "Matching reservations.", "content": { "application/json": { "schema": { "type": "object", "properties": { "data": { "type": "object", "properties": {
            "reservations": { "type": "array", "items": { "$ref": "#/components/schemas/Reservation" } },
            "total": { "type": "integer" }, "limit": { "type": "integer" }, "offset": { "type": "integer" }, "returned": { "type": "integer" }
          } } } } } } },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      }
    },
    "/v1/reservations/{id}": {
      "parameters": [ { "name": "id", "in": "path", "required": true, "description": "Internal reservation UUID or the guest-facing booking code.", "schema": { "type": "string" } } ],
      "get": {
        "tags": ["Reservations"],
        "operationId": "getReservation",
        "summary": "Get one reservation",
        "description": "Adds confirmation_code, door_code, cleaning_fee and avg_night_rate where available. Returns 404 if a booking code matches more than one row; use the list filter instead.",
        "responses": {
          "200": { "description": "The reservation.", "content": { "application/json": { "schema": { "type": "object", "properties": { "data": { "type": "object", "properties": { "reservation": { "$ref": "#/components/schemas/Reservation" } } } } } } } },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "404": { "$ref": "#/components/responses/NotFound" },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      }
    },
    "/v1/reservations/{id}/payment-link": {
      "parameters": [ { "name": "id", "in": "path", "required": true, "schema": { "type": "string" } } ],
      "post": {
        "tags": ["Reservations"],
        "operationId": "createPaymentLink",
        "summary": "Generate a guest payment link",
        "description": "Builds a guidebook URL with the payment overlay pre-opened. Nothing is charged until the guest pays. Requires Stripe Connect with charges enabled (HTTP 412 otherwise).",
        "requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", "required": ["amount"], "properties": {
          "amount": { "type": "number", "exclusiveMinimum": 0, "maximum": 100000 },
          "currency": { "type": "string", "description": "3-letter ISO code. Defaults to property currency, then Stripe default, then USD.", "minLength": 3, "maxLength": 3 }
        } }, "example": { "amount": 30, "currency": "NZD" } } } },
        "responses": {
          "200": { "description": "The link.", "content": { "application/json": { "schema": { "type": "object", "properties": { "data": { "type": "object", "properties": { "payment_link": { "$ref": "#/components/schemas/PaymentLink" } } } } } } } },
          "400": { "$ref": "#/components/responses/BadRequest" },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "404": { "$ref": "#/components/responses/NotFound" },
          "412": { "description": "stripe_not_connected, stripe_not_ready or no_pms_connected.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" } } } },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      }
    },
    "/v1/tags": {
      "get": {
        "tags": ["Tags"],
        "operationId": "listTags",
        "summary": "List tags",
        "responses": {
          "200": { "description": "Tags with active property counts.", "content": { "application/json": { "schema": { "type": "object", "properties": { "data": { "type": "object", "properties": {
            "tags": { "type": "array", "items": { "$ref": "#/components/schemas/Tag" } }, "total": { "type": "integer" }
          } } } } } } },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      },
      "post": {
        "tags": ["Tags"],
        "operationId": "createTag",
        "summary": "Create a tag",
        "requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", "required": ["name"], "properties": {
          "name": { "type": "string" }, "color": { "type": "string", "example": "#1e88e5" }, "description": { "type": "string" }
        } } } } },
        "responses": {
          "201": { "description": "Created.", "content": { "application/json": { "schema": { "type": "object", "properties": { "data": { "type": "object", "properties": { "tag": { "$ref": "#/components/schemas/Tag" } } } } } } } },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "409": { "description": "tag_exists; body carries the existing tag id.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" } } } },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      }
    },
    "/v1/tags/{tag_id}": {
      "parameters": [ { "name": "tag_id", "in": "path", "required": true, "schema": { "type": "integer" } } ],
      "get": {
        "tags": ["Tags"],
        "operationId": "getTag",
        "summary": "Get one tag with its properties",
        "responses": {
          "200": { "description": "The tag and the properties it covers.", "content": { "application/json": { "schema": { "type": "object", "properties": { "data": { "type": "object", "properties": {
            "tag": { "$ref": "#/components/schemas/Tag" },
            "properties": { "type": "array", "items": { "type": "object", "properties": { "uuid": { "type": "string" }, "name": { "type": "string" } } } }
          } } } } } } },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "404": { "$ref": "#/components/responses/NotFound" },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      }
    },
    "/v1/locations": {
      "get": {
        "tags": ["Locations"],
        "operationId": "listLocations",
        "summary": "List map pins",
        "parameters": [
          { "name": "scope", "in": "query", "schema": { "type": "string", "enum": ["all", "property", "tag"] } },
          { "name": "property_uuid", "in": "query", "schema": { "type": "string" } },
          { "name": "tag_id", "in": "query", "schema": { "type": "integer" } },
          { "name": "limit", "in": "query", "schema": { "type": "integer", "minimum": 1, "maximum": 500, "default": 200 } },
          { "name": "offset", "in": "query", "schema": { "type": "integer", "minimum": 0, "default": 0 } }
        ],
        "responses": {
          "200": { "description": "Pins.", "content": { "application/json": { "schema": { "type": "object", "properties": { "data": { "type": "object", "properties": {
            "locations": { "type": "array", "items": { "$ref": "#/components/schemas/Location" } }, "total": { "type": "integer" }
          } } } } } } },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      },
      "post": {
        "tags": ["Locations"],
        "operationId": "createLocation",
        "summary": "Create a map pin",
        "description": "scope, name, lat and lng are required; property_uuid or tag_id must match the scope. Supplying place_id triggers Google enrichment and requires Guidebook Premium (HTTP 402 premium_required otherwise).",
        "requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/LocationWrite" } } } },
        "responses": {
          "201": { "description": "Created.", "content": { "application/json": { "schema": { "type": "object", "properties": { "data": { "type": "object", "properties": { "location": { "$ref": "#/components/schemas/Location" } } } } } } } },
          "400": { "$ref": "#/components/responses/BadRequest" },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "402": { "$ref": "#/components/responses/PaymentRequired" },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      }
    },
    "/v1/locations/{location_id}": {
      "parameters": [ { "name": "location_id", "in": "path", "required": true, "schema": { "type": "integer" } } ],
      "get": {
        "tags": ["Locations"],
        "operationId": "getLocation",
        "summary": "Get one map pin",
        "responses": {
          "200": { "description": "The pin.", "content": { "application/json": { "schema": { "type": "object", "properties": { "data": { "type": "object", "properties": { "location": { "$ref": "#/components/schemas/Location" } } } } } } } },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "404": { "$ref": "#/components/responses/NotFound" },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      },
      "patch": {
        "tags": ["Locations"],
        "operationId": "updateLocation",
        "summary": "Update a map pin",
        "description": "Send only the fields to change. Changing scope (with the matching property_uuid or tag_id) moves the pin in one call. A new place_id re-runs Google enrichment (premium).",
        "requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/LocationWrite" } } } },
        "responses": {
          "200": { "description": "Updated pin.", "content": { "application/json": { "schema": { "type": "object", "properties": { "data": { "type": "object", "properties": { "location": { "$ref": "#/components/schemas/Location" } } } } } } } },
          "400": { "$ref": "#/components/responses/BadRequest" },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "402": { "$ref": "#/components/responses/PaymentRequired" },
          "404": { "$ref": "#/components/responses/NotFound" },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      },
      "delete": {
        "tags": ["Locations"],
        "operationId": "deleteLocation",
        "summary": "Delete a map pin",
        "responses": {
          "200": { "description": "Deleted.", "content": { "application/json": { "schema": { "type": "object", "properties": { "data": { "type": "object", "properties": { "deleted": { "type": "integer" } } } } } } } },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "404": { "$ref": "#/components/responses/NotFound" },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      }
    },
    "/v1/places/search": {
      "get": {
        "tags": ["Places"],
        "operationId": "searchPlaces",
        "summary": "Search Google Places",
        "description": "Up to 5 candidates. Guidebook Premium only. Cached 6 hours.",
        "parameters": [
          { "name": "q", "in": "query", "required": true, "schema": { "type": "string" } },
          { "name": "near", "in": "query", "description": "lat,lng,radius_metres bias, e.g. -36.85,174.76,2000.", "schema": { "type": "string" } }
        ],
        "responses": {
          "200": { "description": "Candidates.", "content": { "application/json": { "schema": { "type": "object", "properties": { "data": { "type": "object", "properties": {
            "places": { "type": "array", "items": { "$ref": "#/components/schemas/Place" } }
          } } } } } } },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "402": { "$ref": "#/components/responses/PaymentRequired" },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      }
    },
    "/v1/places/{place_id}": {
      "parameters": [ { "name": "place_id", "in": "path", "required": true, "schema": { "type": "string" } } ],
      "get": {
        "tags": ["Places"],
        "operationId": "getPlace",
        "summary": "Google Place details",
        "description": "Adds phone and website to the search fields. Guidebook Premium only. Cached 24 hours.",
        "responses": {
          "200": { "description": "Place details.", "content": { "application/json": { "schema": { "type": "object", "properties": { "data": { "type": "object", "properties": { "place": { "$ref": "#/components/schemas/Place" } } } } } } } },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "402": { "$ref": "#/components/responses/PaymentRequired" },
          "404": { "$ref": "#/components/responses/NotFound" },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      }
    },
    "/v1/sms/send": {
      "post": {
        "tags": ["SMS"],
        "operationId": "sendSms",
        "summary": "Quote, then send, an SMS to reservation guests",
        "description": "Two-stage. Stage 1: post the message plus at least one recipient filter and receive a quote (data.quote = true) with recipients, segments and total cost. Stage 2: repeat the request adding the acknowledged_* values from quote.confirm_with; the messages are then queued and your balance debited. Recipients always come from your own reservations; phone numbers cannot be supplied. GSM-7 characters only. Include the literal token {guide_link} to insert each guest's short guidebook URL.",
        "requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SmsRequest" }, "examples": {
          "quote": { "summary": "Stage 1 quote", "value": { "message": "Power outage in Ohakune, updates to follow.", "tag_id": 15, "currently_staying": true } },
          "send": { "summary": "Stage 2 send", "value": { "message": "Power outage in Ohakune, updates to follow.", "tag_id": 15, "currently_staying": true, "acknowledged_total_cost_cents": 40, "acknowledged_segments": 1, "acknowledged_past_checkout": true } }
        } } } },
        "responses": {
          "200": { "description": "A quote (stage 1) or the send result (stage 2).", "content": { "application/json": { "schema": { "type": "object", "properties": { "data": { "$ref": "#/components/schemas/SmsResult" } } } } } },
          "400": { "description": "missing_message, message_too_long, not_gsm, no_filter_supplied or no_recipients.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" } } } },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "402": { "description": "no_balance_record or insufficient_credit.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" } } } },
          "409": { "description": "quote_stale (fresh quote in body), recipient_warning_unconfirmed, duplicate_send or phone_rate_limit.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" } } } },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      }
    },
    "/v1/wifi/guests": {
      "get": {
        "tags": ["WiFi"],
        "operationId": "listWifiGuests",
        "summary": "List captured WiFi guests (marketing list)",
        "description": "Every guest who signed in through your captive portal, one row per unique (property, email) pair from that guest's most recent sign-in. Same data as Members > WiFi > Marketing. Not subscription-gated. Dates are YYYY-MM-DD in your account timezone (a from value snaps to the start of the day, a to value to the end) or full ISO 8601. For marketing use, filter marketing_opt_in=true. For incremental syncs use first_seen_from set to your last run. format=csv streams the whole filtered set with no paging.",
        "parameters": [
          { "name": "property_uuid", "in": "query", "description": "One uuid or several comma-separated (max 200). Omit for every property.", "schema": { "type": "string" } },
          { "name": "first_seen_from", "in": "query", "description": "Guests whose FIRST sign-in is on or after this date.", "schema": { "type": "string" } },
          { "name": "first_seen_to", "in": "query", "schema": { "type": "string" } },
          { "name": "last_seen_from", "in": "query", "description": "Guests whose LATEST sign-in is on or after this date.", "schema": { "type": "string" } },
          { "name": "last_seen_to", "in": "query", "schema": { "type": "string" } },
          { "name": "marketing_opt_in", "in": "query", "description": "true keeps only guests who ticked the marketing opt-in.", "schema": { "type": "boolean" } },
          { "name": "email_verified", "in": "query", "schema": { "type": "boolean" } },
          { "name": "q", "in": "query", "description": "Fuzzy match on guest name, email, phone or property name.", "schema": { "type": "string" } },
          { "name": "sort", "in": "query", "schema": { "type": "string", "enum": ["last_seen", "first_seen", "name", "email", "property_name"], "default": "last_seen" } },
          { "name": "order", "in": "query", "schema": { "type": "string", "enum": ["asc", "desc"], "default": "desc" } },
          { "name": "limit", "in": "query", "schema": { "type": "integer", "minimum": 1, "maximum": 1000, "default": 200 } },
          { "name": "offset", "in": "query", "schema": { "type": "integer", "minimum": 0, "default": 0 } },
          { "name": "format", "in": "query", "schema": { "type": "string", "enum": ["json", "csv"], "default": "json" } }
        ],
        "responses": {
          "200": {
            "description": "Guest rows (JSON) or a CSV stream when format=csv.",
            "content": {
              "application/json": { "schema": { "type": "object", "properties": { "data": { "$ref": "#/components/schemas/WifiGuestList" } } } },
              "text/csv": { "schema": { "type": "string" }, "example": "\"Property Name\",\"Property Address\",\"Guest Name\",Email,Phone,\"Marketing Opt-In\",\"Email Verified\",\"First Seen\",\"Last Seen\",\"Total Visits\",\"Email Opened\",\"Property UUID\"\n" }
            }
          },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "422": { "description": "invalid_property_uuid, too_many_properties, invalid_date, invalid_flag, invalid_sort or invalid_format.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" } } } },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      }
    },
    "/v1/wifi/controllers": {
      "get": {
        "tags": ["WiFi"],
        "operationId": "listWifiControllers",
        "summary": "List WiFi controllers",
        "description": "Requires an active WiFi plan (HTTP 402 wifi_subscription_required otherwise).",
        "responses": {
          "200": { "description": "Controllers.", "content": { "application/json": { "schema": { "type": "object", "properties": { "data": { "type": "object", "properties": {
            "controllers": { "type": "array", "items": { "$ref": "#/components/schemas/WifiController" } },
            "total": { "type": "integer" },
            "active_subscriptions": { "type": "array", "items": { "type": "string" } }
          } } } } } } },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "402": { "$ref": "#/components/responses/PaymentRequired" },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      }
    },
    "/v1/wifi/properties/{property_uuid}/status": {
      "parameters": [ { "$ref": "#/components/parameters/PropertyUuid" } ],
      "get": {
        "tags": ["WiFi"],
        "operationId": "getWifiStatus",
        "summary": "Live WiFi status for one property",
        "description": "Access points with online state, client counts (total, wifi, wired), clients by SSID and unauthorised captive-portal devices. Cached 30 seconds; X-Wifi-Cache header says hit or miss. If the controller is unreachable the response still succeeds with _controller_error set. Requires an active WiFi plan.",
        "responses": {
          "200": { "description": "Status snapshot.", "content": { "application/json": { "schema": { "type": "object", "properties": { "data": { "$ref": "#/components/schemas/WifiStatus" } } } } } },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "402": { "$ref": "#/components/responses/PaymentRequired" },
          "404": { "$ref": "#/components/responses/NotFound" },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      }
    },
    "/v1/wifi/properties/{property_uuid}/clients": {
      "parameters": [ { "$ref": "#/components/parameters/PropertyUuid" } ],
      "get": {
        "tags": ["WiFi"],
        "operationId": "listWifiClients",
        "summary": "List connected clients for one property",
        "description": "Requires an active WiFi plan.",
        "parameters": [
          { "name": "ap_mac", "in": "query", "schema": { "type": "string" } },
          { "name": "ssid", "in": "query", "schema": { "type": "string" } },
          { "name": "unauthorised", "in": "query", "description": "1 keeps only captive-portal guests not yet authorised.", "schema": { "type": "integer", "enum": [0, 1] } },
          { "name": "wired", "in": "query", "description": "1 wired only, 0 wireless only.", "schema": { "type": "integer", "enum": [0, 1] } },
          { "name": "limit", "in": "query", "schema": { "type": "integer", "minimum": 1, "maximum": 500, "default": 200 } },
          { "name": "offset", "in": "query", "schema": { "type": "integer", "minimum": 0, "default": 0 } }
        ],
        "responses": {
          "200": { "description": "Clients.", "content": { "application/json": { "schema": { "type": "object", "properties": { "data": { "type": "object", "properties": {
            "clients": { "type": "array", "items": { "$ref": "#/components/schemas/WifiClient" } }, "total": { "type": "integer" }
          } } } } } } },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "402": { "$ref": "#/components/responses/PaymentRequired" },
          "404": { "$ref": "#/components/responses/NotFound" },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      }
    },
    "/v1/wifi/properties/{property_uuid}/clients/{mac}/authorize": {
      "parameters": [ { "$ref": "#/components/parameters/PropertyUuid" }, { "$ref": "#/components/parameters/ClientMac" } ],
      "post": {
        "tags": ["WiFi"],
        "operationId": "authorizeWifiClient",
        "summary": "Authorise a captive-portal device",
        "requestBody": { "required": false, "content": { "application/json": { "schema": { "type": "object", "properties": { "duration_minutes": { "type": "integer", "minimum": 1, "maximum": 525600, "default": 480 } } } } } },
        "responses": {
          "200": { "description": "Authorised.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/DataObject" } } } },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "402": { "$ref": "#/components/responses/PaymentRequired" },
          "404": { "$ref": "#/components/responses/NotFound" },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      }
    },
    "/v1/wifi/properties/{property_uuid}/clients/{mac}/kick": {
      "parameters": [ { "$ref": "#/components/parameters/PropertyUuid" }, { "$ref": "#/components/parameters/ClientMac" } ],
      "post": {
        "tags": ["WiFi"],
        "operationId": "kickWifiClient",
        "summary": "Disconnect a client",
        "description": "The client may re-associate if it holds valid credentials.",
        "responses": {
          "200": { "description": "Disconnected.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/DataObject" } } } },
          "401": { "$ref": "#/components/responses/Unauthorized" },
          "402": { "$ref": "#/components/responses/PaymentRequired" },
          "404": { "$ref": "#/components/responses/NotFound" },
          "429": { "$ref": "#/components/responses/RateLimited" }
        }
      }
    }
  },
  "components": {
    "securitySchemes": {
      "basicAuth": {
        "type": "http",
        "scheme": "basic",
        "description": "Username = client_id (bsc_...), password = client_secret (bss_...). Create a pair under API Credentials at https://gb.betterstr.com/members/profile/."
      }
    },
    "parameters": {
      "PropertyUuid": { "name": "property_uuid", "in": "path", "required": true, "description": "Property UUID from GET /v1/properties.", "schema": { "type": "string" } },
      "EntryId": { "name": "entry_id", "in": "path", "required": true, "schema": { "type": "integer" } },
      "ClientMac": { "name": "mac", "in": "path", "required": true, "description": "aa:bb:cc:dd:ee:ff", "schema": { "type": "string" } }
    },
    "responses": {
      "BadRequest": { "description": "Validation error; see error.code.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" } } } },
      "Unauthorized": { "description": "missing_credentials, invalid_credentials, credentials_revoked or credentials_expired.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" } } } },
      "PaymentRequired": { "description": "premium_required (Guidebook Premium) or wifi_subscription_required (WiFi plan).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" } } } },
      "NotFound": { "description": "property_not_found, entry_not_found, reservation_not_found or similar.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" } } } },
      "Unprocessable": { "description": "Rule violation, e.g. content_requires_depth_3.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" } } } },
      "RateLimited": {
        "description": "rate_limited. Retry-After header gives the seconds to wait; the body carries scope, limit and retry_after.",
        "headers": { "Retry-After": { "schema": { "type": "integer" } } },
        "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" } } }
      }
    },
    "schemas": {
      "ErrorResponse": {
        "type": "object",
        "properties": { "error": { "type": "object", "required": ["code", "message"], "properties": { "code": { "type": "string" }, "message": { "type": "string" } }, "additionalProperties": true } }
      },
      "DataObject": { "type": "object", "properties": { "data": { "type": "object", "additionalProperties": true } } },
      "Property": {
        "type": "object",
        "properties": {
          "uuid": { "type": "string" }, "name": { "type": "string" }, "display_address": { "type": ["string", "null"] },
          "city": { "type": ["string", "null"] }, "country": { "type": ["string", "null"] }, "timezone": { "type": ["string", "null"] },
          "entry_count": { "type": "integer" }, "guide_url": { "type": "string", "format": "uri" }
        }
      },
      "Entry": {
        "type": "object",
        "properties": {
          "id": { "type": "integer" }, "parent_id": { "type": ["integer", "null"] }, "property_uuid": { "type": "string" },
          "lang": { "type": "string" }, "title": { "type": "string" }, "content": { "type": ["string", "null"] }, "icon": { "type": ["string", "null"] },
          "display_order": { "type": "integer" }, "visible": { "type": "boolean" }, "depth": { "type": "integer", "description": "1 top-level menu, 2 sub-menu, 3+ content card." },
          "created_at": { "type": "string" }, "updated_at": { "type": "string" }
        }
      },
      "EntryWrite": {
        "type": "object",
        "properties": {
          "title": { "type": "string" }, "content": { "type": "string", "description": "Only allowed on depth-3 entries." }, "icon": { "type": "string", "description": "Font Awesome name, e.g. car." },
          "parent_id": { "type": ["integer", "null"] }, "display_order": { "type": "integer" }, "visible": { "type": "boolean" }, "lang": { "type": "string", "default": "en" }
        }
      },
      "Reservation": {
        "type": "object",
        "properties": {
          "id": { "type": "string", "description": "Internal UUID; use this in other calls." },
          "reservation_code": { "type": ["string", "null"] }, "platform_id": { "type": ["string", "null"], "description": "Guest-facing booking code." },
          "guide_url": { "type": "string", "format": "uri" }, "status": { "type": "string" },
          "arrival_date": { "type": "string" }, "departure_date": { "type": "string" }, "booking_date": { "type": ["string", "null"] }, "nights": { "type": "integer" },
          "guest_name": { "type": ["string", "null"] }, "guest_email": { "type": ["string", "null"] }, "guest_phone": { "type": ["string", "null"] }, "guests": { "type": ["integer", "null"] },
          "channel": { "type": ["string", "null"] }, "total_price": { "type": ["string", "null"] }, "currency": { "type": ["string", "null"] },
          "property": { "type": "object", "additionalProperties": true },
          "confirmation_code": { "type": ["string", "null"] }, "door_code": { "type": ["string", "null"] }, "cleaning_fee": { "type": ["string", "number", "null"] }, "avg_night_rate": { "type": ["string", "number", "null"] },
          "created_at": { "type": "string" }
        }
      },
      "PaymentLink": {
        "type": "object",
        "properties": { "url": { "type": "string", "format": "uri" }, "reservation_id": { "type": "string" }, "amount": { "type": "number" }, "currency": { "type": "string" }, "stripe_account": { "type": "string" }, "expires_at": { "type": ["string", "null"] } }
      },
      "Tag": {
        "type": "object",
        "properties": { "id": { "type": "integer" }, "name": { "type": "string" }, "color": { "type": ["string", "null"] }, "description": { "type": ["string", "null"] }, "property_count": { "type": "integer" } }
      },
      "Location": {
        "type": "object",
        "properties": {
          "id": { "type": "integer" }, "scope": { "type": "string", "enum": ["all", "property", "tag"] }, "property_uuid": { "type": ["string", "null"] }, "tag_id": { "type": ["integer", "null"] },
          "name": { "type": "string" }, "lat": { "type": "number" }, "lng": { "type": "number" }, "place_id": { "type": ["string", "null"] },
          "icon": { "type": ["string", "null"] }, "comments": { "type": ["string", "null"] }, "color_primary": { "type": ["string", "null"] }, "color_secondary": { "type": ["string", "null"] },
          "extra_info": { "type": ["object", "null"], "additionalProperties": true }
        }
      },
      "LocationWrite": {
        "type": "object",
        "properties": {
          "scope": { "type": "string", "enum": ["all", "property", "tag"] }, "property_uuid": { "type": "string" }, "tag_id": { "type": "integer" },
          "name": { "type": "string" }, "lat": { "type": "number", "minimum": -90, "maximum": 90 }, "lng": { "type": "number", "minimum": -180, "maximum": 180 },
          "place_id": { "type": "string", "description": "Google place id. Premium only; triggers enrichment." }, "icon": { "type": "string" }, "comments": { "type": "string" },
          "color_primary": { "type": "string" }, "color_secondary": { "type": "string" }, "extra_info": { "type": "object", "additionalProperties": true }
        }
      },
      "Place": {
        "type": "object",
        "properties": {
          "place_id": { "type": "string" }, "name": { "type": "string" }, "formatted_address": { "type": ["string", "null"] }, "lat": { "type": "number" }, "lng": { "type": "number" },
          "phone": { "type": ["string", "null"] }, "website": { "type": ["string", "null"] }, "google_maps_url": { "type": "string", "format": "uri" }
        }
      },
      "SmsRequest": {
        "type": "object",
        "required": ["message"],
        "properties": {
          "message": { "type": "string", "maxLength": 1530, "description": "GSM-7 only. May contain {guide_link}." },
          "reservation_ids": { "type": "array", "items": { "type": "string" }, "description": "Internal UUIDs or guest-facing booking codes." },
          "property_uuid": { "type": "string" }, "tag_id": { "type": "integer" }, "currently_staying": { "type": "boolean" },
          "checkin_from": { "type": "string", "format": "date" }, "checkin_to": { "type": "string", "format": "date" },
          "checkout_from": { "type": "string", "format": "date" }, "checkout_to": { "type": "string", "format": "date" },
          "status": { "type": "string", "default": "accepted", "description": "Empty string to skip the status filter." },
          "skip_opt_out": { "type": "boolean", "description": "Do not append the STOP opt-out text." },
          "acknowledged_total_cost_cents": { "type": "integer", "description": "Echo from quote.confirm_with to send." },
          "acknowledged_segments": { "type": "integer" },
          "acknowledged_past_checkout": { "type": "boolean" },
          "acknowledged_duplicate_send": { "type": "boolean" },
          "acknowledged_recent_send": { "type": "boolean" }
        }
      },
      "SmsResult": {
        "type": "object",
        "properties": {
          "quote": { "type": "boolean", "description": "true = preview only, nothing sent." },
          "message": { "type": "object", "additionalProperties": true },
          "recipients": { "type": "array", "items": { "type": "object", "additionalProperties": true } },
          "total_recipients": { "type": "integer" }, "skipped": { "type": "array", "items": { "type": "object", "additionalProperties": true } },
          "warning_counts": { "type": "object", "additionalProperties": true },
          "total_cost_cents": { "type": "integer" }, "user_balance_cents": { "type": "integer" }, "currency": { "type": "string" },
          "confirm_with": { "type": "object", "additionalProperties": true, "description": "Values to echo back in the send request." }
        },
        "additionalProperties": true
      },
      "WifiGuest": {
        "type": "object",
        "properties": {
          "property_uuid": { "type": "string" }, "property_name": { "type": "string" }, "property_address": { "type": ["string", "null"] },
          "name": { "type": "string" }, "email": { "type": "string", "format": "email" }, "phone": { "type": ["string", "null"] },
          "marketing_opt_in": { "type": "boolean" }, "email_verified": { "type": "boolean" }, "email_opened": { "type": "boolean", "description": "Opened the verification email without necessarily clicking the link." },
          "verification_sent_at": { "type": ["string", "null"], "format": "date-time" },
          "first_seen": { "type": ["string", "null"], "format": "date-time", "description": "ISO 8601 in the account timezone." },
          "last_seen": { "type": ["string", "null"], "format": "date-time" },
          "visit_count": { "type": "integer" }
        }
      },
      "WifiGuestList": {
        "type": "object",
        "properties": {
          "guests": { "type": "array", "items": { "$ref": "#/components/schemas/WifiGuest" } },
          "total": { "type": "integer", "description": "Whole filtered set, not the page." }, "opt_ins": { "type": "integer" }, "opt_in_rate": { "type": "number" },
          "limit": { "type": "integer" }, "offset": { "type": "integer" }, "has_more": { "type": "boolean" },
          "timezone": { "type": "string" }, "sort": { "type": "string" }, "order": { "type": "string" },
          "filters": { "type": "object", "additionalProperties": true }
        }
      },
      "WifiController": {
        "type": "object",
        "properties": {
          "id": { "type": ["integer", "string"] }, "name": { "type": "string" }, "type": { "type": "string" }, "mode": { "type": "string" },
          "controller_type": { "type": "string", "enum": ["unifi_byod", "unifi_hosted", "omada_hosted"] },
          "property_uuid": { "type": ["string", "null"] }, "site_name": { "type": ["string", "null"] }, "connection_status": { "type": ["string", "null"] },
          "last_connection_test": { "type": ["string", "null"] }, "updated_at": { "type": ["string", "null"] }
        }
      },
      "WifiStatus": {
        "type": "object",
        "properties": {
          "property": { "type": "object", "additionalProperties": true }, "controller": { "type": "object", "additionalProperties": true },
          "access_points": { "type": "array", "items": { "type": "object", "additionalProperties": true } },
          "access_point_count": { "type": "integer" }, "access_points_online": { "type": "integer" }, "access_points_offline": { "type": "integer" },
          "client_count": { "type": "integer" }, "wifi_client_count": { "type": "integer" }, "wired_client_count": { "type": "integer" },
          "clients_by_ssid": { "type": "object", "additionalProperties": { "type": "integer" } },
          "unauthorised_count": { "type": "integer" }, "unauthorised_clients": { "type": "array", "items": { "$ref": "#/components/schemas/WifiClient" } },
          "_controller_error": { "type": ["string", "null"] }
        },
        "additionalProperties": true
      },
      "WifiClient": {
        "type": "object",
        "properties": {
          "mac": { "type": "string" }, "name": { "type": ["string", "null"] }, "hostname": { "type": ["string", "null"] }, "ip": { "type": ["string", "null"] },
          "ssid": { "type": ["string", "null"] }, "ap_mac": { "type": ["string", "null"] }, "ap_name": { "type": ["string", "null"] },
          "is_wired": { "type": "boolean" }, "is_guest": { "type": "boolean" }, "authorized": { "type": "boolean" }, "manufacturer": { "type": ["string", "null"] },
          "first_seen": { "type": ["string", "integer", "null"] }, "last_seen": { "type": ["string", "integer", "null"] }, "last_seen_seconds_ago": { "type": ["integer", "null"] }
        },
        "additionalProperties": true
      }
    }
  }
}
