Developers

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 as active: 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.

JSON
{
  "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"
  }
}
Envelope fields
FieldTypeRequiredDescription
idstringRequiredUnique delivery ID. Dedupe retried deliveries on this value.
typestringRequiredOne of the subscribed event types.
createdstring (date-time)RequiredWhen the event occurred.
dataobjectRequiredThe event payload.
Reservation event data
FieldTypeRequiredDescription
idstringRequiredReservation ID. Read the full record with GET /api/v1/reservations/{id}.
propertyIdstringRequiredProperty ID.
confirmationCodestringRequiredGuest-facing confirmation code.
statusstringRequiredReservation status after the change.
arrivalDatestring (date)RequiredArrival date as YYYY-MM-DD.
departureDatestring (date)RequiredDeparture date as YYYY-MM-DD.
sourcestringOptionalchannex when the change came from a channel webhook; absent for dashboard changes.
channelstring | nullOptionalOriginating channel name (for example booking.com) for channel-sourced events.
externalIdstringOptionalChannel-side booking ID for channel-sourced events.
Property event data
FieldTypeRequiredDescription
idstringRequiredProperty ID. Read the full record with GET /api/v1/properties/{id}.
namestringRequiredProperty name after the change.
timezonestringRequiredIANA time zone.
activebooleanRequiredWhether the property is active after the change.
Message event data
FieldTypeRequiredDescription
idstringRequiredMessage ID.
threadIdstringRequiredConversation thread ID.
propertyIdstring | nullRequiredProperty the conversation belongs to. Always set on message.received; message.sent can carry null when the thread lookup misses.
reservationIdstring | nullConditionalmessage.received only: linked reservation, when the thread is tied to one.
directionstringRequiredinbound for guest messages, outbound for staff replies.
senderTypestringRequiredguest or staff.
guestNamestringConditionalmessage.received only: guest display name.
bodystringRequiredMessage text.
occurredAtstring (date-time)RequiredWhen the message was sent, matching the stored record.
Task event data
FieldTypeRequiredDescription
idstringRequiredTask ID.
titlestringRequiredTask title.
categorystringRequiredFor example cleaning, maintenance, or guest.
prioritystringRequiredlow, normal, high, or urgent.
statusstringRequiredTask status after the change: open, in_progress, completed, or cancelled.
propertyIdstring | nullRequiredLinked property, when the task targets one.
reservationIdstring | nullRequiredLinked reservation, when the task targets one.
assigneeUserIdstring | nullRequiredAssigned team member.
dueAtstring (date-time) | nullRequiredDue time, when set.
Calendar event data
FieldTypeRequiredDescription
propertyIdstringRequiredProperty whose calendar changed.
startsOnstring (date) | nullRequiredFirst affected date as YYYY-MM-DD.
endsOnstring (date) | nullRequiredLast affected date as YYYY-MM-DD.
kindstringRequiredWhat 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:

Text
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.

cURL
# 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.
TypeScript
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))
  )
}
Python
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.

Next steps