# BetterSTR User API

Public REST API for BetterSTR users to manage their guidebooks (and, over time, other
account data) from scripts, integrations, and the BetterSTR Claude MCP.

**Base URL**

```
https://api.betterstr.com/user-api/
```

All responses are JSON. All write endpoints accept `Content-Type: application/json`.

**Rate limits at a glance** (per credential, per-minute, independent buckets — see [Rate limits](#rate-limits) for full detail):

| Scope | Endpoints | Free tier | Premium tier |
|-------|-----------|-----------|--------------|
| `global`       | everything except the two below | **30 / min** | **120 + active_properties / min** |
| `reservations` | `/v1/reservations*`             | **5 / min**  | **60 + active_properties / min**  |
| `places`       | `/v1/places*`                   | **5 / min**  | **60 + active_properties / min**  |

"Premium" = an active guidebook-premium subscription; the premium cap scales with your active property count.

---

## Authentication

The API uses **HTTP Basic auth** with a `client_id` + `client_secret` pair.

1. Sign in to `https://gb.betterstr.com/members/profile/`.
2. Scroll to **API Credentials** → click **New API Credentials** → give it a name.
3. Copy the **client_id** (format `bsc_…`) and **client_secret** (format `bss_…`).
   The secret is shown once and never displayed again — the client_id stays
   visible in the UI and is safe to log.
4. Send both with every request:

```
Authorization: Basic <base64(client_id:client_secret)>
```

With curl this is just:

```bash
curl -u "bsc_xxxxxxxxxxxx:bss_yyyyyyyyyyyyyyyyyyy" \
     https://api.betterstr.com/user-api/v1/properties
```

Revoke from the same page at any time; revoked credentials immediately return `401`.

### Error codes for auth

| HTTP | `error.code`            | Meaning                                                  |
|------|-------------------------|----------------------------------------------------------|
| 401  | `missing_credentials`   | No `Authorization: Basic …` header.                      |
| 401  | `invalid_credentials`   | client_id not recognised, client_secret wrong, or format invalid. |
| 401  | `credentials_revoked`   | Credentials were revoked in the profile UI.              |
| 401  | `credentials_expired`   | Credentials have an `expires_at` that has passed.        |

---

## Response shape

Success:

```json
{ "data": { ... } }
```

Error:

```json
{ "error": { "code": "missing_title", "message": "A title is required." } }
```

---

## Endpoints (v1)

### List active properties

```
GET /v1/properties
```

Returns the authenticated user's active properties.

```bash
curl -u "$CLIENT_ID:$CLIENT_SECRET" \
     https://api.betterstr.com/user-api/v1/properties
```

```json
{
  "data": {
    "properties": [
      {
        "uuid": "abc-123-...",
        "name": "Oceanview Beach House",
        "display_address": "123 Beach Dr, Malibu",
        "city": "Malibu",
        "country": "US",
        "timezone": "America/Los_Angeles",
        "entry_count": 14,
        "guide_url": "https://gb.betterstr.com/1/g/abc-123-..."
      }
    ],
    "total": 1
  }
}
```

---

### List guidebook entries

```
GET /v1/properties/{property_uuid}/entries
```

A guidebook entry is one item in the guide — a top-level menu, a sub-menu, or
a leaf content card. Hierarchy is by `parent_id`.

**The three-level rule (enforced server-side):**

| `depth` | What it is | Carries `content`? |
|---------|------------|--------------------|
| 1 | Top-level menu (`parent_id` is `null`) | No |
| 2 | Sub-menu under a top-level menu       | No |
| 3 | Content card                          | **Yes** |

The guidebook UI renders the body from depth-3 (and deeper) entries only.
Attempting to set non-empty `content` on a depth-1 or depth-2 entry — either
via `POST` or via `PATCH` that would leave it under depth 3 — returns
**HTTP 422** with `code: content_requires_depth_3`. To add a new content
section under an existing top-level menu, you'll do two calls: create the
depth-2 sub-menu (title + icon, no content), then create the depth-3 content
card underneath with the body.

Every entry returned includes a `depth` field so you can see where you are.

Query string:

| Param       | Default | Notes                                                     |
|-------------|---------|-----------------------------------------------------------|
| `parent_id` | `null`  | `null` (or omitted) returns top-level menus. Pass an id to list children of a specific entry. |
| `lang`      | `en`    | Two-letter language code.                                 |

```bash
curl -u "$CLIENT_ID:$CLIENT_SECRET" \
     "https://api.betterstr.com/user-api/v1/properties/$UUID/entries"
```

```json
{
  "data": {
    "entries": [
      {
        "id": 1234,
        "parent_id": null,
        "property_uuid": "abc-...",
        "lang": "en",
        "title": "Wi-Fi",
        "content": "Network: BeachHouse_Guest\nPassword: ...",
        "icon": "wifi",
        "display_order": 0,
        "visible": true,
        "created_at": "2026-04-01 12:00:00",
        "updated_at": "2026-04-12 09:13:00"
      }
    ],
    "total": 1,
    "lang": "en"
  }
}
```

---

### Get one entry (with children)

```
GET /v1/properties/{property_uuid}/entries/{id}
```

Returns the entry plus an array of its direct children (one level deep) so a
sub-menu can be rendered in a single round-trip.

---

### Create an entry

```
POST /v1/properties/{property_uuid}/entries
Content-Type: application/json
```

Body (all fields except `title` optional):

```json
{
  "title": "Parking",
  "content": "Driveway fits 2 cars. Street parking is fine.",
  "icon": "car",
  "parent_id": null,
  "display_order": 5,
  "visible": true,
  "lang": "en"
}
```

- `parent_id: null` (or omitted) → top-level menu.
- `parent_id: 1234` → a child entry under that parent. The parent must belong
  to the same property and language.

Returns `201 Created` with the new entry.

---

### Update an entry

```
PATCH /v1/properties/{property_uuid}/entries/{id}
```

Send only the fields you want to change. Accepted: `title`, `content`, `icon`,
`lang`, `visible`, `display_order`, `parent_id` (set to `null` to promote to
top-level; cannot point at the entry itself).

---

### Delete an entry

```
DELETE /v1/properties/{property_uuid}/entries/{id}
```

Cascades to all descendants. The response includes a count:

```json
{ "data": { "deleted": 4 } }
```

---

## Reservations (read-only)

Reservations cannot be edited via the API. Guidebook entries are the only
writable resource; everything under `/v1/reservations` is read-only with the
single exception of the payment-link helper, which builds a URL but does not
modify the reservation row.

### List reservations

```
GET /v1/reservations
```

Query string (all optional):

| Param            | Notes |
|------------------|-------|
| `checkin_from`   | ISO date (`YYYY-MM-DD`). Inclusive lower bound on `arrival_date`. |
| `checkin_to`     | Inclusive upper bound on `arrival_date`. |
| `checkout_from`  | Inclusive lower bound on `departure_date`. |
| `checkout_to`    | Inclusive upper bound on `departure_date`. |
| `booked_from`    | Inclusive lower bound on the booking timestamp (`booking_date` or `created_at`). |
| `booked_to`      | Inclusive upper bound on the booking timestamp. |
| `today=1`        | Shortcut: sets `checkin_from`/`checkin_to` to today. |
| `this_week=1`    | Shortcut: sets `checkin_from`/`checkin_to` to Mon–Sun of the current week. |
| `next_week=1`    | Shortcut for next week. |
| `this_month=1`   | Shortcut: sets `booked_from`/`booked_to` to the current calendar month. |
| `last_month=1`   | Shortcut: sets `booked_from`/`booked_to` to last calendar month. |
| `status`         | Exact match on `reservation_status` (e.g. `accepted`, `cancelled`). |
| `property_uuid`  | Limit to one of your properties. |
| `platform_id`    | Exact match on the guest-facing booking code (e.g. an Airbnb confirmation code). |
| `guest`          | Server-side `LIKE %fragment%` against the parsed guest name in `json_data`. |
| `sort`           | `checkin_asc` (default), `checkin_desc`, `booked_asc`, `booked_desc`. |
| `limit`          | 1–200, default 50. |
| `offset`         | Default 0. |

All dates are **ISO 8601 (`YYYY-MM-DD`)**. US-style `m/d/yyyy` and European
`dd/mm/yyyy` are not accepted — callers should normalise before sending.

`data.total` is the total count matching the filter (not the page size). Use
it for "how many bookings did I get last month" style queries without
iterating.

```json
{
  "data": {
    "reservations": [
      {
        "id": "e134f2f4-…",
        "reservation_code": "5336791289",
        "platform_id": "5336791289",
        "guide_url": "https://gb.betterstr.com/1/g/r/e134f2f4-…",
        "status": "accepted",
        "arrival_date": "2026-05-21 16:00:00",
        "departure_date": "2026-05-23 10:00:00",
        "booking_date": "2026-05-12 09:30:00",
        "nights": 2,
        "guest_name": "Burke Michayla",
        "guest_email": "…",
        "guest_phone": "…",
        "guests": 3,
        "channel": "airbnb",
        "total_price": "AU$420.00",
        "currency": "AUD",
        "property": {
          "uuid": "158ff572-…",
          "name": "2 Bed, 2 bath, self-contained",
          "address": { "display": "…", "city": "Surfers Paradise", … },
          "timezone": "+1000",
          "currency": null,
          "bedrooms": 2,
          "bathrooms": 2,
          "max_capacity": 4,
          "picture": "https://…"
        },
        "created_at": "2026-05-12 09:30:01"
      }
    ],
    "total": 17,
    "limit": 50,
    "offset": 0,
    "returned": 17
  }
}
```

Two id fields:

- `id` — our internal UUID; this is what you use in subsequent API calls.
- `reservation_code` / `platform_id` — the guest-facing booking code (e.g.
  the Airbnb confirmation code). Useful for cross-referencing with the
  channel; **not** the API identifier.

### Get one reservation

```
GET /v1/reservations/{id_or_platform_id}
```

The path segment accepts either:

- the internal UUID (`reservation.id`, e.g. `e134f2f4-…`), or
- the guest-facing booking code (`reservation.platform_id`, e.g. an Airbnb
  confirmation code like `RC6SZG`).

If a `platform_id` happens to match more than one row in your data (rare,
but possible across channels), the API returns 404 — fall back to
`GET /v1/reservations?platform_id=…` to disambiguate.

Returns the same shape as the list, plus a few extra fields when verbose:
`confirmation_code`, `door_code`, `cleaning_fee`, `avg_night_rate`.

### Generate a payment link

```
POST /v1/reservations/{id}/payment-link
Content-Type: application/json
```

Body:

```json
{ "amount": 30, "currency": "NZD" }
```

- `amount` (required, > 0, ≤ 100,000) — what the guest will be charged.
- `currency` (optional, 3-letter ISO) — defaults to the property's currency,
  then your Stripe account default, then USD.

The returned URL is a regular guidebook page with the payment overlay
pre-opened. The guest visits it; Stripe Connect handles the actual charge to
your account.

```json
{
  "data": {
    "payment_link": {
      "url": "https://gb.betterstr.com/1/g/r/e134f2f4-…?showPayment=1&paymentType=custom&customAmount=30&customCurrency=NZD&accordion=custom",
      "reservation_id": "e134f2f4-…",
      "amount": 30,
      "currency": "NZD",
      "stripe_account": "acct_…",
      "expires_at": null
    }
  }
}
```

Errors specific to this endpoint:

| HTTP | `code` | Meaning |
|------|--------|---------|
| 400 | `missing_amount` | `amount` missing or non-positive. |
| 400 | `amount_too_large` | `amount` > 100,000. |
| 400 | `invalid_currency` | `currency` was not a 3-letter code. |
| 404 | `reservation_not_found` | No such reservation belongs to you. |
| 412 | `stripe_not_connected` | No Stripe Connect account on file. |
| 412 | `stripe_not_ready` | Stripe is connected but `charges_enabled` is false. |
| 412 | `no_pms_connected` | No active PMS integration (needed for the URL's `api_type` segment). |

---

---

## Tags

Tags are user-defined groups of properties (e.g. "Ohakune", "Australia"). They
let you apply a single location to many properties at once.

### List tags

```
GET /v1/tags
```

Each row carries the `property_count` of active properties in that tag (demo
excluded).

### Get one tag (with property list)

```
GET /v1/tags/{id}
```

Returns the tag plus the full list of properties in it (`uuid` + `name`). Use
this when you want to confirm the blast radius before applying a location to
the tag.

### Create a tag

```
POST /v1/tags
Content-Type: application/json
```

```json
{ "name": "Ski lifts", "color": "#1e88e5", "description": "Snowfields" }
```

Name must be unique per user (case-insensitive); duplicates return **409
`tag_exists`** with the existing tag's id in the body.

---

## Locations (map pins)

A location is a pin (name + lat/lng + Google place_id + optional metadata)
that shows up on the guidebook map. Scope can be:

- `all` — every property guide
- `property` — one property (`property_uuid`)
- `tag` — every property in a tag (`tag_id`)

### List locations

```
GET /v1/locations[?scope=all|property|tag][&property_uuid=...][&tag_id=N]
```

### Create a location

```
POST /v1/locations
Content-Type: application/json
```

```json
{
  "scope": "property",
  "property_uuid": "5ad9d824-...",
  "name": "Sky Tower",
  "lat": -36.848448,
  "lng": 174.762191,
  "place_id": "ChIJJdxLbfBHDW0Rh5OtgMO10QI",
  "comments": "Phone: +64 9 363 6000\nWebsite: https://skycityauckland.co.nz/sky-tower/",
  "extra_info": {
    "phone": "+64 9 363 6000",
    "website": "https://skycityauckland.co.nz/sky-tower/"
  }
}
```

- `scope` is required: `"all"`, `"property"`, or `"tag"`.
- `lat` must be -90..90, `lng` -180..180.
- `place_id` is optional but strongly recommended — when supplied (and you're
  on the premium tier), the server fetches full Google Place details and
  populates the row's `extra_info` with `google_place_types`, `google_rating`,
  `google_price_level`, `google_user_ratings_total`, and auto-builds
  `comments` from phone + website when you didn't supply your own. This is
  the same data the web UI saves when you pick a place from the autocomplete.
- Non-premium accounts can save locations *without* a `place_id`; supplying
  one returns HTTP **402 `premium_required`**.

### Update a location

```
PATCH /v1/locations/{id}
Content-Type: application/json
```

Send only the fields you want to change. Accepted: `name`, `lat`, `lng`,
`icon`, `comments`, `color_primary`, `color_secondary`, `extra_info`,
`scope` (+ matching `property_uuid` or `tag_id`), and `place_id`.

Scope changes flip both `property_uuid` and `global_show` atomically — e.g.
moving a pin "from the Atiamuri property to the Ohakune tag" is a single
PATCH:

```json
{ "scope": "tag", "tag_id": 15 }
```

If you supply a **new** `place_id`, the server re-fetches Google enrichment
(premium-only) and refreshes the `google_*` fields in `extra_info`. Leave
`place_id` out of the body to keep the existing one (and skip the Google
call entirely).

### Delete a location

```
DELETE /v1/locations/{id}
```

Prefer `PATCH` over delete-and-recreate when the user wants to edit a pin —
delete is for actually removing it.

---

## Google Places (proxy)

We hold the Google Maps API key, so callers don't need their own. These calls
share a tighter rate-limit bucket (`places` scope) because each one costs us
a Google Maps Platform request.

**Premium-only.** All `/v1/places/*` endpoints, and `POST /v1/locations` with
a `place_id`, require an active guidebook-premium subscription. Non-premium
callers receive HTTP **402** with `code: premium_required`. Non-premium users
can still save locations — they just have to supply name + coords manually
(no place_id).

The responses are intentionally minimal — only the fields needed to identify
the place to the user (name, address, coords, phone, website) and a
`google_maps_url` for confirmation. Ratings, types, opening hours, etc. are
*not* exposed here; if you save the location with the returned `place_id`,
those fields are fetched server-side and stored on the row (see Locations
below).

**Caching.** Search responses are cached for 6 hours and place-details
responses for 24 hours (per place, regardless of which scope read them).
That means a workflow of `get_place(X)` → user confirms → `add_location(X)`
costs only **one** Google call instead of two. Each response stamps an
`X-Places-Cache: hit|miss` header so you can see what happened. If the
cache is unavailable the API falls back to calling Google directly — every
call still works, you just don't get the savings.

### Search

```
GET /v1/places/search?q=Sky+Tower+Auckland
GET /v1/places/search?q=cafe&near=-36.85,174.76,2000
```

Returns up to **5** candidates, each with `place_id`, `name`,
`formatted_address`, `lat`, `lng`, and a `google_maps_url`.

### Details

```
GET /v1/places/{place_id}
```

Adds `phone` and `website` to the above. Use this to confirm with the user
before calling `POST /v1/locations`.

---

---

## SMS (bulk to reservation guests)

```
POST /v1/sms/send
Content-Type: application/json
```

Sends an SMS to the guest phone(s) attached to one or more reservations. The
destination MSISDN is **never** caller-supplied — it always comes from a
reservation row owned by the authenticated user. Phone numbers are pulled
through `ReservationDataParser` so every supported PMS (Hospitable, OwnerRez,
Tokeet, Hostex, Hosttools, Guesty, Lodgify, MyRent, Hostaway, Streamline)
resolves consistently.

### Recipient spec (at least one is required)

| Field | Notes |
|-------|-------|
| `reservation_ids` | Array of internal UUIDs or guest-facing platform_ids. |
| `property_uuid`   | All reservations on one property. |
| `tag_id`          | All reservations on every property in the tag. |
| `currently_staying` | `true` to limit to guests whose stay covers `NOW()`. |
| `checkin_from` / `checkin_to` / `checkout_from` / `checkout_to` | ISO 8601 ranges. |
| `status` | Default `accepted`. Pass `""` to skip. |

### Encoding

GSM-7 only. Non-GSM characters (emoji, smart quotes, `—`, etc.) return
**HTTP 400 `not_gsm`** with the offending characters listed. The extension-set
characters (`^ { } [ ] ~ | € \`) count as 2 chars each per GSM 03.38.

### Templates: `{guide_link}`

Include the literal token `{guide_link}` anywhere in your message and the
server will substitute the **per-recipient short URL**
(`https://r.betterstr.com/XXXXXXXX`, 32 chars total) at send time. Each
reservation gets its own short URL — created on first send, reused on
subsequent sends (so the same guest always sees the same link).

The quote shows you `message.sample_expanded` (template with placeholder
expanded to a 32-char dummy URL) and accounts for the substitution in
segment math. The actual sent body is the fully expanded form;
`recipients[].short_url` in the send response gives you the final URL
each guest received.

### Auto-appended opt-out

If your message doesn't already mention one of the keywords the inbound DLR
honours (`stop`, `unsubscribe`, `remove`, `cancel`, `opt out`), the server
appends the **longest STOP variant that fits within the current segment
count** (so you never accidentally pay for an extra segment because of our
append). Variants tried in order: `Reply STOP to opt out.` → `STOP to opt
out.` → `Reply STOP` → `STOP=stop` → `STOP`. The quote tells you what was
appended (`message.opt_out_appended`). Pass `skip_opt_out: true` to disable
entirely.

### Spam protections (always on)

Two server-side gates guard against a runaway / hallucinating client texting
the same number repeatedly:

| Layer | Trigger | Default override flag |
|-------|---------|-----------------------|
| **Duplicate body** | Same `(user_id, phone, body)` sent within 10 minutes | `acknowledged_duplicate_send: true` |
| **Phone rate cap** | A phone has already received ≥3 SMS from this account in 10 minutes | `acknowledged_recent_send: true` |

Both are **unbypassable by default** — the only way through is for the
caller to set the explicit acknowledgement field, which surfaces in
`confirm_with` on the preview quote. The race-safe recheck inside the send
loop skips individual recipients (rather than aborting the whole batch) if
a new duplicate appears between quote and send.

### Two-stage flow

#### Stage 1 — preview (no acknowledgement)

```json
{
  "message": "Power outage in Ohakune — power company has been contacted, updates to follow.",
  "tag_id": 15,
  "currently_staying": true
}
```

Server returns a **quote**:

```json
{
  "data": {
    "quote": true,
    "message":          { "length_chars_gsm7": 84, "encoding": "GSM-7", "segments": 1, "note": null },
    "recipients":       [ { "reservation_id": "...", "platform_id": "RC6SZG", "guest_name": "...", "phone": "+64...", "property_uuid": "...", "property_name": "...", "arrival_date": "...", "departure_date": "...", "cost_cents": 10, "warnings": [] }, ... ],
    "total_recipients": 4,
    "skipped":          [ { "reservation_id": "...", "reason": "no_phone" }, ... ],
    "warning_counts":   { "past_checkout": 1, "far_future_arrival": 0 },
    "total_cost_cents": 40,
    "user_balance_cents": 38957,
    "currency":         "USD",
    "confirm_with":     {
      "acknowledged_total_cost_cents": 40,
      "acknowledged_segments":         1,
      "acknowledged_past_checkout":    true
    }
  }
}
```

#### Stage 2 — send (echo the quote)

```json
{
  "message": "Power outage in Ohakune — …",
  "tag_id": 15,
  "currently_staying": true,
  "acknowledged_total_cost_cents": 40,
  "acknowledged_segments": 1,
  "acknowledged_past_checkout": true
}
```

If the recipient set or message changed between stages the server returns
**409 `quote_stale`** with a fresh quote — re-fetch the preview and retry.

If any recipient is past their check-out or more than 30 days out and you
omit `acknowledged_past_checkout: true`, the server returns
**409 `recipient_warning_unconfirmed`** — Claude must surface the warning
explicitly before retrying.

### Billing

Per-segment, per-recipient. Default $0.10 per segment per number; country
overrides come from `$supported_sms_countries` in site_settings. A 2-segment
message to 3 recipients costs 6 × $0.10 = $0.60. `usd_balance` is debited
atomically; if the debit fails nothing is queued.

### Error codes

| HTTP | `code` | Meaning |
|------|--------|---------|
| 400 | `missing_message` | Empty / whitespace-only message. |
| 400 | `message_too_long` | Over 1530 chars (~10 segments). |
| 400 | `not_gsm` | Non-GSM-7 characters in message. |
| 400 | `no_filter_supplied` | At least one recipient filter is required. |
| 400 | `no_recipients` | Filter matched nothing with a valid phone. |
| 402 | `no_balance_record` / `insufficient_credit` | Top up `/members/payment/`. |
| 409 | `quote_stale` | The acknowledged total no longer matches the recompute. |
| 409 | `recipient_warning_unconfirmed` | At least one recipient has a `past_checkout` or `far_future_arrival` warning — pass `acknowledged_past_checkout: true` after confirming with the user. |
| 409 | `duplicate_send` | The same SMS body was sent to a recipient within the last 10 minutes. The response carries the previous notification's `uuid` and `created_at`. Pass `acknowledged_duplicate_send: true` to override. |
| 409 | `phone_rate_limit` | A recipient already received 3+ SMS from this account in the last 10 minutes. Pass `acknowledged_recent_send: true` to override. |

---

## WiFi (BetterSTR-managed networks)

Read-only live status for properties whose WiFi is managed through BetterSTR.
Requires an active BetterSTR WiFi subscription on the account (any of the
hosted UniFi, hosted Omada, or customer-owned UniFi tiers). Accounts
without one get HTTP **402 `wifi_subscription_required`**.

### List controllers

```
GET /v1/wifi/controllers
```

Returns every WiFi controller the user has. Each row carries a
`controller_type` of:

- `unifi_byod` — customer-owned UniFi (multiple allowed; each mapped to one property)
- `unifi_hosted` — BetterSTR-hosted UniFi (one per user, covers every property)
- `omada_hosted` — BetterSTR-hosted Omada (one per user, covers every property)

Plus site_name, connection_status, and last_connection_test for each.

### Live status for one property

```
GET /v1/wifi/properties/{property_uuid}/status
```

Hits the user's controller (UniFi BYOD, UniFi hosted, or Omada hosted —
chosen automatically per property) and returns:

```json
{
  "data": {
    "property":  { "uuid": "994a8fd7-…", "name": "Hidden Hollow On The Pond" },
    "controller": { "id": 8, "name": "HHOTP Dream Machine Pro", "type": "unifi", "mode": "customer_owned", "site_name": "default", "connection_status": "connected" },
    "access_points": [
      { "name": "Pond AP",
        "mac": "…",
        "model": "U7-Pro",
        "state": 1,
        "state_label": "connected",
        "adopted": true,
        "uptime_seconds": 184221,
        "client_count": 12 }
    ],
    "access_point_count": 1,
    "client_count":        20,
    "wifi_client_count":   12,
    "wired_client_count":   8,
    "clients_by_ssid":   { "MY-VE 2.4 Ghz": 11, "MY-VE Guest": 1 },
    "unauthorised_count":  1
  }
}
```

- `client_count` is the total — wired + wireless.
- `wifi_client_count` is the answer to "how many wifi devices are connected".
- `access_points_online` / `access_points_offline` answer "is any AP down".
- `unauthorised_count` counts captive-portal clients (`is_guest=true`,
  `authorized=false`); `unauthorised_clients[]` lists them with MAC, device
  name/hostname, SSID, AP name, and last-seen age.
- Wired clients are excluded from `clients_by_ssid` (they don't have one).

Responses are cached for 30 seconds per (user × controller × property).
The `X-Wifi-Cache: hit|miss` header tells you the source. The cache is
invalidated when an authorize/kick action runs against the same property.

If the controller is unreachable the response still comes back successfully
with an `_controller_error` field and empty arrays — distinguishing "offline
controller" from "0 clients".

### List clients

```
GET /v1/wifi/properties/{property_uuid}/clients
```

Optional filters:

| Param          | Notes |
|----------------|-------|
| `ap_mac`       | Limit to clients on one access point. |
| `ssid`         | Limit to one SSID. |
| `unauthorised` | `1` to keep only `is_guest=true` + `authorized=false`. |
| `wired`        | `1` for wired only, `0` for wireless only. |
| `limit`        | 1–500, default 200. |
| `offset`       | Default 0. |

Each client carries `mac`, `name`, `hostname`, `ip`, `ssid`, `ap_mac`,
`ap_name`, `is_wired`, `is_guest`, `authorized`, `manufacturer`,
`first_seen`, `last_seen`, `last_seen_seconds_ago`.

### Authorize a captive-portal guest

```
POST /v1/wifi/properties/{property_uuid}/clients/{mac}/authorize
Content-Type: application/json
```

```json
{ "duration_minutes": 480 }
```

`duration_minutes` is 1..525600 (1 year), default 480 (8 h).

### Force-disconnect a client (kick)

```
POST /v1/wifi/properties/{property_uuid}/clients/{mac}/kick
```

No body. The client may re-associate if it has valid credentials — for a
permanent block use the controller UI.

Both write actions invalidate the status/snapshot cache for that property
so the next read reflects the change.

---

## Common error codes

| HTTP | `code`              | Meaning                                              |
|------|---------------------|------------------------------------------------------|
| 400  | `invalid_json`      | Request body wasn't a JSON object.                   |
| 400  | `missing_title`     | `title` is required for create.                      |
| 400  | `invalid_parent`    | `parent_id` doesn't belong to this property.         |
| 400  | `lang_mismatch`     | Parent is in a different `lang`.                     |
| 400  | `nothing_to_update` | PATCH body had no updatable fields.                  |
| 404  | `property_not_found`| No active property with that UUID belongs to you.    |
| 404  | `entry_not_found`   | No such entry on that property.                      |
| 405  | `method_not_allowed`| Wrong HTTP verb for the URL.                         |
| 422  | `content_requires_depth_3` | Tried to set non-empty `content` on a depth-1 or depth-2 entry. The response body's `depth` tells you where the entry would end up. |
| 500  | `create_failed` / `update_failed` / `delete_failed` | Database write failed; check the message. |

---

## Rate limits

Limits are enforced per credential, on a fixed one-minute window. The
counter resets at the top of each clock minute.

There are two **independent** scopes, each with its own bucket:

- `global` — applies to every endpoint except reservations and places.
- `reservations` — applies to `/v1/reservations*`.
- `places` — applies to `/v1/places*` (each call costs us a Google Maps
  Platform request).

Spending one scope's budget does not affect the others.

| Scope | Free tier | Premium tier |
|-------|-----------|--------------|
| `global`       | **30 / min** | **120 + active_properties / min** |
| `reservations` | **5 / min**  | **60 + active_properties / min**  |
| `places`       | **5 / min**  | **60 + active_properties / min**  |

"Premium" means your account has an active guidebook-premium subscription
and a non-negative balance — the same eligibility the rest of BetterSTR
uses. "active_properties" is your count of active properties **excluding**
the shared demo property (`11111111-1111-1111-1111-111111111111`), which
is hidden from `/v1/properties` and never counts toward your budget.

Every response includes:

```
X-RateLimit-Scope:     global
X-RateLimit-Limit:     130
X-RateLimit-Remaining: 117
X-RateLimit-Reset:     32       # seconds until the bucket resets
```

When you exceed the limit you'll get HTTP **429** with a `Retry-After: <seconds>`
header and a body of:

```json
{
  "error": {
    "code": "rate_limited",
    "message": "Rate limit of 70 requests/minute for the 'reservations' scope exceeded. Try again in 32s.",
    "scope": "reservations",
    "limit": 70,
    "premium": true,
    "properties": 10,
    "retry_after": 32
  }
}
```

---

## Versioning

The path includes the version (`/v1/`). Breaking changes ship as `/v2/`. Within
a major version we only make additive changes (new fields, new endpoints).

---

## Using with the BetterSTR Claude MCP

A separate MCP server (under `cli-tools/mcp-server/` once published) wraps these
endpoints as Claude tools (`list_properties`, `list_entries`, `create_entry`, etc.).
Configure it once with your token and Claude can edit your guidebooks
conversationally.
