Skip to content

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 = true

Most channel apps also request:

  • read_properties and read_rates to discover inventory and back-fill rates on first connect (see Backfill).
  • property.updated in webhooks to 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

  1. 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.
  2. 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.
  3. Register the integration. Once connected, call channelIntegrationCreate for each property the host wants to sync, passing the external account/property id on the OTA side.
  4. Link listings. Call channelListingLink for 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.
  5. Sync runs. Stayblox pushes ARI to endpoints.ari_push whenever rates, availability, or restrictions change on the linked unit types. Your app submits reservations via reservationUpsert as they come in from the OTA.
  6. Disconnect. channelIntegrationDisconnect stops sync for one integration; its history (reservations, mappings) is preserved.
  7. 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

HeaderValue
Content-Typeapplication/json
X-Stayblox-TeamThe installing team's slug.
X-Stayblox-TimestampUnix timestamp (seconds) of the request.
X-Stayblox-Signaturesha256=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"
}
FieldDescription
external_property_idThe property/account id on your OTA account, as passed to channelIntegrationCreate.
listingsOne 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_idThe OTA room type id, as passed to channelListingLink.
listings[].external_rate_plan_idThe OTA rate plan id, or null if the OTA doesn't use rate plans.
listings[].datesThe full pushed window, one entry per date.
dates[].dateISO date (YYYY-MM-DD).
dates[].rateNightly rate in the property's currency.
dates[].availabilityNumber of units of this type open for arrival on this date.
dates[].min_stayMinimum stay length in nights (defaults to 1 when unset).
dates[].max_stayMaximum stay length in nights, or null if unset.
dates[].closed_to_arrival / closed_to_departureWhether a stay may start/end on this date.
dates[].stop_sellWhether this date is closed for sale regardless of availability.
api_base_urlThe 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." }
FieldRequiredValuesDescription
statusYes"applied" or "failed"Whether the push was accepted by the OTA.
errorWhen status is "failed"stringHuman-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:

StatusMeaning
syncedThe reservation was mapped to a Stayblox booking. bookingId is set.
unmappedOne of the rooms in the reservation has no matching channelListingLink for its externalRoomTypeId on this integration. No booking is created for this submission.
cancelledThe 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", the provide_channel scope, and a valid endpoints.ari_push URL.
  • [ ] ari_push handler verifies the HMAC signature and responds { "status": "applied" } or { "status": "failed", "error": "..." } within the timeout.
  • [ ] channelIntegrationCreate and channelListingLink are called for every property/unit type the host connects.
  • [ ] reservationUpsert is called for every new, modified, and cancelled reservation, with a monotonic revisionId.
  • [ ] Per-night prices in rooms[].nights reflect what the guest actually paid on the OTA.
  • [ ] unmapped results are handled by linking the missing room type and resubmitting.
  • [ ] unitTypeRates is used to back-fill the calendar on first connect.
  • [ ] userErrors is checked on every mutation.

© Stayblox — Developer Platform