Appearance
Channel apps
A channel app connects a Stayblox property to an OTA (Airbnb, VRBO, Booking.com, or any channel you support). Stayblox is the source of truth for rates and availability: it pushes ARI (availability, rates, inventory) to your server as it changes. Your app is the source of truth for reservations: when a guest books on the OTA, you submit the reservation back to Stayblox.
There is no availability write-back. Your app never tells Stayblox what's available; it only reports bookings that consume availability Stayblox already pushed you.
Like payment apps, a channel app authenticates with a per-install bearer token and the same HMAC signing scheme. See Signing & security for verification details.
1. Declare your app (manifest)
Set type = "channel" and add the provide_channel scope. The scope requires endpoints.ari_push, the HTTPS URL Stayblox pushes ARI to.
toml
name = "Acme Channel Manager"
type = "channel"
distribution = "public"
slug = "acme-channel-manager"
scopes = ["provide_channel", "read_properties", "read_rates"]
webhooks = ["property.updated"]
webhook_url = "https://app.acme.example/webhooks/stayblox"
[oauth]
redirect_uris = ["https://app.acme.example/auth/stayblox/callback"]
[endpoints]
ari_push = "https://app.acme.example/stayblox/ari-push"
[[settings_schema]]
key = "account_id"
type = "string"
label = "Acme account ID"
required = trueMost channel apps also request:
read_propertiesandread_ratesto discover inventory and back-fill rates on first connect (see Backfill).property.updatedinwebhooksto re-sync listing content when it changes on the host side (see Listing content).
A channel app can also provide guest messaging on the same install by adding provide_inbox_channel, [[channels]], and endpoints.message_send. That is a separate protocol; see Inbox channel-provider apps.
2. Lifecycle
- Install. The host installs your app (OAuth or a direct install link), same as any other app. You receive a runtime token and a webhook secret.
- Connect the OTA account. Your app runs its own connect flow (its own OAuth against the OTA, or an API key form). Stayblox has no part in this step.
- Register the integration. Once connected, call
channelIntegrationCreatefor each property the host wants to sync, passing the external account/property id on the OTA side. - Link listings. Call
channelListingLinkfor each unit type, mapping it to the OTA's room type (and rate plan, if the OTA has them). Linking triggers an initial full ARI push for that unit type. - Sync runs. Stayblox pushes ARI to
endpoints.ari_pushwhenever rates, availability, or restrictions change on the linked unit types. Your app submits reservations viareservationUpsertas they come in from the OTA. - Disconnect.
channelIntegrationDisconnectstops sync for one integration; its history (reservations, mappings) is preserved. - Uninstall. If the host uninstalls your app entirely, every integration it owns is disabled and sync stops for all of them.
3. Receiving ARI (platform to app)
Whenever a linked unit type's rates, availability, or restrictions change, Stayblox POSTs a signed command to your endpoints.ari_push URL with the full current window for that listing.
Request headers
| Header | Value |
|---|---|
Content-Type | application/json |
X-Stayblox-Team | The installing team's slug. |
X-Stayblox-Timestamp | Unix timestamp (seconds) of the request. |
X-Stayblox-Signature | sha256=HMAC_SHA256("{timestamp}.{raw_body}", webhook_secret) |
Verify the signature before processing (same algorithm used for webhooks; see Signing & security).
Request body
jsonc
{
"external_property_id": "12345678",
"listings": [
{
"external_room_type_id": "987654",
"external_rate_plan_id": "rp_standard",
"dates": [
{
"date": "2026-07-01",
"rate": 128.0,
"availability": 2,
"min_stay": 2,
"max_stay": null,
"closed_to_arrival": false,
"closed_to_departure": false,
"stop_sell": false
},
{
"date": "2026-07-02",
"rate": 128.0,
"availability": 2,
"min_stay": 2,
"max_stay": null,
"closed_to_arrival": false,
"closed_to_departure": false,
"stop_sell": false
}
]
}
],
"api_base_url": "https://api.stayblox.com/developer/api/2026-01/graphql"
}| Field | Description |
|---|---|
external_property_id | The property/account id on your OTA account, as passed to channelIntegrationCreate. |
listings | One entry per listing included in this push. A push only covers the listing(s) it names; it may not include every unit type linked on the integration, so treat it as an incremental update rather than the full set of linked listings. |
listings[].external_room_type_id | The OTA room type id, as passed to channelListingLink. |
listings[].external_rate_plan_id | The OTA rate plan id, or null if the OTA doesn't use rate plans. |
listings[].dates | The full pushed window, one entry per date. |
dates[].date | ISO date (YYYY-MM-DD). |
dates[].rate | Nightly rate in the property's currency. |
dates[].availability | Number of units of this type open for arrival on this date. |
dates[].min_stay | Minimum stay length in nights (defaults to 1 when unset). |
dates[].max_stay | Maximum stay length in nights, or null if unset. |
dates[].closed_to_arrival / closed_to_departure | Whether a stay may start/end on this date. |
dates[].stop_sell | Whether this date is closed for sale regardless of availability. |
api_base_url | The Developer GraphQL endpoint for this app's calls (e.g. reservationUpsert). |
Response
Apply the push and respond 200 with a JSON body:
jsonc
{ "status": "applied" }or, if you couldn't apply it:
jsonc
{ "status": "failed", "error": "OTA account is not authorized." }| Field | Required | Values | Description |
|---|---|---|---|
status | Yes | "applied" or "failed" | Whether the push was accepted by the OTA. |
error | When status is "failed" | string | Human-readable reason, surfaced to the host in their sync health view. |
Any response other than {"status": "applied"} is treated as a failure: a non-200 status, a network timeout, or an explicit {"status": "failed", ...} all count. Failed pushes are retried up to 3 times with 30s / 2m / 8m backoff, and the integration's error surfaces in the host's sync health view until a push succeeds.
4. Submitting reservations (app to platform)
When a reservation is created, modified, or cancelled on the OTA, call reservationUpsert with your install's runtime token.
graphql
mutation UpsertReservation($input: ReservationInput!) {
reservationUpsert(input: $input) {
reservation { id externalId status bookingId }
userErrors { field message }
}
}New reservation
jsonc
{
"input": {
"integrationId": "51",
"externalId": "HMABCD1234",
"revisionId": "2026-07-01T10:00:00Z",
"status": "NEW",
"checkIn": "2026-07-10",
"checkOut": "2026-07-14",
"currency": "USD",
"paymentCollect": "OTA",
"externalPaymentId": "pi_ota_9182",
"guest": {
"firstName": "Jordan",
"lastName": "Lee",
"email": "[email protected]",
"phone": "+15551234567"
},
"totalAmount": 640.0,
"rooms": [
{
"externalRoomTypeId": "987654",
"externalRatePlanId": "rp_standard",
"adults": 2,
"children": 0,
"nights": [
{ "date": "2026-07-10", "price": 160.0 },
{ "date": "2026-07-11", "price": 160.0 },
{ "date": "2026-07-12", "price": 160.0 },
{ "date": "2026-07-13", "price": 160.0 }
]
}
]
}
}Modified reservation
Send the same externalId with a newer revisionId and status: "MODIFIED"; the full reservation shape (dates, rooms, nights) again, since this is a full-state upsert rather than a diff.
jsonc
{
"input": {
"integrationId": "51",
"externalId": "HMABCD1234",
"revisionId": "2026-07-02T09:15:00Z",
"status": "MODIFIED",
"checkIn": "2026-07-11",
"checkOut": "2026-07-14",
"currency": "USD",
"paymentCollect": "OTA",
"guest": { "firstName": "Jordan", "lastName": "Lee", "email": "[email protected]" },
"totalAmount": 480.0,
"rooms": [
{
"externalRoomTypeId": "987654",
"adults": 2,
"children": 0,
"nights": [
{ "date": "2026-07-11", "price": 160.0 },
{ "date": "2026-07-12", "price": 160.0 },
{ "date": "2026-07-13", "price": 160.0 }
]
}
]
}
}Cancelled reservation
jsonc
{
"input": {
"integrationId": "51",
"externalId": "HMABCD1234",
"revisionId": "2026-07-03T18:40:00Z",
"status": "CANCELLED",
"checkIn": "2026-07-11",
"checkOut": "2026-07-14",
"currency": "USD",
"paymentCollect": "OTA",
"guest": { "firstName": "Jordan", "lastName": "Lee" },
"totalAmount": 480.0,
"rooms": [
{
"externalRoomTypeId": "987654",
"adults": 2,
"children": 0,
"nights": [
{ "date": "2026-07-11", "price": 160.0 },
{ "date": "2026-07-12", "price": 160.0 },
{ "date": "2026-07-13", "price": 160.0 }
]
}
]
}
}checkIn/checkOut/rooms are still required on a cancellation; only status needs to change for Stayblox to release the held inventory.
Revision semantics
revisionId is a monotonic identifier from your OTA (a timestamp or sequence number both work, as long as it sorts correctly as a string). Use a fixed-width, sortable format, such as zero-padded counters or ISO-8601 timestamps, so that lexicographic string comparison matches the true order (an unpadded 9 otherwise sorts after 10, breaking staleness detection). Each call is compared lexicographically against the reservation's stored revision: a revisionId that is not greater than what's stored is a safe no-op and returns the reservation as it already stands, with no userErrors. This makes reservationUpsert safe to call on redelivery or out-of-order webhooks from your OTA without special-casing them on your side.
Per-night pricing
Each room in rooms carries its own nights array with one price per night. Stayblox never re-prices a channel reservation. The amounts you send are recorded as-is, regardless of what Stayblox's own rate calendar says for those dates. Send the actual price the guest paid on the OTA.
Payment collection
paymentCollect tells Stayblox who collected the guest's payment: OTA if the channel collected it (pass externalPaymentId if you have one), or PROPERTY if the property collects it directly (e.g. pay-at-property bookings routed through the channel).
The unmapped status
The returned reservation.status is one of:
| Status | Meaning |
|---|---|
synced | The reservation was mapped to a Stayblox booking. bookingId is set. |
unmapped | One of the rooms in the reservation has no matching channelListingLink for its externalRoomTypeId on this integration. No booking is created for this submission. |
cancelled | The reservation was cancelled and its booking (if any) released. |
An unmapped result is not a userErrors failure. The call succeeds, but no booking exists yet. Link the missing room type with channelListingLink and resubmit the same reservation to resolve it.
5. Managing integrations
graphql
mutation CreateIntegration($input: ChannelIntegrationCreateInput!) {
channelIntegrationCreate(input: $input) {
integration { id externalPropertyId status listings { id unitTypeId externalRoomTypeId } }
userErrors { field message }
}
}jsonc
{
"input": {
"propertyId": "128",
"externalPropertyId": "12345678",
"settings": "{\"syncFrequencyMinutes\": 15}"
}
}settings is a JSON-encoded string of provider-specific configuration you want stored against the integration (not host-facing settings; those come from settings_schema). Pass null or omit it if you don't need any.
channelIntegrationCreate is idempotent per property: calling it again for a property this install already registered re-activates the integration and updates externalPropertyId/settings rather than creating a duplicate.
Query your integrations at any time:
graphql
query MyIntegrations {
channelIntegrations(first: 50) {
nodes { id propertyId externalPropertyId status lastSyncedAt lastError listings { id unitTypeId externalRoomTypeId externalRatePlanId } }
pageInfo { hasNextPage endCursor }
}
}status is one of pending, active, error, or disabled. lastError carries the most recent ari_push failure reason, if any.
Link and unlink individual unit types:
graphql
mutation LinkListing($input: ChannelListingLinkInput!) {
channelListingLink(input: $input) {
listing { id unitTypeId externalRoomTypeId externalRatePlanId }
userErrors { field message }
}
}jsonc
{
"input": {
"integrationId": "51",
"unitTypeId": "412",
"externalRoomTypeId": "987654",
"externalRatePlanId": "rp_standard"
}
}channelListingLink triggers an initial full ARI push for that unit type, so you don't need to call unitTypeRates yourself just to seed the very first sync. channelListingUnlink(id: ID!) removes a mapping; ARI stops flowing for that unit type on this integration.
Disconnect an integration (sync stops, mappings and reservation history are preserved):
graphql
mutation Disconnect($id: ID!) {
channelIntegrationDisconnect(id: $id) {
integration { id status }
userErrors { field message }
}
}6. Listing content
Beyond rates and availability, most OTAs need description-level content to list a property. property and properties expose it (requires read_properties):
graphql
query ListingContent($id: ID!) {
property(id: $id) {
name
address
city
country
countryCode
lat
lng
description
cancellationPolicy
checkInTime
checkOutTime
imageUrl
facilities { name scope icon }
unitTypes {
id
name
maxGuests
description
occAdults
occChildren
occInfants
photoUrls
facilities { name scope icon }
bedConfigurations { bedType quantity sleeps }
}
}
}facilities[].scope is property, unit, or both. checkInTime / checkOutTime are HH:MM in the property's local convention.
If you subscribed to property.updated, Stayblox delivers a debounced digest webhook whenever a property's listing content or its unit types' content changes: description, photos, facilities, beds, check-in/out times, and similar fields. Re-fetch the property via property/properties on delivery and push the refreshed content to the OTA; the webhook payload itself carries no field values, only which properties changed. See Webhooks for the envelope shape.
7. Backfill (unitTypeRates)
On first connect, before ongoing ari_push deliveries carry you forward, pull the current rate and availability calendar for a unit type with unitTypeRates (requires read_rates):
graphql
query Backfill($unitTypeId: ID!, $from: String!, $to: String!) {
unitTypeRates(unitTypeId: $unitTypeId, from: $from, to: $to) {
date
rate
available
minStay
maxStay
closedToArrival
closedToDeparture
stopSell
}
}unitTypeRates serves at most 366 days per call; page longer windows by date. Use it once to seed your OTA listing right after channelListingLink, then rely on ari_push for everything after that; you don't need to poll it on an ongoing basis.
Checklist
- [ ] Manifest declares
type = "channel", theprovide_channelscope, and a validendpoints.ari_pushURL. - [ ]
ari_pushhandler verifies the HMAC signature and responds{ "status": "applied" }or{ "status": "failed", "error": "..." }within the timeout. - [ ]
channelIntegrationCreateandchannelListingLinkare called for every property/unit type the host connects. - [ ]
reservationUpsertis called for every new, modified, and cancelled reservation, with a monotonicrevisionId. - [ ] Per-night prices in
rooms[].nightsreflect what the guest actually paid on the OTA. - [ ]
unmappedresults are handled by linking the missing room type and resubmitting. - [ ]
unitTypeRatesis used to back-fill the calendar on first connect. - [ ]
userErrorsis checked on every mutation.