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.
Payload
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 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.
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
2xxwithin 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 payloadid), 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.
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)