# hospitable

TypeScript SDK for the [Hospitable Public API](https://developer.hospitable.com/docs/public-api-docs/). Typed resources for properties, reservations, inquiries, calendars, messages, reviews, user/billing, transactions, payouts, and knowledge hub. OAuth2 + PAT auth, auto-retry on 429/5xx, async-iterator pagination, PII + business-identity masking.

> **This README is written for AI coding agents.** It prioritizes exact type signatures, decision tables, and invariants over narrative. If a snippet disagrees with the source in `src/`, the source wins.
>
> **Design priorities** (agent-first): runtime errors with descriptive messages beat strict types alone, JSDoc discoverability beats minimal interfaces, semantic method names beat primitive composition, breaking changes are acceptable when they fix drift between types and API reality. See `decisions/0002-hospitable-sdk-schema-drift-and-agent-first-design.md` for the full rationale.

## Install

```bash
npm install hospitable
```

Requires Node ≥ 18. ESM and CJS both ship.

## Initialize

```ts
import { HospitableClient } from 'hospitable'
```

| Scenario | Call |
| --- | --- |
| PAT in `HOSPITABLE_API_PAT` env var | `new HospitableClient()` |
| PAT passed explicitly | `new HospitableClient({ token: 'hps_...' })` |
| OAuth2 client credentials (m2m) | `new HospitableClient({ clientId, clientSecret })` |
| OAuth2 access + refresh token | `new HospitableClient({ token, refreshToken, clientId, clientSecret })` |

Env-var resolution: `token` is read from `HOSPITABLE_API_PAT` **only** when `token` is not set in config. No other env vars are consulted. No dotenv loading — the agent/host must populate `process.env`.

### `HospitableClientConfig`

```ts
interface HospitableClientConfig {
  token?: string              // PAT or OAuth2 access token. Defaults to $HOSPITABLE_API_PAT.
  refreshToken?: string       // OAuth2 refresh token.
  clientId?: string           // OAuth2 client ID.
  clientSecret?: string       // OAuth2 client secret.
  baseURL?: string            // Default: 'https://public.api.hospitable.com'
  retry?: RetryConfig         // See "Retry & rate limiting".
  debug?: boolean             // Log requests with PII/auth masked. Default: false.
  cache?: {
    properties?:   CacheConfig  // Default: { enabled: false }
    reservations?: CacheConfig
    inquiries?:    CacheConfig
  }
}

interface CacheConfig { enabled: boolean; ttl?: number; maxSize?: number }
```

## Method index

Every callable surface. Use this as a jump table.

| Call | Signature | HTTP |
| --- | --- | --- |
| `client.properties.list` | `(params?: PropertyListParams) => Promise<PropertyList>` | `GET /v2/properties` |
| `client.properties.get` | `(id: string, include?: string) => Promise<Property>` | `GET /v2/properties/{id}` |
| `client.properties.listTags` | `(id: string) => Promise<PropertyTag[]>` | `GET /v2/properties/{id}/tags` |
| `client.properties.getImages` | `(id: string) => Promise<PropertyImage[]>` | `GET /v2/properties/{id}/images` |
| `client.properties.search` | `(params: PropertySearchParams) => Promise<PropertyList>` | `GET /v2/properties/search` |
| `client.properties.iter` | `(params?: Omit<PropertyListParams,'page'>) => AsyncGenerator<Property>` | (paginates) |
| `client.properties.clearCache` | `() => void` | — |
| `client.reservations.list` | `(params: ReservationListParams) => Promise<ReservationList>` **(properties required)** | `GET /v2/reservations` |
| `client.reservations.get` | `(id: string, include?: string) => Promise<Reservation>` | `GET /v2/reservations/{id}` |
| `client.reservations.getUpcoming` | `(propertyIds: string[], options?: { include?: string }) => Promise<ReservationList>` | `GET /v2/reservations` (status=accepted, startDate=today, dateQuery=checkin) |
| `client.reservations.getInHouse` | `(propertyIds: string[], options?: { include?: string }) => Promise<Reservation[]>` | `GET /v2/reservations` (dateQuery=checkout, startDate=today) + client-side `arrivalDate<=today` filter |
| `client.reservations.iter` | `(params: Omit<ReservationListParams,'page'>) => AsyncGenerator<Reservation>` **(properties required)** | (paginates) |
| `client.reservations.clearCache` | `() => void` | — |
| `client.inquiries.list` | `(params: InquiryListParams) => Promise<InquiryList>` **(properties required)** | `GET /v2/inquiries` |
| `client.inquiries.get` | `(uuid: string, include?: string) => Promise<Inquiry>` | `GET /v2/inquiries/{uuid}` |
| `client.inquiries.iter` | `(params: Omit<InquiryListParams,'page'>) => AsyncGenerator<Inquiry>` | (paginates) |
| `client.inquiries.clearCache` | `() => void` | — |
| `client.messages.list` | `(conversationId: string) => Promise<MessageThread>` | `GET /v2/reservations/{id}/messages` |
| `client.messages.send` | `(reservationId: string, body: string, options?: SendReservationMessageOptions) => Promise<MessageReceipt>` | `POST /v2/reservations/{id}/messages` |
| `client.messages.sendForInquiry` | `(inquiryUuid: string, body: string, options?: SendMessageOptions) => Promise<MessageReceipt>` | `POST /v2/inquiries/{uuid}/messages` |
| `client.messages.listTemplates` | `() => Promise<MessageTemplate[]>` | `GET /v2/message-templates` |
| `client.messages.sendTemplate` | `(reservationId: string, templateId: string, variables?: Record<string,string>) => Promise<Message>` | `POST /v2/reservations/{id}/messages/template` |
| `client.calendar.get` | `(propertyId: string, startDate: string, endDate: string) => Promise<CalendarData>` | `GET /v2/properties/{id}/calendar` |
| `client.calendar.update` | `(propertyId: string, updates: CalendarUpdate[], options?: { note?: string \| null }) => Promise<void>` | `PUT /v2/properties/{id}/calendar` |
| `client.calendar.block` | `(propertyId: string, startDate: string, endDate: string, reason?: string) => Promise<void>` | `POST /v2/properties/{id}/calendar/block` |
| `client.calendar.unblock` | `(propertyId: string, startDate: string, endDate: string) => Promise<void>` | `POST /v2/properties/{id}/calendar/unblock` |
| `client.reviews.list` | `(propertyId: string, params?: ReviewListParams) => Promise<ReviewList>` | `GET /v2/properties/{id}/reviews` |
| `client.reviews.respond` | `(reviewId: string, responseText: string) => Promise<Review>` | `POST /v2/reviews/{id}/respond` |
| `client.reviews.iter` | `(propertyId: string, params?: Omit<ReviewListParams,'page'>) => AsyncGenerator<Review>` | (paginates) |
| `client.user.get` | `() => Promise<User>` | `GET /v2/user` |
| `client.transactions.list` | `(params?: TransactionListParams) => Promise<TransactionList>` | `GET /v2/transactions` *(financials:read scope)* |
| `client.transactions.iter` | `(params?: Omit<TransactionListParams,'page'>) => AsyncGenerator<Transaction>` | (paginates) |
| `client.reservations.cancel` | `(uuid: string, initiatedBy: 'host' \| 'guest') => Promise<Reservation>` | `POST /v2/reservations/{id}/cancel` |
| `client.reservations.create` | `(params: CreateReservationParams) => Promise<Reservation>` | `POST /v2/reservations` |
| `client.reservations.update` | `(uuid: string, params: UpdateReservationParams) => Promise<Reservation>` | `PUT /v2/reservations/{id}` |
| `client.reservations.listEnrichment` | `(uuid: string) => Promise<EnrichmentField[]>` | `GET /v2/reservations/{id}/enrichment` |
| `client.reservations.getEnrichment` | `(uuid: string, key: string) => Promise<EnrichmentField>` | `GET /v2/reservations/{id}/enrichment` |
| `client.reservations.updateEnrichment` | `(uuid: string, key: string, value: string \| null) => Promise<EnrichmentField>` | `PUT /v2/reservations/{id}/enrichment` |
| `client.properties.addTags` | `(uuid: string, tags: string[]) => Promise<void>` | `POST /v2/properties/{id}/tags` |
| `client.properties.createQuote` | `(uuid: string, params: CreateQuoteParams) => Promise<unknown>` | `POST /v2/properties/{id}/quote` |
| `client.properties.createIcalImport` | `(uuid: string, url: string, options?: CreateIcalImportOptions) => Promise<PropertyIcalImport>` | `POST /v2/properties/{id}/ical-imports` |
| `client.properties.updateIcalImport` | `(uuid: string, icalUuid: string, options?: UpdateIcalImportOptions) => Promise<PropertyIcalImport>` | `PUT /v2/properties/{id}/ical-imports/{icalId}` |
| `client.transactions.get` | `(uuid: string, include?: string) => Promise<Transaction>` | `GET /v2/transactions/{id}` *(financials:read scope)* |
| `client.transactions.list` | `(params?: TransactionListParams) => Promise<TransactionList>` | `GET /v2/transactions` *(financials:read scope)* |
| `client.transactions.iter` | `(params?: Omit<TransactionListParams,'page'>) => AsyncGenerator<Transaction>` | (paginates) |
| `client.payouts.get` | `(uuid: string, include?: string) => Promise<Payout>` | `GET /v2/payouts/{id}` *(financials:read scope)* |
| `client.payouts.list` | `(params?: PayoutListParams) => Promise<PayoutList>` | `GET /v2/payouts` *(financials:read scope)* |
| `client.payouts.iter` | `(params?: Omit<PayoutListParams,'page'>) => AsyncGenerator<Payout>` | (paginates) |
| `client.knowledgeHub.get` | `(propertyUuid: string) => Promise<KnowledgeHub>` | `GET /v2/properties/{id}/knowledge-hub` |
| `client.knowledgeHub.createItem` | `(propertyUuid: string, content: string, options?: CreateKnowledgeHubItemOptions) => Promise<KnowledgeHubItem>` | `POST /v2/properties/{id}/knowledge-hub` |
| `client.knowledgeHub.updateItem` | `(propertyUuid: string, itemId: number, content: string, options?: UpdateKnowledgeHubItemOptions) => Promise<KnowledgeHubItem>` | `PUT /v2/properties/{id}/knowledge-hub` |
| `client.knowledgeHub.deleteItem` | `(propertyUuid: string, itemId: number) => Promise<void>` | `DELETE /v2/properties/{id}/knowledge-hub` |
| `client.knowledgeHub.deleteTopic` | `(propertyUuid: string, topicId: number) => Promise<void>` | `DELETE /v2/properties/{id}/knowledge-hub` |

### Not covered

Surfaces that exist in the Hospitable product but are **not** part of the Public or Connect REST API as of 2026-04-25, and therefore have no SDK method. Do not invent paths — calling a guessed endpoint will 404.

| Surface | Status | Notes |
| --- | --- | --- |
| Tasks (assign / coordinate / pay teammates) | In-app only — no REST | Shipped in Hospitable app on 2026-03-06. No `tasks` category on `developer.hospitable.com`. Open user request: [feedback.hospitable.com/p/task-based-reminders](https://feedback.hospitable.com/p/task-based-reminders). When the API ships, the SDK will expose `client.tasks` (Public) and/or `connect.tasks` (Connect) following the existing resource-class pattern. |

## Decision tables

**Which send method?**

| Conversation state | Call | Why |
| --- | --- | --- |
| `reservation.id` known (booking exists) | `client.messages.send(reservation.id, body, { images?, senderId? })` | Booking endpoint; accepts image attachments. |
| `inquiry.id` known, no reservation yet (`reservation_id === null`) | `client.messages.sendForInquiry(inquiry.id, body, { senderId? })` | Pre-booking endpoint; **images not supported**. |
| You only have a conversation ID | `inquiry.id === conversation_id` — use `sendForInquiry` if no reservation, `send` otherwise. | — |

Calling the wrong endpoint → `410 Gone` or `422 Unprocessable`. TypeScript prevents passing `images` to `sendForInquiry` at compile time.

**Which read method for a thread?**

| Goal | Call |
| --- | --- |
| Get messages for a reservation or inquiry | `client.messages.list(reservationOrInquiryId)` — the resource is the reservation/conversation id |
| Get the inquiry record with its messages embedded | `client.inquiries.get(uuid, 'messages')` — `messages` include only works on `get`, not `list` |

**Which reservation date filter?**

| Goal | Call |
| --- | --- |
| Guests arriving in a window | `list({ properties, startDate, endDate, dateQuery: 'checkin' })` *(default — can omit)* |
| Guests departing in a window | `list({ properties, startDate, endDate, dateQuery: 'checkout' })` |
| Upcoming reservations (from today forward) | `getUpcoming(propertyIds)` — wrapper: status='accepted', startDate=today, dateQuery='checkin' |
| **Guests currently in-house** (arrived + not yet departed) | `getInHouse(propertyIds)` — two-filter strategy |
| Any reservation by exact ID | `get(id, include?)` |

`dateQuery` only accepts `'checkin'` or `'checkout'`. Anything else → `400`.

**Which reservation write method?**

| Goal | Call |
| --- | --- |
| Cancel a manual reservation | `reservations.cancel(uuid, 'host')` or `'guest'` |
| Create a direct/manual booking | `reservations.create(params)` — see `CreateReservationParams` |
| Update dates/guests/financials on a manual reservation | `reservations.update(uuid, params)` |
| Update a single enrichment field (e.g. door code) | `reservations.updateEnrichment(uuid, 'smartlock_code', '1234')` |

**Why does `getInHouse()` exist as a dedicated helper?** The Hospitable
API only accepts a single `date_query` per request, so "arrived AND not
yet departed" can't be expressed in one query. `getInHouse()` fetches
everyone whose checkout is today-or-later (via `dateQuery='checkout'`,
`startDate=today`), then filters locally for `arrivalDate<=today`. Returns
a plain `Reservation[]` (not a paginated wrapper) because the client-side
filter would make pagination metadata misleading.

## Params

### `PropertyListParams`

```ts
interface PropertyListParams {
  page?: number
  perPage?: number
  tags?: string[]
  include?: string           // comma-separated — see PropertyIncludeField
}

type PropertyIncludeField = 'user' | 'listings' | 'details' | 'bookings'
```

`include` values are verified against the live API:

- **`user`** — populates `property.user` with `{id, email, name, profilePicture}`
- **`listings`** — populates `property.listings: PropertyListing[]`, one entry per booking channel (airbnb, vrbo, direct, manual, gvr, booking_com…) with `platform`, `platformId`, `coHosts`, etc.
- **`details`** — populates `property.details: PropertyDetails` with host-operational info: `wifiName`, `wifiPassword`, `houseManual`, `guestAccess`, `gettingAround`, `neighborhoodDescription`, `additionalRules`, `otherDetails`, `spaceOverview`
- **`bookings`** — populates `property.bookings` (typed as `unknown` — opaque pricing/policy config; narrow at call site)

`include` works on both `list()` and `get()`:

```ts
// List all properties with host + listings + operational details
const { data } = await client.properties.list({
  include: 'user,listings,details',
  perPage: 100,
})

// Fetch one property with everything
const p = await client.properties.get(propertyId, 'user,listings,details,bookings')
```

Unknown `include` values are silently ignored by the API (no error
feedback for typos) — prefer `PropertyFilter.include('user', 'details')`
for TypeScript-level narrowing.

`details.wifiPassword` is deliberately **not** redacted by `sanitize()`.
It's semi-public by design (hosts share it with every guest), and
agent workflows typically read it to include in a check-in message.
If you need it redacted in a specific context, filter at your log
boundary rather than relying on SDK-level masking.

### `PropertySearchParams`

Availability search — distinct from `list` in that only properties that can
host the given window and party size appear.

```ts
interface PropertySearchParams {
  startDate: string   // ISO YYYY-MM-DD — REQUIRED
  endDate: string     // ISO YYYY-MM-DD — REQUIRED
  adults: number      // REQUIRED
  children?: number
  infants?: number
  pets?: number
  page?: number
  perPage?: number
}
```

All three of `startDate`, `endDate`, and `adults` are required by the API.
Missing any returns `400`.

### `ReservationListParams`

```ts
interface ReservationListParams {
  properties: string[]                            // REQUIRED by the API
  startDate?: string                              // ISO YYYY-MM-DD
  endDate?: string                                // ISO YYYY-MM-DD
  dateQuery?: 'checkin' | 'checkout'              // default: 'checkin' server-side
  lastMessageAt?: string                          // 'YYYY-MM-DD HH:MM:SS' (not ISO 8601!)
  status?: ReservationStatus | ReservationStatus[]
  include?: string                                // comma-separated — see ReservationIncludeField
  page?: number
  perPage?: number
}

type ReservationStatus =
  | 'not_accepted' | 'request' | 'accepted' | 'cancelled' | 'checkpoint'

type ReservationDateQuery = 'checkin' | 'checkout'

type ReservationIncludeField =
  | 'guest' | 'user' | 'financials' | 'listings' | 'properties' | 'review' | 'smartlock_code'
```

- **`properties` is required.** The SDK throws `ConfigurationError` locally before the HTTP call if it's missing or an empty array — see [Gotchas](#gotchas-read-this).
- **`dateQuery`** picks which date field `startDate`/`endDate` filter against. Defaults to `'checkin'` server-side. Use `'checkout'` for "guests still in-house" or "departing in this window" queries. Only `'checkin'` and `'checkout'` are valid — anything else returns `400`.
- **`lastMessageAt` format quirk**: the API expects `YYYY-MM-DD HH:MM:SS` (space-separated, no timezone), **not ISO 8601**.
- **`include=review`** fetches the review associated with each reservation alongside the reservation itself — useful for batch review-status audits.
- **`include=smartlock_code`** side-loads the property's smart-lock access code for this reservation as `reservation.smartlockCode` (string, typically a 4-digit numeric code, or `null` for cancelled/far-future reservations). Not redacted by `sanitize()` — agents need to include the code in guest check-in messages, same semantic as `wifiPassword` on a property's `details`.
- `status` may be passed as a string or array; the SDK always serializes as an array.

### `TransactionListParams` / `PayoutListParams`

```ts
interface TransactionListParams {
  startDate?: string    // ISO YYYY-MM-DD
  endDate?: string      // ISO YYYY-MM-DD
  properties?: string[]
  page?: number
  perPage?: number
}

interface PayoutListParams {
  startDate?: string    // ISO YYYY-MM-DD
  endDate?: string      // ISO YYYY-MM-DD
  properties?: string[]
  page?: number
  perPage?: number
}
```

⚠️ **Always pass bounds for agent workflows.** Both endpoints accept no-param calls, which stream the account's entire financial history — see [Gotchas](#gotchas-read-this).

### `InquiryListParams`

```ts
interface InquiryListParams {
  properties: string[]              // REQUIRED by the API
  include?: string                  // comma-separated subset of InquiryIncludeField (minus 'messages')
  lastMessageAt?: string            // ISO 8601 datetime
  page?: number
  perPage?: number
}

type InquiryIncludeField =
  | 'financials' | 'guest' | 'user' | 'properties' | 'listings' | 'messages'
```

Valid `include` values on **list**: `financials`, `guest`, `user`, `properties`, `listings`. The `messages` include is **only** supported on `client.inquiries.get` — the list endpoint rejects it with `400 "You cannot include messages when fetching all inquiries"`.

### `ReviewListParams`

```ts
interface ReviewListParams {
  responded?: boolean
  include?: string          // e.g. 'guest,reservation,property'
  page?: number
  perPage?: number
}

type ReviewIncludeField = 'guest' | 'reservation' | 'property'
```

Pass `include: 'guest,reservation,property'` to side-load:

- **`review.guest`** — first/last name, language (minimal — no PII beyond that)
- **`review.reservation`** — `{id, code, checkIn, checkOut}` summary for cross-reference
- **`review.property`** — `{id, name, publicName}` summary, useful for building cross-property review feeds

⚠️ `reservation` and `property` are **singular** include values. Passing
`'reservations'` or `'properties'` (plural) returns HTTP 200 with the
field silently missing — the API silent-ignores unknown includes, so
use the `ReviewIncludeField` literal union via the filter builder for
compile-time protection.

Unknown `include` values are silently ignored by the API — don't rely
on error feedback for typos.

### `CreateReservationParams`

```ts
interface CreateReservationParams {
  propertyId: string
  currency: string                       // ISO 4217
  arrivalDate: string                    // ISO YYYY-MM-DD
  departureDate: string                  // ISO YYYY-MM-DD
  guest: CreateReservationGuest
  guests: CreateReservationGuestCounts
  financials: CreateReservationFinancials // ⚠️ flat write-side shape — NOT ReservationFinancials
}

interface CreateReservationGuest {
  firstName: string
  lastName: string
  email?: string
  phone?: string
}

interface CreateReservationGuestCounts {
  adults: number
  children?: number
  infants?: number
}
```

### `UpdateReservationParams`

Same shape as `CreateReservationParams` minus `propertyId` and `currency`. All fields optional — send only what changed.

```ts
interface UpdateReservationParams {
  arrivalDate?: string
  departureDate?: string
  guest?: Partial<CreateReservationGuest>
  guests?: Partial<CreateReservationGuestCounts>
  financials?: Partial<CreateReservationFinancials>
}
```

### `CreateQuoteParams`

```ts
interface CreateQuoteParams {
  checkinDate: string       // ISO YYYY-MM-DD
  checkoutDate: string      // ISO YYYY-MM-DD
  guests: number
  guestDetails?: {
    adults: number
    children?: number
    infants?: number
  }
  promoCode?: string
}
```

### `EnrichmentField`

```ts
interface EnrichmentField {
  key: string             // shortcode, e.g. 'smartlock_code', 'wifi_password'
  value: string | null    // current value, null if unset
  description: string     // human-readable label
  example: string         // example value hint
}
```

### `CreateKnowledgeHubItemOptions` / `UpdateKnowledgeHubItemOptions`

```ts
interface CreateKnowledgeHubItemOptions {
  topicId?: number       // add to existing topic by ID
  topicName?: string     // create a new topic with this name (ignored if topicId set)
}

interface UpdateKnowledgeHubItemOptions {
  topicId?: number       // move item to a different topic
}
```

## Shapes

### `Reservation`

The reservation object exposes status in **three** fields to cover the
API's evolution. See the [Gotchas](#gotchas-read-this) section for the
spelling trap.

```ts
interface Reservation {
  id: string
  code: string
  platform: ReservationPlatform
  platformId: string
  bookingDate: string
  arrivalDate: string        // ISO — may include timezone offset
  departureDate: string
  checkIn: string
  checkOut: string
  nights: number
  stayType: string
  ownerStay: boolean | null

  // Status — prefer `reservationStatus` in new code
  reservationStatus: {
    current: { category: ReservationStatus; subCategory: string | null }
    history: Array<{
      category: ReservationStatus
      subCategory: string | null
      changedAt: string
    }>
  }
  /** @deprecated use reservationStatus.current.category */
  status: ReservationStatus
  /** @deprecated use reservationStatus.history. SDK normalizes
   *  american 'canceled' → british 'cancelled' on every response. */
  statusHistory: Array<{
    category: string
    status: string         // normalized to british spelling by the SDK
    changedAt: string
  }>

  guests: ReservationGuests
  guest?: Guest                         // only when include=guest
  user?: ReservationUser                // only when include=user
  financials?: ReservationFinancials    // only when include=financials — see below
  properties?: unknown[]                // only when include=properties
  listings?: unknown[]                  // only when include=listings
  review?: unknown | null               // only when include=review
  smartlockCode?: string | null         // only when include=smartlock_code

  notes: string | null
  conversationId: string
  conversationLanguage: string | null
  lastMessageAt: string | null
  issueAlert: unknown
}
```

### `ReservationFinancials` (when `include=financials`)

The financial breakdown splits into `guest` (what the guest pays) and
`host` (what the host receives). Every line item shares the same shape,
so narrowing is easy.

```ts
interface ReservationFinancialLineItem {
  amount: number            // minor currency units; CAN BE NEGATIVE
  formatted: string         // e.g. "$1,483.35" or "-$1,213.65"
  label: string             // e.g. "Cleaning Fee", "Early Bird Discount"
  category: string          // e.g. "Accommodation", "Guest fees", "Discounts"
}

interface ReservationFinancials {
  currency: string          // ISO 4217
  guest: {
    accommodation: ReservationFinancialLineItem
    averageNightlyRate: ReservationFinancialLineItem
    fees: ReservationFinancialLineItem[]           // cleaning, pet, extra-guest
    discounts: ReservationFinancialLineItem[]      // NEGATIVE amounts
    taxes: ReservationFinancialLineItem[]
    adjustments: ReservationFinancialLineItem[]
    payments: ReservationFinancialLineItem[]
    totalPrice: ReservationFinancialLineItem
  }
  host: {
    accommodation: ReservationFinancialLineItem
    accommodationBreakdown: ReservationFinancialLineItem[] | null  // per-night
    guestFees: ReservationFinancialLineItem[]
    hostFees: ReservationFinancialLineItem[]       // service fees — NEGATIVE
    discounts: ReservationFinancialLineItem[]
    adjustments: ReservationFinancialLineItem[]
    taxes: ReservationFinancialLineItem[]
    revenue: ReservationFinancialLineItem          // final host take-home
  }
}
```

⚠️ **`amount` can be negative.** Discounts and host-side service fees
arrive as negative integers (e.g. `-121365` for a `-$1,213.65` Early
Bird Discount). Don't assume positivity when summing or displaying.

### `Review`

Reviews are split into public (what the guest posted on the platform)
and private (host-only feedback not shown to other guests).

```ts
interface Review {
  id: string
  platform: string
  public: {
    rating: number                    // integer 1-5
    ratingPlatformOriginal: string    // platform's native format, e.g. "5.00"
    review: string                    // guest's public review text
    response: string | null           // host's public response, if any
  }
  private: {
    feedback: string | null           // host-only private note
    detailedRatings: Array<{
      type: 'value' | 'cleanliness' | 'communication' | 'location'
          | 'checkin' | 'accuracy' | 'facilities' | 'staff' | 'services'
          | (string & {})
      rating: number                  // 0-5; 0 means category not collected
      comment: string | null
    }>
  }
  reviewedAt: string                  // ISO — when guest submitted
  respondedAt: string | null          // ISO — when host responded
  canRespond: boolean                 // false after platform response window closes

  guest?: {                           // only when include=guest
    firstName: string
    lastName: string
    language: string
  }
  reservation?: {                     // only when include=reservation
    id: string
    code: string
    checkIn: string
    checkOut: string
  }
  property?: {                        // only when include=property
    id: string
    name: string                      // host-facing internal name
    publicName: string                // public-facing listing name
  }
}
```

### `Message`

```ts
interface Message {
  id: number | string
  platform: string
  platformId: string                  // upstream platform's message id
  conversationId: string
  reservationId: string
  contentType: 'text/plain' | (string & {})
  body: string
  attachments: Array<{                // typed — NOT opaque
    type: 'image' | (string & {})
    url: string                       // ⚠️ pre-signed S3, ~1h expiry. DO NOT CACHE.
  }>
  reactions: unknown[]                // never observed populated
  senderType: 'host' | 'guest' | (string & {})
  senderRole: string | null
  sender: {
    firstName: string
    fullName: string
    locale: string
    pictureUrl: string | null
    thumbnailUrl: string | null
    location: string                  // e.g. 'Kippa-Ring, Australia'; empty for hosts
  }
  createdAt: string
  source: 'hospitable' | 'platform' | 'automated' | 'AI' | 'public_api' | (string & {})
  integration: unknown | null
  sentReferenceId: string | null      // match against MessageReceipt.sentReferenceId
}
```

**Observability trick**: `source === 'public_api'` tags every message the
SDK itself sent. Filter on it to see your own agent's sends vs. everyone
else's:

```ts
const thread = await client.messages.list(reservationId)
const mySends = thread.messages.filter(m => m.source === 'public_api')
```

### `Property` (when includes are requested)

```ts
interface Property {
  // ... core fields (id, name, address, capacity, amenities, etc.)

  // Include-gated fields, populated only when the matching `include=` value is passed:
  user?: PropertyUser                 // include=user
  listings?: PropertyListing[]        // include=listings
  details?: PropertyDetails           // include=details
  bookings?: PropertyBookings         // include=bookings
  icalImports?: PropertyIcalImport[]  // include=listings (bundled with listings)
}

interface PropertyUser {
  id: string
  email: string
  name: string
  profilePicture: string | null
}

interface PropertyListing {
  platform: string                    // 'airbnb' | 'vrbo' | 'direct' | 'manual' | ...
  platformId: string
  platformUserId: string | null
  platformPicture: string | null
  platformName: string | null
  platformEmail: string | null
  coHosts: Array<{
    userId: string
    name: string
    channelName: string
  }>
}

interface PropertyDetails {
  additionalRules: string | null
  gettingAround: string | null
  guestAccess: string | null
  houseManual: string | null
  neighborhoodDescription: string | null
  otherDetails: string | null
  spaceOverview: string | null
  wifiName: string | null
  wifiPassword: string | null         // passed through sanitize() unchanged
}

interface PropertyBookings {
  fees: PropertyBookingFee[]          // { name, type, value: { amount, formatted } }
  occupancyBasedRules: {
    guestsIncluded: number
    extraGuestFee: { type: string; value: { amount: number; formatted: string } }
    petFee:        { type: string; value: { amount: number; formatted: string } }
  }
  discounts: unknown[]                // shape not yet observed populated
  listingMarkups: Array<{
    platform: string                  // 'airbnb' | 'vrbo' | ...
    type: string                      // 'percentage' | 'flat'
    markup: number
  }>
  securityDeposits: unknown[]         // shape not yet observed populated
  securityDepositCollector: unknown | null
  bookingPolicies: {
    cancellation: string[]            // one line per rule/tier
    paymentTerms: {
      status: string                  // 'full_payment' | 'deposit_required' | ...
      description: string[]
      gracePeriod: number             // hours
    }
  }
  siteUrls: string[]                  // where the listing is published
}

interface PropertyIcalImport {
  id: string
  url: string                         // ⚠️ effectively a credential — see below
  name: string                        // display name
  host: { firstName: string; lastName: string }
  lastSyncAt: string                  // ISO 8601
  disconnectedAt: string | null       // ISO 8601 or null if active
}
```

⚠️ **`icalImports[].url` is a shared secret.** iCal URLs embed an opaque
auth token in the path — anyone holding one can read the calendar.
`sanitize()` does NOT redact this field (`url` is too common a field
name to blanket-mask). Handle it the way you'd handle any other
credential in your logging/observability layer.

⚠️ **`icalImports` is gated on `include=listings`** — undocumented but
empirically verified. The field is absent (undefined) when `listings`
isn't requested, because Hospitable bundles iCal imports into the
"listings" conceptual subtree. Pass `include=listings` or
`include=listings,user,details,bookings` to populate it.

### `User`

```ts
interface User {
  id: string
  email: string
  name: string
  profilePicture: string | null
  business: boolean
  company: string | null
  vat: string | null
  taxId: string | null
  streetLine1: string | null
  streetLine2: string | null
  postalCode: string | null
  city: string | null
  state: string | null
  country: string | null
}
```

### `Transaction` / `Payout`

```ts
interface Money {
  amount: number        // minor currency units (cents for USD)
  formatted: string     // pre-formatted, e.g. "$191.48"
  currency: string      // ISO 4217
}

interface Transaction {
  id: string
  platform: string
  type: string          // 'Payout' | 'Rent' | 'Refund' | 'Adjustment' | ...
  details: string | null
  reference: string | null
  currency: string
  amount: number | null           // null when paidOutAmount is used instead
  paidOutAmount: Money | null
  date: string                    // ISO 8601
  startDate: string | null
}

interface Payout {
  id: string
  platform: string
  platformId: string              // platform's payout id (e.g. Airbnb's "G-...")
  bankAccount: string             // display, e.g. "Checking ••4169 (USD)"
  reference: string | null
  amount: Money
  date: string                    // ISO 8601
}
```

### `CreateReservationFinancials` (write-side — NOT `ReservationFinancials`)

⚠️ **This is NOT the same as `ReservationFinancials`.** The write-side
shape is flat (simple key-value amounts); the read-side shape
(`ReservationFinancials` returned by `include=financials`) is deeply
nested with `guest.fees[]`, `host.hostFees[]`, line-item objects, etc.
Passing a read-side object to `reservations.create()` causes a
TypeScript error — this is intentional.

```ts
interface CreateReservationFinancials {
  accommodation: number      // total accommodation in minor currency units
  cleaningFee?: number
  petFee?: number
  extraGuestFee?: number
  tax?: number
  platformFee?: number
  hostServiceFee?: number
}
```

### `KnowledgeHub`

The knowledge hub is a per-property structure with topics containing
nested items, plus optional sources.

```ts
interface KnowledgeHub {
  topics: KnowledgeHubTopic[]
  sources: KnowledgeHubSource[]
}

interface KnowledgeHubTopic {
  id: number                    // numeric, not UUID
  name: string
  items: KnowledgeHubItem[]
}

interface KnowledgeHubItem {
  id: number                    // numeric, not UUID
  content: string
  topicId: number
  property: KnowledgeHubProperty
}

interface KnowledgeHubProperty {
  id: string
  name: string
}

interface KnowledgeHubSource {
  id: number
  type: string
  url: string
}
```

### `EnrichmentField`

```ts
interface EnrichmentField {
  key: string             // shortcode key, e.g. 'smartlock_code'
  value: string | null    // current value, null if unset
  description: string     // human-readable label
  example: string         // example value for the field
}
```

## Filters

Immutable fluent builders. Terminal call: `.toParams()`.

| Filter | Chainable methods |
| --- | --- |
| `PropertyFilter` | `.tags(string[])`, `.include(...PropertyIncludeField)`, `.perPage(n)` |
| `ReservationFilter` | `.properties(ids)` **required**, `.checkinAfter(date)`, `.checkinBefore(date)`, `.dateQuery('checkin'\|'checkout')`, `.lastMessageAt('YYYY-MM-DD HH:MM:SS')`, `.status(ReservationStatus \| ReservationStatus[])`, `.include(...ReservationIncludeField)`, `.perPage(n)` |
| `InquiryFilter` | `.properties(ids)` **required**, `.include(...InquiryIncludeField)`, `.lastMessageAfter(datetime)`, `.page(n)`, `.perPage(n)` |

Both `ReservationFilter.toParams()` and `InquiryFilter.toParams()` **throw `ConfigurationError` at runtime** if `.properties()` was never called — the underlying endpoints reject the request otherwise.

```ts
import { ReservationFilter, InquiryFilter } from 'hospitable'

const params = new ReservationFilter()
  .properties([propertyId])            // required — throws without it
  .checkinAfter('2026-01-01')
  .checkinBefore('2026-12-31')
  .dateQuery('checkout')               // optional — default 'checkin'
  .status(['accepted', 'request'])
  .include('guest', 'properties', 'review')
  .perPage(50)
  .toParams()

await client.reservations.list(params)
```

## Canonical snippets

### List all properties

```ts
for await (const p of client.properties.iter()) {
  // p: Property — one at a time, memory-safe
}
```

### Upcoming reservations for specific listings

```ts
const { data } = await client.reservations.getUpcoming(
  ['prop-uuid-1', 'prop-uuid-2'],
  { include: 'guest,properties' },
)
```

`getUpcoming` is a thin wrapper — it calls `list` with `status='accepted'`, `startDate=today`, `dateQuery='checkin'`, and the given `properties`.

### Guests currently in-house

```ts
const inHouse = await client.reservations.getInHouse(
  ['prop-uuid-1', 'prop-uuid-2'],
  { include: 'guest,properties' },
)
// inHouse: Reservation[] — NOT a paginated wrapper, because this method
// applies a client-side arrivalDate filter that would break pagination
// metadata.
for (const r of inHouse) {
  console.log(`${r.guest?.firstName} ${r.guest?.lastName} — checks out ${r.departureDate.slice(0,10)}`)
}
```

### Generate a check-in message with wifi + door code

```ts
// Fetch both the property (for wifi) and the reservation (for smart lock code)
const { data: reservations } = await client.reservations.list({
  properties: [propertyId],
  status: 'accepted',
  startDate: today,
  include: 'guest,smartlock_code',
  perPage: 1,
})
const r = reservations[0]!

const property = await client.properties.get(propertyId, 'details')

const message = [
  `Hi ${r.guest?.firstName}! Welcome to your stay.`,
  `Wifi: ${property.details?.wifiName} / ${property.details?.wifiPassword}`,
  `Door code: ${r.smartlockCode ?? '(will send closer to arrival)'}`,
].join('\n')

await client.messages.send(r.id, message)
```

Neither `wifiPassword` nor `smartlockCode` is redacted by the SDK's
`sanitize()` helper — both are shareable credentials that agents
legitimately need in debug output. See [Gotchas](#gotchas-read-this).

### Check a property's availability

```ts
const { data } = await client.properties.search({
  startDate: '2026-07-01',
  endDate:   '2026-07-07',
  adults:    2,
  children:  1,
})
// Only properties that can host those dates + party size appear.
```

### Fetch property photos

```ts
const images = await client.properties.getImages('prop-uuid')
// images[i].url is pre-signed S3 with ~1h expiry. Do not cache it.
```

### Fetch properties with host + operational details

```ts
// Single property with everything side-loaded
const p = await client.properties.get(propertyId, 'user,listings,details,bookings')

console.log(`Host: ${p.user?.name} <${p.user?.email}>`)
console.log(`Platforms: ${p.listings?.map(l => l.platform).join(', ')}`)
console.log(`Wifi: ${p.details?.wifiName}`)

// All properties at once, just with host info
const { data } = await client.properties.list({
  include: 'user',
  perPage: 100,
})
for (const prop of data) {
  console.log(`${prop.name} is hosted by ${prop.user?.name}`)
}
```

Combine with `PropertyFilter` for TypeScript-level typo protection:

```ts
import { PropertyFilter } from 'hospitable'

const params = new PropertyFilter()
  .include('user', 'listings', 'details')   // typed — 'bookngs' would fail at compile
  .perPage(50)
  .toParams()

const { data } = await client.properties.list(params)
```

### Fetch an inquiry with its thread

```ts
const inquiry = await client.inquiries.get(uuid, 'guest,properties,messages')
inquiry.property   // === inquiry.properties  (aliased by normalizeInquiry)
inquiry.messages   // Message[] — requires include='messages'
```

### Reply to a pre-booking inquiry

```ts
const receipt = await client.messages.sendForInquiry(
  inquiry.id,
  'Thanks for asking — those dates are available.',
)
// receipt.sentReferenceId : use to correlate with messages fetched later
```

### Reply to a booking with an image

```ts
await client.messages.send(reservation.id, 'Here is the gate code.', {
  images: ['https://example.com/gate.jpg'],
  senderId: 'cohost-user-id',   // omit to send as listing owner
})
```

### Calendar block + price override

```ts
await client.calendar.block('prop-uuid', '2026-07-01', '2026-07-07', 'Owner stay')

await client.calendar.update('prop-uuid', [
  { date: '2026-07-15', price: { amount: 20000 }, available: false },
  { date: '2026-07-16', price: { amount: 22000 }, available: true, minStay: 3 },
  // Block check-in on a Saturday turnover day to enforce min-stay.
  { date: '2026-07-18', closedForCheckin: true, note: 'turnover only' },
  // Clear a previously-set per-date note.
  { date: '2026-07-19', note: null },
], { note: 'Blocked for maintenance' })
```

Dates are always ISO `YYYY-MM-DD`. `update` merges additively with existing calendar state.

`CalendarUpdate` fields: `date` (required), `price?: { amount }`, `available?`, `minStay?`, `closedForCheckin?`, `closedForCheckout?`, `note?: string | null` (pass `null` to clear, max 512 chars).

The optional third argument `{ note }` sets a top-level note applied to every date in the batch that doesn't define its own `note`. Wire body shape: `{ note?, dates: [...] }` per the Hospitable spec.

### Respond to unanswered reviews

```ts
for await (const review of client.reviews.iter('prop-uuid', { responded: false })) {
  await client.reviews.respond(review.id, 'Thank you for your feedback!')
}
```

### Collect a small list into an array

```ts
import { collectAll } from 'hospitable'

const all = await collectAll(
  client.reservations.iter({
    properties: propertyIds,                // required
    startDate: '2026-01-01',
  }),
)
```

### Who am I?

```ts
const me = await client.user.get()
console.log(`${me.name} (${me.email})`)
if (me.business) {
  console.log(`Business: ${me.company}, Tax ID: ${me.taxId}`)
}
```

### Financial reporting — always pass bounds

```ts
// A reporting window — scope transactions and payouts with date ranges
// so you never accidentally stream the entire account history.
const start = '2026-01-01'
const end   = '2026-03-31'

const transactions = await collectAll(
  client.transactions.iter({ startDate: start, endDate: end }),
)
const payouts = await collectAll(
  client.payouts.iter({ startDate: start, endDate: end }),
)

const totalPaidOut = payouts.reduce((sum, p) => sum + p.amount.amount, 0)
console.log(`Paid out Q1: $${(totalPaidOut / 100).toFixed(2)}`)
```

⚠️ **Do not** call `collectAll(client.payouts.iter())` with no params — it
will stream the account's entire history. See
[Gotchas](#gotchas-read-this).

### Review with guest + reservation included

```ts
for await (const review of client.reviews.iter('prop-uuid', {
  include: 'guest,reservation',
})) {
  const name = review.guest ? `${review.guest.firstName} ${review.guest.lastName}` : 'Anonymous'
  console.log(`${name}: ${review.public.rating}/5 "${review.public.review}"`)
  if (review.private.feedback) {
    console.log(`  (private feedback: ${review.private.feedback})`)
  }
}
```

### Create a direct reservation

```ts
const reservation = await client.reservations.create({
  propertyId: 'prop-uuid',
  currency: 'USD',
  arrivalDate: '2026-08-01',
  departureDate: '2026-08-05',
  guest: { firstName: 'Jane', lastName: 'Doe', email: 'jane@example.com' },
  guests: { adults: 2, children: 1 },
  financials: {
    accommodation: 80000,  // $800.00 in cents
    cleaningFee: 15000,
    tax: 9500,
  },
})
```

⚠️ `financials` here is `CreateReservationFinancials` (flat), not the
read-side `ReservationFinancials` (nested). See [Shapes](#createreservationfinancials-write-side--not-reservationfinancials).

### Cancel a reservation

```ts
const cancelled = await client.reservations.cancel('res-uuid', 'host')
// Only works on manual/direct reservations.
```

### Read + update enrichment data (setting a door code)

```ts
// List all enrichment fields for a reservation
const fields = await client.reservations.listEnrichment('res-uuid')
// fields: EnrichmentField[] — [{key: 'smartlock_code', value: null, ...}, ...]

// Set a door code
const updated = await client.reservations.updateEnrichment(
  'res-uuid',
  'smartlock_code',
  '1234',
)

// Clear a value — pass null
await client.reservations.updateEnrichment('res-uuid', 'smartlock_code', null)
```

### Get the Knowledge Hub for a property

```ts
const hub = await client.knowledgeHub.get('prop-uuid')
for (const topic of hub.topics) {
  console.log(`Topic: ${topic.name}`)
  for (const item of topic.items) {
    console.log(`  - ${item.content}`)
  }
}
```

### Create a Knowledge Hub item

```ts
// Add to an existing topic
const item = await client.knowledgeHub.createItem(
  'prop-uuid',
  'Checkout is at 11 AM. Please strip the beds before leaving.',
  { topicId: 42 },
)

// Or create a new topic in one call
const item2 = await client.knowledgeHub.createItem(
  'prop-uuid',
  'The nearest grocery store is a 5 minute walk north on Main St.',
  { topicName: 'Local Tips' },
)
```

### Get a single transaction with payout included

```ts
const txn = await client.transactions.get('txn-uuid', 'payout')
console.log(`${txn.type}: ${txn.paidOutAmount?.formatted}`)
if (txn.payout) {
  console.log(`Paid out via ${txn.payout.bankAccount} on ${txn.payout.date}`)
}
```

### Add tags to a property

```ts
await client.properties.addTags('prop-uuid', ['pet-friendly', 'pool'])
// Additive — does not replace existing tags.
// Clears the entire property cache.
```

### Create an iCal import

```ts
const ical = await client.properties.createIcalImport(
  'prop-uuid',
  'https://calendar.google.com/calendar/ical/abc123/basic.ics',
  { name: 'Google Calendar', host: { firstName: 'Jane', lastName: 'Doe' } },
)
console.log(`Import created: ${ical.name}, last sync: ${ical.lastSyncAt}`)
```

## Pagination

Every list resource exposes `iter()` — async-generator auto-pagination. Pull one item at a time; the generator fetches new pages lazily.

```ts
for await (const r of client.reservations.iter({ startDate: '2026-01-01' })) {
  // stop at any time — no further pages are fetched
  if (r.status === 'accepted') break
}
```

Helpers from the `paginate` / `collectAll` exports are also available if you need to drive pagination manually against a custom `PageFetcher`.

## Async message delivery

Both `send` and `sendForInquiry` return **202 Accepted** with a `MessageReceipt`:

```ts
interface MessageReceipt { sentReferenceId: string }
```

Delivery happens out-of-band on the upstream channel (Airbnb, VRBO, Booking.com, direct). To confirm a message actually landed:

1. Persist `receipt.sentReferenceId` after the send.
2. Poll `client.messages.list(conversationId)` later.
3. Match against each `Message.sentReferenceId` — when it appears, delivery succeeded.

Rate limits (both endpoints): **2/minute per target conversation**, **50 per 5 minutes globally**. 429s are retried automatically by the retry layer.

## Errors

Every failure mode is a typed subclass of `HospitableError`. Use `instanceof` for narrowing.

| Class | `HospitableXxx` alias | `statusCode` | `err.name` | Extra fields |
| --- | --- | --- | --- | --- |
| `ConfigurationError` | `HospitableConfigurationError` | `0` (no HTTP made) | `'HospitableConfigurationError'` | — |
| `AuthenticationError` | `HospitableAuthError` | `401` | `'HospitableAuthError'` | — |
| `ForbiddenError` | `HospitableForbiddenError` | `403` | `'HospitableForbiddenError'` | — |
| `NotFoundError` | `HospitableNotFoundError` | `404` | `'HospitableNotFoundError'` | `resource?: string` |
| `ValidationError` | `HospitableValidationError` | `422` | `'HospitableValidationError'` | `fields: Record<string, string[]>` |
| `RateLimitError` | `HospitableRateLimitError` | `429` | `'HospitableRateLimitError'` | `retryAfter: number` (seconds) |
| `ServerError` | `HospitableServerError` | `5xx` | `'HospitableServerError'` | `attempts: number` |
| `HospitableError` | — (base) | any | `'HospitableError'` | `statusCode: number`, `requestId?: string` |

`ForbiddenError extends AuthenticationError`, so `err instanceof HospitableAuthError` catches **both** 401 and 403 per the AGENTS.md spec. The `Hospitable*` aliases are the canonical export names; the short names (e.g. `AuthenticationError`) continue to work for backward compatibility and refer to the same classes.

All subclasses inherit `statusCode` and `requestId`. `ConfigurationError` is thrown **before** any network request is made — used when required params are missing locally (e.g. `reservations.list({})` without `properties`). `statusCode: 0` distinguishes it from HTTP errors in catch blocks.

```ts
import {
  HospitableError, ConfigurationError,
  AuthenticationError, ForbiddenError,
  NotFoundError, ValidationError, RateLimitError, ServerError,
} from 'hospitable'

try {
  await client.reservations.list({ properties: [propertyId] })
} catch (err) {
  if (err instanceof ConfigurationError)  return console.error(`Bad call: ${err.message}`)
  if (err instanceof NotFoundError)       return null
  if (err instanceof ValidationError)     console.error(err.fields)
  if (err instanceof RateLimitError)      console.warn(`retry in ${err.retryAfter}s`)
  if (err instanceof AuthenticationError) throw new Error('bad token')
  if (err instanceof ForbiddenError)      throw new Error('insufficient permissions')
  if (err instanceof ServerError)         throw new Error(`5xx after ${err.attempts} attempts`)
  if (err instanceof HospitableError)     throw new Error(`${err.statusCode}: ${err.message}`)
  throw err
}
```

Non-obvious:

- **The SDK's retry layer already handles 429 and 5xx transparently.** A `RateLimitError` reaching user code means retries were exhausted — do not wrap it in another retry loop.
- **`ValidationError.fields` is automatically sanitized.** Sensitive keys (`email`, `firstName`, `taxId`, `bankAccount`, etc.) are replaced with `'***'` before the error is thrown. A caught error shipped to Sentry/winston won't leak PII or business identity. See [Debug logging](#debug-logging) for the full field list.

## Retry & rate limiting

```ts
interface RetryConfig {
  maxAttempts?: number            // default 4 (includes initial attempt)
  baseDelay?: number              // default 1000 ms
  maxDelay?: number               // default 60_000 ms
  onRateLimit?: (info: {
    retryAfter: number
    endpoint: string
    attempt: number
  }) => void
}
```

Backoff is jittered exponential, capped at `maxDelay`. 401 triggers a silent OAuth refresh and a single retry when `refreshToken` + `clientId` + `clientSecret` are configured. On successful re-auth, all resource caches are cleared automatically.

```ts
new HospitableClient({
  token,
  retry: {
    maxAttempts: 4,
    baseDelay: 1000,
    maxDelay: 60_000,
    onRateLimit: ({ retryAfter, endpoint, attempt }) => {
      console.warn(`ratelimit ${endpoint} attempt=${attempt} retryAfter=${retryAfter}s`)
    },
  },
})
```

## Caching

Opt-in per-resource in-memory cache. Only `properties`, `reservations`, and `inquiries` are cacheable. Keys are derived from the full request params. The cache is cleared automatically when the client performs a 401 → refresh cycle.

**Deliberately NOT cached:**

- `properties.getImages()` — returns pre-signed S3 URLs with ~1h expiry. A 24h cache would serve URLs that return 403 Forbidden.
- `user.get()` — business profile changes rarely but not never; no cache to avoid stale identity on account edits.
- `transactions` / `payouts` — financial data should always reflect the latest state; caching could mask just-posted rows.
- `messages` — conversation state is dynamic; stale message threads are worse than useless.

```ts
new HospitableClient({
  token,
  cache: {
    properties:   { enabled: true, ttl: 86_400_000 },  // 24 h — properties change rarely
    reservations: { enabled: true, ttl: 60_000     },  //  1 m — reservations move
    inquiries:    { enabled: true, ttl: 60_000     },  //  1 m
  },
})

// manual invalidation
client.properties.clearCache()
client.reservations.clearCache()
client.inquiries.clearCache()
```

Default TTLs if `enabled: true` but `ttl` is omitted: `properties` 24h, `reservations` 1m, `inquiries` 1m.

Note: `addTags()` clears the **entire** property cache (no per-property
invalidation available). If you have a large property set cached,
expect a cold-cache re-fetch on the next `list()` or `get()` call.

## Debug logging

```ts
new HospitableClient({ token, debug: true })
```

Logs each request: method, URL, params, request/response bodies. Sanitization runs recursively over any object before it hits the log stream or a caught `ValidationError.fields`. Three categories are masked:

- **Guest PII**: `email`, `phone`, `phoneNumbers`, `firstName`, `lastName`, `fullName`, `dateOfBirth`, `guestName`, `displayName`, `hostName`, `passportNumber`, `senderId`
- **Credentials**: anything matching `token`, `secret`, `password`, `credential`, `apiKey`, `api_key`, `authorization`
- **Business identity** (financial + address fine-grained): `taxId`/`tax_id`, `vat`, `bankAccount`/`bank_account`, `streetLine1`/`street_line1`, `streetLine2`/`street_line2`, `postalCode`/`postal_code`

Deliberately **not** masked (too broad to be individually identifying, and masking would cripple debugging):

- `city`, `state`, `country`, `company`
- `amount`, `paidOutAmount` (sensitive financial but not identity)
- `platformId` — overloaded: on messages/reservations it's the public platform ID; on payouts it's a bank-transfer reference. Field-name-based redaction can't distinguish the two.
- **`wifiPassword` / `wifi_password`** — semi-public by design (hosts share with every guest). Agent workflows read this to generate check-in messages, so redacting would force operators to bypass sanitization globally. Explicit `SAFE_OVERRIDES` allowlist in `src/utils/sanitize.ts` carves it out of the broad `/password/i` match. A bare `password` field (without the wifi prefix) is still redacted.
- **`smartlockCode` / `smartlock_code`** — same rationale as wifi. The smart-lock access code is shared with the guest for their stay; agents need the real value when composing check-in messages. The field name doesn't match any sanitize pattern naturally, but a test pins this behavior so future pattern additions don't accidentally capture it.

## Gotchas (read this)

Things that routinely trip up agents generating code against this SDK.

1. **`reservations.list` requires `properties`.** Calling with an empty or missing array throws `ConfigurationError` locally before any HTTP request is made. The error message names the field and gives an example. Same goes for `iter()` and the fluent `ReservationFilter.toParams()`.
2. **Status spelling trap** — the legacy `statusHistory[].status` field uses American `canceled` in the raw API response, while `status` and `reservationStatus.current.category` use British `cancelled`. **The SDK normalizes on read**: `normalizeReservation()` rewrites `canceled` → `cancelled` on every response passing through `list()`/`get()`/`iter()`, so `r.statusHistory.some(h => h.status === 'cancelled')` works correctly through the SDK. But if you receive a `Reservation` from a **webhook payload** or a **cached pre-normalization** source, the raw value may still be `'canceled'`. Prefer `reservationStatus.history` in new code — it's consistent all the way down.
3. **`dateQuery` only accepts `'checkin'` or `'checkout'`.** Anything else returns `400`. Default is `'checkin'`. Use `'checkout'` for "still-in-house" or "departing in window" queries. Only one `date_query` per request — see `getInHouse()` for the two-sided case.
4. **`lastMessageAt` format is NOT ISO 8601.** The API expects `'YYYY-MM-DD HH:MM:SS'` (space-separated, no timezone). Passing ISO 8601 returns `400`.
5. **`getInHouse()` timezone caveat.** "Today" is computed from the SDK host's **UTC** clock, not per-property local time. For properties in strongly-offset timezones (e.g. Hawaii at UTC-10), calling during the ~0:00–10:00 UTC window can misclassify a same-day turnover by one day. If you need millisecond-correct boundary behavior, query `list()` directly with a timezone-aware `today`.
6. **`getImages()` returns pre-signed S3 URLs with ~1h expiry.** The SDK does **not** cache this method for that reason. Do not persist the URL — re-fetch when you need the content.
7. **Message attachments are also pre-signed S3 URLs with short expiry.** Same advice: don't cache. Re-fetch the message thread when you need the content.
8. **`transactions` and `payouts` are UNBOUNDED by default.** Calling `.iter()` with no params streams the entire account history (hundreds to thousands of rows). **Always pass `startDate`/`endDate` or `properties`** — especially in agent-driven code paths, where a prompt-injected agent could exfil full financial history in one turn.
9. **`user.get()` and `properties.get()` unwrap a `.data` envelope; other resource `.get()` methods don't.** The Hospitable API is inconsistent: `/v2/user` and `/v2/properties/{id}` wrap their responses in `{data: ...}` while `/v2/reservations/{id}` and `/v2/inquiries/{id}` don't. The SDK handles the unwrap so callers always get a bare resource object regardless of endpoint.
10. **`source: 'public_api'` tags messages sent through this SDK.** Useful for audit: `thread.messages.filter(m => m.source === 'public_api')` returns your own agent's sends.
11. **`inquiry.id === conversation_id`.** Pass it directly to `client.messages.list(inquiry.id)`. Do not look for a separate `conversationId` field on inquiries.
12. **Inquiry → reservation handoff.** While a conversation is still an inquiry (`reservation_id === null`), use `sendForInquiry`. Once a reservation exists, switch to `send`. Using the wrong endpoint returns 410 or 422.
13. **Inquiry sends reject images.** `sendForInquiry`'s options type does not include `images` — pre-booking channels strip attachments. Attach images only on `send`.
14. **`properties` is singular on an `Inquiry`.** The API returns a single `Property` under the plural-sounding `properties` field. The SDK's `normalizeInquiry` (applied automatically) additionally sets `inquiry.property` as an alias; both reference the same object. Prefer `inquiry.property` in new code.
15. **`InquiryFilter.toParams()` throws** if `.properties()` was not called. The underlying endpoint requires it.
16. **`messages` include is `get`-only.** On `client.inquiries.list`, `include=messages` is silently ignored (or rejected). Fetch per-inquiry with `get(uuid, 'messages')`, or call `client.messages.list(inquiry.id)` separately.
17. **`send` / `sendForInquiry` return 202, not the message.** They return `MessageReceipt { sentReferenceId }`. Do not treat the return value as a persisted `Message`.
18. **Do not wrap calls in your own retry loop** for 429/5xx — the SDK already does jittered exponential backoff. Re-wrapping causes double-retries and can trigger 50/5min hard caps.
19. **Dates are ISO strings, not `Date` objects.** `YYYY-MM-DD` for calendar and reservation ranges; ISO 8601 with time/zone for inquiry `lastMessageAt`; `'YYYY-MM-DD HH:MM:SS'` for reservation `lastMessageAt` (yes, different formats on different endpoints — this is the API's doing, not the SDK's).
20. **Only reservation message sends accept `senderId`** (co-host impersonation). Inquiry sends also accept `senderId`, but per the upstream API, it is only honored on Airbnb.
21. **Unknown `include` values are silently ignored by the API.** Don't rely on error feedback for typos — pass only the literals in `ReservationIncludeField` / `ReviewIncludeField` / `InquiryIncludeField`. A misspelled include returns an empty-but-successful response.
22. **URL IDs are auto-encoded.** Every `${id}` interpolation goes through `encodeURIComponent()` before hitting the wire — path-traversal attempts via `'../../admin'` resolve to an encoded no-op, not a different endpoint. You can pass untrusted-ish IDs without extra sanitization.
23. **`CreateReservationFinancials` is NOT the same as `ReservationFinancials`.** The write-side is flat (`accommodation`, `cleaningFee`, etc.); the read-side is nested (`guest.fees[]`, `host.hostFees[]`, etc.). Passing a read-side object to `reservations.create()` causes a TS error — this is intentional.
24. **`addTags()` is additive, not a replace.** To remove tags, use the Hospitable web UI. The method clears the entire property cache.
25. **`updateIcalImport()` resync only triggers if >15 min since last sync.** Calling it repeatedly is a no-op until the 15-minute window elapses.
26. **Knowledge Hub item/topic IDs are numeric (integers), not UUIDs.** Pass numbers, not strings, to `updateItem()`, `deleteItem()`, and `deleteTopic()`.
27. **`createQuote()` requires the "Direct" feature** on the Hospitable account. Returns `unknown` because we couldn't probe the response shape — narrow at your call site.
28. **Enrichment endpoint path is `/enrichment` not `/enrichment-data`.** The MCP docs say "enrichment-data" but the actual API 404s on that path and accepts `/enrichment`.

## Exported types

```ts
// Client
HospitableClient, HospitableClientConfig, ResourceCacheConfig

// Resources (classes — mostly used via client.<resource>)
PropertiesResource, PropertyListParams
ReservationsResource
MessagesResource
CalendarResource
ReviewsResource
InquiriesResource
UserResource
TransactionsResource
PayoutsResource
KnowledgeHubResource

// Property models
Property, PropertyList, PropertyTag, PropertyImage, PropertySearchParams,
  PropertyAddress, PropertyCapacity, PropertyHouseRules,
  PropertyRoomDetail, PropertyRoomBed, PropertyParentChild,
  PropertyIncludeField, PropertyUser, PropertyListing,
  PropertyListingCoHost, PropertyDetails, PropertyBookings,
  PropertyBookingFee, PropertyListingMarkup, PropertyOccupancyFee,
  PropertyOccupancyBasedRules, PropertyPaymentTerms,
  PropertyBookingPolicies, PropertyIcalImport,
  CreateIcalImportOptions, UpdateIcalImportOptions

// Reservation models
Reservation, ReservationList, ReservationListParams,
  ReservationStatus, RESERVATION_STATUSES, isReservationStatus,
  ReservationPlatform, ReservationDateQuery, ReservationIncludeField,
  ReservationStatusObject, ReservationStatusHistoryEntry,
  ReservationLegacyStatusHistoryEntry, ReservationUser,
  ReservationFinancials, ReservationFinancialsGuest,
  ReservationFinancialsHost, ReservationFinancialLineItem,
  CancelReservationInitiatedBy, CreateReservationFinancials,
  CreateReservationGuest, CreateReservationGuestCounts,
  CreateReservationParams, UpdateReservationParams,
  Guest, ReservationGuests, normalizeReservation

// Inquiry models
Inquiry, InquiryList, InquiryListParams, InquiryIncludeField,
  InquiryGuest, InquiryGuestCounts, InquiryListing, InquiryUser,
  normalizeInquiry

// Message models
Message, MessageReceipt, MessageThread, MessageTemplate,
  MessageSender, MessageAttachment, MessageReaction,
  MessageSource, MessageSenderType, MessageContentType,
  SendMessageOptions, SendReservationMessageOptions

// Review models
Review, ReviewList, ReviewListParams, ReviewIncludeField,
  ReviewPublic, ReviewPrivate, ReviewDetailedRating,
  ReviewDetailedRatingType, ReviewGuest, ReviewReservation,
  ReviewProperty, ReviewRespondBody

// Calendar models
CalendarData, CalendarDay, CalendarDayPrice, CalendarDayStatus,
  CalendarUpdate

// User / billing models
User

// Financial models
Money, Transaction, TransactionList, TransactionListParams,
  Payout, PayoutList, PayoutListParams

// Knowledge Hub models
KnowledgeHub, KnowledgeHubTopic, KnowledgeHubItem,
  KnowledgeHubSource, KnowledgeHubProperty,
  CreateKnowledgeHubItemOptions, UpdateKnowledgeHubItemOptions

// Enrichment models
EnrichmentField

// Quote models
CreateQuoteParams, Quote

// Pagination
PaginatedResponse<T>, paginate, collectAll, PageFetcher

// Filters
ReservationFilter, PropertyFilter, InquiryFilter

// Errors
HospitableError, ConfigurationError,
  AuthenticationError, ForbiddenError, NotFoundError,
  ValidationError, RateLimitError, ServerError,
  createErrorFromResponse
// Aliases (per AGENTS.md)
HospitableAuthError, HospitableRateLimitError,
  HospitableValidationError, HospitableServerError

// Auth
TokenManager, TokenManagerConfig

// Utils
sanitize, MemoryCache, cacheKey, CacheConfig
```

## Connect API

Hospitable's **Connect API** is a separate, partner-facing surface for
multi-customer integrations — distinct from the host-facing Public API
everything above this section covers. Use Connect when you're building
a vendor app that onboards multiple Hospitable customers through OTA
channel connections (the auth-code / magic-link flow). Use the Public
API when a single host has given you a PAT or OAuth2 credentials
against their own account.

| Aspect | Public API (`HospitableClient`) | Connect API (`HospitableConnectClient`) |
| --- | --- | --- |
| Base URL | `https://public.api.hospitable.com` | `https://connect.hospitable.com/api/v1` |
| Auth | PAT or OAuth2 (with refresh) | Static bearer token from Partner Portal |
| Primary key | Property UUID | Customer ID → Channel ID → Listing ID |
| Rate limit | 1000 req/min (most endpoints) | 60 req/min per vendor |
| Filter syntax | Typed builders (`ReservationFilter`) | `field[operator]=value` via `ConnectFilter` |
| Webhooks | 8 event types | 17 event types |

### Initialize

```ts
import { HospitableConnectClient } from 'hospitable'

const connect = new HospitableConnectClient({ token: 'hsc_...' })
// Or via env:
// process.env.HOSPITABLE_CONNECT_TOKEN = 'hsc_...'
// const connect = new HospitableConnectClient()
```

`HospitableConnectClientConfig`:

```ts
interface HospitableConnectClientConfig {
  token?: string              // Partner-portal bearer token. Defaults to $HOSPITABLE_CONNECT_TOKEN.
  baseURL?: string            // Default: 'https://connect.hospitable.com/api/v1'
  retry?: RetryConfig         // Same shape as HospitableClient. Connect rate-limits at 60/min.
  debug?: boolean
  onTokenExpired?: () =>      // Optional — invoked on 401 to mint a fresh token.
    | string                  // The SDK swaps it in and transparently retries the
    | Promise<string>         // failing request. Without this, 401 is terminal.
}
```

Throws `ConfigurationError` when no token is resolvable.

**Rotating tokens mid-session.** Connect's static bearer has no built-in
refresh loop, so by default a 401 throws `AuthenticationError` and stops.
For long-running agents that rotate tokens via an external system,
supply `onTokenExpired`:

```ts
const connect = new HospitableConnectClient({
  token: await fetchCurrentToken(),
  onTokenExpired: async () => fetchFreshToken(),   // runs on 401, sync or async
})
```

### Connect method index

| Call | Signature | HTTP |
| --- | --- | --- |
| `connect.authCodes.create` | `(input: CreateAuthCodeInput) => Promise<AuthCode>` | `POST /auth-codes` |
| `connect.customers.list` | `(params?) => Promise<ConnectPaginatedResponse<Customer>>` | `GET /customers` |
| `connect.customers.create` | `(input: CreateCustomerInput) => Promise<Customer>` | `POST /customers` |
| `connect.customers.get` | `(id: string) => Promise<Customer>` | `GET /customers/{id}` |
| `connect.customers.delete` | `(id: string) => Promise<void>` | `DELETE /customers/{id}` |
| `connect.customers.iter` | `(params?) => AsyncGenerator<Customer>` | auto-paginates |
| `connect.channels.list` | `(customerId: string) => Promise<Channel[]>` | `GET /customers/{c}/channels` |
| `connect.channels.get` | `(customerId, channelId) => Promise<Channel>` | `GET /customers/{c}/channels/{ch}` |
| `connect.channels.delete` | `(customerId, channelId) => Promise<void>` | `DELETE /customers/{c}/channels/{ch}` |
| `connect.channels.listListings` | `(channelId: string) => Promise<Listing[]>` | `GET /channels/{ch}/listings` |
| `connect.channels.getListing` | `(channelId, listingId) => Promise<Listing>` | `GET /channels/{ch}/listings/{l}` |
| `connect.listings.list` | `(customerId, params?) => Promise<ConnectPaginatedResponse<Listing>>` | `GET /customers/{c}/listings` |
| `connect.listings.get` | `(customerId, listingId) => Promise<Listing>` | `GET /customers/{c}/listings/{l}` |
| `connect.listings.getImages` | `(customerId, listingId) => Promise<ListingImage[]>` | `GET …/listings/{l}/images` |
| `connect.listings.getCalendar` | `(listingId, { startDate, endDate }) => Promise<CalendarDay[]>` | `GET /listings/{l}/calendar` |
| `connect.listings.updateCalendar` | `(listingId, days: UpdateCalendarDay[]) => Promise<void>` | `PUT /listings/{l}/calendar` |
| `connect.reservations.listByListing` | `(listingId, params?) => Promise<ConnectPaginatedResponse<Reservation>>` | `GET /listings/{l}/reservations` |
| `connect.reservations.getByListing` | `(listingId, reservationId) => Promise<Reservation>` | `GET /listings/{l}/reservations/{r}` |
| `connect.reservations.listByCustomer` | `(customerId, params?) => Promise<…>` | `GET /customers/{c}/reservations` |
| `connect.reservations.getByCustomer` | `(customerId, reservationId) => Promise<Reservation>` | `GET /customers/{c}/reservations/{r}` |
| `connect.messaging.listTemplates` | `(params?) => Promise<ConnectPaginatedResponse<MessageTemplate>>` | `GET /message-templates` |
| `connect.messaging.getTemplate` | `(templateId) => Promise<MessageTemplate>` | `GET /message-templates/{t}` |
| `connect.messaging.send` | `(reservationId, { templateId, placeholders? }) => Promise<void>` | `POST /reservations/{r}/messages` |
| `connect.reviews.list` | `(channelId, params?) => Promise<ConnectPaginatedResponse<Review>>` | `GET /channels/{ch}/reviews` |
| `connect.transactions.list` | `(channelId, params?) => Promise<…>` *(beta)* | `GET /channels/{ch}/transactions` |
| `connect.transactions.get` | `(channelId, transactionId) => Promise<Transaction>` *(beta)* | `GET …/transactions/{t}` |
| `connect.payouts.list` | `(channelId, params?) => Promise<…>` *(beta)* | `GET /channels/{ch}/payouts` |
| `connect.payouts.get` | `(channelId, payoutId) => Promise<Payout>` *(beta)* | `GET …/payouts/{p}` |
| `connect.resolutions.list` | `(channelId, params?) => Promise<…>` *(beta)* | `GET /channels/{ch}/resolutions` |

Every list resource also exposes an `iter()` / `iterByListing()` /
`iterByCustomer()` async generator that auto-paginates via the
`links.next` chain. Drain with `for await (const x of connect.Y.iter())`.

### Filtering (ConnectFilter)

Connect uses `field[operator]=value` query params instead of named
params. Compose them with `ConnectFilter`:

```ts
import { Connect } from 'hospitable'

const params = new Connect.ConnectFilter()
  .where('city', 'is', ['New York', 'Seattle'])
  .where('status', 'not', ['deny', 'cancelled'])
  .where('arrival_date', 'before', '2026-02-01')
  .sortDesc('arrival_date')
  .select('id', 'arrival_date', 'financials.host')
  .perPage(50)
  .toParams()

await connect.reservations.listByCustomer(customerId, params)
```

Supported operators: `is`, `not` (multi-value, comma-joined); `lt`,
`lte`, `gt`, `gte`, `before`, `after` (single value); `between` (exactly
two values). The builder throws `ConfigurationError` on malformed
operator/value pairs — no silent server-side failures.

### Webhooks

Connect ships 17 event payloads across 6 families. Each shares an
envelope `{ id, created, action, version, data }` — return 200 to ack.

```ts
import type { Connect } from 'hospitable'
import { Connect as C } from 'hospitable'

// In your webhook handler:
function handle(payload: C.ConnectWebhookPayload) {
  if (C.isConnectWebhookFamily(payload, 'reservation')) {
    // payload is ReservationWebhookPayload here
    const { data } = payload
    // data.listing, data.channel, data.customer are embedded
  }

  if (C.isConnectWebhookAction(payload, 'review.published')) {
    // payload is ReviewWebhookPayload (specifically review.published)
  }
}
```

Families: `channel.*`, `listing.*`, `reservation.*`, `review.*`,
`payout.*`, `transaction.*`.

#### Signature verification

The type guards narrow shape only — they do **not** authenticate the
sender. Verify the HMAC signature on every payload before trusting it;
anyone who discovers your webhook URL can POST a forged body that
passes both `isConnectWebhookAction` and `isConnectWebhookFamily`.

```ts
import { Connect } from 'hospitable'

// Express route — capture the raw body, then verify before parsing.
app.post(
  '/webhooks/hospitable',
  express.raw({ type: 'application/json' }),
  async (req, res) => {
    const ok = await Connect.verifyWebhookSignature({
      rawBody: req.body,
      signatureHeader: req.header('X-Hospitable-Signature') ?? '',
      secret: process.env.HOSPITABLE_WEBHOOK_SECRET!,
    })
    if (!ok) return res.status(401).end()

    const payload = JSON.parse(new TextDecoder().decode(req.body))
    // ... route via isConnectWebhookFamily / isConnectWebhookAction ...
    res.status(200).end()
  },
)
```

Options:

```ts
interface VerifyWebhookSignatureOptions {
  rawBody: string | Uint8Array  // Byte-exact; not the parsed JSON.
  signatureHeader: string       // `algo=…` prefix is stripped automatically.
  secret: string                // From the Partner Portal webhook registration.
  algorithm?: 'sha256' | 'sha1' // Default 'sha256'.
  encoding?: 'hex' | 'base64'   // Default 'hex'.
  timestamp?: string            // Seconds-since-epoch. Signed payload becomes `${ts}.${body}`.
  toleranceSeconds?: number     // Max age. Default 300.
}
```

Resolves to `true` on a valid signature (and, if `timestamp` is set,
within tolerance). Resolves to `false` for any mismatch, malformed
input, or stale payload. **Never throws on mismatch** — returning a
boolean makes it safe to call on arbitrary inbound bodies without a
DoS vector. Uses a constant-time comparison, Web Crypto only (no
`@types/node` required).

### Namespace layout

Connect types would collide with Public (both surface `Reservation`,
`Review`, `Transaction`, `Payout`). To disambiguate, Connect lives
under a `Connect` namespace — the client class is re-exported at the
top level for ergonomics:

```ts
// Top-level exports (safe):
import { HospitableClient, HospitableConnectClient } from 'hospitable'

// Connect-specific types / builders / webhooks:
import { Connect } from 'hospitable'
const filter: Connect.ConnectFilter = new Connect.ConnectFilter()
type Res = Connect.Reservation
type Wh = Connect.ReservationWebhookPayload
```

## License

MIT
