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's manifest:
json
{
"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 (the same one shown under Connection details, see Getting started). Both are per install, so one server can receive events for every host that installs your app — key your handling by the X-Stayblox-TeamApp header.
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. |
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 |
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_id": 42,
"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_id | 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 to the resource — { "type": "message", "id": 88, "direction": "inbound" } — so you can ignore your own outbound traffic without a fetch.
Digest topics
rates.updated and availability.changed would fire constantly during bulk calendar edits, so they are debounced: changes buffer until a team + topic has been quiet for about 60 seconds, then one digest event is emitted. Its resource is 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.
Verifying deliveries
Webhooks use the platform-wide HMAC scheme — 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-TeamAppX-Stayblox-SignatureX-Stayblox-Timestamp
Headers on platform → app event webhooks:
X-Stayblox-SignatureX-Stayblox-TimestampX-Stayblox-Event-IdX-Stayblox-TopicX-Stayblox-TeamApp
A minimal Node.js receiver — verify, acknowledge fast, 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