Guest support
An App can answer the guests of a ZenHost account on behalf of the host: it hears every guest message through App Events, reads the conversation, replies on the Sales Channel messaging, by email or by SMS, and closes the conversation once handled. This guide follows that loop end to end.
Permissions to ask for
Ask ZenHost for messages.read (read the conversations), messages.write (send, close and reopen) and, to answer practical questions, property_guide.read and lock_codes.read. Add bookings.read to know the stay dates and guests.read for the guest name, email and phone. Each Permission is independent: messages.write does not imply messages.read, and only guests.read exposes the guest contact details.
The reply loop
Subscribe to message.received and message.sent. The loop is message.received → GET /bookings/{booking_id}/messages → POST /bookings/{booking_id}/messages → POST /bookings/{booking_id}/message-thread/close: the event names the booking, you read the whole conversation, reply, then close it so it leaves the unread conversations of the host.
on webhook message.received (data.booking_id):
messages = every page of GET /api/v1/bookings/{booking_id}/messages
last = messages[-1]
if last.direction == "outbound":
stop # the host, Zenny or an automation already answered
reply = your_model.answer(messages, GET /api/v1/properties/{property_id}/guide)
if reply.needs_a_human:
stop # leave it unread for the host (reopen only if it was closed meanwhile)
POST /api/v1/bookings/{booking_id}/messages # Idempotency-Key: one per reply
{ "channel": last.channel, "text": reply.text }
POST /api/v1/bookings/{booking_id}/message-thread/close # out of the host's unread conversations
on webhook message.sent (data.id):
skip if data.id is already known # your own sends come back too
messages = GET /api/v1/bookings/{booking_id}/messages # the event carries no author
mark the conversation answered by the author.type of message data.idAn event is light: data carries the message id, booking_id and property_id, never the text. message.sent arrives for every outbound message, whoever wrote it, yours included. It carries no author: read the conversation and check author.type. When the host, Zenny or an automation answered first, stop working on that conversation.
Events can be missed. As a safety net, poll GET /message-threads with updated_since: a new message, a message read or unread in ZenHost, or a closed or reopened conversation brings the thread back. A thread with is_closed: false is one the host still has to read.
Read a conversation
GET /bookings/{booking_id}/messages lists the conversation oldest first. Each message gives its direction (inbound or outbound), its channel (platform, email or sms), its delivery_status and its author:
guest: the guest wrote it.host: the host or a team member, never named.zenny: written by Zenny, the ZenHost AI assistant.automation: a Scheduled Message of the host. Scheduled Messages not sent yet are not listed.app: an App, with its name inapp_name. That is how you recognise your own messages and those of another tool.
The author never carries the guest email or phone. Bookings without stay dates are readable too.
Download a photo from its attachments[].url with your access token. A file that is not a photo, or announced larger than 10 MB, answers not_found. Otherwise the file is streamed as it arrives, without Content-Length: a file that turns out larger than 10 MB on the way, or a transfer the Sales Channel breaks off, ends the connection before the body is complete, so treat a download that does not end normally as failed and retry later. attachment_unavailable means the Sales Channel could not hand the file over right now: retry later with a back-off.
Reply
POST /bookings/{booking_id}/messages sends the text right away, the way the ZenHost inbox does, and answers 201 with the message once sent. Reply on the channel the guest used: platform writes on the messaging of the Sales Channel, email (with an optional subject) and sms use the guest contact details ZenHost holds. A text holds up to 10,000 characters. The host sees each message marked as sent by your App.
curl -X POST https://app.zenhost.com/api/v1/bookings/BOOKING_ID/messages \
-H "Authorization: Bearer zh_at_..." \
-H "Idempotency-Key: reply-MESSAGE_ID" \
-H "Content-Type: application/json" \
-d '{ "channel": "platform", "text": "Hello! The Wi-Fi password is on the fridge." }'Send an Idempotency-Key header, one per reply: the same key within 24 hours answers what the first request did (the message, or the same refusal) instead of sending again, so retrying after a timeout never doubles your answer. While the first send is still in flight, it answers 409 idempotency_key_in_use: wait and retry. The same key with a different body answers idempotency_key_reused.
Refusals known before sending answer 422 and send nothing: channel_unavailable (the chosen channel cannot reach the guest, try another one: on platform, no messaging on the Sales Channel, no conversation there yet, or a connection the host must restore; on email, no sending address on the account), missing_guest_contact, sms_billing_unavailable. A failed send answers 502 delivery_failed: with details.delivery_outcome = not_sent, send again; with unknown, read the conversation before sending again.
Sends have their own rate limit: 30 messages per 60 seconds per App Connection.
Send photos
To attach photos, send the same fields as multipart/form-data with one photos part per photo: up to 5 photos (JPEG, PNG, WebP or AVIF, 10 MB each), on platform only; email and SMS answer validation_failed. In practice a whole call stays under about 4.5 MB (see below). The text stays required. The text and each photo reach the guest as separate messages: the answer is the text message, and the photo messages follow it in GET /bookings/{booking_id}/messages.
curl -X POST https://app.zenhost.com/api/v1/bookings/BOOKING_ID/messages \
-H "Authorization: Bearer zh_at_..." \
-H "Idempotency-Key: reply-MESSAGE_ID" \
-F channel=platform \
-F "text=Here is the key box, left of the door." \
-F "photos=@keybox.jpg;type=image/jpeg"SMS billing
Close and reopen
In ZenHost, a conversation is closed when all its messages are read. POST /bookings/{booking_id}/message-thread/close marks them read, like the close button of the inbox: the conversation leaves the unread conversations of the host. POST …/reopen marks them unread to hand a case over to the host. Both answer is_closed and unread_count, and replaying them changes nothing.
A booking without messages answers is_closed: true and unread_count: 0, for close and reopen alike: there is nothing for the host to read.
No App Event is sent: the change comes back through updated_since on GET /message-threads. A new guest message reopens the conversation on its own. Closing and reopening count against the write rate limit.
Zenny and your App
Zenny, the ZenHost AI assistant, may also answer the guests of the host. For Zenny, a message from your App is a reply of the host: a reply Zenny was preparing for this conversation is dropped, and a conversation Zenny handed over to the host is released. Your messages never become Zenny Knowledge.
Practical information and access codes
GET /properties/{id}/guide returns the Property Guide: address and GPS coordinates, contact, check-in and check-out times, Wi-Fi, rooms, amenities, website texts, the welcome booklet link, the custom Shortcodes of the host and their active Knowledge. An empty field is null, never omitted. Only active Knowledge is returned: suggested or rejected entries never leave ZenHost.
GET /bookings/{booking_id}/lock-codes returns one entry per Lock of the booking’s Property. A key box gives a static_code that does not change from one stay to the next: its valid_from and valid_until are null. A smart lock gives the newest lock_code of the booking that is not revoked; pending means the lock has not confirmed it yet. A Lock without a code for this booking is left out. A cancelled booking returns an empty list, and a booking without stay dates answers not_found.