Skip to content

Webhooks

Stayblox pushes events to your server as they happen. Deliveries are thin: the signed JSON envelope tells you what changed, and you fetch the current state through the GraphQL API. That keeps payloads stable across API versions and means a missed webhook never leaves you with stale data; the fetch always returns the latest truth.

Subscribing

Subscriptions are declared in your app.toml:

toml
scopes = ["read_bookings", "read_rates"]
webhooks = ["booking.confirmed", "booking.cancelled", "rates.updated"]
  • webhooks: the topic strings you want delivered.
  • scopes: the access scopes your app holds. A topic is only delivered if the app holds its required scope; subscribing without the scope silently delivers nothing.

Deliveries are POSTs to the install's webhook URL, signed with the install's webhook secret (issued once when you run stayblox app install --team <slug> and not independently rotatable; to get a new secret, reinstall using stayblox app uninstall --team <slug> then stayblox app install --team <slug>). Both the URL and the signing key are per install, so one server can receive events for every host that installs your app. Key your verification by the X-Stayblox-App header, which carries the app slug.

Topics

Topics an app may subscribe to (from app/Enums/WebhookTopic.php). A topic is only delivered if the app's manifest also holds its required scope:

TopicResourceRequired scopeNotes
booking.createdbookingread_bookings
booking.confirmedbookingread_bookings
booking.cancelledbookingread_bookings
booking.checked_inbookingread_bookings
booking.checked_outbookingread_bookings
booking.no_showbookingread_bookings
booking_request.createdbooking_requestread_bookings
contact.createdcontactread_contacts
contact.updatedcontactread_contacts
conversation.createdconversationread_conversations
message.createdmessageread_conversationsResource carries "direction": "inbound" or "outbound".
rates.updatedunit_type_rates digestread_ratesDebounced; resource is a date span, not an id.
availability.changedunit_type_availability digestread_ratesDebounced; resource is a date span, not an id.
property.updatedproperty digestread_propertiesDebounced; resource is a list of property ids, not an id.
payment.succeededpaymentread_payments
payment.failedpaymentread_payments
payment.refundedpaymentread_payments
invoice.createdinvoiceread_invoices
invoice.voidedinvoiceread_invoices
review.createdreviewread_reviews
task.createdtaskread_tasks
task.assignedtaskread_tasks
task.status_changedtaskread_tasks
task.completedtaskread_tasks
task.overduetaskread_tasks
task.assignment_requestedtaskread_tasks

The envelope

Every delivery carries the same envelope shape:

json
{
  "event_id": "01J9XYZ...",
  "topic": "booking.confirmed",
  "occurred_at": "2026-06-11T14:02:11Z",
  "api_version": "2026-01",
  "team": "8cs3o-qe",
  "resource": { "type": "booking", "id": 1234 }
}
FieldDescription
event_idULID, unique per event. Use it as your idempotency key; the same event can be delivered more than once (retries, replays).
topicThe topic string, also sent as X-Stayblox-Topic.
occurred_atWhen the event happened, UTC ISO 8601.
api_versionThe current Developer API version at emit time.
teamThe slug of the installing team (host) the event belongs to.
resourceWhat changed: a type plus an id for entity topics. Fetch the current state via the API; the envelope never carries entity fields.

message.created adds a direction field to the resource object: { "type": "message", "id": 88, "direction": "inbound" }. This lets you ignore your own outbound traffic without a fetch.

Digest topics

rates.updated, availability.changed, and property.updated would fire constantly during bulk edits, so they are debounced: changes buffer until a team + topic has been quiet for about 60 seconds, then one digest event is emitted. rates.updated and availability.changed resources are a span instead of an id:

json
{ "type": "unit_type_rates", "unit_type_ids": [3, 7], "from": "2026-07-01", "to": "2026-07-31" }

The from/to span is the union across the listed unit types. Re-fetch each one via unitTypeRates for the span to get exact values.

property.updated fires when a property's listing content changes (description, photos, facilities, beds, check-in/out times, and similar fields on the property or its unit types). Its resource is a list of ids instead of a span:

json
{ "type": "property", "property_ids": [12, 45] }

Re-fetch each listed property via property to get its current content.

Verifying deliveries

Webhooks use the platform-wide HMAC scheme, the same as session calls, verified against the raw request body. The full walkthrough (and a PHP example) lives in Signing & security.

Algorithm: HMAC-SHA256, keyed with the install's webhook secret.

Signed string: {timestamp}.{body} — the request timestamp, a literal dot, then the raw JSON body.

Signature header value: sha256=<hex digest>

Headers on platform → app session calls:

  • X-Stayblox-Team
  • X-Stayblox-Signature
  • X-Stayblox-Timestamp

Headers on platform → app event webhooks:

  • X-Stayblox-Signature
  • X-Stayblox-Timestamp
  • X-Stayblox-Event-Id
  • X-Stayblox-Topic
  • X-Stayblox-App

A minimal Node.js receiver. Verify, acknowledge fast, then process after responding:

js
import crypto from 'node:crypto'
import express from 'express'

const app = express()

app.post('/webhooks/stayblox', express.raw({ type: 'application/json' }), (req, res) => {
  const timestamp = req.header('X-Stayblox-Timestamp') ?? ''
  const signature = req.header('X-Stayblox-Signature') ?? ''
  const secret = process.env.STAYBLOX_WEBHOOK_SECRET

  // The signature covers the exact raw body bytes. Never re-serialize JSON.
  const expected =
    'sha256=' + crypto.createHmac('sha256', secret).update(`${timestamp}.${req.body}`).digest('hex')

  const ok =
    signature.length === expected.length &&
    crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected))

  if (!ok) return res.status(401).end()

  // X-Stayblox-Timestamp is unix seconds; reject stale deliveries to bound replays.
  if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return res.status(401).end()

  res.status(200).end() // acknowledge before doing any work

  const event = JSON.parse(req.body)
  // Queue for processing, deduplicated by event.event_id.
})

Delivery & retries

  • Respond 2xx quickly. Requests time out after 15 seconds; do your work after acknowledging, not before.
  • Failed deliveries retry up to 3 attempts with 30s / 2m / 8m backoff.
  • Use event_id for idempotency; duplicates are possible after retries and replays.
  • If every delivery to an install keeps failing for about 3 days, webhooks for that install are auto-disabled and both the host and you (the app owner) are notified. The host re-enables them from the app's settings page once the endpoint is fixed; events missed while disabled can be replayed by support.

Next

© Stayblox — Developer Platform