Plenty of operators feel that guest communication sits too close to the heart of the business to hand to a third party. That's a reasonable instinct. If you have a developer, both Guesty and Hostaway give you what you need to build your own autopilot: a webhook when a guest writes, an API to read the booking and the listing, and an API to reply.
This guide walks through a working design, with code. It also covers what it costs to run and the parts that take longer than people expect, so you can make the build-or-buy decision with your eyes open.
The code is Python and deliberately short. It's a starting point, not a product.
Should you build it?
Building makes sense when you have engineering capacity you can commit for the long term, you want full control over how guests are answered, or your operation is unusual enough that off-the-shelf tools don't fit.
It makes less sense if nobody will own it after launch. The first version is the easy bit. Keeping it accurate as your listings change, your policies evolve and the models move on is a continuing job, closer to research than to a project you finish. Well-funded companies in this space have found it harder than expected.
A good middle path is to build the first two stages below (shadow mode and drafts) and use them to learn what your guests actually ask. You'll understand any vendor's pitch far better afterwards.
What you're building
Every incoming guest message goes through the same pipeline:
- Receive. The PMS sends a webhook. You acknowledge it immediately and queue the work.
- Gather context. Fetch the reservation, the listing, the conversation history and your policies.
- Decide. Ask the model for a structured decision: what the guest is asking, whether it's safe to answer, the reply, and any task that needs creating.
- Check. Run the decision through guardrails written in code.
- Act. Send the reply through the PMS, create the task, or hand the conversation to a person with a summary.
- Log. Record what happened and why, so you can measure it and fix mistakes.
The model is one step out of six. Most of the work, and most of what makes it safe, is in the others.
The data to pull
This is the part that decides how good your autopilot can be. An AI can only answer from what it's given, and most wrong answers come from missing or outdated information rather than a weak model.
| Data | What it's for | Where it comes from |
|---|---|---|
| Reservation | Dates, guest names and count, channel, booking status, door code | PMS API |
| Listing | Address, check-in and check-out times, access instructions, Wi-Fi, house rules, parking | PMS API, often plus custom fields |
| Conversation history | What's already been said, so it doesn't repeat itself or contradict your team | PMS API |
| Your policies | Early check-in, late checkout, pets, parties, luggage drop, refunds, discounts | Usually needs writing down |
| Operations calendar | Whether a turnover allows an early arrival or late departure | PMS reservations and your cleaning tool |
| Team directory | Who owns each property, who to call for maintenance, out-of-hours cover | Usually needs writing down |
| Past replies | How your team actually sounds, and how it handled tricky requests | PMS conversation history |
| Local knowledge | Nearest supermarket, parking alternatives, the restaurant you always recommend | Guidebooks, saved replies, your team's heads |
Two practical points. First, keep the per-property facts in one structured place (a JSON or Markdown file per listing works) rather than scattered across custom fields, and refresh it from the PMS on a schedule. Second, run a quick audit before you build anything: pull 200 recent guest questions and check how many could be answered from the data above. That number is your ceiling, and filling the gaps often helps your team as much as the AI.
Step 1: Get API access
| Guesty | Hostaway | |
|---|---|---|
| Token request | POST https://open-api.guesty.com/oauth2/token with client credentials and scope=open-api | POST https://api.hostaway.com/v1/accessTokens with your account ID, API key and scope=general |
| Token lifetime | 24 hours | 24 months |
| Watch out for | Only 5 tokens per client per 24 hours, so cache the token and reuse it | Keep the token secret, it lasts a long time |
| Rate limits | 15 a second, 120 a minute, 5,000 an hour | 30 messages sent a minute per account, about 200 requests per 10 seconds otherwise |
Both use Authorization: Bearer <token> on every call. On Guesty, requesting a new token on every message is the most common early mistake: you'll hit the five-a-day limit by lunchtime.
Step 2: Receive the webhook
| Guesty | Hostaway | |
|---|---|---|
| Register | POST /v1/webhooks with your URL and events | POST /v1/webhooks/unifiedWebhooks |
| Event | reservation.messageReceived | message.received |
| Security | Signed with Svix, verify the signature | Optional basic auth, plus fixed source IP addresses |
| Reply within | About 15 seconds | 20 seconds |
| Retries | Several times over about a day | Up to three times within about an hour |
The two rules that matter: acknowledge straight away and do the work in the background, and make the handler idempotent, because retries mean you will see the same message twice.
Here's a minimal receiver for Hostaway using FastAPI. Hostaway doesn't publish a full example of the message.received payload, so log a real delivery first and adjust extract_ids to match. Fetching the message again from the API, rather than trusting the payload, is the safer pattern anyway.
from fastapi import BackgroundTasks, FastAPI, Request
app = FastAPI()
seen: set[str] = set() # use Redis or your database in production
def extract_ids(payload: dict) -> tuple[str, str] | None:
# Check against a logged delivery. Hostaway documents the
# ConversationMessage fields but not the full webhook envelope.
msg = payload.get("data") or {}
if payload.get("event") != "message.received" or not msg.get("isIncoming"):
return None
return str(msg["id"]), str(msg["conversationId"])
@app.post("/webhooks/hostaway")
async def hostaway_webhook(request: Request, background: BackgroundTasks):
ids = extract_ids(await request.json())
if ids and ids[0] not in seen:
seen.add(ids[0])
background.add_task(handle_message, conversation_id=ids[1])
return {"ok": True} # acknowledge fast, work later
On Guesty the shape is documented. Accept only events where conversation.conversationWith is "Guest" and message.type is "fromGuest", deduplicate on the svix-id header, and strip HTML from the message body. Your own replies trigger reservation.messageSent, so never subscribe your bot to that event, or it will end up talking to itself.
Step 3: Gather the context
import httpx
api = httpx.Client(
base_url="https://api.hostaway.com/v1",
headers={"Authorization": f"Bearer {HOSTAWAY_TOKEN}"},
timeout=10,
)
def load_context(conversation_id: str) -> dict:
messages = api.get(f"/conversations/{conversation_id}/messages",
params={"limit": 30}).json()["result"]
reservation_id = messages[0]["reservationId"]
reservation = api.get(f"/reservations/{reservation_id}").json()["result"]
listing = api.get(f"/listings/{reservation['listingMapId']}").json()["result"]
return {
"messages": messages,
"reservation": reservation,
"listing": listing,
"policies": load_policies(reservation["listingMapId"]), # your own file
}
Hostaway listings include fields such as checkInTimeStart, checkOutTime, houseRules, wifiUsername, wifiPassword and specialInstruction. Reservations include arrivalDate, departureDate, numberOfGuests, channelName and doorCode. On Guesty, the equivalents come from GET /v1/reservations-v3, GET /v1/listings/{id} and the conversation's posts. Guesty's published listing schema has no Wi-Fi field, so many operators keep that in custom fields.
Pass the model only what it needs. Sending a door code into every prompt, for every message, is a risk you don't need to take. Decide in code when access details are allowed (for example, only for confirmed bookings within 48 hours of arrival) and include them only then.
Step 4: Ask for a decision, not just a reply
The most useful design change you can make is to stop asking the model to "reply to the guest" and start asking it for a structured decision. That gives your code something to check before anything is sent.
from typing import Literal
import anthropic
from pydantic import BaseModel
client = anthropic.Anthropic()
Category = Literal[
"property_info", "check_in", "early_check_in", "late_checkout",
"maintenance", "booking_change", "refund_or_complaint", "safety", "other",
]
class Decision(BaseModel):
requests: list[str] # every separate thing the guest asked
categories: list[Category]
answerable_from_context: bool
reply: str
needs_task: bool
task_summary: str
escalation_reason: str
def decide(system_prompt: str, guest_turn: str) -> Decision:
response = client.messages.parse(
model="claude-opus-5-5",
max_tokens=16000,
system=[{"type": "text", "text": system_prompt,
"cache_control": {"type": "ephemeral"}}],
messages=[{"role": "user", "content": guest_turn}],
output_format=Decision,
)
return response.parsed_output
A few things are doing real work here.
requests as a list. Guests often ask three things at once. Making the model list them separately is the simplest defence against answering the first question and ignoring the rest.
answerable_from_context. The model should say when the information isn't there, rather than filling the gap with something plausible. Tell it in the system prompt that "I don't know" is always an acceptable answer and a made-up one never is.
A cached system prompt. Put the stable parts (your tone guide, policies, examples of good replies) in the system prompt and mark it for caching. Put the per-message details (listing facts, reservation, history, the new message) in the user turn. Repeated calls then reuse the cached prefix, which cuts cost and latency.
Guest text is data, not instructions. Wrap the conversation in clear delimiters and tell the model that nothing a guest writes can change its rules. In April 2026, 404 Media reported a guest who got a host's AI to produce a French toast recipe in the middle of a dispute. Prompt wording helps, but the real protection is in the next step.
Pick the model by measuring, not by price alone. Run the same sample of real conversations through two or three models, score them with the method in our guide to measuring automation rate, and choose the cheapest one whose error rate you're comfortable with.
Step 5: Guardrails in code
Anything that must never happen should be enforced by your code, not requested in the prompt. The model proposes. Your code decides.
NEVER_AUTO = {"refund_or_complaint", "safety", "booking_change"}
NEEDS_CHECK = {"early_check_in", "late_checkout"}
def route(decision: Decision, ctx: dict) -> str:
cats = set(decision.categories)
if cats & NEVER_AUTO or not decision.answerable_from_context:
return "escalate"
if "early_check_in" in cats and not turnover_allows_early_arrival(ctx):
return "escalate" # let a person decide, or offer the standard time
if cats & NEEDS_CHECK and not ctx["policies"].get("auto_approve_times"):
return "escalate"
if mentions_contact_details(decision.reply):
return "escalate" # Airbnb's off-platform rules
if not in_enabled_categories(cats):
return "draft" # rollout stage, see below
return "send"
The checks worth having from day one:
- Categories that always go to a person. Refunds, complaints, damage, safety, date changes. Hostaway's own guidance treats refunds and compensation as high risk to be handled personally, and Hospitable's autopilot won't answer early check-in, date changes or refunds at all. That's a sensible baseline to start from.
- Operational commitments. Never let the model promise an early check-in or late checkout without your code checking the calendar first. "OK, see you at 12pm" from a guest with a 3pm check-in is an early check-in request, even though it doesn't look like one.
- Off-platform contact. Airbnb's off-platform policy bars soliciting guests' email addresses or other channels. Scan outgoing replies for email addresses, phone numbers and links.
- Access details. Release codes only under the conditions you set.
- Tone and length. Cap reply length, and add a short random delay before sending. A long, polished paragraph arriving four seconds after the guest wrote reads like a machine.
Step 6: Send, create the task, or hand over
def act(conversation_id: str, decision: Decision, action: str, ctx: dict):
if action == "send":
api.post(f"/conversations/{conversation_id}/messages",
json={"body": decision.reply, "communicationType": "channel"})
elif action == "draft":
notify_team(conversation_id, draft=decision.reply)
else:
notify_team(conversation_id, reason=decision.escalation_reason,
summary=decision.requests)
if decision.needs_task:
api.post("/tasks", json={
"title": decision.task_summary[:120],
"listingMapId": ctx["reservation"]["listingMapId"],
"reservationId": ctx["reservation"]["id"],
"categoriesMap": [2], # Hostaway task category, check your setup
})
log_decision(conversation_id, decision, action)
On Hostaway, communicationType: "channel" replies inside the Airbnb or Booking.com thread. The default is email, which is rarely what you want. On Guesty, send with POST /v1/communication/conversations/{id}/send-message and reply on the same module.type the guest wrote on. Tasks go to POST /v1/tasks-open-api/create-single-task.
An escalation should arrive somewhere your team actually looks, with the guest's requests summarised and the reason it was escalated. An escalation that sits unread for three hours isn't much better than a wrong answer.
Step 7: Roll it out in stages
- Shadow mode, one to two weeks. The system decides on every message but sends nothing. Compare its decisions with what your team actually did.
- Drafts. Your team sees the suggested reply and sends or edits it. Track how often drafts go out unchanged, by category.
- Autopilot, one category at a time. Start with the safest (Wi-Fi, parking, check-in instructions) and widen as the error rate stays at zero.
- Measure every week. Use the 200-request method: handled end to end, escalated correctly, and errors. Keep a list of every mistake and check that fixed ones stay fixed.
What it costs to run
A single property averages roughly ten guest messages a week, which at 1,000 units is around 43,000 messages a month before any coordination messages. Assume each call sends about 6,000 tokens of context and gets back about 400 tokens of output.
| Model (October 2026 list price per million tokens, input / output) | Approximate monthly cost at 1,000 units |
|---|---|
| Claude Haiku 5.5 ($0.10 / $0.50) | About $35 |
| Claude Sonnet 5.5 ($2 / $10) | About $690 |
| Claude Opus 5.5 ($4 / $20) | About $1,380, before thinking tokens |
Caching the stable system prompt brings those figures down. Multiple calls per message (a classifier, a reply, a checker), long conversation histories and reasoning tokens push them up, often by several times. A naive pipeline can easily cost five to ten times the simple one. Measure your real usage from the API's usage fields rather than estimating.
For comparison, published SaaS pricing for messaging autopilot runs roughly $7 to $18 per listing per month, or $7,000 to $18,000 a month at 1,000 units. On model costs alone, building looks cheap. The true comparison is with the engineering time: building, monitoring, handling the edge cases, updating property data, and keeping up as models change. One experienced engineer for a year costs more than most vendor contracts at this size.
Where home-built systems stall
Teams that build their own tend to hit the same walls.
- The first 40% comes quickly. The next 30% doesn't. The easy questions fall fast. What remains is long-tail, multi-part and operational, and each fix tends to uncover another case.
- Problems that rotate rather than stop. You fix the early check-in case, and then "can we drop our bags?" breaks. Without a regression test set of real conversations, you can't tell whether a change helped overall.
- Stale data. A listing changes its parking arrangements, the property file doesn't, and the AI confidently gives last month's answer. Across hundreds of properties, keeping that file current becomes a job of its own. See why keeping the AI up to date is the real job at scale.
- The work after the reply. Answering is the smaller half. Checking the turnover, asking the right manager, waiting for the answer, telling the guest and updating the booking is where most of the time goes, and it's the hardest part to automate well.
- Channel quirks. Different channels, HTML in message bodies, inquiries that arrive without a conversation id on Guesty, webhook retries that create duplicate replies.
- Keeping current. New models arrive every few months. Each one changes behaviour and cost, and needs re-testing against your conversations.
If you'd rather compare what's available off the shelf, see our comparison of AI guest messaging software and why AI guest messaging goes wrong.
Sources
- Guesty Open API: authentication · documentation index
- Hostaway Public API: documentation
- Svix webhook verification: docs.svix.com
- Anthropic API pricing: docs.anthropic.com
- Airbnb off-platform policy: Airbnb Help Centre
- AI replies and the April 2026 prompt-injection case: 404 Media
- Hospitable auto-reply exclusions: Hospitable help