Webhooks
Register an HTTPS endpoint, choose its events, and Supahost POSTs a signed JSON envelope for every matching change.
Event types
reservation.created— a reservation was created.reservation.updated— a reservation changed, for example new stay dates.reservation.cancelled— a reservation was cancelled.message.received— a guest message arrived from a connected channel.message.sent— a staff reply was queued for delivery to the guest.property.created— a property was created (as a draft).property.updated— property details or active status changed; deactivation arrives asactive: false.task.created— a task was created.task.updated— a task changed, for example a new status.calendar.updated— availability or rates changed for a date range.
One endpoint subscribes to one or more event types. Disabled endpoints receive nothing, and deleting an endpoint also removes its delivery log.
Payload envelope
Every delivery is a POST with Content-Type: application/json and the same envelope. The data object identifies what changed; use the public API to read the complete record.
{
"id": "41",
"type": "reservation.created",
"created": "2026-08-20T10:00:00.000Z",
"data": {
"id": "9",
"propertyId": "1",
"confirmationCode": "RS-7XK2P9QA",
"status": "confirmed",
"arrivalDate": "2030-08-01",
"departureDate": "2030-08-04"
}
}| Field | Type | Required | Description |
|---|---|---|---|
id | string | Required | Unique delivery ID. Dedupe retried deliveries on this value. |
type | string | Required | One of the subscribed event types. |
created | string (date-time) | Required | When the event occurred. |
data | object | Required | The event payload. |
| Field | Type | Required | Description |
|---|---|---|---|
id | string | Required | Reservation ID. Read the full record with GET /api/v1/reservations/{id}. |
propertyId | string | Required | Property ID. |
confirmationCode | string | Required | Guest-facing confirmation code. |
status | string | Required | Reservation status after the change. |
arrivalDate | string (date) | Required | Arrival date as YYYY-MM-DD. |
departureDate | string (date) | Required | Departure date as YYYY-MM-DD. |
source | string | Optional | channex when the change came from a channel webhook; absent for dashboard changes. |
channel | string | null | Optional | Originating channel name (for example booking.com) for channel-sourced events. |
externalId | string | Optional | Channel-side booking ID for channel-sourced events. |
| Field | Type | Required | Description |
|---|---|---|---|
id | string | Required | Property ID. Read the full record with GET /api/v1/properties/{id}. |
name | string | Required | Property name after the change. |
timezone | string | Required | IANA time zone. |
active | boolean | Required | Whether the property is active after the change. |
| Field | Type | Required | Description |
|---|---|---|---|
id | string | Required | Message ID. |
threadId | string | Required | Conversation thread ID. |
propertyId | string | null | Required | Property the conversation belongs to. Always set on message.received; message.sent can carry null when the thread lookup misses. |
reservationId | string | null | Conditional | message.received only: linked reservation, when the thread is tied to one. |
direction | string | Required | inbound for guest messages, outbound for staff replies. |
senderType | string | Required | guest or staff. |
guestName | string | Conditional | message.received only: guest display name. |
body | string | Required | Message text. |
occurredAt | string (date-time) | Required | When the message was sent, matching the stored record. |
| Field | Type | Required | Description |
|---|---|---|---|
id | string | Required | Task ID. |
title | string | Required | Task title. |
category | string | Required | For example cleaning, maintenance, or guest. |
priority | string | Required | low, normal, high, or urgent. |
status | string | Required | Task status after the change: open, in_progress, completed, or cancelled. |
propertyId | string | null | Required | Linked property, when the task targets one. |
reservationId | string | null | Required | Linked reservation, when the task targets one. |
assigneeUserId | string | null | Required | Assigned team member. |
dueAt | string (date-time) | null | Required | Due time, when set. |
| Field | Type | Required | Description |
|---|---|---|---|
propertyId | string | Required | Property whose calendar changed. |
startsOn | string (date) | null | Required | First affected date as YYYY-MM-DD. |
endsOn | string (date) | null | Required | Last affected date as YYYY-MM-DD. |
kind | string | Required | What changed: availability_block, nightly_availability, or rates. |
Verify signatures
Supahost signs every delivery with the endpoint signing secret shown once at creation (whsec_…). The Supahost-Signature header contains a timestamp and an HMAC-SHA256 hex digest:
Supahost-Signature: t=1724224800,v1=9f2c…Compute HMAC_SHA256(secret, "<t>.<raw request body>") and compare it with the v1 value using a constant-time comparison. Reject timestamps older than 5 minutes to stop replayed requests.
Always verify against the raw request body. Parsing and re-serializing JSON can change the bytes and break the signature.
# Recompute the signature for a received delivery with openssl:
T=1724224800 # the t= value from the header
BODY='{"id":"41","type":"reservation.created","created":"2026-08-20T10:00:00.000Z","data":{}}'
printf '%s.%s' "$T" "$BODY" | openssl dgst -sha256 -hmac "$SUPAHOST_WEBHOOK_SECRET"
# Compare the hex output with the header's v1= value.import { createHmac, timingSafeEqual } from 'node:crypto'
export function verifySupahostSignature(
rawBody: string,
header: string,
secret: string,
toleranceSeconds = 300,
): boolean {
const parts = Object.fromEntries(
header.split(',').map((part) => part.split('=')),
)
const timestamp = Number(parts.t)
const age = Math.abs(Math.floor(Date.now() / 1000) - timestamp)
if (!timestamp || age > toleranceSeconds) return false
const expected = createHmac('sha256', secret)
.update(`${timestamp}.${rawBody}`)
.digest('hex')
const received = parts.v1 ?? ''
return (
expected.length === received.length &&
timingSafeEqual(Buffer.from(expected), Buffer.from(received))
)
}import hashlib
import hmac
import time
def verify_supahost_signature(
raw_body: bytes, header: str, secret: str, tolerance: int = 300
) -> bool:
parts = dict(part.split("=", 1) for part in header.split(","))
timestamp = int(parts.get("t", "0"))
if abs(int(time.time()) - timestamp) > tolerance:
return False
signed = f"{timestamp}.".encode() + raw_body
expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, parts.get("v1", ""))Retries and delivery states
Answer with any 2xx status to accept a delivery. Anything else — a non-2xx status or a network failure — is retried up to 5 times with exponential backoff, starting around 10 seconds and growing to about 5 minutes, with jitter.
pending— queued, not yet attempted.delivered— the endpoint answered 2xx.failed— the last attempt failed and a retry is scheduled.exhausted— the final attempt failed; Supahost stops retrying.
A 404 or 410 answer tells Supahost the receiver is gone for good, so the delivery becomes exhausted immediately without further attempts.
Deliveries are at-least-once. If your endpoint accepts a delivery but the acknowledgement is lost, the same envelope is sent again — dedupe on the envelope id.
Manage endpoints
Open Settings → Webhooks to add an endpoint, choose its events, and copy its signing secret. Premade setups (Everything, Reservations, Guest messaging, Listings, Operations) subscribe an endpoint to a sensible bundle in one click; individual events can still be toggled underneath. The same card edits, enables, disables, and deletes endpoints and shows each endpoint’s 25 most recent deliveries with status, attempts, and response code. Editing keeps the existing signing secret.
Managing endpoints requires the Webhooks permission, which only the owner role includes by default. Owners can delegate it to trusted team members through a custom role.