# ZenHost Public API Read Properties, Restrictions and bookings, and write Restrictions, for the ZenHost accounts that connect your App. The ZenHost Public API lets software you build (a revenue management tool, a PMS, an in-house script) read the Properties, Restrictions and bookings of a ZenHost account, and write its Restrictions (rates, minimum stays, closed to arrival or departure, availability), and answer its guests (conversations, Property Guide, access codes), once the owner of that account has connected your App. ## Common tasks Bookings and Restrictions are filtered by Property: pass `property_id[]` once to work on a single Property, or repeat it to name up to 100 Properties in one call. - [Find the ids of your Properties GET `/api/v1/properties`](https://zenhost.com/developers/reference/list-properties) - [Get the bookings of one Property GET `/api/v1/bookings?property_id[]=PROPERTY_ID`](https://zenhost.com/developers/reference/list-bookings) - [Read a Property’s restrictions for a month GET `/api/v1/restrictions?property_id[]=PROPERTY_ID&from=2026-11-01&to=2026-11-30`](https://zenhost.com/developers/reference/list-restrictions) - [Push prices for one Property POST `/api/v1/restriction-batches` One entry per Property and date range:`{ "entries": [{ "property_id": "PROPERTY_ID", "from": "2026-11-01", "to": "2026-11-30", "values": { "rate": 129.5 } }] }`](https://zenhost.com/developers/guides/restriction-batches) ## Base URL and contract Base URL: `https://app.zenhost.com/api/v1`. Every call is JSON over HTTPS and carries an OAuth access token. The account comes from the token, never from the URL: an App serving several accounts holds one App Connection, and one set of tokens, per account. The complete contract is the OpenAPI 3.1 document at [`https://app.zenhost.com/api/v1/openapi.json`](https://app.zenhost.com/api/v1/openapi.json): generate your client from it. The API reference is rendered from the same document. ## Get an App There are no self-service API keys. ZenHost registers every App itself, so only software it has approved can ask for access to an account. To get one, [contact ZenHost](https://zenhost.com/contact) (or write to [support@zenhost.com](mailto:support@zenhost.com)) with: - the name, publisher, logo and a contact email for your App; - your exact redirect URIs (HTTPS; `http://localhost` is accepted for development); - the Permissions you need, and why; - optionally, an HTTPS App Webhook URL and the App Events you want to receive. ZenHost sends you a `client_id` and a `client_secret` (and a webhook signing secret). Secrets are shown once: store them in a secret manager. A new App is **private**: only the accounts ZenHost lists for it can connect it. ZenHost can later make it public, without touching existing connections. ## Permissions The owner accepts all the Permissions of your App, or none, and chooses which Properties it may act on (all of them, including future ones, or a list). Permissions are independent: a pricing tool needs both `restrictions.read` and `restrictions.write`. > **Note:** An App's permissions are set by ZenHost; changes apply to all existing connections immediately. | Permission | Allows | | --- | --- | | `properties.read` | Read the Properties | | `restrictions.read` | Read the Restrictions (rates, availability, rules) | | `restrictions.write` | Write the Restrictions (rates, availability, rules) | | `bookings.read` | Read the Bookings | | `guests.read` | Read the guest contact details of the Bookings | | `prices.read` | Read the amounts of the Bookings | | `messages.read` | Read the Message Threads and their messages | | `messages.write` | Send messages to guests and close or reopen conversations (SMS billed to the host) | | `property_guide.read` | Read the practical information of the Properties (Property Guide), Wi-Fi included | | `lock_codes.read` | Read the access codes of the Bookings (Lock Codes and Static Codes) | Source: https://zenhost.com/developers --- # Build with AI agents These docs are written to be read by coding agents (Claude Code, Codex, Cursor) and AI chats as much as by people. Point your agent at the sources below rather than letting it guess the API. ## Every page in Markdown Add `.md` to the URL of any page of these docs to get it as Markdown, without navigation or styling. The **Copy page** menu at the top of every page copies that Markdown, or opens the page in ChatGPT or Claude. **This page’s Markdown** ```bash curl https://zenhost.com/developers/ai-agents.md ``` ## llms.txt Following the llms.txt convention, [`https://zenhost.com/developers/llms.txt`](https://zenhost.com/developers/llms.txt) lists every page with a one-line summary and its Markdown URL. [`https://zenhost.com/developers/llms-full.txt`](https://zenhost.com/developers/llms-full.txt) holds the whole documentation, API reference included, in a single file: give it to an agent that should read everything before it writes code. **Index and full documentation** ``` https://zenhost.com/developers/llms.txt https://zenhost.com/developers/llms-full.txt ``` ## OpenAPI spec The OpenAPI 3.1 document at [`https://app.zenhost.com/api/v1/openapi.json`](https://app.zenhost.com/api/v1/openapi.json) is the machine-readable contract of every endpoint. Have your agent generate a typed client from it or check each request and response against it, instead of inferring shapes from examples. ## A prompt to start with Paste this into your agent at the start of an integration, then describe what you are building. **Starter prompt** ``` I am building an integration with the ZenHost Public API. Before writing any code, read the full documentation: https://zenhost.com/developers/llms-full.txt The OpenAPI spec is the source of truth for every request and response: https://app.zenhost.com/api/v1/openapi.json Follow the documented behaviour for authentication (OAuth 2.0 with PKCE and rotating refresh tokens), errors, rate limits and pagination. Write Restrictions through Restriction Batches. Read the client secret and tokens from environment variables, never from the code. ``` > **Warning:** **Check what the agent writes.** An agent can misread a rule or invent a field. Review its code against these pages and test it on an account you own before connecting a customer. Source: https://zenhost.com/developers/ai-agents --- # Connect an account (OAuth 2.0) ZenHost uses the OAuth 2.0 authorization code flow with PKCE (S256, mandatory). The owner consents once in the browser; your server then lives on the refresh token, for example in a nightly cron job, without anyone signing in again. ## 1. Send the owner to ZenHost Create a random `code_verifier` and a `state`, keep them server-side, and redirect the owner to the authorization URL. The `redirect_uri` must match one of your registered URIs exactly. Only the primary owner of an account can approve; they pick the account and the Properties your App may act on. **PKCE (Node.js)** ```javascript const crypto = require('node:crypto'); const codeVerifier = crypto.randomBytes(32).toString('base64url'); const codeChallenge = crypto .createHash('sha256') .update(codeVerifier) .digest('base64url'); // Keep codeVerifier and state server-side until the owner comes back. ``` **Authorization URL** ``` https://app.zenhost.com/oauth/authorize ?response_type=code &client_id=zh_ci_... &redirect_uri=https://your-app.example/oauth/callback &state=RANDOM_STATE &code_challenge=BASE64URL(SHA256(code_verifier)) &code_challenge_method=S256 ``` ## 2. Receive the code ZenHost redirects back with a `code` and your `state` unchanged (check it). The code is single-use and expires after 10 minutes. If the owner refuses, you receive `error=access_denied`. An invalid request (unknown client, unregistered redirect URI, missing PKCE) is never redirected to you: ZenHost shows the error to the owner. **Callback** ``` https://your-app.example/oauth/callback?code=...&state=RANDOM_STATE https://your-app.example/oauth/callback?error=access_denied&state=RANDOM_STATE ``` ## 3. Exchange it for tokens From your server, authenticate with your client secret, either in the body (`client_secret_post`) or with HTTP Basic (`client_secret_basic`). The body may be form-encoded or JSON. **POST /api/oauth/token** ```bash curl -X POST https://app.zenhost.com/api/oauth/token \ -d grant_type=authorization_code \ -d code=CODE_FROM_THE_CALLBACK \ -d redirect_uri=https://your-app.example/oauth/callback \ -d code_verifier=CODE_VERIFIER \ -d client_id=zh_ci_... \ -d client_secret=zh_cs_... ``` **200 OK** ```json { "access_token": "zh_at_...", "token_type": "Bearer", "expires_in": 3600, "refresh_token": "zh_rt_..." } ``` ## 4. Run unattended on the refresh token > **Warning:** Access tokens last 60 minutes. Refresh them with the `refresh_token` grant. Every refresh returns a **new refresh token and invalidates the old one**: save the new pair before using it. Presenting a refresh token that was already rotated is treated as theft and revokes the whole token family; the owner then has to connect your App again. This includes retrying a refresh whose response you lost: refresh from a single worker per account, and treat `invalid_grant` as “ask for a new consent”. A refresh token expires after 90 days without use, and each refresh starts a new 90-day period: a job that refreshes at least that often never needs a new consent. When an account connects your App again, its older tokens stop working. **Refresh** ```bash curl -X POST https://app.zenhost.com/api/oauth/token \ -u "zh_ci_...:zh_cs_..." \ -d grant_type=refresh_token \ -d refresh_token=zh_rt_... ``` **Nightly job (pseudo-code)** ```python # Runs every night, months after a single consent. tokens = store.load(account) # refresh_token saved at consent time if tokens.access_expired(): response = POST https://app.zenhost.com/api/oauth/token grant_type=refresh_token, refresh_token=tokens.refresh_token, client_id, client_secret if response.error == "invalid_grant": alert("Ask the owner of", account, "to connect the App again") stop store.save(account, response) # save BEFORE using: the old refresh token is now dead for page in every page of GET /api/v1/properties: # see "Every Property, 100 at a time" restrictions = GET /api/v1/restrictions?property_id[]=...&from=...&to=... batch = POST /api/v1/restriction-batches # with an Idempotency-Key per run and page ``` ## 5. Revoke Revoke a token with RFC 7009, authenticated like the token endpoint: once your client is authenticated the answer is always `200`, even for an unknown token. Revoking the last live refresh token of the current consent ends the App Connection, exactly as if the owner had revoked it: you receive `app_connection.revoked`, unsent Restriction changes are cancelled, and only a new consent brings the account back. Revoking an access token, or a refresh token from before the account connected again, only kills that token. **POST /api/oauth/revoke** ```bash curl -X POST https://app.zenhost.com/api/oauth/revoke \ -u "zh_ci_...:zh_cs_..." \ -d token=zh_rt_... ``` ## Token formats and OAuth errors Tokens are opaque and prefixed so secret scanners can spot a leak: `zh_at_` (access), `zh_rt_` (refresh), `zh_cs_` (client secret), `whsec_` (webhook signing secret). The token and revocation endpoints answer standard OAuth errors as `{ "error", "error_description" }`: | error | Meaning | | --- | --- | | `invalid_request` | A required parameter is missing, the body is neither form-encoded nor JSON, or the client authenticates twice (Basic and `client_secret`) or with a mismatched `client_id` (400). | | `invalid_client` | Unknown `client_id` or wrong `client_secret` (401). | | `invalid_grant` | The code or refresh token is unknown, expired, already used, issued to another App, or superseded by a newer consent; or `redirect_uri` / `code_verifier` does not match (400). Ask the owner to connect again. | | `unsupported_grant_type` | Only `authorization_code` and `refresh_token` are supported (400). | | `server_error` | ZenHost failed to answer. Retry later (500). | Source: https://zenhost.com/developers/authentication --- # Errors Every `/api/v1` error has the same JSON shape: a stable `code` to branch on, a human `message` that may change, and sometimes `details`. ## Error shape ```http HTTP/1.1 422 Unprocessable Entity { "error": { "code": "property_out_of_scope", "message": "Some Properties are outside the Connection Scope", "details": { "property_ids": ["PROPERTY_ID"] } } } ``` ## Error codes | Status | code | Meaning | | --- | --- | --- | | 401 | `unauthorized` | The access token is missing, unknown, expired or revoked, or was issued before the account connected the App again. Refresh it; if refreshing fails, ask the owner to connect again. | | 401 | `connection_revoked` | The owner revoked the App Connection (or your App revoked its own refresh token). Stop calling for this account and drop its tokens. | | 402 | `subscription_blocked` | The ZenHost subscription of the account is blocked (unpaid, cancelled…). The connection is kept: tell your customer, calls work again once the subscription is restored. | | 403 | `forbidden_permission` | Your App was not granted the Permission this endpoint requires. | | 403 | `app_suspended` | ZenHost suspended your App, or no longer allows it on this account. Stop calling and contact ZenHost. | | 404 | `not_found` | The resource does not exist or is outside the Connection Scope (both look the same), or the path is not an endpoint of `/api/v1`. | | 405 | `method_not_allowed` | The endpoint exists but not with this HTTP method; the `Allow` header lists the accepted ones. | | 409 | `idempotency_key_in_use` | A request with this `Idempotency-Key` is still being processed. Retry in a few seconds to get its answer. | | 422 | `property_out_of_scope` | A requested Property is outside the Connection Scope; `details.property_ids` lists them. | | 422 | `validation_failed` | The query, headers or body are invalid; `details.issues` points at each problem (`path`, `message`, and `maximum` when a value or list is too big). | | 422 | `idempotency_key_reused` | This `Idempotency-Key` was already used on this App Connection with a different body. | | 422 | `channel_unavailable` | The chosen channel cannot reach the guest of this booking. On `platform`: the Sales Channel has no messaging for this booking, no conversation exists there yet, or the host must restore their connection to that Sales Channel. On `email`: the account has no sending address. Nothing was sent; try another channel. | | 422 | `missing_guest_contact` | The guest of this booking has no email address (for `email`) or phone number (for `sms`). Nothing was sent. | | 422 | `sms_billing_unavailable` | The ZenHost subscription of the account cannot be billed for SMS. Nothing was sent and nothing was billed; use another channel. | | 429 | `rate_limited` | Too many calls on this App Connection. Wait for the number of seconds in `Retry-After`. | | 500 | `internal_error` | ZenHost failed to answer. Retry later with a back-off. | | 502 | `attachment_unavailable` | The Sales Channel could not hand over the attachment right now. Retry later with a back-off. | | 502 | `delivery_failed` | The message could not be delivered. `details.delivery_outcome` is `not_sent` when it certainly did not leave (safe to send again) or `unknown` when its fate is not known (read the messages of the booking before sending again). | Source: https://zenhost.com/developers/errors --- # Rate limits ## Calls per App Connection Limits apply per App Connection, that is per account your App serves: 60 reads (`GET`) per 60 seconds, 10 writes (`POST`: Restriction Batch submissions, `dry_run` included, and Message Thread closes and reopens) per 60 seconds, and 30 messages sent to guests (`POST /bookings/{booking_id}/messages`) per 60 seconds. Past the limit, ZenHost answers `429` with a `Retry-After` header in seconds. Every call with a valid token counts, including calls refused with `forbidden_permission` or `validation_failed`. Prefer few large requests (up to 100 Properties per call, long date ranges, big batches) over many small ones. ```http HTTP/1.1 429 Too Many Requests Retry-After: 4 { "error": { "code": "rate_limited", "message": "...", "details": { "retry_after_seconds": 4 } } } ``` ## Sending to the Sales Channels Changes then reach the Sales Channels through a queue per Property that respects their own limits (10 rate and restriction updates and 10 availability updates per minute and Property): all the changes waiting for a Property leave together, a few seconds apart from the previous send, so a large batch takes a little while to be fully applied. When the Sales Channels are unavailable, the Property pauses 60 seconds and retries with growing delays for about 15 minutes; after 5 failed attempts, the remaining changes fail with `sales_channel_unavailable`. Source: https://zenhost.com/developers/rate-limits --- # Every Property, 100 at a time ## At most 100 Properties per call One call names at most 100 Properties: the `property_id[]` of `GET /restrictions` and `GET /bookings`, and the distinct Properties of a Restriction Batch. Past that, the call answers `422 validation_failed` and the issue carries `maximum: 100`. ## Walk through every Property `GET /properties` is paged so you can always reach the whole Connection Scope: pages hold up to 100 Properties (`limit`, 100 by default), ordered by `id`. Pass `next_cursor` as `cursor` until it is `null`. A walk through the pages never repeats a Property, and returns every Property present for the whole walk, even while Properties are renamed. Then make one call per page: read the Restrictions of the page, and send one Restriction Batch per page. `GET /bookings` needs no such loop: leave `property_id[]` out to page through every booking of the Connection Scope. **Every Property (pseudo-code)** ```python cursor = null repeat: page = GET /api/v1/properties?limit=100&cursor={cursor} # omit cursor on the first call ids = [property.id for property in page.data] # at most 100 GET /api/v1/restrictions?property_id[]={ids[0]}&property_id[]={ids[1]}...&from=...&to=... POST /api/v1/restriction-batches # entries for these ids only cursor = page.next_cursor until cursor is null ``` Source: https://zenhost.com/developers/pagination --- # Versioning Version 1 only ever gains things: new endpoints, new optional parameters, new response fields, new enum values and error codes. No field or endpoint is removed or changes meaning. Ignore fields you do not know, and handle an unknown enum value or error code generically. A breaking change would ship as `/api/v2`. Source: https://zenhost.com/developers/versioning --- # Write Restrictions Restrictions are written in **Restriction Batches**: one call covers many Properties and date ranges, ZenHost answers at once, then sends the changes to the Sales Channels at their pace. The rate you write is the base rate of the Property: its Property Pricing Modifiers (for example +15 % on one Sales Channel) still apply on top. A batch never sets a Manual Override, so you can push your whole calendar every day. ## 1. Read the calendar first `GET /restrictions` returns, per Property and date (up to 100 Properties and 366 days per call), the current values and `unwritable_fields`: the fields a batch will refuse on that date, and why. Skip them instead of discovering refusals afterwards. ```bash curl -G https://app.zenhost.com/api/v1/restrictions \ -H "Authorization: Bearer zh_at_..." \ -d "property_id[]=PROPERTY_ID" \ -d from=2026-11-01 \ -d to=2026-11-30 ``` | Unwritable reason | Meaning | | --- | --- | | `manual_override` | The host froze this field on this date in ZenHost. Your value is ignored until the host lifts the Manual Override. | | `managed_by_another_pricing_tool` | The Property is priced by another pricing tool, which owns `rate`, `min_stay`, `closed_to_arrival`, and `closed_to_departure`. Only `availability` is writable. | | `blocked_by_linked_property` | The night is closed because a linked Property (for example the whole house this room belongs to) is booked or closed. `availability` is not writable on that night. | ## One Property, one pricing tool > **Note:** A Property is priced either by another pricing tool connected in ZenHost or by your App, never both, so two engines never overwrite each other silently. On a Property priced by another tool, a batch may only change `availability`; `rate`, `min_stay`, `closed_to_arrival`, and `closed_to_departure` are refused with `managed_by_another_pricing_tool`. To hand pricing over to your App, the host unlinks the Property from the other tool in ZenHost. ## 2. Try it with dry_run With `"dry_run": true`, ZenHost answers `200` with what would change on each Property (current and new value per date and field, and the refused fields), without storing or sending anything. Test your script against the real calendar this way. A dry run still counts against the submission limit of 10 per 60 seconds. ```bash curl -X POST https://app.zenhost.com/api/v1/restriction-batches \ -H "Authorization: Bearer zh_at_..." \ -H "Content-Type: application/json" \ -d '{ "dry_run": true, "entries": [ { "property_id": "PROPERTY_ID", "from": "2026-11-01", "to": "2026-11-30", "values": { "rate": 129.5, "min_stay": 2 } } ] }' ``` ## 3. Submit, idempotently Without `dry_run`, ZenHost answers `202` with the batch id. Send an `Idempotency-Key` header (one per logical run): replaying the same key on the same App Connection within 24 hours returns the batch it created instead of a second one, so a retry after a timeout is safe. The same key with a different body answers `idempotency_key_reused`. Limits: up to 20,000 entries over 100 Properties and 200,000 date × field changes per batch (one batch per page of `GET /properties` covers any account); dates from today to today + 730 days, both read in the time zone of each Property. A malformed body, a date outside that range or a Property outside your Connection Scope rejects the whole batch with `422`; nothing is stored. ```bash curl -X POST https://app.zenhost.com/api/v1/restriction-batches \ -H "Authorization: Bearer zh_at_..." \ -H "Content-Type: application/json" \ -H "Idempotency-Key: nightly-2026-10-10" \ -d '{ "entries": [ ... ] }' # 202 Accepted { "id": "BATCH_ID", "status": "queued" } ``` ## 4. Read the outcome Read `GET /restriction-batches/{id}`, or wait for the `restriction_batch.completed` App Event instead of polling. The batch moves from `queued` and `processing` to a final status: `completed`, `partially_failed`, `failed` or `cancelled`. For each Property, the report counts the changes applied, unchanged, superseded, refused, failed and cancelled, and lists refused fields and failures as runs of dates. Reports are kept 30 days. - Properties succeed or fail on their own: a refused Property never blocks the others, and nothing is rolled back. - Batches apply in submission order: on the same date and field, the latest batch wins and the older change, if not sent yet, is `superseded` (not a failure). - A value equal to the current one is `unchanged` and is not sent again: pushing an identical calendar every day costs nothing on the Sales Channels. - A field the host froze after you submitted is still refused at send time with `manual_override`. - If the owner revokes the connection (or you revoke your last refresh token), ZenHost suspends your App, or the account is no longer allowed on your Private App, changes not sent yet are `cancelled` at once and are never replayed, even if the App is reactivated. The same happens to the changes of a Property the owner removes from your Property Access (or leaves out when connecting again), and to all of them once your App no longer holds `restrictions.write`: the Connection Scope is checked again when sending. A change already on its way to the Sales Channels completes. ```bash curl https://app.zenhost.com/api/v1/restriction-batches/BATCH_ID \ -H "Authorization: Bearer zh_at_..." ``` Source: https://zenhost.com/developers/guides/restriction-batches --- # App Events and webhooks ZenHost can POST App Events to your App Webhook, a single HTTPS URL for every account connected to your App. ZenHost sets the URL and the events you subscribe to when it registers your App. An event is light: it says what changed and where, never the data. Read the current state through the API, within your Connection Scope. ## Event types | Event | Sent when | | --- | --- | | `app_connection.created` | An account connected your App, or connected it again (older tokens of that account stop working). | | `app_connection.revoked` | The owner revoked the App Connection, or your App revoked its last refresh token. Delivered even though the connection is no longer active, unless ZenHost has paused your App on that account. | | `booking.created` | A booking appeared on a Property of your Property Access, whatever its origin (Sales Channel, entered by the host, direct website, import). A booking that arrives already cancelled sends only this event: read its `status`. Needs `bookings.read`. | | `booking.updated` | What `GET /bookings` returns for a booking changed (dates, Property, occupancy, Sales Channel reference, reinstatement after a cancellation). Guest contact changes are sent only with `guests.read`, amount changes only with `prices.read`. An identical rewrite or a change to fields the API does not expose sends nothing. Needs `bookings.read`. | | `booking.cancelled` | A booking of your Property Access was cancelled. Needs `bookings.read`. | | `restriction_batch.completed` | A Restriction Batch of this App Connection reached a final status. Read its outcome with `GET /restriction-batches/{id}`. | | `message.received` | A guest wrote on a booking of your Property Access, whatever the channel (Sales Channel messaging, email, SMS). `data` also carries `booking_id` and `property_id`: read the message with `GET /bookings/{booking_id}/messages`. Needs `messages.read`. | | `message.sent` | An outbound message to a guest was sent, whoever wrote it (the host, Zenny, an automation, another App or your own App). Your own sends come back too. The event carries no author: deduplicate by message `id`, then read the message and check its `author.type` (`app`, with your `app_name`, for yours). Sent when the message is sent, never for an edit, a read or a delivery status change. `data` also carries `booking_id` and `property_id`. Needs `messages.read`. | Booking events only reach connections that hold `bookings.read` and whose Property Access covers the booking’s Property; message events, those that hold `messages.read` under the same Property Access. A revoked connection receives nothing but `app_connection.revoked`. While ZenHost suspends your App, or no longer allows it on an account, nothing at all is delivered for the connections concerned, revocations included, and those events are dropped rather than sent later. ## Webhook URL requirements - Your webhook URL must use https on the default port 443 and a public domain name. IP addresses, localhost and private names (.local, .internal) are refused. - ZenHost resolves your domain before every delivery and only calls public IP addresses. If it resolves to a private, loopback, link-local or otherwise reserved address, the event is not sent and is not retried. - Redirects are not followed: answer the webhook URL itself with a 2xx status. > **Note:** For local development, expose your receiver through a public https tunnel. ## Payload ```http POST https://your-app.example/zenhost/webhooks webhook-id: 6f1c2a9e-... webhook-timestamp: 1791676800 webhook-signature: v1,K5oZfzN95Z9UVu1EsfQmfVNQhnkZ2pj9o9NDN/H/pI4= content-type: application/json { "id": "6f1c2a9e-...", "type": "booking.created", "created_at": "2026-10-10T08:00:00.000Z", "connection_id": "CONNECTION_ID", "account_id": "ACCOUNT_ID", "data": { "object": "booking", "id": "BOOKING_ID" } } ``` ## Verify the signature Deliveries are signed with the [Standard Webhooks](https://www.standardwebhooks.com/) scheme, so any Standard Webhooks library verifies them with your `whsec_` secret. By hand: the signature is `v1,` followed by the base64 HMAC-SHA256 of `{webhook-id}.{webhook-timestamp}.{raw body}`, keyed with the base64-decoded part of the secret after `whsec_`. Compute it on the raw body, compare in constant time, accept any of the space-separated signatures, and reject timestamps more than a few minutes old. **Node.js, no dependency** ```javascript const crypto = require('node:crypto'); const TOLERANCE_SECONDS = 5 * 60; // rawBody: the request body exactly as received (string), before any JSON parsing. function verifyWebhook(rawBody, headers, secret) { const id = headers['webhook-id']; const timestamp = headers['webhook-timestamp']; const signatures = headers['webhook-signature']; if (!id || !timestamp || !signatures) return false; if (Math.abs(Date.now() / 1000 - Number(timestamp)) > TOLERANCE_SECONDS) { return false; } const key = Buffer.from(secret.replace(/^whsec_/, ''), 'base64'); const expected = crypto .createHmac('sha256', key) .update(`${id}.${timestamp}.${rawBody}`) .digest(); return signatures.split(' ').some((entry) => { const [version, value] = entry.split(','); if (version !== 'v1' || !value) return false; const received = Buffer.from(value, 'base64'); return ( received.length === expected.length && crypto.timingSafeEqual(received, expected) ); }); } ``` ## Respond, retries and deduplication - Answer with a `2xx` within 10 seconds, then do the work asynchronously. Anything else, a timeout or a redirect (not followed) is a failure. - A failed delivery is retried after 1 min, 5 min, 30 min, 2 h, 5 h, 8 h, and 8 h: 8 attempts over about 24 hours, then it is dropped. - The same event can arrive more than once and events can arrive out of order: up to 10 deliveries of your App are in flight at once, and a retried event lands after newer ones. Deduplicate on `webhook-id` (equal to the payload `id`), and always re-read the resource rather than trusting the order. - Each attempt checks the Connection Scope again: an event your App may no longer hear (event type unsubscribed, Permission withdrawn, Property removed from the Property Access) is dropped, not retried. So is a message event whose message no longer exists. ## Polling as a safety net Webhooks can be missed (your endpoint down for more than a day, a suspension). Keep a periodic incremental sync of bookings: read every page of `GET /bookings` with `updated_since`, upsert by `id`, and keep the `next_updated_since` of the last page for the next run. Cancelled bookings stay listed with their status. Poll without `from`/`to`, or a booking whose dates leave the range is never reported again. A booking removed from ZenHost (a Sales Channel disconnected from a Property, a deleted Property) sends no event and leaves the listing: list the stay range again to reconcile. **Incremental sync (pseudo-code)** ```python cursor = null since = store.load("next_updated_since") # null on the first run repeat: page = GET /api/v1/bookings?updated_since={since}&cursor={cursor}&limit=500 upsert every booking of page.data by id # cancelled ones included cursor = page.next_cursor until cursor is null store.save("next_updated_since", page.next_updated_since) ``` Source: https://zenhost.com/developers/guides/app-events --- # 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. > **Warning:** `messages.read` exposes every message text as written. It holds whatever the guest or the host typed, guest personal data included, and the codes or Wi-Fi details sent by an automation (a Scheduled Message that inserted a door code, for instance), even without `lock_codes.read` or `property_guide.read`. Store and log the texts accordingly. ## 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. **Reply loop (pseudo-code)** ```python 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.id ``` An 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 in `app_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. ```bash 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`. ```bash 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" ``` > **Warning:** The hosting platform refuses any request body above 4.5 MB, whatever the limits above: the call then fails with `413` and a body that is not the JSON error of the API. Send fewer or smaller photos per call, for instance one compressed photo per reply. ## SMS billing > **Warning:** SMS are billed to the host: €0.10 per segment, on their ZenHost subscription. Prefer `platform` or `email`, and keep `sms` for urgent cases. When the subscription cannot be billed, the call answers `sms_billing_unavailable`: nothing is sent and nothing is billed. ## 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. > **Warning:** In Auto mode, Zenny and your App can both reply if they answer at the same moment, and the guest then gets two messages. Read the conversation just before sending; on `message.sent`, read the conversation (`GET /bookings/{booking_id}/messages`) and check `author.type` for a `zenny` reply; and agree with the host on who answers which Properties. ## 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`. > **Note:** ZenHost does not restrict when you read a code. Hand it to the guest only from the arrival day to the departure day, as Zenny does, and never to anyone who is not the guest of the booking. Source: https://zenhost.com/developers/guides/guest-support --- # API reference ZenHost Public API 1.0.0, rendered from [openapi.json](https://app.zenhost.com/api/v1/openapi.json). Every endpoint but the spec itself needs `Authorization: Bearer zh_at_...` and the Permission shown. ## Authentication Send the access token of the App Connection in the `Authorization` header of every call. The base URL is `https://app.zenhost.com/api/v1`. ```http GET /api/v1/properties Authorization: Bearer zh_at_... ``` ## Endpoints ### Properties - [GET `/properties` List the Properties of the Connection Scope](https://zenhost.com/developers/reference/list-properties) - [GET `/properties/{id}` Get one Property of the Connection Scope](https://zenhost.com/developers/reference/get-property) - [GET `/properties/{id}/guide` Get the Property Guide of a Property](https://zenhost.com/developers/reference/get-property-guide) ### Restrictions - [GET `/restrictions` Read the Restrictions of Properties over a date range](https://zenhost.com/developers/reference/list-restrictions) - [POST `/restriction-batches` Write Restrictions of Properties over date ranges](https://zenhost.com/developers/reference/submit-restriction-batch) - [GET `/restriction-batches/{id}` Read the outcome of a Restriction Batch](https://zenhost.com/developers/reference/get-restriction-batch) ### Bookings - [GET `/bookings` List the bookings of the Connection Scope](https://zenhost.com/developers/reference/list-bookings) - [GET `/bookings/{booking_id}/lock-codes` Get the access codes of a booking](https://zenhost.com/developers/reference/get-booking-lock-codes) ### Messages - [GET `/message-threads` List the Message Threads of the Connection Scope](https://zenhost.com/developers/reference/list-message-threads) - [POST `/bookings/{booking_id}/message-thread/close` Close the conversation of a booking](https://zenhost.com/developers/reference/close-message-thread) - [POST `/bookings/{booking_id}/message-thread/reopen` Reopen the conversation of a booking](https://zenhost.com/developers/reference/reopen-message-thread) - [GET `/bookings/{booking_id}/messages` List the messages of a booking](https://zenhost.com/developers/reference/list-booking-messages) - [POST `/bookings/{booking_id}/messages` Send a message to the guest of a booking](https://zenhost.com/developers/reference/send-booking-message) - [GET `/bookings/{booking_id}/messages/{message_id}/attachments/{index}` Download an attachment of a message](https://zenhost.com/developers/reference/get-message-attachment) ### Specification - [GET `/openapi.json` This OpenAPI document](https://zenhost.com/developers/reference/get-open-api-document) Source: https://zenhost.com/developers/reference --- # List the Properties of the Connection Scope GET `/api/v1/properties` Requires `properties.read` Every Property the App Connection may act on, with its currency, time zone and Property Pricing Modifiers per Sales Channel, ordered by `id` and paged with `next_cursor`. A walk through the pages never repeats a Property, and returns every Property present for the whole walk, even while Properties are renamed. `GET /restrictions` and Restriction Batches take at most 100 Properties per call: read every page (up to 100 Properties each), then make one call per page. **Request (cURL)** ```bash curl https://app.zenhost.com/api/v1/properties \ -H "Authorization: Bearer zh_at_..." ``` **Request (JavaScript)** ```javascript const response = await fetch('https://app.zenhost.com/api/v1/properties', { headers: { Authorization: 'Bearer zh_at_...', }, }); const data = await response.json(); ``` **Request (Python)** ```python import requests response = requests.get( "https://app.zenhost.com/api/v1/properties", headers={"Authorization": "Bearer zh_at_..."}, ) data = response.json() ``` **Response (200)** ```json { "data": [ { "id": "3f6c1a52-8d0e-4b7a-9f21-5c4e8b7d2a10", "name": "Sea view apartment", "currency": "EUR", "timezone": "Europe/Paris", "pricing_modifiers": [ { "sales_channel": "ABB", "name": "Airbnb", "percent": 15 } ] } ], "next_cursor": null } ``` **Response (401)** ```json { "error": { "code": "unauthorized", "message": "A human-readable explanation", "details": {} } } ``` **Response (402)** ```json { "error": { "code": "subscription_blocked", "message": "A human-readable explanation", "details": {} } } ``` **Response (403)** ```json { "error": { "code": "app_suspended", "message": "A human-readable explanation", "details": {} } } ``` **Response (422)** ```json { "error": { "code": "validation_failed", "message": "A human-readable explanation", "details": {} } } ``` **Response (429)** ```json { "error": { "code": "rate_limited", "message": "A human-readable explanation", "details": {} } } ``` **Response (500)** ```json { "error": { "code": "internal_error", "message": "A human-readable explanation", "details": {} } } ``` ## Parameters ### Query parameters - `cursor` (string): `next_cursor` of the previous page. - `limit` (integer): Page size. A full page is exactly what `GET /restrictions` and a Restriction Batch accept in one call. min 1 · max 100 · default 100 ## Responses ### 200 One page of the Properties of the Connection Scope. application/json PropertyList - `data` (Property[], required): One page of Properties, ordered by `id`. - `id` (string (uuid), required) - `name` (string, required) - `currency` (string, required): ISO 4217 code of every amount of the property. - `timezone` (string, required): IANA time zone of the property dates. - `pricing_modifiers` (PricingModifier[], required) - `sales_channel` (string, required): `website` for the direct website, else the Sales Channel shortcode (e.g. `ABB` for Airbnb, `BDC` for Booking.com). - `name` (string, required): Channel Display Name. - `percent` (number, required): Applied on top of the base rate on this Sales Channel, e.g. 15 for +15 %. Channels without a modifier are omitted. - `next_cursor` (string | null, required): Pass it as `cursor` to read the next page; `null` on the last page. ### 401 Error code: `unauthorized`, `connection_revoked`. Error codes - `unauthorized` The access token is missing, unknown, expired or revoked, or was issued before the account connected the App again. Refresh it; if refreshing fails, ask the owner to connect again. - `connection_revoked` The owner revoked the App Connection (or your App revoked its own refresh token). Stop calling for this account and drop its tokens. ### 402 Error code: `subscription_blocked`. Error codes - `subscription_blocked` The ZenHost subscription of the account is blocked (unpaid, cancelled…). The connection is kept: tell your customer, calls work again once the subscription is restored. ### 403 Error code: `app_suspended`, `forbidden_permission`. Error codes - `app_suspended` ZenHost suspended your App, or no longer allows it on this account. Stop calling and contact ZenHost. - `forbidden_permission` Your App was not granted the Permission this endpoint requires. ### 422 Error code: `validation_failed`. Error codes - `validation_failed` The query, headers or body are invalid; `details.issues` points at each problem (`path`, `message`, and `maximum` when a value or list is too big). ### 429 Error code: `rate_limited`. Header `Retry-After` — Seconds to wait before the next call is allowed. Limits per App Connection: 60 reads (GET), 10 writes (other POST) and 30 messages sent to guests per minute. Error codes - `rate_limited` Too many calls on this App Connection. Wait for the number of seconds in `Retry-After`. ### 500 Error code: `internal_error`. Error codes - `internal_error` ZenHost failed to answer. Retry later with a back-off. [Every error code and what to do](https://zenhost.com/developers/errors) Source: https://zenhost.com/developers/reference/list-properties --- # Get one Property of the Connection Scope GET `/api/v1/properties/{id}` Requires `properties.read` A Property outside the Connection Scope answers `not_found`, exactly like a missing one. **Request (cURL)** ```bash curl https://app.zenhost.com/api/v1/properties/7d1e4c09-2b5a-4e83-a6f0-91c8d3b2e4f7 \ -H "Authorization: Bearer zh_at_..." ``` **Request (JavaScript)** ```javascript const response = await fetch('https://app.zenhost.com/api/v1/properties/7d1e4c09-2b5a-4e83-a6f0-91c8d3b2e4f7', { headers: { Authorization: 'Bearer zh_at_...', }, }); const data = await response.json(); ``` **Request (Python)** ```python import requests response = requests.get( "https://app.zenhost.com/api/v1/properties/7d1e4c09-2b5a-4e83-a6f0-91c8d3b2e4f7", headers={"Authorization": "Bearer zh_at_..."}, ) data = response.json() ``` **Response (200)** ```json { "id": "3f6c1a52-8d0e-4b7a-9f21-5c4e8b7d2a10", "name": "Sea view apartment", "currency": "EUR", "timezone": "Europe/Paris", "pricing_modifiers": [ { "sales_channel": "ABB", "name": "Airbnb", "percent": 15 } ] } ``` **Response (401)** ```json { "error": { "code": "unauthorized", "message": "A human-readable explanation", "details": {} } } ``` **Response (402)** ```json { "error": { "code": "subscription_blocked", "message": "A human-readable explanation", "details": {} } } ``` **Response (403)** ```json { "error": { "code": "app_suspended", "message": "A human-readable explanation", "details": {} } } ``` **Response (404)** ```json { "error": { "code": "not_found", "message": "A human-readable explanation", "details": {} } } ``` **Response (429)** ```json { "error": { "code": "rate_limited", "message": "A human-readable explanation", "details": {} } } ``` **Response (500)** ```json { "error": { "code": "internal_error", "message": "A human-readable explanation", "details": {} } } ``` ## Parameters ### Path parameters - `id` (string (uuid), required): Property id. ## Responses ### 200 The Property. application/json Property - `id` (string (uuid), required) - `name` (string, required) - `currency` (string, required): ISO 4217 code of every amount of the property. - `timezone` (string, required): IANA time zone of the property dates. - `pricing_modifiers` (PricingModifier[], required) - `sales_channel` (string, required): `website` for the direct website, else the Sales Channel shortcode (e.g. `ABB` for Airbnb, `BDC` for Booking.com). - `name` (string, required): Channel Display Name. - `percent` (number, required): Applied on top of the base rate on this Sales Channel, e.g. 15 for +15 %. Channels without a modifier are omitted. ### 401 Error code: `unauthorized`, `connection_revoked`. Error codes - `unauthorized` The access token is missing, unknown, expired or revoked, or was issued before the account connected the App again. Refresh it; if refreshing fails, ask the owner to connect again. - `connection_revoked` The owner revoked the App Connection (or your App revoked its own refresh token). Stop calling for this account and drop its tokens. ### 402 Error code: `subscription_blocked`. Error codes - `subscription_blocked` The ZenHost subscription of the account is blocked (unpaid, cancelled…). The connection is kept: tell your customer, calls work again once the subscription is restored. ### 403 Error code: `app_suspended`, `forbidden_permission`. Error codes - `app_suspended` ZenHost suspended your App, or no longer allows it on this account. Stop calling and contact ZenHost. - `forbidden_permission` Your App was not granted the Permission this endpoint requires. ### 404 Error code: `not_found`. Error codes - `not_found` The resource does not exist or is outside the Connection Scope (both look the same), or the path is not an endpoint of `/api/v1`. ### 429 Error code: `rate_limited`. Header `Retry-After` — Seconds to wait before the next call is allowed. Limits per App Connection: 60 reads (GET), 10 writes (other POST) and 30 messages sent to guests per minute. Error codes - `rate_limited` Too many calls on this App Connection. Wait for the number of seconds in `Retry-After`. ### 500 Error code: `internal_error`. Error codes - `internal_error` ZenHost failed to answer. Retry later with a back-off. [Every error code and what to do](https://zenhost.com/developers/errors) Source: https://zenhost.com/developers/reference/get-property --- # Get the Property Guide of a Property GET `/api/v1/properties/{id}/guide` Requires `property_guide.read` The practical information a guest asks about: address and GPS coordinates, contact details, check-in and check-out times, Wi-Fi, bedrooms, beds and bathrooms, amenities, website texts, welcome booklet, the custom Shortcodes of the host that apply to the Property (with their value per language) and the active Knowledge of the account and of the Property. An empty field is `null`, never omitted. A Property outside the Connection Scope answers `not_found`, exactly like a missing one. **Request (cURL)** ```bash curl https://app.zenhost.com/api/v1/properties/7d1e4c09-2b5a-4e83-a6f0-91c8d3b2e4f7/guide \ -H "Authorization: Bearer zh_at_..." ``` **Request (JavaScript)** ```javascript const response = await fetch('https://app.zenhost.com/api/v1/properties/7d1e4c09-2b5a-4e83-a6f0-91c8d3b2e4f7/guide', { headers: { Authorization: 'Bearer zh_at_...', }, }); const data = await response.json(); ``` **Request (Python)** ```python import requests response = requests.get( "https://app.zenhost.com/api/v1/properties/7d1e4c09-2b5a-4e83-a6f0-91c8d3b2e4f7/guide", headers={"Authorization": "Bearer zh_at_..."}, ) data = response.json() ``` **Response (200)** ```json { "property_id": "3f6c1a52-8d0e-4b7a-9f21-5c4e8b7d2a10", "name": "string", "address": { "street": "string", "postal_code": "string", "city": "string", "state": "string", "country": "string", "formatted": "string" }, "coordinates": { "latitude": 1, "longitude": 1 }, "contact": { "phone": "+33612345678", "email": "camille@example.com", "website": "string" }, "check_in_time": "string", "check_out_time": "string", "wifi": { "network_name": "string", "password": "string" }, "rooms": { "bedrooms": 1, "beds": 1, "bathrooms": 1 }, "amenities": [ { "id": "string", "name": "string" } ], "website_texts": [ { "language": "string", "description": "string", "important_information": "string", "good_to_know": "string" } ], "welcome_booklet_url": "string", "shortcodes": [ { "shortcode": "string", "name": "string", "description": "string", "values": [ { "language": "string", "value": "string" } ] } ], "knowledge": [ { "theme": "string", "kind": "fact", "content": "string" } ] } ``` **Response (401)** ```json { "error": { "code": "unauthorized", "message": "A human-readable explanation", "details": {} } } ``` **Response (402)** ```json { "error": { "code": "subscription_blocked", "message": "A human-readable explanation", "details": {} } } ``` **Response (403)** ```json { "error": { "code": "app_suspended", "message": "A human-readable explanation", "details": {} } } ``` **Response (404)** ```json { "error": { "code": "not_found", "message": "A human-readable explanation", "details": {} } } ``` **Response (429)** ```json { "error": { "code": "rate_limited", "message": "A human-readable explanation", "details": {} } } ``` **Response (500)** ```json { "error": { "code": "internal_error", "message": "A human-readable explanation", "details": {} } } ``` ## Parameters ### Path parameters - `id` (string (uuid), required): Property id. ## Responses ### 200 The Property Guide. application/json PropertyGuide - `property_id` (string (uuid), required) - `name` (string, required) - `address` (PropertyGuideAddress, required): Address of the Property. - `street` (string | null, required): Street address. - `postal_code` (string | null, required): Postal code. - `city` (string | null, required): City. - `state` (string | null, required): State or region. - `country` (string | null, required): Country, as entered by the host (usually an ISO 3166-1 alpha-2 code). - `formatted` (string | null, required): The full address on one line. - `coordinates` (object, required): GPS coordinates (WGS 84). - `latitude` (number | null, required) - `longitude` (number | null, required) - `contact` (object, required): Contact details of the Property. - `phone` (string | null, required): Phone number of the Property. - `email` (string | null, required): Email address of the Property. - `website` (string | null, required): Website of the Property. - `check_in_time` (string | null, required): Check-in time, `HH:MM` in the time zone of the Property. - `check_out_time` (string | null, required): Check-out time, `HH:MM` in the time zone of the Property. - `wifi` (object, required): Wi-Fi of the Property. - `network_name` (string | null, required): Wi-Fi network name. - `password` (string | null, required): Wi-Fi password. - `rooms` (object, required): Number of bedrooms, beds and bathrooms. - `bedrooms` (integer | null, required) - `beds` (integer | null, required) - `bathrooms` (number | null, required): May be fractional, e.g. `1.5`. - `amenities` (PropertyGuideAmenity[], required) - `id` (string, required): Stable amenity id. - `name` (string, required): Amenity name, in English. - `website_texts` (PropertyGuideWebsiteTexts[], required): Website texts of the Property, one entry per language. - `language` (string | null, required): ISO 639-1 language of the texts; `null` for the texts of a Property without a translated website. - `description` (string | null, required): Description of the Property. - `important_information` (string | null, required): Important information for guests. - `good_to_know` (string | null, required): Good to know. - `welcome_booklet_url` (string | null, required): Link to the welcome booklet of the Property. - `shortcodes` (PropertyGuideShortcode[], required) - `shortcode` (string, required): The Shortcode as the host writes it in messages, e.g. `{{custom.parking}}`. - `name` (string, required) - `description` (string | null, required) - `values` (object[], required): The value for this Property, per language. - `language` (string, required): ISO 639-1 language. - `value` (string, required) - `knowledge` (PropertyGuideKnowledge[], required): Account entries first, then the entries of this Property. - `theme` (string, required): One of `arrival`, `departure`, `parking`, `amenities`, `rules`, `neighbourhood`, `housekeeping`, `services`, `other`. - `kind` (string, required): `fact`: something true about the stay. `instruction`: how the host wants guests to be answered. One of: `fact`, `instruction` - `content` (string, required) ### 401 Error code: `unauthorized`, `connection_revoked`. Error codes - `unauthorized` The access token is missing, unknown, expired or revoked, or was issued before the account connected the App again. Refresh it; if refreshing fails, ask the owner to connect again. - `connection_revoked` The owner revoked the App Connection (or your App revoked its own refresh token). Stop calling for this account and drop its tokens. ### 402 Error code: `subscription_blocked`. Error codes - `subscription_blocked` The ZenHost subscription of the account is blocked (unpaid, cancelled…). The connection is kept: tell your customer, calls work again once the subscription is restored. ### 403 Error code: `app_suspended`, `forbidden_permission`. Error codes - `app_suspended` ZenHost suspended your App, or no longer allows it on this account. Stop calling and contact ZenHost. - `forbidden_permission` Your App was not granted the Permission this endpoint requires. ### 404 Error code: `not_found`. Error codes - `not_found` The resource does not exist or is outside the Connection Scope (both look the same), or the path is not an endpoint of `/api/v1`. ### 429 Error code: `rate_limited`. Header `Retry-After` — Seconds to wait before the next call is allowed. Limits per App Connection: 60 reads (GET), 10 writes (other POST) and 30 messages sent to guests per minute. Error codes - `rate_limited` Too many calls on this App Connection. Wait for the number of seconds in `Retry-After`. ### 500 Error code: `internal_error`. Error codes - `internal_error` ZenHost failed to answer. Retry later with a back-off. [Every error code and what to do](https://zenhost.com/developers/errors) Source: https://zenhost.com/developers/reference/get-property-guide --- # Read the Restrictions of Properties over a date range GET `/api/v1/restrictions` Requires `restrictions.read` For each requested Property and each date from `from` to `to` (at most 366 days): base rate, minimum stay, closed to arrival, closed to departure, availability, and the fields a Restriction Batch will not write (`unwritable_fields`). Every date of the range is returned; a date ZenHost holds no Restriction for has `in_calendar: false` and `null` values. Dates are nights in the time zone of the Property; rates are decimals in its currency. **Request (cURL)** ```bash curl -G https://app.zenhost.com/api/v1/restrictions \ -H "Authorization: Bearer zh_at_..." \ -d "property_id[]=3f6c1a52-8d0e-4b7a-9f21-5c4e8b7d2a10" \ -d "property_id[]=9b2e7d41-c3a8-4f65-8e1d-0a7f3c9b5e62" \ -d "from=2026-11-01" \ -d "to=2026-11-30" ``` **Request (JavaScript)** ```javascript const params = new URLSearchParams([ ['property_id[]', '3f6c1a52-8d0e-4b7a-9f21-5c4e8b7d2a10'], ['property_id[]', '9b2e7d41-c3a8-4f65-8e1d-0a7f3c9b5e62'], ['from', '2026-11-01'], ['to', '2026-11-30'], ]); const response = await fetch(`https://app.zenhost.com/api/v1/restrictions?${params}`, { headers: { Authorization: 'Bearer zh_at_...', }, }); const data = await response.json(); ``` **Request (Python)** ```python import requests response = requests.get( "https://app.zenhost.com/api/v1/restrictions", params={"property_id[]": ["3f6c1a52-8d0e-4b7a-9f21-5c4e8b7d2a10", "9b2e7d41-c3a8-4f65-8e1d-0a7f3c9b5e62"], "from": "2026-11-01", "to": "2026-11-30"}, headers={"Authorization": "Bearer zh_at_..."}, ) data = response.json() ``` Repeat `property_id[]` once per value, up to 100 per call. **Response (200)** ```json { "data": [ { "property_id": "3f6c1a52-8d0e-4b7a-9f21-5c4e8b7d2a10", "currency": "EUR", "days": [ { "date": "2026-11-01", "in_calendar": false, "rate": 129.5, "min_stay": 2, "closed_to_arrival": false, "closed_to_departure": false, "availability": true, "unwritable_fields": [ null ] } ] } ] } ``` **Response (401)** ```json { "error": { "code": "unauthorized", "message": "A human-readable explanation", "details": {} } } ``` **Response (402)** ```json { "error": { "code": "subscription_blocked", "message": "A human-readable explanation", "details": {} } } ``` **Response (403)** ```json { "error": { "code": "app_suspended", "message": "A human-readable explanation", "details": {} } } ``` **Response (422)** ```json { "error": { "code": "validation_failed", "message": "A human-readable explanation", "details": {} } } ``` **Response (429)** ```json { "error": { "code": "rate_limited", "message": "A human-readable explanation", "details": {} } } ``` **Response (500)** ```json { "error": { "code": "internal_error", "message": "A human-readable explanation", "details": {} } } ``` ## Parameters ### Query parameters - `property_id[]` (string (uuid)[], required): Properties to read (repeat the parameter, 1 to 100). To read more, page through `GET /properties` and call once per page. One outside the Connection Scope answers `property_out_of_scope`. 1 to 100 items - `from` (string (date), required): First date, inclusive. - `to` (string (date), required): Last date, inclusive; at most 366 days from `from`. ## Responses ### 200 The Restrictions, per Property and date. application/json RestrictionList - `data` (PropertyRestrictions[], required): One entry per requested Property, in request order; a repeated id is answered once. - `property_id` (string (uuid), required) - `currency` (string, required): ISO 4217 code of the rates of the Property. - `days` (RestrictionDay[], required): One entry per date of the requested range, in order. - `date` (string (date), required): Night, in the time zone of the Property. - `in_calendar` (boolean, required): `false` when ZenHost holds no Restriction for this date (beyond the calendar horizon, or a Property without a rate plan): every value is then `null`. - `rate` (number | null, required): Base nightly rate, decimal, in the currency of the Property; Property Pricing Modifiers apply on top per Sales Channel. `null` when unset. - `min_stay` (integer | null, required): Minimum stay in nights; `null` when unset. - `closed_to_arrival` (boolean | null, required) - `closed_to_departure` (boolean | null, required) - `availability` (boolean | null, required): Whether the host opened the night for sale. A booked night stays `true`: read the Bookings for occupancy. - `unwritable_fields` (UnwritableField[], required) - `field` (string, required) One of: `rate`, `min_stay`, `closed_to_arrival`, `closed_to_departure`, `availability` - `reason` (string, required): `manual_override`: the host froze this field on this date. `managed_by_another_pricing_tool`: the Property is priced by another pricing tool, which owns `rate`, `min_stay`, `closed_to_arrival` and `closed_to_departure`; `availability` stays writable. `blocked_by_linked_property`: the night is closed because a linked Property (for example the whole house this room belongs to) is booked or closed; `availability` is not writable. New reasons may be added. One of: `manual_override`, `managed_by_another_pricing_tool`, `blocked_by_linked_property` ### 401 Error code: `unauthorized`, `connection_revoked`. Error codes - `unauthorized` The access token is missing, unknown, expired or revoked, or was issued before the account connected the App again. Refresh it; if refreshing fails, ask the owner to connect again. - `connection_revoked` The owner revoked the App Connection (or your App revoked its own refresh token). Stop calling for this account and drop its tokens. ### 402 Error code: `subscription_blocked`. Error codes - `subscription_blocked` The ZenHost subscription of the account is blocked (unpaid, cancelled…). The connection is kept: tell your customer, calls work again once the subscription is restored. ### 403 Error code: `app_suspended`, `forbidden_permission`. Error codes - `app_suspended` ZenHost suspended your App, or no longer allows it on this account. Stop calling and contact ZenHost. - `forbidden_permission` Your App was not granted the Permission this endpoint requires. ### 422 Error code: `validation_failed`, `property_out_of_scope`. Error codes - `validation_failed` The query, headers or body are invalid; `details.issues` points at each problem (`path`, `message`, and `maximum` when a value or list is too big). - `property_out_of_scope` A requested Property is outside the Connection Scope; `details.property_ids` lists them. ### 429 Error code: `rate_limited`. Header `Retry-After` — Seconds to wait before the next call is allowed. Limits per App Connection: 60 reads (GET), 10 writes (other POST) and 30 messages sent to guests per minute. Error codes - `rate_limited` Too many calls on this App Connection. Wait for the number of seconds in `Retry-After`. ### 500 Error code: `internal_error`. Error codes - `internal_error` ZenHost failed to answer. Retry later with a back-off. [Every error code and what to do](https://zenhost.com/developers/errors) Source: https://zenhost.com/developers/reference/list-restrictions --- # Write Restrictions of Properties over date ranges POST `/api/v1/restriction-batches` Requires `restrictions.write` Stores the batch and answers at once; the changes then reach ZenHost and the Sales Channels shortly after, in submission order: on a date and field, the latest batch wins and an older change not sent yet is `superseded`. A value equal to the current one is not sent again. A field ZenHost will not write (Manual Override, another pricing tool, a night closed by a linked Property; see `unwritable_fields` of `GET /restrictions`) is refused and reported, never forced; the rest of the batch still applies, with no rollback. Read the outcome with `GET /restriction-batches/{id}`. Every POST, `dry_run` included, counts against the submission rate limit. **Request (cURL)** ```bash curl -X POST https://app.zenhost.com/api/v1/restriction-batches \ -H "Authorization: Bearer zh_at_..." \ -H "Content-Type: application/json" \ -H "Idempotency-Key: nightly-2026-10-10" \ -d '{ "entries": [ { "property_id": "3f6c1a52-8d0e-4b7a-9f21-5c4e8b7d2a10", "from": "2026-11-01", "to": "2026-11-30", "values": { "rate": 129.5, "min_stay": 2, "availability": true } } ], "dry_run": false }' ``` **Request (JavaScript)** ```javascript const response = await fetch('https://app.zenhost.com/api/v1/restriction-batches', { method: 'POST', headers: { Authorization: 'Bearer zh_at_...', 'Content-Type': 'application/json', 'Idempotency-Key': 'nightly-2026-10-10', }, body: JSON.stringify({ "entries": [ { "property_id": "3f6c1a52-8d0e-4b7a-9f21-5c4e8b7d2a10", "from": "2026-11-01", "to": "2026-11-30", "values": { "rate": 129.5, "min_stay": 2, "availability": true } } ], "dry_run": false }), }); const data = await response.json(); ``` **Request (Python)** ```python import requests response = requests.post( "https://app.zenhost.com/api/v1/restriction-batches", headers={"Authorization": "Bearer zh_at_...", "Idempotency-Key": "nightly-2026-10-10"}, json={ "entries": [ { "property_id": "3f6c1a52-8d0e-4b7a-9f21-5c4e8b7d2a10", "from": "2026-11-01", "to": "2026-11-30", "values": { "rate": 129.5, "min_stay": 2, "availability": True, }, }, ], "dry_run": False, }, ) data = response.json() ``` **Response (200)** ```json { "dry_run": true, "properties": [ { "property_id": "3f6c1a52-8d0e-4b7a-9f21-5c4e8b7d2a10", "changes": { "submitted": 1, "changed": 1, "unchanged": 1, "refused": 1 }, "changed": [ { "date": "2026-11-01", "field": "rate", "current": 1, "new": 1 } ], "refused_fields": [ { "field": "rate", "reason": "manual_override", "from": "2026-11-01", "to": "2026-11-30" } ] } ] } ``` **Response (202)** ```json { "id": "7d1e4c09-2b5a-4e83-a6f0-91c8d3b2e4f7", "status": "queued" } ``` **Response (401)** ```json { "error": { "code": "unauthorized", "message": "A human-readable explanation", "details": {} } } ``` **Response (402)** ```json { "error": { "code": "subscription_blocked", "message": "A human-readable explanation", "details": {} } } ``` **Response (403)** ```json { "error": { "code": "app_suspended", "message": "A human-readable explanation", "details": {} } } ``` **Response (422)** ```json { "error": { "code": "validation_failed", "message": "A human-readable explanation", "details": {} } } ``` **Response (429)** ```json { "error": { "code": "rate_limited", "message": "A human-readable explanation", "details": {} } } ``` **Response (500)** ```json { "error": { "code": "internal_error", "message": "A human-readable explanation", "details": {} } } ``` ## Parameters ### Headers - `Idempotency-Key` (string): Replaying the same key on the same App Connection within 24 hours returns the batch it created instead of a new one. Reusing it with a different body answers `idempotency_key_reused`. Ignored with `dry_run`. at most 255 characters ## Request body application/json RestrictionBatchRequest Up to 20000 entries over at most 100 Properties (to cover more, send one batch per page of `GET /properties`). Dates run from today to today + 730 days, both in the time zone of each Property (its `timezone`). - `entries` (RestrictionBatchEntry[], required) 1 to 20000 items - `property_id` (string (uuid), required) - `from` (string (date), required): First night, inclusive. - `to` (string (date), required): Last night, inclusive. - `values` (RestrictionValues, required): The values to write on every date of the entry; a field left out is not touched. At least one field. - `rate` (number): Base nightly rate, decimal, in the currency of the Property, rounded to the cent. Property Pricing Modifiers still apply on top per Sales Channel. min 0.01 · max 99999999.99 - `min_stay` (integer): Minimum stay in nights. min 1 · max 365 - `closed_to_arrival` (boolean) - `closed_to_departure` (boolean) - `availability` (boolean): Open (`true`) or close (`false`) the night for sale. A booked night stays closed on the Sales Channels whatever this value. - `dry_run` (boolean): When `true`, nothing is stored or sent: the answer is `200` with what would change per Property. ## Responses ### 200 With `dry_run`: what the batch would change now. application/json RestrictionBatchPreview - `dry_run` (true, required) - `properties` (RestrictionBatchPreviewProperty[], required) - `property_id` (string (uuid), required) - `changes` (object, required): Date × field changes of this Property. - `submitted` (integer, required) - `changed` (integer, required) - `unchanged` (integer, required) - `refused` (integer, required) - `changed` (RestrictionChangePreview[], required): Every change that would be sent, in date order. - `date` (string (date), required) - `field` (string, required) One of: `rate`, `min_stay`, `closed_to_arrival`, `closed_to_departure`, `availability` - `current` (number | boolean | null, required): The value now; `null` when unset or not in the calendar. - `new` (number | boolean | null, required) - `refused_fields` (RefusedField[], required) - `field` (string, required) One of: `rate`, `min_stay`, `closed_to_arrival`, `closed_to_departure`, `availability` - `reason` (string, required) One of: `manual_override`, `managed_by_another_pricing_tool`, `blocked_by_linked_property` - `from` (string (date), required) - `to` (string (date), required) ### 202 The batch is stored and queued. application/json RestrictionBatchAccepted - `id` (string (uuid), required) - `status` (string, required): `queued` then `processing` while changes wait for the Sales Channels (each Property is sent at a pace the Sales Channels accept, and retried when they are unavailable); final: `completed` (nothing failed), `partially_failed`, `failed` (nothing applied), `cancelled`. One of: `queued`, `processing`, `completed`, `partially_failed`, `failed`, `cancelled` ### 401 Error code: `unauthorized`, `connection_revoked`. Error codes - `unauthorized` The access token is missing, unknown, expired or revoked, or was issued before the account connected the App again. Refresh it; if refreshing fails, ask the owner to connect again. - `connection_revoked` The owner revoked the App Connection (or your App revoked its own refresh token). Stop calling for this account and drop its tokens. ### 402 Error code: `subscription_blocked`. Error codes - `subscription_blocked` The ZenHost subscription of the account is blocked (unpaid, cancelled…). The connection is kept: tell your customer, calls work again once the subscription is restored. ### 403 Error code: `app_suspended`, `forbidden_permission`. Error codes - `app_suspended` ZenHost suspended your App, or no longer allows it on this account. Stop calling and contact ZenHost. - `forbidden_permission` Your App was not granted the Permission this endpoint requires. ### 422 Error code: `validation_failed`, `property_out_of_scope`, `idempotency_key_reused`. Error codes - `validation_failed` The query, headers or body are invalid; `details.issues` points at each problem (`path`, `message`, and `maximum` when a value or list is too big). - `property_out_of_scope` A requested Property is outside the Connection Scope; `details.property_ids` lists them. - `idempotency_key_reused` This `Idempotency-Key` was already used on this App Connection with a different body. ### 429 Error code: `rate_limited`. Header `Retry-After` — Seconds to wait before the next call is allowed. Limits per App Connection: 60 reads (GET), 10 writes (other POST) and 30 messages sent to guests per minute. Error codes - `rate_limited` Too many calls on this App Connection. Wait for the number of seconds in `Retry-After`. ### 500 Error code: `internal_error`. Error codes - `internal_error` ZenHost failed to answer. Retry later with a back-off. [Every error code and what to do](https://zenhost.com/developers/errors) Source: https://zenhost.com/developers/reference/submit-restriction-batch --- # Read the outcome of a Restriction Batch GET `/api/v1/restriction-batches/{id}` Requires `restrictions.write` Per Property: changes applied, unchanged, superseded, refused (and why) and failed (and why), as runs of dates. Only batches of the calling App Connection are visible; another one answers `not_found`. **Request (cURL)** ```bash curl https://app.zenhost.com/api/v1/restriction-batches/7d1e4c09-2b5a-4e83-a6f0-91c8d3b2e4f7 \ -H "Authorization: Bearer zh_at_..." ``` **Request (JavaScript)** ```javascript const response = await fetch('https://app.zenhost.com/api/v1/restriction-batches/7d1e4c09-2b5a-4e83-a6f0-91c8d3b2e4f7', { headers: { Authorization: 'Bearer zh_at_...', }, }); const data = await response.json(); ``` **Request (Python)** ```python import requests response = requests.get( "https://app.zenhost.com/api/v1/restriction-batches/7d1e4c09-2b5a-4e83-a6f0-91c8d3b2e4f7", headers={"Authorization": "Bearer zh_at_..."}, ) data = response.json() ``` **Response (200)** ```json { "id": "7d1e4c09-2b5a-4e83-a6f0-91c8d3b2e4f7", "status": "queued", "created_at": "2026-10-10T08:00:00.000Z", "completed_at": "2026-10-10T08:00:00.000Z", "properties": [ { "property_id": "3f6c1a52-8d0e-4b7a-9f21-5c4e8b7d2a10", "status": "pending", "changes": { "submitted": 1, "pending": 1, "applied": 1, "unchanged": 1, "superseded": 1, "refused": 1, "failed": 1, "cancelled": 1 }, "refused_fields": [ { "field": "rate", "reason": "manual_override", "from": "2026-11-01", "to": "2026-11-30" } ], "failures": [ { "field": "rate", "code": "sales_channel_rejected", "message": "A human-readable explanation", "from": "2026-11-01", "to": "2026-11-30" } ] } ] } ``` **Response (401)** ```json { "error": { "code": "unauthorized", "message": "A human-readable explanation", "details": {} } } ``` **Response (402)** ```json { "error": { "code": "subscription_blocked", "message": "A human-readable explanation", "details": {} } } ``` **Response (403)** ```json { "error": { "code": "app_suspended", "message": "A human-readable explanation", "details": {} } } ``` **Response (404)** ```json { "error": { "code": "not_found", "message": "A human-readable explanation", "details": {} } } ``` **Response (429)** ```json { "error": { "code": "rate_limited", "message": "A human-readable explanation", "details": {} } } ``` **Response (500)** ```json { "error": { "code": "internal_error", "message": "A human-readable explanation", "details": {} } } ``` ## Parameters ### Path parameters - `id` (string (uuid), required) ## Responses ### 200 The Restriction Batch and its outcome. application/json RestrictionBatch - `id` (string (uuid), required) - `status` (string, required): `queued` then `processing` while changes wait for the Sales Channels (each Property is sent at a pace the Sales Channels accept, and retried when they are unavailable); final: `completed` (nothing failed), `partially_failed`, `failed` (nothing applied), `cancelled`. One of: `queued`, `processing`, `completed`, `partially_failed`, `failed`, `cancelled` - `created_at` (string (date-time), required) - `completed_at` (string (date-time) | null, required) - `properties` (RestrictionBatchProperty[], required) - `property_id` (string (uuid), required) - `status` (string, required): `pending`: changes still wait. `applied`: some changes were applied or already current and none failed (refused, superseded or cancelled ones aside, see `changes`). `failed`: some changes failed, see `failures`. `refused`: every change was refused. `superseded`: nothing was sent, every change not refused was replaced by a later batch first. `cancelled`: nothing was sent because the App Connection was revoked or paused by ZenHost, the Property left its Property Access, or the App lost `restrictions.write`. One of: `pending`, `applied`, `failed`, `refused`, `superseded`, `cancelled` - `changes` (object, required): Date × field changes of this Property, by outcome. - `submitted` (integer, required) - `pending` (integer, required) - `applied` (integer, required) - `unchanged` (integer, required): Already the current value: nothing was sent. - `superseded` (integer, required): Replaced by a later batch on the same date and field before being sent; not a failure. - `refused` (integer, required) - `failed` (integer, required) - `cancelled` (integer, required): Not sent: the App Connection was revoked or paused by ZenHost first, the Property left its Property Access, or the App lost `restrictions.write`. - `refused_fields` (RefusedField[], required) - `field` (string, required) One of: `rate`, `min_stay`, `closed_to_arrival`, `closed_to_departure`, `availability` - `reason` (string, required) One of: `manual_override`, `managed_by_another_pricing_tool`, `blocked_by_linked_property` - `from` (string (date), required) - `to` (string (date), required) - `failures` (RestrictionBatchFailure[], required) - `field` (string, required) One of: `rate`, `min_stay`, `closed_to_arrival`, `closed_to_departure`, `availability` - `code` (string, required): `sales_channel_rejected`: the Sales Channels refused the change. `sales_channel_unavailable`: the Sales Channels could not be reached, after retries spread over about 15 minutes. `sales_channel_mapping_missing`: the Property is not fully connected to its Sales Channels. `property_not_configured`: the Property has no primary rate plan or room in ZenHost. `not_in_calendar`: ZenHost holds no Restriction for that date. `property_not_found`: the Property no longer exists. `write_failed`: ZenHost could not store the change. New codes may be added. One of: `sales_channel_rejected`, `sales_channel_unavailable`, `sales_channel_mapping_missing`, `property_not_configured`, `not_in_calendar`, `property_not_found`, `write_failed` - `message` (string, required) - `from` (string (date), required) - `to` (string (date), required) ### 401 Error code: `unauthorized`, `connection_revoked`. Error codes - `unauthorized` The access token is missing, unknown, expired or revoked, or was issued before the account connected the App again. Refresh it; if refreshing fails, ask the owner to connect again. - `connection_revoked` The owner revoked the App Connection (or your App revoked its own refresh token). Stop calling for this account and drop its tokens. ### 402 Error code: `subscription_blocked`. Error codes - `subscription_blocked` The ZenHost subscription of the account is blocked (unpaid, cancelled…). The connection is kept: tell your customer, calls work again once the subscription is restored. ### 403 Error code: `app_suspended`, `forbidden_permission`. Error codes - `app_suspended` ZenHost suspended your App, or no longer allows it on this account. Stop calling and contact ZenHost. - `forbidden_permission` Your App was not granted the Permission this endpoint requires. ### 404 Error code: `not_found`. Error codes - `not_found` The resource does not exist or is outside the Connection Scope (both look the same), or the path is not an endpoint of `/api/v1`. ### 429 Error code: `rate_limited`. Header `Retry-After` — Seconds to wait before the next call is allowed. Limits per App Connection: 60 reads (GET), 10 writes (other POST) and 30 messages sent to guests per minute. Error codes - `rate_limited` Too many calls on this App Connection. Wait for the number of seconds in `Retry-After`. ### 500 Error code: `internal_error`. Error codes - `internal_error` ZenHost failed to answer. Retry later with a back-off. [Every error code and what to do](https://zenhost.com/developers/errors) Source: https://zenhost.com/developers/reference/get-restriction-batch --- # List the bookings of the Connection Scope GET `/api/v1/bookings` Requires `bookings.read` Bookings of the Properties in the Connection Scope, cancelled ones included, ordered by `id` and paged with `next_cursor`. A page never repeats nor skips a booking, even while bookings change. Without more Permissions a booking carries no guest name, email or phone and no amount: `guests.read` adds the `guest` block, `prices.read` adds the `amounts` block. Incremental sync: read every page, keep `next_updated_since`, and pass it as `updated_since` next time. A change to a booking, its status, its amounts or its guest details brings it back. Poll without `from`/`to`, or a booking whose dates leave the range is never reported. A booking removed from ZenHost (for instance when a Sales Channel is disconnected from a Property, or a Property is deleted) is not reported, and sends no App Event: list the stay range again to reconcile. The `booking.created`, `booking.updated` and `booking.cancelled` App Events announce a change to what this endpoint returns, only to the connections that see it (a guest change needs `guests.read`, an amount change `prices.read`). A booking that arrives already cancelled sends only `booking.created`. **Request (cURL)** ```bash curl -G https://app.zenhost.com/api/v1/bookings \ -H "Authorization: Bearer zh_at_..." \ -d "property_id[]=3f6c1a52-8d0e-4b7a-9f21-5c4e8b7d2a10" \ -d "property_id[]=9b2e7d41-c3a8-4f65-8e1d-0a7f3c9b5e62" ``` **Request (JavaScript)** ```javascript const params = new URLSearchParams([ ['property_id[]', '3f6c1a52-8d0e-4b7a-9f21-5c4e8b7d2a10'], ['property_id[]', '9b2e7d41-c3a8-4f65-8e1d-0a7f3c9b5e62'], ]); const response = await fetch(`https://app.zenhost.com/api/v1/bookings?${params}`, { headers: { Authorization: 'Bearer zh_at_...', }, }); const data = await response.json(); ``` **Request (Python)** ```python import requests response = requests.get( "https://app.zenhost.com/api/v1/bookings", params={"property_id[]": ["3f6c1a52-8d0e-4b7a-9f21-5c4e8b7d2a10", "9b2e7d41-c3a8-4f65-8e1d-0a7f3c9b5e62"]}, headers={"Authorization": "Bearer zh_at_..."}, ) data = response.json() ``` Repeat `property_id[]` once per value, up to 100 per call. **Response (200)** ```json { "data": [ { "id": "7d1e4c09-2b5a-4e83-a6f0-91c8d3b2e4f7", "property_id": "3f6c1a52-8d0e-4b7a-9f21-5c4e8b7d2a10", "status": "confirmed", "arrival_date": "2026-11-01", "departure_date": "2026-11-04", "sales_channel": { "code": "ABB", "name": "Airbnb" }, "channel_reference": "HMABCD1234", "occupancy": { "adults": 2, "children": 0, "infants": 1 }, "created_at": "2026-10-10T08:00:00.000Z", "updated_at": "2026-10-10T08:00:00.000Z", "guest": { "first_name": "Camille", "last_name": "Martin", "email": "camille@example.com", "phone": "+33612345678" }, "amounts": { "currency": "EUR", "total": 480, "commission": 72, "net": 408 } } ], "next_cursor": null, "next_updated_since": "2026-10-10T08:00:00.000Z" } ``` **Response (401)** ```json { "error": { "code": "unauthorized", "message": "A human-readable explanation", "details": {} } } ``` **Response (402)** ```json { "error": { "code": "subscription_blocked", "message": "A human-readable explanation", "details": {} } } ``` **Response (403)** ```json { "error": { "code": "app_suspended", "message": "A human-readable explanation", "details": {} } } ``` **Response (422)** ```json { "error": { "code": "property_out_of_scope", "message": "A human-readable explanation", "details": {} } } ``` **Response (429)** ```json { "error": { "code": "rate_limited", "message": "A human-readable explanation", "details": {} } } ``` **Response (500)** ```json { "error": { "code": "internal_error", "message": "A human-readable explanation", "details": {} } } ``` ## Parameters ### Query parameters - `property_id[]` (string (uuid)[]): Only these Properties (repeat the parameter, at most 100). Each must be in the Connection Scope. Leave it out to list the whole Connection Scope, page by page. at most 100 items - `from` (string (date)): Only stays with a night on or after this day (`departure_date` > `from`). - `to` (string (date)): Only stays with a night on or before this day (`arrival_date` <= `to`). - `updated_since` (string (date-time)): Only bookings created or changed at or after this instant, cancellations included. Use `next_updated_since` of your previous listing. - `cursor` (string): `next_cursor` of the previous page. - `limit` (integer): Page size. min 1 · max 500 · default 100 ## Responses ### 200 One page of bookings. application/json BookingList - `data` (Booking[], required) - `id` (string (uuid), required) - `property_id` (string (uuid), required) - `status` (string, required): A cancelled booking stays listed with this status. Version 1 may add values. One of: `confirmed`, `cancelled` - `arrival_date` (string (date), required): First night, `YYYY-MM-DD` in the property time zone. - `departure_date` (string (date), required): Check-out day (not a night), `YYYY-MM-DD`. - `sales_channel` (object, required): Where the booking was made. - `code` (string, required): `direct` (entered by the host), `website` (direct website), `other`, or the Sales Channel shortcode (e.g. `ABB` for Airbnb, `BDC` for Booking.com). - `name` (string, required) - `channel_reference` (string | null, required): Confirmation code of the booking on its Sales Channel. - `occupancy` (object, required) - `adults` (integer | null, required) - `children` (integer | null, required) - `infants` (integer | null, required) - `created_at` (string (date-time), required) - `updated_at` (string (date-time), required): Last change of the booking, its status, amounts or guest details. - `guest` (BookingGuest | null) - `first_name` (string | null, required) - `last_name` (string | null, required) - `email` (string | null, required) - `phone` (string | null, required) - `amounts` (BookingAmounts): Booking amounts. Present only with the `prices.read` Permission. - `currency` (string, required): ISO 4217 code of the amounts below. - `total` (number | null, required): Total paid by the guest, in decimal units of `currency`. - `commission` (number, required): Commission kept by the Sales Channel (0 when none is known). - `net` (number | null, required): `total` minus `commission`: the host payout. - `next_cursor` (string | null, required): Pass it as `cursor`, with the same filters, to read the next page; `null` on the last page. - `next_updated_since` (string (date-time), required): Once the last page is read, pass it as `updated_since` to receive every booking changed since this listing began. Some bookings may come again: upsert them by `id`. ### 401 Error code: `unauthorized`, `connection_revoked`. Error codes - `unauthorized` The access token is missing, unknown, expired or revoked, or was issued before the account connected the App again. Refresh it; if refreshing fails, ask the owner to connect again. - `connection_revoked` The owner revoked the App Connection (or your App revoked its own refresh token). Stop calling for this account and drop its tokens. ### 402 Error code: `subscription_blocked`. Error codes - `subscription_blocked` The ZenHost subscription of the account is blocked (unpaid, cancelled…). The connection is kept: tell your customer, calls work again once the subscription is restored. ### 403 Error code: `app_suspended`, `forbidden_permission`. Error codes - `app_suspended` ZenHost suspended your App, or no longer allows it on this account. Stop calling and contact ZenHost. - `forbidden_permission` Your App was not granted the Permission this endpoint requires. ### 422 Error code: `property_out_of_scope`, `validation_failed`. Error codes - `property_out_of_scope` A requested Property is outside the Connection Scope; `details.property_ids` lists them. - `validation_failed` The query, headers or body are invalid; `details.issues` points at each problem (`path`, `message`, and `maximum` when a value or list is too big). ### 429 Error code: `rate_limited`. Header `Retry-After` — Seconds to wait before the next call is allowed. Limits per App Connection: 60 reads (GET), 10 writes (other POST) and 30 messages sent to guests per minute. Error codes - `rate_limited` Too many calls on this App Connection. Wait for the number of seconds in `Retry-After`. ### 500 Error code: `internal_error`. Error codes - `internal_error` ZenHost failed to answer. Retry later with a back-off. [Every error code and what to do](https://zenhost.com/developers/errors) Source: https://zenhost.com/developers/reference/list-bookings --- # Get the access codes of a booking GET `/api/v1/bookings/{booking_id}/lock-codes` Requires `lock_codes.read` One entry per Lock of the booking's Property, by Lock Scope (unit, building, parking…), with its Access Window and its state. A key box gives its `static_code`, which does not change from one stay to the next. A smart lock gives the newest `lock_code` issued for this booking that is not revoked, `pending` included: the code is valid, 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 revoked code is never returned. ZenHost does not restrict when you read a code. Hand it to the guest only from the arrival day to the departure day, as Zenny does. A booking outside the Connection Scope answers `not_found`, exactly like a missing one. **Request (cURL)** ```bash curl https://app.zenhost.com/api/v1/bookings/7d1e4c09-2b5a-4e83-a6f0-91c8d3b2e4f7/lock-codes \ -H "Authorization: Bearer zh_at_..." ``` **Request (JavaScript)** ```javascript const response = await fetch('https://app.zenhost.com/api/v1/bookings/7d1e4c09-2b5a-4e83-a6f0-91c8d3b2e4f7/lock-codes', { headers: { Authorization: 'Bearer zh_at_...', }, }); const data = await response.json(); ``` **Request (Python)** ```python import requests response = requests.get( "https://app.zenhost.com/api/v1/bookings/7d1e4c09-2b5a-4e83-a6f0-91c8d3b2e4f7/lock-codes", headers={"Authorization": "Bearer zh_at_..."}, ) data = response.json() ``` **Response (200)** ```json { "data": [ { "lock_scope": "string", "kind": "lock_code", "code": "ABB", "valid_from": "2026-10-10T08:00:00.000Z", "valid_until": "2026-10-10T08:00:00.000Z", "status": "pending" } ] } ``` **Response (401)** ```json { "error": { "code": "unauthorized", "message": "A human-readable explanation", "details": {} } } ``` **Response (402)** ```json { "error": { "code": "subscription_blocked", "message": "A human-readable explanation", "details": {} } } ``` **Response (403)** ```json { "error": { "code": "app_suspended", "message": "A human-readable explanation", "details": {} } } ``` **Response (404)** ```json { "error": { "code": "not_found", "message": "A human-readable explanation", "details": {} } } ``` **Response (429)** ```json { "error": { "code": "rate_limited", "message": "A human-readable explanation", "details": {} } } ``` **Response (500)** ```json { "error": { "code": "internal_error", "message": "A human-readable explanation", "details": {} } } ``` ## Parameters ### Path parameters - `booking_id` (string (uuid), required): Booking id. ## Responses ### 200 The access codes of the booking. application/json LockCodeList - `data` (LockCode[], required): One entry per Lock of the Property that has a code for this booking, the unit first. Empty for a cancelled booking. - `lock_scope` (string, required): The area the Lock opens: one of `unit`, `building`, `parking`, `mailbox`, `garage`, `storage`, `common_area`, `pool`, `garden`, `other`. A Property has at most one Lock per scope. - `kind` (string, required): `lock_code`: a code issued for this booking only. `static_code`: the fixed code of a key box, the same from one stay to the next. One of: `lock_code`, `static_code` - `code` (string, required) - `valid_from` (string (date-time) | null, required): Start of the Access Window (operational check-in time); `null` for a `static_code`. - `valid_until` (string (date-time) | null, required): End of the Access Window (operational check-out time); `null` for a `static_code`. - `status` (string, required): `pending`: the code is issued but the lock has not confirmed it yet. `active`: confirmed. A `static_code` is always `active`. One of: `pending`, `active` ### 401 Error code: `unauthorized`, `connection_revoked`. Error codes - `unauthorized` The access token is missing, unknown, expired or revoked, or was issued before the account connected the App again. Refresh it; if refreshing fails, ask the owner to connect again. - `connection_revoked` The owner revoked the App Connection (or your App revoked its own refresh token). Stop calling for this account and drop its tokens. ### 402 Error code: `subscription_blocked`. Error codes - `subscription_blocked` The ZenHost subscription of the account is blocked (unpaid, cancelled…). The connection is kept: tell your customer, calls work again once the subscription is restored. ### 403 Error code: `app_suspended`, `forbidden_permission`. Error codes - `app_suspended` ZenHost suspended your App, or no longer allows it on this account. Stop calling and contact ZenHost. - `forbidden_permission` Your App was not granted the Permission this endpoint requires. ### 404 Error code: `not_found`. Error codes - `not_found` The resource does not exist or is outside the Connection Scope (both look the same), or the path is not an endpoint of `/api/v1`. ### 429 Error code: `rate_limited`. Header `Retry-After` — Seconds to wait before the next call is allowed. Limits per App Connection: 60 reads (GET), 10 writes (other POST) and 30 messages sent to guests per minute. Error codes - `rate_limited` Too many calls on this App Connection. Wait for the number of seconds in `Retry-After`. ### 500 Error code: `internal_error`. Error codes - `internal_error` ZenHost failed to answer. Retry later with a back-off. [Every error code and what to do](https://zenhost.com/developers/errors) Source: https://zenhost.com/developers/reference/get-booking-lock-codes --- # List the Message Threads of the Connection Scope GET `/api/v1/message-threads` Requires `messages.read` One conversation per booking that has at least one message, on the Properties of the Connection Scope, latest message first and paged with `next_cursor`. Each gives the booking, its Property and Sales Channel, a preview of the latest message `GET /bookings/{booking_id}/messages` lists (a Scheduled Message not sent yet is left out) and its author, the number of unread messages and whether the conversation is closed. The author never carries the guest email or phone: those stay under `guests.read` on `GET /bookings`. The preview is the message text as written, so it holds whatever the guest or the host typed. Incremental sync: read every page, keep `next_updated_since`, and pass it as `updated_since` next time. A new message, a message read or unread in ZenHost, or a closed or reopened conversation brings the thread back. A thread whose latest message changes while you page through may move ahead of your cursor: the next poll returns it. Some threads may come twice: upsert them by `booking_id`. **Request (cURL)** ```bash curl -G https://app.zenhost.com/api/v1/message-threads \ -H "Authorization: Bearer zh_at_..." \ -d "property_id[]=3f6c1a52-8d0e-4b7a-9f21-5c4e8b7d2a10" \ -d "property_id[]=9b2e7d41-c3a8-4f65-8e1d-0a7f3c9b5e62" ``` **Request (JavaScript)** ```javascript const params = new URLSearchParams([ ['property_id[]', '3f6c1a52-8d0e-4b7a-9f21-5c4e8b7d2a10'], ['property_id[]', '9b2e7d41-c3a8-4f65-8e1d-0a7f3c9b5e62'], ]); const response = await fetch(`https://app.zenhost.com/api/v1/message-threads?${params}`, { headers: { Authorization: 'Bearer zh_at_...', }, }); const data = await response.json(); ``` **Request (Python)** ```python import requests response = requests.get( "https://app.zenhost.com/api/v1/message-threads", params={"property_id[]": ["3f6c1a52-8d0e-4b7a-9f21-5c4e8b7d2a10", "9b2e7d41-c3a8-4f65-8e1d-0a7f3c9b5e62"]}, headers={"Authorization": "Bearer zh_at_..."}, ) data = response.json() ``` Repeat `property_id[]` once per value, up to 100 per call. **Response (200)** ```json { "data": [ { "booking_id": "7d1e4c09-2b5a-4e83-a6f0-91c8d3b2e4f7", "property_id": "3f6c1a52-8d0e-4b7a-9f21-5c4e8b7d2a10", "sales_channel": { "code": "ABB", "name": "string" }, "latest_message": { "id": "7d1e4c09-2b5a-4e83-a6f0-91c8d3b2e4f7", "preview": "string", "author": { "type": null, "app_name": null }, "created_at": "2026-10-10T08:00:00.000Z", "attachments_count": 1 }, "unread_count": 1, "is_closed": false, "updated_at": "2026-10-10T08:00:00.000Z" } ], "next_cursor": null, "next_updated_since": "2026-10-10T08:00:00.000Z" } ``` **Response (401)** ```json { "error": { "code": "unauthorized", "message": "A human-readable explanation", "details": {} } } ``` **Response (402)** ```json { "error": { "code": "subscription_blocked", "message": "A human-readable explanation", "details": {} } } ``` **Response (403)** ```json { "error": { "code": "app_suspended", "message": "A human-readable explanation", "details": {} } } ``` **Response (422)** ```json { "error": { "code": "property_out_of_scope", "message": "A human-readable explanation", "details": {} } } ``` **Response (429)** ```json { "error": { "code": "rate_limited", "message": "A human-readable explanation", "details": {} } } ``` **Response (500)** ```json { "error": { "code": "internal_error", "message": "A human-readable explanation", "details": {} } } ``` ## Parameters ### Query parameters - `property_id[]` (string (uuid)[]): Only these Properties (repeat the parameter, at most 100). Each must be in the Connection Scope. Leave it out to list the whole Connection Scope, page by page. at most 100 items - `updated_since` (string (date-time)): Only Message Threads changed at or after this instant. Use `next_updated_since` of your previous listing. - `cursor` (string): `next_cursor` of the previous page. - `limit` (integer): Page size. min 1 · max 500 · default 100 ## Responses ### 200 One page of Message Threads. application/json MessageThreadList - `data` (MessageThread[], required) - `booking_id` (string (uuid), required): The booking of the conversation; a booking has at most one Message Thread. - `property_id` (string (uuid), required) - `sales_channel` (object, required): Where the booking was made. - `code` (string, required): `direct`, `website`, `other`, or the Sales Channel shortcode, as on `GET /bookings`. - `name` (string, required) - `latest_message` (LatestMessage, required): The latest message of the conversation. - `id` (string (uuid), required) - `preview` (string, required): First 200 characters of the message text. - `author` (MessageAuthor, required): Who wrote the message. Never carries a guest email or phone, nor the name of the team member. - `type` (string, required): `guest` (inbound), `host` (the host or a team member), `zenny` (written by Zenny, the ZenHost assistant), `automation` (a Scheduled Message) or `app` (an App, see `app_name`). Version 1 may add values. One of: `guest`, `host`, `zenny`, `automation`, `app` - `app_name` (string): Name of the App that sent the message, when `type` is `app`. - `created_at` (string (date-time), required) - `attachments_count` (integer, required) min 0 - `unread_count` (integer, required): Messages of the conversation not read in ZenHost yet. min 0 - `is_closed` (boolean, required): In ZenHost a conversation is closed once its latest message is read; a new guest message reopens it. - `updated_at` (string (date-time), required): Last change of the conversation: a message added, changed or removed, or its read state. - `next_cursor` (string | null, required): Pass it as `cursor`, with the same filters, to read the next page; `null` on the last page. - `next_updated_since` (string (date-time), required): Once the last page is read, pass it as `updated_since` to receive every Message Thread changed since this listing began. Some may come again: upsert them by `booking_id`. ### 401 Error code: `unauthorized`, `connection_revoked`. Error codes - `unauthorized` The access token is missing, unknown, expired or revoked, or was issued before the account connected the App again. Refresh it; if refreshing fails, ask the owner to connect again. - `connection_revoked` The owner revoked the App Connection (or your App revoked its own refresh token). Stop calling for this account and drop its tokens. ### 402 Error code: `subscription_blocked`. Error codes - `subscription_blocked` The ZenHost subscription of the account is blocked (unpaid, cancelled…). The connection is kept: tell your customer, calls work again once the subscription is restored. ### 403 Error code: `app_suspended`, `forbidden_permission`. Error codes - `app_suspended` ZenHost suspended your App, or no longer allows it on this account. Stop calling and contact ZenHost. - `forbidden_permission` Your App was not granted the Permission this endpoint requires. ### 422 Error code: `property_out_of_scope`, `validation_failed`. Error codes - `property_out_of_scope` A requested Property is outside the Connection Scope; `details.property_ids` lists them. - `validation_failed` The query, headers or body are invalid; `details.issues` points at each problem (`path`, `message`, and `maximum` when a value or list is too big). ### 429 Error code: `rate_limited`. Header `Retry-After` — Seconds to wait before the next call is allowed. Limits per App Connection: 60 reads (GET), 10 writes (other POST) and 30 messages sent to guests per minute. Error codes - `rate_limited` Too many calls on this App Connection. Wait for the number of seconds in `Retry-After`. ### 500 Error code: `internal_error`. Error codes - `internal_error` ZenHost failed to answer. Retry later with a back-off. [Every error code and what to do](https://zenhost.com/developers/errors) Source: https://zenhost.com/developers/reference/list-message-threads --- # Close the conversation of a booking POST `/api/v1/bookings/{booking_id}/message-thread/close` Requires `messages.write` Marks every message of the booking as read, like the close button of the ZenHost inbox: the conversation leaves the unread conversations of the host. Closing a closed conversation changes nothing and answers the same. A booking without messages answers `is_closed: true` and `unread_count: 0`. A new guest message reopens the conversation. No App Event is sent: the change comes back through `updated_since` on `GET /message-threads`. A booking outside the Connection Scope answers `not_found`, exactly like a missing one. Counts against the write rate limit. **Request (cURL)** ```bash curl -X POST https://app.zenhost.com/api/v1/bookings/7d1e4c09-2b5a-4e83-a6f0-91c8d3b2e4f7/message-thread/close \ -H "Authorization: Bearer zh_at_..." ``` **Request (JavaScript)** ```javascript const response = await fetch('https://app.zenhost.com/api/v1/bookings/7d1e4c09-2b5a-4e83-a6f0-91c8d3b2e4f7/message-thread/close', { method: 'POST', headers: { Authorization: 'Bearer zh_at_...', }, }); const data = await response.json(); ``` **Request (Python)** ```python import requests response = requests.post( "https://app.zenhost.com/api/v1/bookings/7d1e4c09-2b5a-4e83-a6f0-91c8d3b2e4f7/message-thread/close", headers={"Authorization": "Bearer zh_at_..."}, ) data = response.json() ``` **Response (200)** ```json { "booking_id": "7d1e4c09-2b5a-4e83-a6f0-91c8d3b2e4f7", "is_closed": false, "unread_count": 1 } ``` **Response (401)** ```json { "error": { "code": "unauthorized", "message": "A human-readable explanation", "details": {} } } ``` **Response (402)** ```json { "error": { "code": "subscription_blocked", "message": "A human-readable explanation", "details": {} } } ``` **Response (403)** ```json { "error": { "code": "app_suspended", "message": "A human-readable explanation", "details": {} } } ``` **Response (404)** ```json { "error": { "code": "not_found", "message": "A human-readable explanation", "details": {} } } ``` **Response (429)** ```json { "error": { "code": "rate_limited", "message": "A human-readable explanation", "details": {} } } ``` **Response (500)** ```json { "error": { "code": "internal_error", "message": "A human-readable explanation", "details": {} } } ``` ## Parameters ### Path parameters - `booking_id` (string (uuid), required): Booking id. ## Responses ### 200 The read state of the conversation. application/json MessageThreadState - `booking_id` (string (uuid), required) - `is_closed` (boolean, required): Whether the conversation is out of the unread conversations of the host. `true` for a booking without messages: there is nothing for the host to read. - `unread_count` (integer, required): Messages of the conversation not read in ZenHost. min 0 ### 401 Error code: `unauthorized`, `connection_revoked`. Error codes - `unauthorized` The access token is missing, unknown, expired or revoked, or was issued before the account connected the App again. Refresh it; if refreshing fails, ask the owner to connect again. - `connection_revoked` The owner revoked the App Connection (or your App revoked its own refresh token). Stop calling for this account and drop its tokens. ### 402 Error code: `subscription_blocked`. Error codes - `subscription_blocked` The ZenHost subscription of the account is blocked (unpaid, cancelled…). The connection is kept: tell your customer, calls work again once the subscription is restored. ### 403 Error code: `app_suspended`, `forbidden_permission`. Error codes - `app_suspended` ZenHost suspended your App, or no longer allows it on this account. Stop calling and contact ZenHost. - `forbidden_permission` Your App was not granted the Permission this endpoint requires. ### 404 Error code: `not_found`. Error codes - `not_found` The resource does not exist or is outside the Connection Scope (both look the same), or the path is not an endpoint of `/api/v1`. ### 429 Error code: `rate_limited`. Header `Retry-After` — Seconds to wait before the next call is allowed. Limits per App Connection: 60 reads (GET), 10 writes (other POST) and 30 messages sent to guests per minute. Error codes - `rate_limited` Too many calls on this App Connection. Wait for the number of seconds in `Retry-After`. ### 500 Error code: `internal_error`. Error codes - `internal_error` ZenHost failed to answer. Retry later with a back-off. [Every error code and what to do](https://zenhost.com/developers/errors) Source: https://zenhost.com/developers/reference/close-message-thread --- # Reopen the conversation of a booking POST `/api/v1/bookings/{booking_id}/message-thread/reopen` Requires `messages.write` Marks every message of the booking as unread, like the reopen button of the ZenHost inbox: the conversation comes back in the unread conversations of the host, for instance to hand a case over. Reopening an open conversation changes nothing and answers the same. A booking without messages has no conversation to reopen: it answers `is_closed: true` and `unread_count: 0`. No App Event is sent: the change comes back through `updated_since` on `GET /message-threads`. A booking outside the Connection Scope answers `not_found`, exactly like a missing one. Counts against the write rate limit. **Request (cURL)** ```bash curl -X POST https://app.zenhost.com/api/v1/bookings/7d1e4c09-2b5a-4e83-a6f0-91c8d3b2e4f7/message-thread/reopen \ -H "Authorization: Bearer zh_at_..." ``` **Request (JavaScript)** ```javascript const response = await fetch('https://app.zenhost.com/api/v1/bookings/7d1e4c09-2b5a-4e83-a6f0-91c8d3b2e4f7/message-thread/reopen', { method: 'POST', headers: { Authorization: 'Bearer zh_at_...', }, }); const data = await response.json(); ``` **Request (Python)** ```python import requests response = requests.post( "https://app.zenhost.com/api/v1/bookings/7d1e4c09-2b5a-4e83-a6f0-91c8d3b2e4f7/message-thread/reopen", headers={"Authorization": "Bearer zh_at_..."}, ) data = response.json() ``` **Response (200)** ```json { "booking_id": "7d1e4c09-2b5a-4e83-a6f0-91c8d3b2e4f7", "is_closed": false, "unread_count": 1 } ``` **Response (401)** ```json { "error": { "code": "unauthorized", "message": "A human-readable explanation", "details": {} } } ``` **Response (402)** ```json { "error": { "code": "subscription_blocked", "message": "A human-readable explanation", "details": {} } } ``` **Response (403)** ```json { "error": { "code": "app_suspended", "message": "A human-readable explanation", "details": {} } } ``` **Response (404)** ```json { "error": { "code": "not_found", "message": "A human-readable explanation", "details": {} } } ``` **Response (429)** ```json { "error": { "code": "rate_limited", "message": "A human-readable explanation", "details": {} } } ``` **Response (500)** ```json { "error": { "code": "internal_error", "message": "A human-readable explanation", "details": {} } } ``` ## Parameters ### Path parameters - `booking_id` (string (uuid), required): Booking id. ## Responses ### 200 The read state of the conversation. application/json MessageThreadState - `booking_id` (string (uuid), required) - `is_closed` (boolean, required): Whether the conversation is out of the unread conversations of the host. `true` for a booking without messages: there is nothing for the host to read. - `unread_count` (integer, required): Messages of the conversation not read in ZenHost. min 0 ### 401 Error code: `unauthorized`, `connection_revoked`. Error codes - `unauthorized` The access token is missing, unknown, expired or revoked, or was issued before the account connected the App again. Refresh it; if refreshing fails, ask the owner to connect again. - `connection_revoked` The owner revoked the App Connection (or your App revoked its own refresh token). Stop calling for this account and drop its tokens. ### 402 Error code: `subscription_blocked`. Error codes - `subscription_blocked` The ZenHost subscription of the account is blocked (unpaid, cancelled…). The connection is kept: tell your customer, calls work again once the subscription is restored. ### 403 Error code: `app_suspended`, `forbidden_permission`. Error codes - `app_suspended` ZenHost suspended your App, or no longer allows it on this account. Stop calling and contact ZenHost. - `forbidden_permission` Your App was not granted the Permission this endpoint requires. ### 404 Error code: `not_found`. Error codes - `not_found` The resource does not exist or is outside the Connection Scope (both look the same), or the path is not an endpoint of `/api/v1`. ### 429 Error code: `rate_limited`. Header `Retry-After` — Seconds to wait before the next call is allowed. Limits per App Connection: 60 reads (GET), 10 writes (other POST) and 30 messages sent to guests per minute. Error codes - `rate_limited` Too many calls on this App Connection. Wait for the number of seconds in `Retry-After`. ### 500 Error code: `internal_error`. Error codes - `internal_error` ZenHost failed to answer. Retry later with a back-off. [Every error code and what to do](https://zenhost.com/developers/errors) Source: https://zenhost.com/developers/reference/reopen-message-thread --- # List the messages of a booking GET `/api/v1/bookings/{booking_id}/messages` Requires `messages.read` The conversation of a booking with its guest, oldest message first and paged with `next_cursor`. Each message gives its direction, its author (the guest, the host or a team member, Zenny, an automation or an App, named), its channel, its text, its attachments and its delivery status. A booking without any message answers an empty list. Scheduled Messages not sent yet are not listed. The author never carries the guest email or phone, nor the name of the team member: those stay out of reach, and the guest contact details stay under `guests.read` on `GET /bookings`. The text is the message as written, so it holds whatever the guest or the host typed. Download an attachment from its `url`, with the access token of the App Connection. A booking outside the Connection Scope answers `not_found`, exactly like a missing one. **Request (cURL)** ```bash curl https://app.zenhost.com/api/v1/bookings/7d1e4c09-2b5a-4e83-a6f0-91c8d3b2e4f7/messages \ -H "Authorization: Bearer zh_at_..." ``` **Request (JavaScript)** ```javascript const response = await fetch('https://app.zenhost.com/api/v1/bookings/7d1e4c09-2b5a-4e83-a6f0-91c8d3b2e4f7/messages', { headers: { Authorization: 'Bearer zh_at_...', }, }); const data = await response.json(); ``` **Request (Python)** ```python import requests response = requests.get( "https://app.zenhost.com/api/v1/bookings/7d1e4c09-2b5a-4e83-a6f0-91c8d3b2e4f7/messages", headers={"Authorization": "Bearer zh_at_..."}, ) data = response.json() ``` **Response (200)** ```json { "data": [ { "id": "7d1e4c09-2b5a-4e83-a6f0-91c8d3b2e4f7", "direction": "inbound", "author": { "type": "guest", "app_name": "string" }, "channel": "platform", "text": "Hello Camille, your apartment is ready from 4 pm.", "subject": "string", "attachments": [ { "index": 1, "type": "image", "file_name": "string", "content_type": "string", "url": "string" } ], "delivery_status": "received", "sent_at": "2026-10-10T08:00:00.000Z", "created_at": "2026-10-10T08:00:00.000Z" } ], "next_cursor": null } ``` **Response (401)** ```json { "error": { "code": "unauthorized", "message": "A human-readable explanation", "details": {} } } ``` **Response (402)** ```json { "error": { "code": "subscription_blocked", "message": "A human-readable explanation", "details": {} } } ``` **Response (403)** ```json { "error": { "code": "app_suspended", "message": "A human-readable explanation", "details": {} } } ``` **Response (404)** ```json { "error": { "code": "not_found", "message": "A human-readable explanation", "details": {} } } ``` **Response (422)** ```json { "error": { "code": "validation_failed", "message": "A human-readable explanation", "details": {} } } ``` **Response (429)** ```json { "error": { "code": "rate_limited", "message": "A human-readable explanation", "details": {} } } ``` **Response (500)** ```json { "error": { "code": "internal_error", "message": "A human-readable explanation", "details": {} } } ``` ## Parameters ### Path parameters - `booking_id` (string (uuid), required): Booking id. ### Query parameters - `cursor` (string): `next_cursor` of the previous page. - `limit` (integer): Page size. min 1 · max 500 · default 100 ## Responses ### 200 One page of the messages of the booking. application/json MessageList - `data` (Message[], required) - `id` (string (uuid), required) - `direction` (string, required): `inbound` from the guest, `outbound` to the guest. One of: `inbound`, `outbound` - `author` (MessageAuthor, required): Who wrote the message. Never carries a guest email or phone, nor the name of the team member. - `type` (string, required): `guest` (inbound), `host` (the host or a team member), `zenny` (written by Zenny, the ZenHost assistant), `automation` (a Scheduled Message) or `app` (an App, see `app_name`). Version 1 may add values. One of: `guest`, `host`, `zenny`, `automation`, `app` - `app_name` (string): Name of the App that sent the message, when `type` is `app`. - `channel` (string, required): `platform` is the messaging of the Sales Channel of the booking (Airbnb, Booking.com…). Version 1 may add values. One of: `platform`, `email`, `sms` - `text` (string, required) - `subject` (string | null, required): Email subject. - `attachments` (MessageAttachment[], required) - `index` (integer, required): Position of the file in the message, as in the download URL. Files that cannot be downloaded are left out, so indexes may skip. min 0 - `type` (string, required): Only images in version 1; other types may be added. One of: `image` - `file_name` (string | null, required) - `content_type` (string | null, required): MIME type when known; the download answers with the actual one. - `url` (string (uri), required): Download URL of the file. Call it with the access token of the App Connection, like any other endpoint. - `delivery_status` (string, required): `received` for an inbound message. For an outbound one: `pending` while it is not confirmed sent (in flight, or its fate is not known yet), `sent`, or `rejected` when it did not reach the guest: the Sales Channel refused it, or its send failed or stayed unconfirmed for a minute, as the ZenHost inbox shows it. Version 1 may add values. One of: `received`, `pending`, `sent`, `rejected` - `sent_at` (string (date-time), required) - `created_at` (string (date-time), required): When ZenHost recorded the message; the list is sorted on it. - `next_cursor` (string | null, required): Pass it as `cursor` to read the next page; `null` on the last page. ### 401 Error code: `unauthorized`, `connection_revoked`. Error codes - `unauthorized` The access token is missing, unknown, expired or revoked, or was issued before the account connected the App again. Refresh it; if refreshing fails, ask the owner to connect again. - `connection_revoked` The owner revoked the App Connection (or your App revoked its own refresh token). Stop calling for this account and drop its tokens. ### 402 Error code: `subscription_blocked`. Error codes - `subscription_blocked` The ZenHost subscription of the account is blocked (unpaid, cancelled…). The connection is kept: tell your customer, calls work again once the subscription is restored. ### 403 Error code: `app_suspended`, `forbidden_permission`. Error codes - `app_suspended` ZenHost suspended your App, or no longer allows it on this account. Stop calling and contact ZenHost. - `forbidden_permission` Your App was not granted the Permission this endpoint requires. ### 404 Error code: `not_found`. Error codes - `not_found` The resource does not exist or is outside the Connection Scope (both look the same), or the path is not an endpoint of `/api/v1`. ### 422 Error code: `validation_failed`. Error codes - `validation_failed` The query, headers or body are invalid; `details.issues` points at each problem (`path`, `message`, and `maximum` when a value or list is too big). ### 429 Error code: `rate_limited`. Header `Retry-After` — Seconds to wait before the next call is allowed. Limits per App Connection: 60 reads (GET), 10 writes (other POST) and 30 messages sent to guests per minute. Error codes - `rate_limited` Too many calls on this App Connection. Wait for the number of seconds in `Retry-After`. ### 500 Error code: `internal_error`. Error codes - `internal_error` ZenHost failed to answer. Retry later with a back-off. [Every error code and what to do](https://zenhost.com/developers/errors) Source: https://zenhost.com/developers/reference/list-booking-messages --- # Send a message to the guest of a booking POST `/api/v1/bookings/{booking_id}/messages` Requires `messages.write` Sends the text right away, the way the ZenHost inbox does, and answers once it is sent. `platform` writes on the messaging of the Sales Channel of the booking; `email` and `sms` use the guest contact details ZenHost holds. The message is attributed to your App: the host sees "Sent by" and the name of your App in the inbox. To attach photos, send the same fields as `multipart/form-data` with up to 5 `photos` parts (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 and each photo reach the guest as separate messages, the way the ZenHost inbox sends them: the answer is the text message, and `GET /bookings/{booking_id}/messages` lists the photo messages right after it, with their files. Once the text has reached the guest, the request answers `201` with it even if a photo was refused or its fate is unknown: check that list for the photos before sending them again. Whatever these limits, the hosting platform refuses a request body above 4.5 MB with a `413` that is not the JSON error of the API: send fewer or smaller photos per call. **SMS are billed to the host**, €0.10 per segment, on their ZenHost subscription. When the subscription cannot be billed, the request answers `sms_billing_unavailable`: nothing is sent and nothing is billed. Refusals known before sending answer `422` and send nothing: `channel_unavailable` (the chosen channel cannot reach the guest, try another one: on `platform`, the Sales Channel of the booking has no messaging, no conversation exists there yet, or the host must restore their connection to it; on `email`, the account has no sending address), `missing_guest_contact` (no email or phone for the chosen channel). When the send fails, `delivery_failed` tells in `details.delivery_outcome` whether the message certainly did not leave (`not_sent`: safe to send again) or its fate is unknown (`unknown`: check `GET /bookings/{booking_id}/messages` before sending again). 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 message never becomes Knowledge. A booking outside the Connection Scope answers `not_found`, exactly like a missing one. Counts against the send rate limit. **Request (cURL)** ```bash curl -X POST https://app.zenhost.com/api/v1/bookings/7d1e4c09-2b5a-4e83-a6f0-91c8d3b2e4f7/messages \ -H "Authorization: Bearer zh_at_..." \ -H "Content-Type: application/json" \ -H "Idempotency-Key: nightly-2026-10-10" \ -d '{ "channel": "platform", "text": "Hello Camille, your apartment is ready from 4 pm." }' ``` **Request (JavaScript)** ```javascript const response = await fetch('https://app.zenhost.com/api/v1/bookings/7d1e4c09-2b5a-4e83-a6f0-91c8d3b2e4f7/messages', { method: 'POST', headers: { Authorization: 'Bearer zh_at_...', 'Content-Type': 'application/json', 'Idempotency-Key': 'nightly-2026-10-10', }, body: JSON.stringify({ "channel": "platform", "text": "Hello Camille, your apartment is ready from 4 pm." }), }); const data = await response.json(); ``` **Request (Python)** ```python import requests response = requests.post( "https://app.zenhost.com/api/v1/bookings/7d1e4c09-2b5a-4e83-a6f0-91c8d3b2e4f7/messages", headers={"Authorization": "Bearer zh_at_...", "Idempotency-Key": "nightly-2026-10-10"}, json={ "channel": "platform", "text": "Hello Camille, your apartment is ready from 4 pm.", }, ) data = response.json() ``` **Response (201)** ```json { "id": "7d1e4c09-2b5a-4e83-a6f0-91c8d3b2e4f7", "direction": "inbound", "author": { "type": "guest", "app_name": "string" }, "channel": "platform", "text": "Hello Camille, your apartment is ready from 4 pm.", "subject": "string", "attachments": [ { "index": 1, "type": "image", "file_name": "string", "content_type": "string", "url": "string" } ], "delivery_status": "received", "sent_at": "2026-10-10T08:00:00.000Z", "created_at": "2026-10-10T08:00:00.000Z" } ``` **Response (401)** ```json { "error": { "code": "unauthorized", "message": "A human-readable explanation", "details": {} } } ``` **Response (402)** ```json { "error": { "code": "subscription_blocked", "message": "A human-readable explanation", "details": {} } } ``` **Response (403)** ```json { "error": { "code": "app_suspended", "message": "A human-readable explanation", "details": {} } } ``` **Response (404)** ```json { "error": { "code": "not_found", "message": "A human-readable explanation", "details": {} } } ``` **Response (409)** ```json { "error": { "code": "idempotency_key_in_use", "message": "A human-readable explanation", "details": {} } } ``` **Response (422)** ```json { "error": { "code": "validation_failed", "message": "A human-readable explanation", "details": {} } } ``` **Response (429)** ```json { "error": { "code": "rate_limited", "message": "A human-readable explanation", "details": {} } } ``` **Response (500)** ```json { "error": { "code": "internal_error", "message": "A human-readable explanation", "details": {} } } ``` **Response (502)** ```json { "error": { "code": "delivery_failed", "message": "A human-readable explanation", "details": {} } } ``` ## Parameters ### Path parameters - `booking_id` (string (uuid), required): Booking id. ### Headers - `Idempotency-Key` (string): Replaying the same key on the same App Connection within 24 hours answers what the first request did (the same message, or the same refusal) and sends nothing new. Reusing it with a different body answers `idempotency_key_reused`; while the first request is still sending, `idempotency_key_in_use`. at most 255 characters ## Request body application/json SendMessageRequest A text to the guest of the booking. - `channel` (string, required): `platform` is the messaging of the Sales Channel of the booking (Airbnb, Booking.com…); `email` and `sms` reach the guest contact details ZenHost holds. One of: `platform`, `email`, `sms` - `text` (string, required): The message. Shortcodes require their corresponding read permission, including nested custom values: bookings.read (plus prices.read for amounts), guests.read, properties.read for title/currency, property_guide.read for guide/custom values, or lock_codes.read. Missing permission refuses the send with forbidden_permission. Portal credentials and invoice/payment links cannot be expanded by Apps. at most 10000 characters - `subject` (string): Email only. Shortcodes follow the same read-permission checks as the text. Without it, the email takes the name of the Property. at most 255 characters ## Responses ### 201 The message, sent. application/json Message - `id` (string (uuid), required) - `direction` (string, required): `inbound` from the guest, `outbound` to the guest. One of: `inbound`, `outbound` - `author` (MessageAuthor, required): Who wrote the message. Never carries a guest email or phone, nor the name of the team member. - `type` (string, required): `guest` (inbound), `host` (the host or a team member), `zenny` (written by Zenny, the ZenHost assistant), `automation` (a Scheduled Message) or `app` (an App, see `app_name`). Version 1 may add values. One of: `guest`, `host`, `zenny`, `automation`, `app` - `app_name` (string): Name of the App that sent the message, when `type` is `app`. - `channel` (string, required): `platform` is the messaging of the Sales Channel of the booking (Airbnb, Booking.com…). Version 1 may add values. One of: `platform`, `email`, `sms` - `text` (string, required) - `subject` (string | null, required): Email subject. - `attachments` (MessageAttachment[], required) - `index` (integer, required): Position of the file in the message, as in the download URL. Files that cannot be downloaded are left out, so indexes may skip. min 0 - `type` (string, required): Only images in version 1; other types may be added. One of: `image` - `file_name` (string | null, required) - `content_type` (string | null, required): MIME type when known; the download answers with the actual one. - `url` (string (uri), required): Download URL of the file. Call it with the access token of the App Connection, like any other endpoint. - `delivery_status` (string, required): `received` for an inbound message. For an outbound one: `pending` while it is not confirmed sent (in flight, or its fate is not known yet), `sent`, or `rejected` when it did not reach the guest: the Sales Channel refused it, or its send failed or stayed unconfirmed for a minute, as the ZenHost inbox shows it. Version 1 may add values. One of: `received`, `pending`, `sent`, `rejected` - `sent_at` (string (date-time), required) - `created_at` (string (date-time), required): When ZenHost recorded the message; the list is sorted on it. ### 401 Error code: `unauthorized`, `connection_revoked`. Error codes - `unauthorized` The access token is missing, unknown, expired or revoked, or was issued before the account connected the App again. Refresh it; if refreshing fails, ask the owner to connect again. - `connection_revoked` The owner revoked the App Connection (or your App revoked its own refresh token). Stop calling for this account and drop its tokens. ### 402 Error code: `subscription_blocked`. Error codes - `subscription_blocked` The ZenHost subscription of the account is blocked (unpaid, cancelled…). The connection is kept: tell your customer, calls work again once the subscription is restored. ### 403 Error code: `app_suspended`, `forbidden_permission`. Error codes - `app_suspended` ZenHost suspended your App, or no longer allows it on this account. Stop calling and contact ZenHost. - `forbidden_permission` Your App was not granted the Permission this endpoint requires. ### 404 Error code: `not_found`. Error codes - `not_found` The resource does not exist or is outside the Connection Scope (both look the same), or the path is not an endpoint of `/api/v1`. ### 409 Error code: `idempotency_key_in_use`. Error codes - `idempotency_key_in_use` A request with this `Idempotency-Key` is still being processed. Retry in a few seconds to get its answer. ### 422 Error code: `validation_failed`, `idempotency_key_reused`, `channel_unavailable`, `missing_guest_contact`, `sms_billing_unavailable`. Error codes - `validation_failed` The query, headers or body are invalid; `details.issues` points at each problem (`path`, `message`, and `maximum` when a value or list is too big). - `idempotency_key_reused` This `Idempotency-Key` was already used on this App Connection with a different body. - `channel_unavailable` The chosen channel cannot reach the guest of this booking. On `platform`: the Sales Channel has no messaging for this booking, no conversation exists there yet, or the host must restore their connection to that Sales Channel. On `email`: the account has no sending address. Nothing was sent; try another channel. - `missing_guest_contact` The guest of this booking has no email address (for `email`) or phone number (for `sms`). Nothing was sent. - `sms_billing_unavailable` The ZenHost subscription of the account cannot be billed for SMS. Nothing was sent and nothing was billed; use another channel. ### 429 Error code: `rate_limited`. Header `Retry-After` — Seconds to wait before the next call is allowed. Limits per App Connection: 60 reads (GET), 10 writes (other POST) and 30 messages sent to guests per minute. Error codes - `rate_limited` Too many calls on this App Connection. Wait for the number of seconds in `Retry-After`. ### 500 Error code: `internal_error`. Error codes - `internal_error` ZenHost failed to answer. Retry later with a back-off. ### 502 Error code: `delivery_failed`. Error codes - `delivery_failed` The message could not be delivered. `details.delivery_outcome` is `not_sent` when it certainly did not leave (safe to send again) or `unknown` when its fate is not known (read the messages of the booking before sending again). [Every error code and what to do](https://zenhost.com/developers/errors) Source: https://zenhost.com/developers/reference/send-booking-message --- # Download an attachment of a message GET `/api/v1/bookings/{booking_id}/messages/{message_id}/attachments/{index}` Requires `messages.read` The file itself, with its `Content-Type`. Use the `url` given in `attachments` by `GET /bookings/{booking_id}/messages`. Downloads count against the read rate limit. A booking outside the Connection Scope, a message of another booking, an unknown index, a file the Sales Channel no longer holds or a file that cannot be served as a photo answer `not_found`. `attachment_unavailable` means the Sales Channel could not hand over the file right now: try again later. The file is streamed as it arrives, without `Content-Length`. A file that turns out larger than 10 MB on the way, or a Sales Channel transfer that breaks off, ends the connection before the body is complete: treat a download that does not end normally as failed and retry later. **Request (cURL)** ```bash curl https://app.zenhost.com/api/v1/bookings/7d1e4c09-2b5a-4e83-a6f0-91c8d3b2e4f7/messages/7d1e4c09-2b5a-4e83-a6f0-91c8d3b2e4f7/attachments/0 \ -H "Authorization: Bearer zh_at_..." ``` **Request (JavaScript)** ```javascript const response = await fetch('https://app.zenhost.com/api/v1/bookings/7d1e4c09-2b5a-4e83-a6f0-91c8d3b2e4f7/messages/7d1e4c09-2b5a-4e83-a6f0-91c8d3b2e4f7/attachments/0', { headers: { Authorization: 'Bearer zh_at_...', }, }); const data = await response.arrayBuffer(); ``` **Request (Python)** ```python import requests response = requests.get( "https://app.zenhost.com/api/v1/bookings/7d1e4c09-2b5a-4e83-a6f0-91c8d3b2e4f7/messages/7d1e4c09-2b5a-4e83-a6f0-91c8d3b2e4f7/attachments/0", headers={"Authorization": "Bearer zh_at_..."}, ) data = response.content ``` **Response (401)** ```json { "error": { "code": "unauthorized", "message": "A human-readable explanation", "details": {} } } ``` **Response (402)** ```json { "error": { "code": "subscription_blocked", "message": "A human-readable explanation", "details": {} } } ``` **Response (403)** ```json { "error": { "code": "app_suspended", "message": "A human-readable explanation", "details": {} } } ``` **Response (404)** ```json { "error": { "code": "not_found", "message": "A human-readable explanation", "details": {} } } ``` **Response (429)** ```json { "error": { "code": "rate_limited", "message": "A human-readable explanation", "details": {} } } ``` **Response (500)** ```json { "error": { "code": "internal_error", "message": "A human-readable explanation", "details": {} } } ``` **Response (502)** ```json { "error": { "code": "attachment_unavailable", "message": "A human-readable explanation", "details": {} } } ``` ## Parameters ### Path parameters - `booking_id` (string (uuid), required): Booking id. - `message_id` (string (uuid), required): Message id. - `index` (string, required): `index` of the attachment in the message. ## Responses ### 200 The file. ### 401 Error code: `unauthorized`, `connection_revoked`. Error codes - `unauthorized` The access token is missing, unknown, expired or revoked, or was issued before the account connected the App again. Refresh it; if refreshing fails, ask the owner to connect again. - `connection_revoked` The owner revoked the App Connection (or your App revoked its own refresh token). Stop calling for this account and drop its tokens. ### 402 Error code: `subscription_blocked`. Error codes - `subscription_blocked` The ZenHost subscription of the account is blocked (unpaid, cancelled…). The connection is kept: tell your customer, calls work again once the subscription is restored. ### 403 Error code: `app_suspended`, `forbidden_permission`. Error codes - `app_suspended` ZenHost suspended your App, or no longer allows it on this account. Stop calling and contact ZenHost. - `forbidden_permission` Your App was not granted the Permission this endpoint requires. ### 404 Error code: `not_found`. Error codes - `not_found` The resource does not exist or is outside the Connection Scope (both look the same), or the path is not an endpoint of `/api/v1`. ### 429 Error code: `rate_limited`. Header `Retry-After` — Seconds to wait before the next call is allowed. Limits per App Connection: 60 reads (GET), 10 writes (other POST) and 30 messages sent to guests per minute. Error codes - `rate_limited` Too many calls on this App Connection. Wait for the number of seconds in `Retry-After`. ### 500 Error code: `internal_error`. Error codes - `internal_error` ZenHost failed to answer. Retry later with a back-off. ### 502 Error code: `attachment_unavailable`. Error codes - `attachment_unavailable` The Sales Channel could not hand over the attachment right now. Retry later with a back-off. [Every error code and what to do](https://zenhost.com/developers/errors) Source: https://zenhost.com/developers/reference/get-message-attachment --- # This OpenAPI document GET `/api/v1/openapi.json` No authentication. **Request (cURL)** ```bash curl https://app.zenhost.com/api/v1/openapi.json ``` **Request (JavaScript)** ```javascript const response = await fetch('https://app.zenhost.com/api/v1/openapi.json'); const data = await response.json(); ``` **Request (Python)** ```python import requests response = requests.get( "https://app.zenhost.com/api/v1/openapi.json", ) data = response.json() ``` ## Responses ### 200 The OpenAPI 3.1 document of the Public API v1. application/json object [Every error code and what to do](https://zenhost.com/developers/errors) Source: https://zenhost.com/developers/reference/get-open-api-document