Subscribe a URL, verify the HMAC-SHA256 signature on every request,
and respond with a 2xx within 10 seconds. ProhostAI retries on 5xx
and timeouts with exponential backoff — never on 4xx.
💡 Prefer the API?
Manage subscriptions programmatically on the
API Reference tab — see
POST /v1/webhooks, POST /v1/webhooks/test,
and the per-event response examples.
Subscribing
From the ProhostAI dashboard, open
Settings → Webhooks, paste your endpoint URL, pick the
events you care about, and save. The URL must be https:// —
plain HTTP is rejected.
Paste your URL — e.g.
https://hooks.example.com/prohost.
Pick your events — subscribe to as many or as few
as you like (the
full event catalog is below).
Copy the signing secret — it’s shown once on
create. Store it in your secrets manager; you’ll need it to
verify every request.
On subscription create — and on every
POST /v1/webhooks/test call — ProhostAI fires a
webhook.verification event so you can confirm signing and
reachability end-to-end before any real traffic flows. The verification
envelope’s data block is best-effort metadata; key your
verification handler on
event === "webhook.verification", not on specific
data fields.
Request headers
Every delivery carries the headers below. The signature header is the load-bearing one — verify it before parsing the JSON body.
Header
Description
x-webhook-event
Event type, e.g. reservation.created. Mirrors the event field in the JSON body so you can route on either.
x-webhook-signature
HMAC-SHA256 of the raw request body, hex-encoded, prefixed with sha256=. See Signature verification.
x-webhook-deliveryrolling out
Per-attempt-set idempotency key (ULID) — stable across retries of the same delivery. Use this to dedupe at your receiver.
x-webhook-attemptrolling out
1-indexed retry counter. 1 on the first delivery, 2 on the first retry, and so on.
user-agent
Always ProhostAI-Webhook/1.0. Useful for distinguishing live deliveries from your own test traffic in receiver logs.
content-type
Always application/json. The body is canonical JSON (UTF-8 bytes; sorted keys) — HMAC is computed over those exact bytes.
The rolling out headers are part of the live contract you can
rely on going forward; if your subscription was created before they
shipped you may not see them on the very first deliveries. Code your
receiver to dedupe by x-webhook-delivery when present
and fall back to the JSON body’s id field otherwise
— both are stable identifiers ProhostAI never reuses.
Signature verification
ProhostAI signs every delivery with HMAC-SHA256 over the
raw request body — the exact bytes on the wire,
before your framework parses them as JSON. Verify the signature
against those raw bytes, in constant time, and reject any request
whose signature doesn’t match.
The signing secret is the value shown to you once on subscription
create. Treat it like an API key: store it in a secrets manager and
rotate it from the dashboard if it leaks.
Python (Flask / FastAPI / Django)
python
import hmac
import hashlib
def verify_webhook(secret: str, raw_body: bytes, signature_header: str) -> bool:
"""Verify the X-Webhook-Signature header against the raw request body.
Compute HMAC-SHA256 over the EXACT bytes received (not parsed JSON)
and compare in constant time.
"""
if not signature_header.startswith("sha256="):
return False
expected = hmac.new(
secret.encode(),
raw_body,
hashlib.sha256,
).hexdigest()
received = signature_header[len("sha256="):]
return hmac.compare_digest(expected, received)
Node.js (Express / Fastify / Cloudflare Workers)
javascript
const crypto = require("crypto");
function verifyWebhook(secret, rawBody, signatureHeader) {
// rawBody must be Buffer/Uint8Array — verify BEFORE JSON.parse.
if (!signatureHeader || !signatureHeader.startsWith("sha256=")) {
return false;
}
const expected = crypto
.createHmac("sha256", secret)
.update(rawBody)
.digest("hex");
const received = signatureHeader.slice("sha256=".length);
const a = Buffer.from(expected, "hex");
const b = Buffer.from(received, "hex");
if (a.length !== b.length) return false;
return crypto.timingSafeEqual(a, b);
}
⚠️ Capture the raw body before parsing.
In Express, register
express.raw({ type: "application/json" }) on the webhook
route so req.body is a Buffer. In FastAPI,
read await request.body() before
await request.json(). Once your framework parses + reserialises
the body, key order and whitespace can change and the signature check
will fail.
Retries & idempotency
Respond with any 2xx within 10 seconds and we consider the
delivery successful. 5xx, 429, and network /
timeout failures are retried; any other 4xx is treated as
a permanent client bug and never retried (a misconfigured receiver
won’t fix itself).
Backoff is Stripe-style and totals about 31 hours over 7 retries:
After 24 hours of unbroken 5xx failures — or 5
consecutive terminal retries — ProhostAI auto-pauses the
subscription and surfaces it in the dashboard so your team can fix
the receiver and resume.
Dedupe with x-webhook-delivery
Retries replay the same envelope — same body, same
signature, same x-webhook-delivery. Network blips can also
cause a successful delivery to look failed on our side and trigger a
retry your receiver already processed. Dedupe by stashing the delivery
ID in a short-lived store (Redis, a unique index on a
webhook_deliveries table, etc.) and skipping any ID
you’ve seen before:
pseudocode
delivery_id = headers["x-webhook-delivery"] # ULID, e.g. "01HFXAAA…"
event_id = body["id"] # also stable; fallback
key = delivery_id or event_id
if seen_recently(key):
return 200, "already processed" # idempotent ack
process(body)
mark_seen(key, ttl="48h")
return 200
Event catalog 21 events
Every event ProhostAI emits, in the exact envelope shape
POST /v1/webhooks can subscribe to. Click any card to
expand a JSON sample — the
id / event / created_at /
schema_version / data envelope is identical
across every event type, so receivers can route on
event alone.
📅reservation.createdFired when a new reservation is ingested from any OTA (Airbnb, Hostaway, Guesty, Calry, Hospitable) or created directly in the ProhostAI dashboard. **Not yet emitted in production — wires in PR 4.**
📅reservation.check_inFired by the hourly reservation_lifecycle_events Hatchet cron on the calendar day the reservation's check_in_at falls into. Idempotent — fires at most once per reservation. Payload is ReservationExternal plus an event_at field pinning the canonical check-in timestamp.
📅reservation.check_outFired by the hourly reservation_lifecycle_events Hatchet cron on the calendar day the reservation's check_out_at falls into. Idempotent. Payload mirrors reservation.check_in.
🧹cleaning.completedFires only on the not-completed → completed transition for a cleaning. Distinct from cleaning.updated (every-change emitter); both can fire for the same write — subscribe to cleaning.completed only if you want the completion signal alone. Payload is CleaningExternal.
👤guest.createdFired the first time a guest profile is created — typically alongside their first reservation. **Not yet emitted in production — wires in PR 4.**
⭐review.receivedFired when a guest review is ingested from any OTA (Airbnb, Hostaway, Hospitable, Guesty, etc.) — one event per upserted reviews row. Payload mirrors the Review model with category ratings flattened into a category_ratings sub-object.
⭐review.repliedFired when an AI-generated host reply has been successfully posted back to the OTA via the auto-review publish path. Payload includes the body of the reply and the source review identifier.
json
{
"created_at": "2026-04-27T09:00:00Z",
"data": {
"auto_review_id": "01HFXBBBBBBBBBBBBBBBBBBB00",
"is_reviewee_recommended": true,
"listing_id": "aaaaaaaa-bbbb-4ccc-8ddd-eeeeffff0000",
"private_feedback": null,
"public_review": "Thanks for staying with us \u2014 you were a wonderful guest!",
"reservation_id": "11111111-2222-4333-8444-555566667777",
"source_review_id": "abnb-review-1234",
"submitted_at": "2026-04-27T09:00:00Z"
},
"event": "review.replied",
"id": "f6a7b8c9-d0e1-4234-8567-890abcdef125",
"schema_version": 1
}
🤖autopilot.escalationFired when autopilot hands a draft reply off to human review — reason is one of category_not_enabled / low_confidence / negative_sentiment. Payload reuses AutopilotEscalationNotification plus webhook-only fields (reason, sentiment_score, confidence_overall, suggestion_id, trigger_message_id, drafted_reply). Common pattern: route to Slack/PagerDuty for oncall handoff.
json
{
"created_at": "2026-04-24T14:55:00Z",
"data": {
"account_id": "aaaaaaaa-bbbb-4ccc-8ddd-eeeeffff0000",
"category_rule_enabled": true,
"confidence_overall": 0.74,
"conversation_id": "44444444-5555-4666-8777-888899990000",
"drafted_reply": "I'm sorry to hear that \u2014 let me look into this and get back to you within the hour.",
"guest_full_name": "Alex Rivera",
"listing_id": "aaaaaaaa-bbbb-4ccc-8ddd-eeeeffff0000",
"listing_title": "Sunny Loft Downtown",
"message_category": "complaint",
"reason": "negative_sentiment",
"sentiment_score": 0.18,
"suggestion_id": "cccccccc-dddd-4eee-8fff-000011112222",
"trigger_message_id": "33333333-4444-4555-8666-777788889999"
},
"event": "autopilot.escalation",
"id": "f6a7b8c9-d0e1-4234-8567-890abcdef11c",
"schema_version": 1
}
🤖autopilot.reply_sentInverse branch of autopilot.escalation — fired when autopilot accepts a draft reply and schedules it for delivery. Payload includes the eventual scheduled_message_id + scheduled_at so receivers can correlate the eventual message.sent event. Useful for AI message audit trails.
📡smart_device.alertFired by the Seam smart-device ingest path for high-signal alert events (currently noise_sensor.noise_threshold_triggered and lock.access_denied). The alert_type field on the payload echoes the upstream Seam event type so receivers can branch.
📝customer_feedback.submittedFired when a host or guest submits feedback through the in-app form, the MCP tool, or the AI chat bug-report tool. Payload mirrors the CustomerFeedback row.
json
{
"created_at": "2026-04-24T16:00:00Z",
"data": {
"ai_chat_session_id": null,
"created_at": "2026-04-24T16:00:00Z",
"description": "Pages 2+ skip the last item from the previous page.",
"id": "01HFXEEEEEEEEEEEEEEEEEEE00",
"source": "api",
"status": "submitted",
"title": "Reservation list pagination off-by-one",
"type": "bug_report",
"user_id": "99999999-aaaa-4bbb-8ccc-ddddeeeeffff"
},
"event": "customer_feedback.submitted",
"id": "f6a7b8c9-d0e1-4234-8567-890abcdef127",
"schema_version": 1
}
💸expense.createdFired when a new expense is created via the public or internal API. Payload mirrors the Expense row — amount is a decimal string for precision.
🔔webhook.verificationFired on first subscription creation and on every POST /webhooks/test call. Used to verify HMAC signing + endpoint reachability. data is best-effort metadata — receivers should key only on event == 'webhook.verification', not on specific data fields.
json
{
"created_at": "2026-04-24T14:50:00Z",
"data": {
"message": "Webhook subscription created \u2014 this is a verification ping.",
"subscription_id": "e5f6a7b8-c9d0-4123-8456-7890abcdef12",
"url": "https://example.com/hooks/prohost"
},
"event": "webhook.verification",
"id": "f6a7b8c9-d0e1-4234-8567-890abcdef11b",
"schema_version": 1
}
Recipes
How to wire a ProhostAI subscription into the three destinations we get
asked about most. Click a tab to expand.
Zapier’s Catch Hook trigger ingests ProhostAI
webhooks directly — no glue code required to land the event.
Catch Hook does not verify HMAC signatures, though, so
anyone who learns the URL can post arbitrary events into your Zap.
Either accept that risk for low-value automation, or add a
Code by Zapier step that verifies before
branching.
Create the Zap — pick
Webhooks by Zapier → Catch Hook as the trigger.
Copy the https://hooks.zapier.com/hooks/catch/…
URL.
Subscribe in ProhostAI — paste that URL into
Settings → Webhooks, pick the events you want, save.
Sample the right schema — in ProhostAI, fire
Send test on one of the events you actually subscribed to
so Zapier samples the real shape. The auto-fired
webhook.verification event has a generic stub body
that won’t match any real event — if Zapier samples that,
your downstream steps see the wrong fields.
(Optional) Verify the signature — HMAC
verification needs the raw request bytes, but standard
Catch Hook parses the JSON body and discards the raw
payload — raw_body is not exposed. To verify,
switch the trigger to Webhooks by Zapier → Catch Raw
Hook (it surfaces raw_body and the request
headers) and re-parse the JSON yourself in a Code by Zapier
→ Run Python step. Downstream Zap steps then read
fields off the dict you build, not the trigger.
python
# Trigger: Webhooks by Zapier → Catch Raw Hook
import hmac, hashlib, json
secret = "whsec_your_signing_secret" # set in Zapier env
raw_body = input_data["raw_body"] # only available from Catch Raw Hook
sig = input_data["x_webhook_signature"].removeprefix("sha256=")
expected = hmac.new(secret.encode(), raw_body.encode(), hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, sig):
raise ValueError("Invalid signature — drop")
# Re-parse so downstream steps can map data.* fields
event = json.loads(raw_body)
output = {"verified": True, "event": event["event"], "data": event["data"]}
Sticking with Catch Hook? Skip this step —
HMAC verification isn’t possible without the raw
bytes. Anyone who learns the Zap URL can post arbitrary
events; accept that for low-value automation only.
Slack does NOT accept ProhostAI webhooks directly.
ProhostAI sends an envelope shaped
{ "event": "…", "id": "…", "data": { … } };
Slack’s incoming-webhook endpoint requires
{ "text": "…" } or a blocks: […]
array. If you paste a
https://hooks.slack.com/services/… URL into ProhostAI
it will 400 on every delivery.
Pick the easiest of these three options:
Slack Workflow Builder — webhook trigger.
Inside Slack, open Tools → Workflow Builder, create a
workflow with a From a webhook trigger, define variables
that match the ProhostAI envelope (event,
data.guest_full_name, etc.), then add a
Send a message step. Paste the workflow’s URL into
ProhostAI. No code, no middleman, but limited formatting.
Zapier middleman.
Catch Hook (above) → Slack — Send Channel Message
action. Lets you template {{event}} /
{{data__listing_title}} into the Slack message. Pays
a Zapier task per delivery.
Custom Cloudflare Worker.
See the next tab. Best fit when you want HMAC verification +
rich Slack blocks formatting and you don’t want
to pay per event.
A complete reference Worker: verify the HMAC, branch on
event, and post a Slack blocks message
for autopilot.escalation. Drop into a fresh
wrangler init project, set
WEBHOOK_SECRET and SLACK_WEBHOOK_URL as
secrets, paste the Worker URL into ProhostAI, done.
Need to manage subscriptions programmatically? Every dashboard action
has an API equivalent — see POST /v1/webhooks,
POST /v1/webhooks/test, and the per-event response
examples on the API Reference tab.