Appearance
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:
| Topic | Resource | Required scope | Notes |
|---|---|---|---|
booking.created | booking | read_bookings | |
booking.confirmed | booking | read_bookings | |
booking.cancelled | booking | read_bookings | |
booking.checked_in | booking | read_bookings | |
booking.checked_out | booking | read_bookings | |
booking.no_show | booking | read_bookings | |
booking_request.created | booking_request | read_bookings | |
contact.created | contact | read_contacts | |
contact.updated | contact | read_contacts | |
conversation.created | conversation | read_conversations | |
message.created | message | read_conversations | Resource carries "direction": "inbound" or "outbound". |
rates.updated | unit_type_rates digest | read_rates | Debounced; resource is a date span, not an id. |
availability.changed | unit_type_availability digest | read_rates | Debounced; resource is a date span, not an id. |
property.updated | property digest | read_properties | Debounced; resource is a list of property ids, not an id. |
payment.succeeded | payment | read_payments | |
payment.failed | payment | read_payments | |
payment.refunded | payment | read_payments | |
invoice.created | invoice | read_invoices | |
invoice.voided | invoice | read_invoices | |
review.created | review | read_reviews | |
task.created | task | read_tasks | |
task.assigned | task | read_tasks | |
task.status_changed | task | read_tasks | |
task.completed | task | read_tasks | |
task.overdue | task | read_tasks | |
task.assignment_requested | task | read_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 }
}| Field | Description |
|---|---|
event_id | ULID, unique per event. Use it as your idempotency key; the same event can be delivered more than once (retries, replays). |
topic | The topic string, also sent as X-Stayblox-Topic. |
occurred_at | When the event happened, UTC ISO 8601. |
api_version | The current Developer API version at emit time. |
team | The slug of the installing team (host) the event belongs to. |
resource | What 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-TeamX-Stayblox-SignatureX-Stayblox-Timestamp
Headers on platform → app event webhooks:
X-Stayblox-SignatureX-Stayblox-TimestampX-Stayblox-Event-IdX-Stayblox-TopicX-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_idfor 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
- Verify signatures end to end → Signing & security
- Fetch the state behind an event → GraphQL reference