hospitable
v0.7.3
Published
TypeScript SDK for the Hospitable Public API and Connect API
Maintainers
Readme
hospitable
TypeScript SDK for the Hospitable Public API. 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.mdfor the full rationale.
Install
npm install hospitableRequires Node ≥ 18. ESM and CJS both ship.
Initialize
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
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. 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
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— populatesproperty.userwith{id, email, name, profilePicture}listings— populatesproperty.listings: PropertyListing[], one entry per booking channel (airbnb, vrbo, direct, manual, gvr, booking_com…) withplatform,platformId,coHosts, etc.details— populatesproperty.details: PropertyDetailswith host-operational info:wifiName,wifiPassword,houseManual,guestAccess,gettingAround,neighborhoodDescription,additionalRules,otherDetails,spaceOverviewbookings— populatesproperty.bookings(typed asunknown— opaque pricing/policy config; narrow at call site)
include works on both list() and get():
// 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.
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
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'propertiesis required. The SDK throwsConfigurationErrorlocally before the HTTP call if it's missing or an empty array — see Gotchas.dateQuerypicks which date fieldstartDate/endDatefilter 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 returns400.lastMessageAtformat quirk: the API expectsYYYY-MM-DD HH:MM:SS(space-separated, no timezone), not ISO 8601.include=reviewfetches the review associated with each reservation alongside the reservation itself — useful for batch review-status audits.include=smartlock_codeside-loads the property's smart-lock access code for this reservation asreservation.smartlockCode(string, typically a 4-digit numeric code, ornullfor cancelled/far-future reservations). Not redacted bysanitize()— agents need to include the code in guest check-in messages, same semantic aswifiPasswordon a property'sdetails.statusmay be passed as a string or array; the SDK always serializes as an array.
TransactionListParams / PayoutListParams
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.
InquiryListParams
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
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-referencereview.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
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.
interface UpdateReservationParams {
arrivalDate?: string
departureDate?: string
guest?: Partial<CreateReservationGuest>
guests?: Partial<CreateReservationGuestCounts>
financials?: Partial<CreateReservationFinancials>
}CreateQuoteParams
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
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
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 section for the spelling trap.
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.
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).
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
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:
const thread = await client.messages.list(reservationId)
const mySends = thread.messages.filter(m => m.source === 'public_api')Property (when includes are requested)
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
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
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.
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.
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
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.
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
for await (const p of client.properties.iter()) {
// p: Property — one at a time, memory-safe
}Upcoming reservations for specific listings
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
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
// 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.
Check a property's availability
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
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
// 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:
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
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
const receipt = await client.messages.sendForInquiry(
inquiry.id,
'Thanks for asking — those dates are available.',
)
// receipt.sentReferenceId : use to correlate with messages fetched laterReply to a booking with an image
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
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
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
import { collectAll } from 'hospitable'
const all = await collectAll(
client.reservations.iter({
properties: propertyIds, // required
startDate: '2026-01-01',
}),
)Who am I?
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
// 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.
Review with guest + reservation included
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
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: '[email protected]' },
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.
Cancel a reservation
const cancelled = await client.reservations.cancel('res-uuid', 'host')
// Only works on manual/direct reservations.Read + update enrichment data (setting a door code)
// 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
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
// 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
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
await client.properties.addTags('prop-uuid', ['pet-friendly', 'pool'])
// Additive — does not replace existing tags.
// Clears the entire property cache.Create an iCal import
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.
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:
interface MessageReceipt { sentReferenceId: string }Delivery happens out-of-band on the upstream channel (Airbnb, VRBO, Booking.com, direct). To confirm a message actually landed:
- Persist
receipt.sentReferenceIdafter the send. - Poll
client.messages.list(conversationId)later. - 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.
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
RateLimitErrorreaching user code means retries were exhausted — do not wrap it in another retry loop. ValidationError.fieldsis 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 for the full field list.
Retry & rate limiting
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.
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.
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
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,companyamount,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. ExplicitSAFE_OVERRIDESallowlist insrc/utils/sanitize.tscarves it out of the broad/password/imatch. A barepasswordfield (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.
reservations.listrequiresproperties. Calling with an empty or missing array throwsConfigurationErrorlocally before any HTTP request is made. The error message names the field and gives an example. Same goes foriter()and the fluentReservationFilter.toParams().- Status spelling trap — the legacy
statusHistory[].statusfield uses Americancanceledin the raw API response, whilestatusandreservationStatus.current.categoryuse Britishcancelled. The SDK normalizes on read:normalizeReservation()rewritescanceled→cancelledon every response passing throughlist()/get()/iter(), sor.statusHistory.some(h => h.status === 'cancelled')works correctly through the SDK. But if you receive aReservationfrom a webhook payload or a cached pre-normalization source, the raw value may still be'canceled'. PreferreservationStatus.historyin new code — it's consistent all the way down. dateQueryonly accepts'checkin'or'checkout'. Anything else returns400. Default is'checkin'. Use'checkout'for "still-in-house" or "departing in window" queries. Only onedate_queryper request — seegetInHouse()for the two-sided case.lastMessageAtformat is NOT ISO 8601. The API expects'YYYY-MM-DD HH:MM:SS'(space-separated, no timezone). Passing ISO 8601 returns400.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, querylist()directly with a timezone-awaretoday.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.- 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.
transactionsandpayoutsare UNBOUNDED by default. Calling.iter()with no params streams the entire account history (hundreds to thousands of rows). Always passstartDate/endDateorproperties— especially in agent-driven code paths, where a prompt-injected agent could exfil full financial history in one turn.user.get()andproperties.get()unwrap a.dataenvelope; other resource.get()methods don't. The Hospitable API is inconsistent:/v2/userand/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.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.inquiry.id === conversation_id. Pass it directly toclient.messages.list(inquiry.id). Do not look for a separateconversationIdfield on inquiries.- Inquiry → reservation handoff. While a conversation is still an inquiry (
reservation_id === null), usesendForInquiry. Once a reservation exists, switch tosend. Using the wrong endpoint returns 410 or 422. - Inquiry sends reject images.
sendForInquiry's options type does not includeimages— pre-booking channels strip attachments. Attach images only onsend. propertiesis singular on anInquiry. The API returns a singlePropertyunder the plural-soundingpropertiesfield. The SDK'snormalizeInquiry(applied automatically) additionally setsinquiry.propertyas an alias; both reference the same object. Preferinquiry.propertyin new code.InquiryFilter.toParams()throws if.properties()was not called. The underlying endpoint requires it.messagesinclude isget-only. Onclient.inquiries.list,include=messagesis silently ignored (or rejected). Fetch per-inquiry withget(uuid, 'messages'), or callclient.messages.list(inquiry.id)separately.send/sendForInquiryreturn 202, not the message. They returnMessageReceipt { sentReferenceId }. Do not treat the return value as a persistedMessage.- 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.
- Dates are ISO strings, not
Dateobjects.YYYY-MM-DDfor calendar and reservation ranges; ISO 8601 with time/zone for inquirylastMessageAt;'YYYY-MM-DD HH:MM:SS'for reservationlastMessageAt(yes, different formats on different endpoints — this is the API's doing, not the SDK's). - Only reservation message sends accept
senderId(co-host impersonation). Inquiry sends also acceptsenderId, but per the upstream API, it is only honored on Airbnb. - Unknown
includevalues are silently ignored by the API. Don't rely on error feedback for typos — pass only the literals inReservationIncludeField/ReviewIncludeField/InquiryIncludeField. A misspelled include returns an empty-but-successful response. - URL IDs are auto-encoded. Every
${id}interpolation goes throughencodeURIComponent()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. CreateReservationFinancialsis NOT the same asReservationFinancials. The write-side is flat (accommodation,cleaningFee, etc.); the read-side is nested (guest.fees[],host.hostFees[], etc.). Passing a read-side object toreservations.create()causes a TS error — this is intentional.addTags()is additive, not a replace. To remove tags, use the Hospitable web UI. The method clears the entire property cache.updateIcalImport()resync only triggers if >15 min since last sync. Calling it repeatedly is a no-op until the 15-minute window elapses.- Knowledge Hub item/topic IDs are numeric (integers), not UUIDs. Pass numbers, not strings, to
updateItem(),deleteItem(), anddeleteTopic(). createQuote()requires the "Direct" feature on the Hospitable account. Returnsunknownbecause we couldn't probe the response shape — narrow at your call site.- Enrichment endpoint path is
/enrichmentnot/enrichment-data. The MCP docs say "enrichment-data" but the actual API 404s on that path and accepts/enrichment.
Exported types
// 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, CacheConfigConnect 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
import { HospitableConnectClient } from 'hospitable'
const connect = new HospitableConnectClient({ token: 'hsc_...' })
// Or via env:
// proce