--- layout: home hero: name: Stayblox Developer Platform text: Build for Stayblox tagline: "Three ways to build on Stayblox: apps over our Developer GraphQL API, themes that skin a storefront, or AI agents that book stays over UCP." features: - title: 🧩 Build apps details: Remote integrations that talk to the Developer GraphQL API. Accept payments, sync channels, ingest tasks, or inject storefront snippets. Get a per-install token and build on webhooks, metafields, and embedded pages. link: /apps/ linkText: Start building apps - title: 🎨 Build themes details: Twig storefront themes that hosts install to skin their public website. Author templates, expose host-configurable settings, and publish to the marketplace with the Stayblox CLI, or upload a private custom theme straight to a site. link: /themes/ linkText: Start building themes - title: πŸ€– Build for agents details: Every storefront speaks the Universal Commerce Protocol. Discover it at /.well-known/ucp, search live availability over MCP, and place held bookings your users pay for on the host's own page. There is nothing to install and no API key. link: /agents/ linkText: Book stays with agents --- ## Three surfaces, kept separate Stayblox has three distinct developer surfaces. They don't overlap: different audiences, different protocols, different docs. Pick the one that matches what you're building: | You want to… | Build a(n)… | Start here | | --- | --- | --- | | Accept payments, sync an external channel, inject analytics | **App** | [Apps β†’](/apps/) | | Change how a storefront looks and renders | **Theme** | [Themes β†’](/themes/) | | Let an AI assistant search availability and book stays | **Agent** | [AI agents β†’](/agents/) | --- # AI agents & UCP Every Stayblox storefront speaks the [Universal Commerce Protocol](https://ucp.dev) (UCP), the open standard AI assistants use to discover, price, and buy from businesses. If you build an agent (or integrate a UCP client), you can search a storefront's live availability, hold a priced checkout session, and place a real booking. The host stays merchant of record; the guest pays on the storefront's own payment page. There is nothing to install and no API key to request: discovery is automatic on every storefront domain that has agent bookings enabled. ## Discovery Fetch the storefront's UCP profile (public, no auth): ``` GET https://{storefront-domain}/.well-known/ucp ``` ```json { "ucp": { "version": "2026-04-08", "services": { "dev.ucp.shopping": [ { "version": "2026-04-08", "transport": "mcp", "endpoint": "https://stay.example.com/api/mcp", "schema": "https://ucp.dev/2026-04-08/services/shopping/mcp.openrpc.json" } ] }, "capabilities": { "dev.ucp.shopping.catalog.search": [{ "version": "2026-04-08" }], "dev.ucp.shopping.catalog.lookup": [{ "version": "2026-04-08" }], "dev.ucp.shopping.checkout": [{ "version": "2026-04-08" }] }, "payment_handlers": {} }, "signing_keys": [{ "kty": "EC", "crv": "P-256", "alg": "ES256", "use": "sig", "kid": "key_…" }] } ``` The single service binding is an **MCP server** (Streamable HTTP) at `/api/mcp` on the same domain. The capability list is live per storefront: | Profile shows | Means | | --- | --- | | `catalog.search` + `catalog.lookup` | You can always discover and price stays. | | `dev.ucp.shopping.checkout` | The host takes **instant bookings**: you can hold and complete checkouts. | | No checkout capability | The host reviews booking requests manually. Discover and deep-link; don't attempt checkout (the tools aren't registered). | | `404` on the profile | The host has agent bookings switched off, or the domain isn't a storefront. | | `503` on the profile | The site is in maintenance mode. Retry after the `Retry-After` interval. | `payment_handlers` is empty in v1: **agents never handle payment**. Completing a checkout returns a payment link for the guest (see [Checkout & booking](/agents/checkout)). ## The booking model 1. [Search the catalog](/agents/catalog) with dates and party size. Results are priced, bookable **stay item ids**. 2. [Create a checkout](/agents/checkout) with a stay item and the buyer's name and email. You get a session with itemized totals (taxes and fees included). 3. Complete it. That places a **held booking** (pending, unpaid) and returns an `order.permalink_url` where the guest pays. 4. The hold is released automatically if the guest never pays (typically after ~2 hours), exactly like an abandoned web checkout. Amounts are always **integers in the currency's minor unit** (`12345` = €123.45), per the UCP `amount` schema. ## Limits and lifecycle | What | Value | | --- | --- | | Rate limit | 60 requests/min per IP + storefront domain | | Checkout session lifetime | 24 h from last mutation, then `canceled` | | Unpaid booking hold | Released by the storefront's abandoned-booking sweep (~2 h) | | Idempotency | `complete_checkout` retries return the original order, never a duplicate booking | ## ACP (REST checkout) Storefronts that also speak [ACP](https://www.agenticcommerce.dev) (the Agentic Commerce Protocol) advertise a REST checkout surface alongside this profile: look for a top-level `acp` object with a `checkout_url`. It's a separate wire format over the same booking engine, useful if your platform is ACP-native rather than UCP-native. See [ACP checkout](/agents/acp) for the full endpoint reference, auth, and the v1 payment posture. ## Spec version and the lodging extension Storefronts pin UCP **v2026-04-08** with the generic shopping schemas. UCP's official lodging vertical is not yet published, so stay parameters (dates, party size) ride inside the catalog item id; see [stay item ids](/agents/catalog#stay-item-ids). When the lodging extension lands, storefronts will adopt it additively; the profile's date-versioned capability declarations are how you'll detect it. Not in v1 (roadmap): tokenized in-chat payments, identity linking (OAuth), push order webhooks, and a REST binding. --- # ACP checkout Stayblox storefronts can also speak [ACP](https://www.agenticcommerce.dev) (the Agentic Commerce Protocol behind OpenAI's Instant Checkout and the wider ChatGPT commerce ecosystem). If your platform is ACP-native rather than UCP-native, use this REST surface instead of the [MCP checkout tools](/agents/checkout). Both sit on the same booking engine, so pricing, availability, and the held-booking behavior are identical either way; only the wire format differs. Discovery and catalog search still happen over [UCP/MCP](/agents/); ACP only replaces the checkout half. Get your stay item ids from `search_catalog` on the storefront's MCP endpoint, then check out against the ACP endpoints below. Unlike UCP, ACP is opt-in per storefront: a host has to switch it on and issue you an API key before you can check out against their site. ## Discovery Storefronts that enable ACP add an `acp` object to their [UCP profile](/agents/#discovery): ``` GET https://{storefront-domain}/.well-known/ucp ``` ```json { "ucp": { "...": "..." }, "acp": { "version": "2026-04-17", "checkout_url": "https://stay.example.com/api/acp/checkout_sessions" } } ``` The `acp` key only appears when the host has ACP enabled **and** takes instant bookings. No `acp` key means this storefront doesn't support ACP checkout right now. ## Base URL and versioning All five endpoints hang off the same host as `checkout_url`: ``` https://{storefront-domain}/api/acp ``` Send `API-Version: 2026-04-17` (current) or `2026-01-30` on every request. Omit the header and Stayblox assumes the current version; send anything else and you get a `400 unsupported_api_version`. Every response echoes the resolved version back in the `API-Version` header. ## Auth Access is a manual, per-store step, not a self-serve signup: the host enables ACP for their storefront and generates an API key from their dashboard settings, then hands it to you (typically during your platform's merchant onboarding). Send it as a bearer token: ``` Authorization: Bearer ``` A missing or wrong key returns `401 unauthorized`. ### Optional request signing Hosts can additionally configure an inbound signing secret. When they have, every request must also carry: ``` Signature: sha256= Timestamp: ``` The signature covers `"{timestamp}.{raw request body}"`, hashed with the shared secret. Stayblox rejects timestamps more than 5 minutes off and any signature that doesn't verify, both as `401 invalid_signature`. If the host hasn't configured a secret, these headers are ignored and the bearer key alone gates access. ## Rate limit 60 requests/min per IP and storefront domain, the same budget as the MCP surface. ## Item ids Line items reference the same **stay item ids** the storefront's MCP catalog returns from `search_catalog` (see [stay item ids](/agents/catalog#stay-item-ids)): ``` stay:{unit_type_id}:{check_in}:{check_out}:{adults}:{children} ``` Treat them as opaque: take ids from `search_catalog` / `lookup_catalog` on `/api/mcp` and pass them to ACP unchanged. ## The five endpoints | Method & path | Does | | --- | --- | | `POST /checkout_sessions` | Create a session: price `line_items` (+ optional `buyer`). | | `GET /checkout_sessions/{id}` | Fetch current session state. | | `POST /checkout_sessions/{id}` | Update `line_items` and/or `buyer` (per-key semantics); reprices. | | `POST /checkout_sessions/{id}/complete` | Place the held booking. | | `POST /checkout_sessions/{id}/cancel` | Cancel an open session. | Session ids are opaque UUIDs. ## The session object | Field | Notes | | --- | --- | | `id` | Pass it to the other four endpoints. | | `status` | See below. | | `currency` | Lowercase ISO code, e.g. `"eur"`. | | `line_items` | `id`, `item` (`{id, quantity}`), `name`, and `base_amount` / `discount` / `subtotal` / `tax` / `total` in minor units. | | `buyer` | Present once you've set one; absent before that. | | `totals` | Session-level, itemized (see below). | | `fulfillment_options` | Always `[]`. Lodging has nothing to ship; `fulfillment_address` and `fulfillment_option_id` on requests are accepted and ignored. | | `messages` | Business-level problems (see [Errors](#errors)). | | `links` | The storefront's policy pages (terms, privacy, etc.) when the host has them configured. | | `capabilities` | Constant in v1: `payment.handlers` is always `[]`, `interventions.supported` is `[]`, `extensions` is `[]`. | | `order` | Only once `status` is `completed` (see [payment posture](#payment-pay-by-link-not-delegated-checkout)). | **Statuses:** | Status | Means | | --- | --- | | `not_ready_for_payment` | Missing or invalid buyer info, or a line item problem. | | `ready_for_payment` | Priced and ready to complete. | | `complete_in_progress` | Completion is being processed (transient). | | `completed` | Booking placed. | | `canceled` | Canceled explicitly, or after 24 h of inactivity. | | `requires_escalation` | Reserved, unused in v1. | ## Totals Every amount is an **integer in the currency's minor unit** (`30000` = €300.00). Session `totals[]` are typed entries: `items_base_amount`, an optional `discount`, `subtotal`, one `fee` entry per mandatory fee, one `tax` entry per tax added on top of the rate, and `total`. They sum exactly; render them as given, don't recompute. ## Walkthrough Create a session with a stay item, no buyer yet: ``` POST /api/acp/checkout_sessions Authorization: Bearer sk_live_... API-Version: 2026-04-17 ``` ```json { "line_items": [ { "item": { "id": "stay:42:2026-08-10:2026-08-13:2:0" }, "quantity": 1 } ] } ``` ```json { "id": "0b7e9a2c-4f3d-4e8a-9c1b-7a2d6e5f0b91", "status": "not_ready_for_payment", "currency": "eur", "line_items": [ { "id": "li_0", "item": { "id": "stay:42:2026-08-10:2026-08-13:2:0", "quantity": 1 }, "name": "Seaside Resort β€” Deluxe Suite", "base_amount": 30000, "discount": 0, "subtotal": 30000, "tax": 0, "total": 30000 } ], "totals": [ { "type": "items_base_amount", "display_text": "Items", "amount": 30000 }, { "type": "subtotal", "display_text": "Subtotal", "amount": 30000 }, { "type": "total", "display_text": "Total", "amount": 30000 } ], "fulfillment_options": [], "messages": [ { "type": "error", "code": "missing", "content_type": "plain", "content": "Buyer first_name, last_name and email are required.", "param": "$.buyer" } ], "links": [], "capabilities": { "payment": { "handlers": [] }, "interventions": { "supported": [] }, "extensions": [] } } ``` Add the buyer with an update call, same session id: ``` POST /api/acp/checkout_sessions/0b7e9a2c-4f3d-4e8a-9c1b-7a2d6e5f0b91 ``` ```json { "buyer": { "first_name": "Ada", "last_name": "Lovelace", "email": "ada@example.com" } } ``` The response repeats the session with `status: "ready_for_payment"`, a `buyer` object, and no more `missing` message. Complete it: ``` POST /api/acp/checkout_sessions/0b7e9a2c-4f3d-4e8a-9c1b-7a2d6e5f0b91/complete Idempotency-Key: 6d5a9c1e-8b2f-4a3d-9e7c-1f0b6a5d4c3b ``` ```json { "payment_data": { "provider": "stripe", "token": "vt_test_ignored" } } ``` ```json { "id": "0b7e9a2c-4f3d-4e8a-9c1b-7a2d6e5f0b91", "status": "completed", "order": { "id": "9G4T-X2LK", "checkout_session_id": "0b7e9a2c-4f3d-4e8a-9c1b-7a2d6e5f0b91", "permalink_url": "https://stay.example.com/pay/status/eyJpdiI6…" }, "messages": [ { "type": "info", "code": "payment_required", "content_type": "plain", "content": "Booking is held. Payment must be completed at the permalink to confirm the stay." } ] } ``` (Trimmed: the real response still carries `line_items`, `totals`, `links`, and `capabilities`.) ## Payment: pay by link, not delegated checkout `capabilities.payment.handlers` is always empty, so Stayblox never processes payment inside an ACP checkout. If a `payment_data` token still arrives on `complete`, Stayblox accepts the request and ignores the token: no card is charged. What `complete` actually does is place a **held, unpaid booking** and return `order.permalink_url`. Hand that link to the guest; they pay through the host's own payment page to confirm the stay. If nobody pays, the hold releases automatically after a couple of hours, the same as an abandoned checkout on the storefront itself. Send an `Idempotency-Key` header on `complete`. Retrying `complete` on a session that's already `completed` returns the same `order` again, never a duplicate booking. Delegated, in-chat payment is on the roadmap, not part of v1. ## Errors Two layers. Don't confuse them. **Protocol errors** are HTTP 4xx with a `{type, code, message, param}` body: the request itself was malformed. | Status | `code` | Cause | | --- | --- | --- | | 401 | `unauthorized` | Missing/wrong bearer key, or the store hasn't generated one. | | 401 | `invalid_signature` | Inbound signing is configured and the `Signature`/`Timestamp` check failed. | | 400 | `unsupported_api_version` | `API-Version` isn't one Stayblox accepts. | | 400 | `missing` | No `line_items` (or `items`) array on create. | | 404 | `not_found` | Unknown checkout session id. | | 405 | `not_cancelable` | Cancel called on a session that's already `completed` or completing. | **Business errors** ride inside a normal `2xx` session as `messages[]`: the session itself is fine, something about its contents isn't. | `code` | Typical cause | | --- | --- | | `missing` | Buyer fields absent or invalid (`param` points at `$.buyer` or a specific field). | | `invalid` | Mixed dates across line items, or mutating a session that's already finished. | | `out_of_stock` | The requested dates are no longer available, including a capacity race caught at `complete`. | Business errors are recoverable: fix the input (new dates, buyer info) and call `create` or `update` again. ## Limits and lifecycle | What | Value | | --- | --- | | Rate limit | 60 requests/min per IP + storefront domain | | Checkout session lifetime | 24 h from last mutation, then `canceled` | | Unpaid booking hold | Released by the storefront's abandoned-booking sweep (~2 h) | ## Not in v1 Delegated/in-chat payment, a lodging product feed and listing on ChatGPT itself, and order-update webhooks back to your platform. --- # Discovery & catalog The storefront's MCP server lives at `https://{storefront-domain}/api/mcp` (Streamable HTTP, `POST` only). Connect with a standard MCP client: send `initialize`, keep the `MCP-Session-Id` response header for subsequent calls, and use `Accept: application/json, text/event-stream`. Two catalog tools are always available wherever the [profile](/agents/) resolves: `search_catalog` and `lookup_catalog`. ## Request metadata Every UCP tool call should carry a `meta` argument identifying your agent: ```json { "meta": { "ucp-agent": { "profile": "https://your-platform.example/agent-profile.json" } } } ``` Calls without it still succeed but get a `warning` message in the result. `complete_checkout` and `cancel_checkout` additionally **require** `meta["idempotency-key"]` (see [Checkout & booking](/agents/checkout)). ## `search_catalog` Arguments (inside a `catalog` object): | Field | Type | Notes | | --- | --- | --- | | `check_in`, `check_out` | `Y-m-d` | Optional pair; `check_out` must be after `check_in`. Omit both to browse. | | `adults` | int β‰₯ 1 | Default 2. | | `children` | int β‰₯ 0 | Default 0. | | `units` | int β‰₯ 1 | Number of units of the same type. Default 1. | | `query` | string | Optional text match on unit-type or property name. | With dates, results are **bookable stays only**: availability, stay restrictions (min/max stay, closed arrival/departure), and party size are all enforced before an item is returned. ```json { "ucp": { "version": "2026-04-08" }, "products": [ { "id": "stay:42:2026-08-03:2026-08-10:2:0", "title": "Deluxe Suite at Seaside Villa", "price": 70000, "currency": "EUR", "description": "…", "available_units": 2, "check_in": "2026-08-03", "check_out": "2026-08-10", "nights": 7, "max_guests": 4, "url": "https://stay.example.com/accommodations/deluxe-suite" } ] } ``` `price` is the stay subtotal for the requested party and unit count, in minor units. Taxes and fees are itemized later, on the checkout session. Without dates, items are unit types (`"id": "unit_type:42"`) with a per-night `price` and `"price_unit": "per_night"`, and no availability claims. ## Stay item ids Until UCP publishes its lodging extension, the generic schemas carry no check-in/check-out fields, so the **item id encodes the whole stay**: ``` stay:{unit_type_id}:{check_in}:{check_out}:{adults}:{children} ``` Treat it as opaque: take ids from `search_catalog` results and pass them to `create_checkout` unchanged. `quantity` on the checkout line item is the number of units; all line items in one checkout must share the same dates. ## `lookup_catalog` One argument: `id`, in either form above. Returns the same item shape (`products` with a single entry) with live pricing, plus an `available` boolean for stay ids. Unknown ids return a tool error. --- # Checkout & booking Five UCP checkout tools are registered when the storefront takes instant bookings (the profile advertises `dev.ucp.shopping.checkout`): | Tool | Does | | --- | --- | | `create_checkout` | Price a stay into a session: `checkout.line_items` + optional `checkout.buyer`. | | `get_checkout` | Fetch current session state by `id`. | | `update_checkout` | Replace `line_items` and/or `buyer` (per-key PUT semantics); reprices. | | `complete_checkout` | Place the held booking. Requires `meta["idempotency-key"]`. | | `cancel_checkout` | Cancel an open session. Requires `meta["idempotency-key"]`. | ```json { "meta": { "ucp-agent": { "profile": "…" } }, "checkout": { "line_items": [{ "item": { "id": "stay:42:2026-08-03:2026-08-10:2:0" }, "quantity": 1 }], "buyer": { "first_name": "Ada", "last_name": "Lovelace", "email": "ada@example.com", "phone_number": "+359888123456" } } } ``` ## The session Responses are the full UCP checkout resource: `id` (pass it to the other tools), `status`, `currency`, `line_items`, `buyer`, `totals`, `messages`, `links` (the storefront's policy pages), and, once completed, `order` and `continue_url`. **Statuses:** `incomplete` β†’ `ready_for_complete` β†’ `complete_in_progress` β†’ `completed`, or `canceled` (explicit cancel, or 24 h of inactivity). `requires_escalation` is reserved and unused in v1. **Buyer requirements:** non-empty `first_name`, `last_name`, and a valid `email`. `phone_number` is optional. Until those are present the session stays `incomplete` with a `missing` message at `$.buyer`. ## Totals `totals` is the itemized price in **minor units** with signed amounts: `subtotal` (accommodation), optional negative `discount`, one `fee` entry per mandatory fee, one `tax` entry per *added* tax (taxes already included in the nightly rate are not added again), and `total`. The non-`total` entries always sum exactly to `total`; render them as given, don't recompute. ## Messages Problems come back as structured `messages`, not tool errors: | `code` | Typical cause | `severity` | | --- | --- | --- | | `out_of_stock` | Dates no longer available (also on completion races) | `recoverable`: try other dates | | `item_unavailable` | Unknown/inactive item id | `recoverable` | | `capacity_exceeded` | Party larger than `max_guests Γ— quantity` | `requires_buyer_input` | | `missing` | Buyer fields absent/invalid | `requires_buyer_input` | | `min_stay`, `max_stay`, `closed_to_arrival`, `closed_to_departure`, `stop_sell` | Stay restrictions | `recoverable` | | `invalid` | Mixed dates across lines; mutating a finished session | `recoverable` / `unrecoverable` | Tool-level errors are reserved for transport problems: unknown checkout `id`, missing `idempotency-key`, malformed arguments, or calling a checkout tool on a storefront that doesn't register them. ## Completing `complete_checkout` re-validates availability under lock, creates the booking, and returns the session with: ```json { "status": "completed", "order": { "id": "K7MP-Q9RT", "checkout_id": "9c2e…", "permalink_url": "https://stay.example.com/pay/status/eyJpdiI6…" }, "continue_url": "https://stay.example.com/pay/status/eyJpdiI6…", "messages": [{ "type": "info", "code": "payment_required", "content": "Booking is held. Payment must be completed at the permalink to confirm the stay." }] } ``` **Hand the guest the `permalink_url`.** The booking is held but unpaid; the guest pays there through the host's payment providers, and unpaid holds are released automatically (~2 h). `order.id` is the booking reference the guest can quote to the host. Retrying `complete_checkout` on a completed session returns the same order; bookings are never duplicated. If the inventory was taken between pricing and completion, the session drops back to `incomplete` with an `out_of_stock` message and no booking is created. --- # Build apps Apps are remote integrations that run on your server and connect to Stayblox over the **Developer GraphQL API**. Stayblox never hosts or executes third-party code. An app is your configuration in `app.toml` plus the credentials your server uses to authenticate. > Building a storefront theme instead? That is a different surface. > [Build themes](/themes/) ## One app, two distributions Every app belongs to your **account** and declares a distribution: - **`distribution: private`**: installable only on teams in your account. No review process, no store listing. Use this for internal automations and custom integrations. - **`distribution: public`**: submitted for review and listed in the Stayblox App Store after approval. Use this for integrations you want to distribute to other hosts. The `app.toml` config file is the same for both. Private apps skip review entirely; public apps go through a per-dimension review pipeline. ## Two distinct credentials Keep these separate. They serve different purposes. | Credential | Who holds it | What it is for | | --- | --- | --- | | **Account PAT** | You, the developer | Authenticates the CLI and the Management API. Used only for authoring operations. Minted in the Account panel. | | **Per-install runtime token** | Your app server | Authenticates GraphQL API calls your app makes at runtime, scoped to a single team install. Issued by `stayblox app install --team `. | ## What an app can do **General-purpose remote apps** (`type: remote`) are the default. They can subscribe to webhook topics, read the host's data, write back through write mutations, store app-owned metafields on bookings, properties, and contacts, embed a UI page inside the host panel, and let hosts connect through an OAuth flow from your own site. Two protocol specializations extend the general model: - **Payment apps** (`type: payment`): implement the Stayblox payment-session protocol so hosts can offer your provider at checkout. - **Channel apps** (`type: channel`): implement the ARI push and reservation-ingestion protocol for OTA connections. **Injection apps** (`type: injection`) render sanitized HTML snippets into storefront slots such as analytics pixels and chat widgets. No runtime token is needed; just an `app.toml` with `[[injections]]`. All remote types (`remote`, `payment`, `channel`) receive a per-install runtime token and webhook secret when installed on a team. ## App types at a glance ``` remote ─── GraphQL reads + write mutations + metafields + optional: app page (iframe) + OAuth install + webhooks + injections + optional: settings schema (per-install config the host enters) payment ─── remote + payment-session protocol (POST to your endpoint β†’ resolve/reject/pending over GraphQL) channel ─── remote + ARI push + reservation-ingestion protocol injection ── HTML snippet into storefront slots (no runtime token) ``` ## CLI-first authoring Configuration lives in `app.toml`. You edit it locally and push via the CLI: ```bash stayblox app init # scaffold app.toml stayblox app validate # dry-run validation stayblox app push # create or update the app version stayblox app install --team # install on a team; prints the runtime token once ``` For **public apps**, the store listing (description, gallery, pricing) and the app icon are managed in the Account-panel Dashboard, not in `app.toml`. See [App lifecycle](/apps/publishing). ## The payment-session flow ``` Buyer picks your app at checkout β”‚ Stayblox ──POST {payment_session} (HMAC signed)──▢ your app β”‚ opens a hosted payment page Stayblox ◀──────── { redirect_url } β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ redirect buyer ──▢ your hosted page ──▢ buyer pays β”‚ your app ◀──── provider callback / webhook β”€β”˜ β”‚ your app ──GraphQL paymentSessionResolve (runtime token)──▢ Stayblox β”‚ Stayblox marks the booking paid; buyer returns to return_url ``` The session moves through a fixed set of states on the Stayblox side; your mutations drive the transitions. See [Payment apps](/apps/payments) for the full lifecycle and copy-paste examples. ## Where to go next ### Get started - [Getting started](/apps/getting-started): CLI setup, first push, first GraphQL call. - [Configuration (app.toml)](/apps/manifest): every config key with examples. - [Scopes](/apps/scopes): what each scope grants and how consent works. - [App lifecycle](/apps/publishing): distribution, versions, release, and review. ### Capabilities - [GraphQL reference](/apps/graphql): the full schema, generated from the SDL. - [Webhooks](/apps/webhooks): subscribe to topics, verify and process events. - [Metafields](/apps/metafields): app-owned data on bookings, properties, contacts. - [Injections](/apps/injections): render HTML snippets into storefront slots. - [Embedded pages](/apps/app-pages): iframe UI inside the host panel, JWT sessions. - [OAuth install](/apps/oauth): "Connect your Stayblox account" flow from your site. ### Protocols - [Payment apps](/apps/payments): session payload, mutations, and examples. - [Channel apps](/apps/channels): ARI push and reservation ingestion. - [Inbox channel-provider apps](/apps/inbox-channel-provider): manifest declaration, inbound message forwarding, outbound delivery commands. ### Reference - [Management API](/apps/management-api): the `/dev` REST API for authoring. - [Stayblox CLI](/apps/cli): full command reference. - [Signing & security](/apps/signing): verify our requests, sign yours. - [Versioning](/apps/versioning): dated API versions, how to pin one. - [Reference app](/apps/reference-app): a complete working Stripe example. --- # Embedded pages An embedded app page gives your app a UI surface inside the host panel: a full tab with your web app rendered in a sandboxed iframe. Use it for dashboards, configuration screens, or any interactive UI that goes beyond what settings and metafields alone can provide. ## Declare an app page Add `app_page.url` to your manifest: ```jsonc "app_page": { "url": "https://app.example.com/stayblox" } ``` When the app is installed, Stayblox adds a **{App Name}** entry to the host's panel navigation. Clicking it renders your URL in a sandboxed iframe, with a signed session token appended as a query parameter: ``` https://app.example.com/stayblox?session=eyJhbGciOiJIUzI1NiJ9... ``` ## The session token The `session` query parameter is a **HS256-signed JWT**. Verify it on your server to confirm the request comes from Stayblox and to identify the installing host. ### Token structure ```json { "iss": "stayblox", "aud": "your_client_id", "team_id": 42, "user_id": 7, "install_id": 19, "iat": 1749812734, "exp": 1749813034 } ``` | Claim | Description | | --- | --- | | `iss` | Always `"stayblox"`. | | `aud` | Your app's `client_id`. Verify this matches your own credential. | | `team_id` | The host's team ID. Use this to load team-specific data from your own store, or scope API calls. | | `user_id` | The panel user who opened the app page. | | `install_id` | The install's ID. Useful for associating your own session with the Stayblox install record. | | `iat` | Issued-at unix timestamp. | | `exp` | Expiry unix timestamp. **5 minutes from issuance.** | ### Signing key The token is signed with **your app's `client_secret`** using HS256. Verify it with the same key. ### Verification examples **Node.js (using `jsonwebtoken`)** ```js import jwt from 'jsonwebtoken' function verifySession(token) { // Throws if the token is invalid, expired, or signed with the wrong key. const payload = jwt.verify(token, process.env.STAYBLOX_CLIENT_SECRET, { algorithms: ['HS256'], issuer: 'stayblox', audience: process.env.STAYBLOX_CLIENT_ID, }) return payload // { team_id, user_id, install_id, ... } } ``` **PHP (using `firebase/php-jwt`)** ```php use Firebase\JWT\JWT; use Firebase\JWT\Key; function verifySession(string $token): object { // Throws on invalid signature, expiry, wrong issuer/audience. return JWT::decode( $token, new Key($_ENV['STAYBLOX_CLIENT_SECRET'], 'HS256'), ); // Verify aud separately if needed: // assert($payload->aud === $_ENV['STAYBLOX_CLIENT_ID']); } ``` Always: - Verify the signature before trusting any claim. - Check `exp` to reject expired tokens (most JWT libraries do this automatically). - Check `aud` matches your own `client_id`. ## Token refresh The session token has a **5-minute TTL**. For long-lived app pages (dashboards the host keeps open), use the **postMessage token-refresh bridge** to get a fresh token without a full page reload. ### How it works ``` Your iframe Stayblox parent frame β”‚ β”‚ │── postMessage({type:'stayblox:refresh-token'}) ──▢│ β”‚ β”‚ mints a new JWT │◀── postMessage({type:'stayblox:token', token: '...'}) ──│ β”‚ β”‚ ``` Your page posts a message to the parent; Stayblox mints a fresh token and replies to **your declared origin only**. ### Client-side implementation ```js // Request a new token from the Stayblox parent frame. function refreshSession() { return new Promise((resolve, reject) => { const timeout = setTimeout(() => reject(new Error('Token refresh timed out')), 5000) function handler(event) { // Only accept messages from the Stayblox app domain. if (event.origin !== 'https://admin.stayblox.com') return if (event.data?.type !== 'stayblox:token') return clearTimeout(timeout) window.removeEventListener('message', handler) resolve(event.data.token) } window.addEventListener('message', handler) window.parent.postMessage({ type: 'stayblox:refresh-token' }, 'https://admin.stayblox.com') }) } // Example: refresh before an API call when you know the token is near expiry. async function callMyApi(endpoint) { const token = await refreshSession() return fetch(endpoint, { headers: { 'X-Stayblox-Session': token } }) } ``` ::: tip Proactive refresh Rather than waiting for a 401 from your backend, decode the `exp` claim from the initial token on page load and schedule a refresh 30–60 seconds before expiry with `setTimeout`. This avoids any window where the host triggers an action with an expired session. ::: ## Sandbox and security The iframe is rendered with `sandbox` attributes that restrict what it can do: ```html ``` In addition, the platform's Content Security Policy pins `frame-src` to your declared `app_page.url` origin, so the iframe URL cannot be substituted. Design your app page to work within these constraints: - JavaScript execution is allowed (`allow-scripts`). - Popups for OAuth sub-flows are allowed (`allow-popups`). - Form submission is allowed (`allow-forms`). - Top-level navigation from within the iframe is **not** allowed. If you need to send the user somewhere else, open it in a new tab with `target="_blank"`. ## Loading and error states Your page is responsible for its own loading and error UI. The iframe is rendered at full panel width; Stayblox doesn't inject any loading overlay. A good first-render pattern: 1. Parse `?session=...` from the URL on load. 2. Verify the JWT on your server (exchange it for your own session token if needed). 3. Render a spinner while the exchange is in flight. 4. On verification failure (bad signature, expired token), show a clear error and a "Reload" button that re-opens the panel page (which mints a fresh JWT). ## Checklist - [ ] `app_page.url` is an `https://` URL in the manifest. - [ ] Server verifies the `session` JWT signature and `exp` before acting on it. - [ ] Server checks `aud` matches your own `client_id`. - [ ] Client-side implements token refresh via the postMessage bridge for long-lived pages. - [ ] UI degrades gracefully when the token is missing or expired. --- # Assignment provider apps An assignment provider is an app that decides **who** does a task: a cleaning marketplace, a maintenance dispatcher, a staffing service. The host designates your app for a task type (say, cleaning); when such a task is created, Stayblox hands the assignment to you, you pick a worker using your own data, and you write the result back over the API. The golden rule: **your credentials and endpoints live on your server; Stayblox holds only the interface.** Core Stayblox contains no provider-specific code; everything below works for any marketplace through the public API. We use [Turno](https://turno.com) as the concrete example, but nothing here is Turno-specific. For a complete runnable server, see the [reference app](/apps/reference-app). ## 1. Declare your app (manifest) Register as a `remote` app that reads and writes tasks, holds the `act_as_assignment_provider` capability, and subscribes to `task.assignment_requested`. In your `app.toml`: ```toml type = "remote" scopes = ["read_tasks", "write_tasks"] capabilities = ["act_as_assignment_provider"] webhooks = ["task.assignment_requested"] webhook_url = "https://your-app.example.com/stayblox/webhooks" ``` Add `register_task_types` to `capabilities` if you also define your own task types (with their own checklist and custom-field schema). The host consents to scopes and capabilities at install; both are stored on the install and checked on every call (see [Scopes & capabilities](/apps/scopes#capabilities)). Most real providers also want `read_properties` (to map your own locations to Stayblox properties ahead of time) and `read_bookings` (to read the checkout or checkin time so you can schedule the work). Neither is required by the handshake itself, only by whatever scheduling logic you build on top of it. ## 2. Get designated for a task type The host designates your app as the **assignment provider** for a task type in their dashboard (Operations β†’ Task types β†’ *Assignment provider*). From then on, every task of that type that the platform creates (typically via a host-configured rule, e.g. *"on checkout, create a cleaning task"*) is routed to you for assignment. ## 3. Receive the assignment request (platform β†’ app) When such a task is created, Stayblox POSTs a **`task.assignment_requested`** webhook to your `webhook_url`. The request is HMAC-signed; verify it before acting (see [Signing & security](/apps/signing)). The envelope's `resource` identifies the task: ```json { "event_id": "01J…", "topic": "task.assignment_requested", "occurred_at": "2026-06-27T11:00:00Z", "api_version": "2026-01", "team": "8cs3o-qe", "resource": { "type": "task", "id": 42, "task_type": "cleaning", "canonical_status": "open", "property_id": 3, "booking_id": 88, "due_at": "2026-06-27T11:00:00Z" } } ``` Fetch full task state any time with `task(id)` (`read_tasks`). Now schedule a worker on **your** side; Turno calls its own scheduling API on its own server. Stayblox never sees how you pick; it only sees the assignment you write back. Webhook delivery is at-least-once, so treat this as a possible redelivery, not a guaranteed single call: dedupe on `resource.id` (or `event_id`, if you need per-delivery rather than per-task granularity) before you schedule anything. Verifying a delivery and resolving the install is your app's job: read the team slug from the envelope body, look up the install you stored for that team, and verify the signature with that install's webhook secret (see [Signing & security](/apps/signing)). The platform keeps no delivery-dedupe store either, so nothing upstream filters duplicates for you. ## 4. Write the assignment back (app β†’ platform) Call `taskAssign` with your bearer token to record the assignee. This needs the `act_as_assignment_provider` capability and moves the task `OPEN β†’ ASSIGNED`. `assigneeType` is one of `user`, `app`, or `external`: use `external` for a real-world worker who isn't a Stayblox user and isn't your app itself, such as a marketplace cleaner (reserve `app` for cases where your app, not a person it dispatched, is doing the work). Store your own identifiers as namespaced custom fields so you can correlate later. ```graphql mutation Assign($id: ID!, $name: String!) { taskAssign(input: { id: $id, assigneeType: "external", assigneeName: $name }) { task { id canonicalStatus assigneeName } userErrors { field message } } } ``` ```bash curl -s https://api.stayblox.com/developer/api/2026-01/graphql \ -H "Authorization: Bearer $STAYBLOX_APP_TOKEN" \ -H "Content-Type: application/json" -H "Accept: application/json" \ -d '{ "query": "mutation($id: ID!, $name: String!){ taskAssign(input:{ id:$id, assigneeType:\"external\", assigneeName:$name }){ task{ id canonicalStatus } userErrors{ message } } }", "variables": { "id": "42", "name": "Sparkle Cleaners" } }' ``` Then attach your identifiers: ```graphql mutation Tag($id: ID!) { taskSetCustomField(taskId: $id, namespace: "turno", key: "project_id", type: "string", value: "proj_42") { task { id } userErrors { message } } } ``` ## 5. Push status as the work progresses As the job moves, transition the canonical status (and add notes via checklist items or custom fields): ```graphql mutation Progress($id: ID!, $status: TaskStatus!) { taskTransitionStatus(id: $id, status: $status) { task { id canonicalStatus } userErrors { field message } } } ``` Walk `ASSIGNED β†’ IN_PROGRESS β†’ COMPLETED` as your worker accepts, starts, and finishes. Every transition is validated; illegal moves come back in `userErrors`. A rejected transition usually means the task moved on without you (already completed or cancelled through another path). Treat that as terminal for the current job: stop pushing further transitions for that task, refetch it to see its actual state, and if you scheduled work on your own side, cancel it there too. Retrying the same transition just returns the same error. ## Staying in sync Two more things come up once this is running for real, beyond the happy path above. If you also subscribe to `task.status_changed` (to notice cancellations or changes made outside your app), remember that your own `taskTransitionStatus` and `taskAssign` calls trigger that same topic back to you. Keep a record of the status you last wrote for each task, and drop an incoming `task.status_changed` when it just confirms what you already wrote; otherwise you'll reprocess your own writes. Retries are capped (see [Webhooks](/apps/webhooks)), so a sustained outage on your end can leave a task stuck in your last-known state even after you recover. Poll `tasks(filter: { updatedSince: })` on a schedule and replay anything you find through the same logic you use for webhooks. Because that logic is already deduplicated (see step 3), replaying tasks you're already in sync with is safe and should be a no-op. ## The fallback If you don't write an assignment back within the host's SLA window, Stayblox **falls back to its own built-in assignment** so the task never stalls. That fallback checks whether you already responded before it acts, so racing it by writing back first is always safe. The reverse is guarded too: a late `taskAssign` on a task that the fallback (or the host, or another app) has already assigned returns a `userError` instead of succeeding. Treat that error as a lost race: keep the existing assignee, release whatever resources you created for the task on your side, and stand down. Re-assigning a task you assigned yourself is still allowed, so marketplace reassignment (one worker cancels, another takes over) keeps working. ## Guest-readiness A property becomes guest-ready when its cleaning task reaches `COMPLETED` / `VERIFIED`, derived purely from canonical status, never from a label you set. So the single thing that flips a unit to "ready" is your `taskTransitionStatus` to `COMPLETED`. ## Worked example: a cleaning marketplace (Turno) End to end, with **zero Turno code in Stayblox**: 1. The host designates the Turno app as the cleaning provider and keeps the default *"on checkout, create a cleaning task"* rule. 2. A guest checks out β†’ the rule creates a cleaning task β†’ Stayblox emits `task.assignment_requested` to Turno's `webhook_url`. 3. Turno schedules a cleaner via **its own** API on **its own** server, then calls `taskAssign` + `taskSetCustomField(turno.project_id, …)`. 4. As the cleaner accepts β†’ starts β†’ finishes, Turno pushes `taskTransitionStatus` through `ASSIGNED β†’ IN_PROGRESS β†’ COMPLETED`, attaching progress notes via custom fields as it goes. 5. The property flips to guest-ready off the canonical `COMPLETED`. Turno's API keys and endpoints stay on Turno's server throughout. Stayblox stores only the assignment and the custom fields Turno chose to write. ## Checklist - [ ] Manifest registered with `type: remote`, `scopes: [read_tasks, write_tasks]`, `capabilities: [act_as_assignment_provider]`. - [ ] Subscribed to `task.assignment_requested`; `webhook_url` set. - [ ] Webhook handler verifies the HMAC signature before acting. - [ ] Deliveries deduplicated (e.g. by `resource.id`) before any side-effecting call. - [ ] Assignment written back with `taskAssign` (+ your ids via `taskSetCustomField`), using `assigneeType: "external"` for a real-world worker. - [ ] Status pushed with `taskTransitionStatus` as work progresses. - [ ] `userErrors` checked on every mutation, and treated as a signal to stand down, not to retry blindly. - [ ] If subscribed to `task.status_changed`, your own writes are recognized and skipped. - [ ] A periodic resync against `tasks(filter: { updatedSince })` covers whatever webhooks miss. - [ ] Write-back happens within the host's SLA window; a late `taskAssign` returns a `userError` when someone else already got the task, and your app treats that as a lost race and stands down. --- # Becoming a partner Building apps or themes for Stayblox requires a **verified partner account**. Partner status is free, granted at the account level, and covers both surfaces: one verification unlocks the **Developer** section in the panel (for apps) and theme publishing through the CLI. ## Who should apply Apply if you want to: - Build and publish **apps**: integrations that talk to the Developer GraphQL API, payment or channel providers, or embedded panel pages. - Build and publish **storefront themes** for the Theme Store. You don't need a hosting account or an active property to be a partner. A partner account is a standalone publishing identity; no team, no trial, and no subscription. ## How to apply 1. Go to [stayblox.com/partners](https://stayblox.com/partners) and choose **Apply to become a partner**. 2. Sign in, or create a free account if you don't have one yet. Creating an account here does **not** start a property trial; it's a publishing-only account. 3. Review and accept the partner agreement, then submit your application. ## Review & approval A Stayblox reviewer checks each application. You're notified by email and in the panel when a decision is made: - **Approved**: the **Developer** section appears in your panel and the theme CLI commands start working for your account. You can begin building right away. - **More information requested**: we ask for details; your application stays open while you respond. - **Rejected**: the notification explains why. You can re-apply once the concern is resolved. ## After approval Once verified: - **Apps**: follow [Getting started](/apps/getting-started) to create your first app, then [Publishing & review](/apps/publishing) when you're ready to ship. - **Themes**: install the [Stayblox CLI](/themes/cli) and follow the theme [Getting started](/themes/getting-started). Your partner account must stay in good standing for your published apps and themes to remain listed in their stores. --- # Building apps with AI AI coding agents can build Stayblox apps end to end: scaffold, explore the GraphQL API, handle webhooks, and validate the manifest, all through the Stayblox CLI's MCP server. ## Machine-readable documentation - [/llms.txt](/llms.txt) is a compact index of this documentation. - [/llms-full.txt](/llms-full.txt) is the full corpus in one file. ## Start from the scaffold ```bash npm install -g @stayblox/cli stayblox app init my-app ``` The scaffold ships `AGENTS.md` (structure and iteration loop) and `.mcp.json` (registers the `stayblox mcp` server). ## The dev MCP server Run `stayblox login` once, then start your agent. Tools useful for app work: | Tool | What it does | | --- | --- | | `search_docs` | Searches this documentation offline. | | `validate_app_manifest` | Validates `app.toml` and returns the errors to fix. | | `get_graphql_schema` | Fetches the Developer API schema for the current version. | | `run_graphql` | Runs queries and mutations against your development store install. | | `list_webhook_topics` | Lists every webhook topic with its payload shape. | | `get_webhook_deliveries` | Lists real, signed deliveries received by your dev install. | | `replay_webhook` | Re-delivers a recorded webhook to your local server. | ## A working loop 1. Ask the agent to add a capability, for example reacting to new bookings. 2. The agent reads the schema with `get_graphql_schema`, finds the topic with `list_webhook_topics`, and writes the handler. 3. The agent tests the handler by replaying a real delivery with `replay_webhook`. 4. After manifest changes, the agent runs `validate_app_manifest` before you push. --- # Categories Every app in the Stayblox App Store belongs to one **category** and one more specific **subcategory**. When you submit an app you choose the subcategory that best describes it; the store's consumer filter then groups apps by their top-level category. A few rules of thumb when picking where your app fits: - **The store lists optional apps, not built-in features.** Anything every host gets out of the box (the booking engine, the availability calendar) is not in the store and has no category. If only some hosts need it, it's an app under the relevant category. - **Pick the subcategory, not the category.** The category is derived from the subcategory you choose, so you only ever make one decision. - **Keys are stable.** Each category and subcategory has a stable key that never changes, even if its label is later reworded, so filters and saved selections keep working. The full taxonomy below is generated directly from the platform's category system, so it always matches what you see in the submission form. App Store categories and subcategories, derived from `app/Enums/AppCategory.php` and `app/Enums/AppSubcategory.php`. When you submit an app you pick one subcategory; the store's consumer filter groups apps by the top-level category. Keys are stable β€” they never change even if a label is reworded. ### Distribution Category key: `distribution` | Subcategory | Key | | --- | --- | | Channel Managers | `channel_managers` | | OTA / Marketplace Connectors | `ota_connectors` | | Metasearch & GDS | `metasearch_gds` | ### Revenue & Pricing Category key: `revenue` | Subcategory | Key | | --- | --- | | Dynamic Pricing | `dynamic_pricing` | | Revenue Management (RMS) | `revenue_management` | | Rate Shopping & Market Intel | `rate_shopping` | | Forecasting & Budgeting | `forecasting_budgeting` | ### Marketing & Growth Category key: `marketing` | Subcategory | Key | | --- | --- | | Ads & Attribution | `ads_attribution` | | Email & SMS Marketing | `email_sms_marketing` | | CRM & Guest Database | `crm_guest_database` | | Loyalty & Referrals | `loyalty_referrals` | | Social & Content | `social_content` | | SEO & Website Tools | `seo_website_tools` | ### Guest Experience Category key: `guest` | Subcategory | Key | | --- | --- | | Guest Messaging | `guest_messaging` | | Digital Check-In & Registration | `digital_checkin` | | Smart Locks / Keyless Entry | `smart_locks` | | Upselling & Cross-selling | `upselling` | | Reviews & Reputation | `reviews_reputation` | | Guest Portal / App | `guest_portal` | | Concierge & Local Services | `concierge_local` | | Guest WiFi | `guest_wifi` | ### Operations Category key: `operations` | Subcategory | Key | | --- | --- | | Housekeeping | `housekeeping` | | Maintenance & Work Orders | `maintenance` | | Task & Inspection Management | `task_inspection` | | Staff Scheduling & Labor | `staff_scheduling` | | Inventory & Procurement | `inventory_procurement` | | POS (Food & Beverage) | `pos_fnb` | | Spa, Wellness & Activities | `spa_wellness` | | Events, Groups & Catering | `events_groups` | ### Finance & Compliance Category key: `finance` | Subcategory | Key | | --- | --- | | Payments & Gateways | `payments_gateways` | | Invoicing & Billing | `invoicing_billing` | | Tax & Fiscal Compliance | `tax_compliance` | | Guest Registration / Authority Reporting | `authority_reporting` | | Accounting & Bookkeeping | `accounting` | | Deposits, Insurance & Damage | `deposits_insurance` | | Fraud & Chargeback | `fraud_chargeback` | ### Analytics & Data Category key: `analytics` | Subcategory | Key | | --- | --- | | Business Intelligence / Dashboards | `business_intelligence` | | Reporting & Benchmarking | `reporting_benchmarking` | | Data Export / Warehousing | `data_export` | ### Devices & Hardware Category key: `devices` | Subcategory | Key | | --- | --- | | Hardware Terminals & Kiosks | `hardware_terminals` | | Energy & Climate | `energy_climate` | | TV & In-Room Entertainment | `in_room_entertainment` | | Sensors (Noise / Occupancy) | `sensors` | | Building & Access Systems | `building_access` | ### Automation & Integrations Category key: `automation` | Subcategory | Key | | --- | --- | | Public API & Webhooks | `api_webhooks` | | Integration Platforms (Zapier-style) | `integration_platforms` | | Custom Apps / Scripting | `custom_apps` | --- # 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](/apps/signing) 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](#7-backfill-unittyperates)). - `property.updated` in `webhooks` to re-sync listing content when it changes on the host side (see [Listing content](#6-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](/apps/inbox-channel-provider). ## 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 | 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](/apps/signing)). ### 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": "jordan@example.com", "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": "jordan@example.com" }, "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](/apps/webhooks#digest-topics) 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 CLI: apps The `stayblox app` command group lets you author, publish, and debug apps from the terminal. It is part of the same `@stayblox/cli` package used for themes. Install once and both surfaces are available. ```bash npm install -g @stayblox/cli ``` Requires Node 20 or later. ## Authenticate App commands use an **account personal access token (PAT)** minted in the **Account panel** (Developer access tokens). Generate a token with the `develop-apps` ability. It is shown once on creation. ```bash stayblox login --token ``` This stores the token in `~/.config/stayblox/config.json`. The account PAT is your **authoring** credential and is not the same as the runtime token your app server uses to call the GraphQL API. The runtime token is issued when you run `app install`. ## Configuration: `app.toml` All app configuration lives in `app.toml` in your app directory. Commands that need the app slug or manifest read this file automatically; pass `--app ` to override. ```toml name = "Smart Lock Manager" type = "remote" # "remote" | "payment" | "channel" | "injection" distribution = "private" # "private" | "public" # slug = # public: set manually; private: written back on first push scopes = ["read_bookings", "read_contacts", "write_conversations"] capabilities = [] # "act_as_assignment_provider" | "register_task_types" webhooks = ["booking.confirmed", "booking.cancelled"] webhook_url = "https://app.example.com/webhooks/stayblox" # The following keys are for public apps only (not supported for private apps): [oauth] redirect_uris = ["https://app.example.com/auth/stayblox/callback"] [app_page] url = "https://app.example.com/stayblox" [[settings_schema]] key = "api_key" type = "string" label = "API key" required = true [[injections]] slot = "head" template = '' [[metafields]] owner = "booking" key = "access_code" type = "string" visibility = "guest" ``` For the full key reference see [Configuration (app.toml)](/apps/manifest). **Private-app slug write-back:** on the first `push` of a private app (no `slug` in `app.toml`), the server generates a slug and the CLI writes it back into `app.toml`. Commit the updated file. **Not in `app.toml`:** the app icon, store listing, pricing, and gallery images for public apps are managed in the Account-panel Dashboard. --- ## AI coding agents `stayblox mcp` starts the dev MCP server for AI coding agents; see [Building apps with AI](/apps/building-with-ai). ```bash stayblox mcp ``` --- ## Account-scoped commands These commands operate on your account and do not require `--team`. ### `app init [dir]` Scaffolds an `app.toml`, an `icon.png` placeholder, and a `README.md` in a new directory. Prompts for the app name. ```bash stayblox app init my-rate-sync ``` Edit `app.toml` before pushing. --- ### `app validate` Reads `app.toml` in the current directory and sends it to the server for a dry-run validation report. Exits non-zero on any error, making it a useful CI gate. ```bash stayblox app validate ``` Example output on success: ``` βœ” Valid. ``` On failure: ``` βœ– Custom apps support type: remote only in this version. βœ– Scope "read_invoices" is not a known scope. ``` --- ### `app push [--no-release]` Pushes `app.toml` to create or update the app. Each push creates an immutable version. By default the new version is also released (made live). Pass `--no-release` to push a version without releasing it, then promote it manually with `app release`. ```bash stayblox app push stayblox app push --no-release ``` --- ### `app release [version]` Promotes the latest pending version to live. Pass a version number to re-release an older version (rollback). ```bash stayblox app release # promote the latest pending version stayblox app release 3 # roll back to version 3 ``` --- ### `app versions` Lists all versions for the app: id, status, and whether that version is currently live. ```bash stayblox app versions ``` --- ### `app list` Lists all apps on your account. ```bash stayblox app list ``` --- ### `app teams` Lists the teams your account can install apps on, with their slugs. ```bash stayblox app teams ``` --- ### `app show` Shows the full details for the app. Reads the slug from `app.toml`; override with `--app `. ```bash stayblox app show stayblox app show --app rate-sync ``` --- ### `app installs` Lists all team installs for the app. ```bash stayblox app installs stayblox app installs --app rate-sync ``` --- ## Team-scoped commands These commands require `--team ` to identify the team. Use `stayblox app teams` to find your team slugs. ### `app install --team ` Installs the app on a team. On the first install, prints the **per-install runtime token** and **webhook secret**; both are shown only once. ```bash stayblox app install --team 8cs3o-qe ``` Output on first install: ``` Installed rate-sync on 8cs3o-qe. Token: stayblox_app_... Webhook secret: whsec_... Saved once. Copy now. ``` Store the runtime token as `STAYBLOX_APP_TOKEN` (or similar) in your server's environment and pass it as `Authorization: Bearer ` on every GraphQL request. Store the webhook secret for HMAC verification. See [Signing & security](/apps/signing). --- ### `app uninstall --team ` Removes the install from a team. The runtime token is revoked immediately. ```bash stayblox app uninstall --team 8cs3o-qe ``` --- ### `app token --team ` Rotates the install's runtime token. The old token stops working immediately. The new token is printed once. ```bash stayblox app token --team 8cs3o-qe ``` --- ### `app logs --team ` Shows recent webhook deliveries for the install: event id, topic, timestamp, status, and attempt count. ```bash stayblox app logs --team 8cs3o-qe ``` Output: ``` 01J9A... booking.confirmed 2026-06-28 12:00:01 delivered attempts:1 01J9B... booking.cancelled 2026-06-28 11:58:43 delivered attempts:1 ``` The first column is the event id (ULID). Pass it to `app replay` to re-POST that delivery to your local server. --- ### `app forward --team --to ` Routes live webhook deliveries from the platform to your local server. No tunnel or persistent server needed; it polls every second and re-POSTs signed deliveries to the local target. Press Ctrl-C to stop; normal delivery to your `webhook_url` resumes automatically about 30 seconds after the last poll. ```bash stayblox app forward --team 8cs3o-qe --to localhost:3000 ``` Forwarded deliveries carry the same HMAC headers as production deliveries, so your verification code is exercised as-is. See the [Signing & security](/apps/signing) guide for verification examples. --- ### `app replay --team --to ` Re-POSTs a persisted delivery to your local server. Headers and body are identical to the original, including the HMAC signature. ```bash stayblox app replay 01J9XYZ... --team 8cs3o-qe --to localhost:3000 ``` --- ### `app dev --team [--to ]` Convenience command that ensures a dev install exists on the team, then starts forwarding. Use this to spin up a local development session in one step. (`--to` is optional; omit it to capture traffic in `app logs` without re-POSTing to a local server.) ```bash stayblox app dev --team 8cs3o-qe --to localhost:3000 ``` --- ## Forwarding mechanics 1. On start, the CLI polls `GET /dev/apps/{slug}/forward`. The server returns the current head cursor and no deliveries, so only events that arrive after you start the loop are forwarded. 2. Every second the CLI polls with `?since=`, receiving any new deliveries and the updated cursor. 3. For each delivery the CLI re-POSTs the body with headers verbatim to `--to`. 4. Each poll resets the server's forwarding TTL (30 s). When you stop polling, the TTL expires and normal dispatch resumes. No delivery is lost: the platform stores every delivery row regardless of whether forwarding is active. While the loop runs, the dispatch job is skipped and the row stays `pending`. `app logs` and `app replay` still see it. ### Webhook headers on forwarded deliveries | Header | Description | | --- | --- | | `X-Stayblox-Signature` | `sha256=`: HMAC-SHA256 of `{timestamp}.{body}` keyed with the install's `webhook_secret`. | | `X-Stayblox-Timestamp` | Unix seconds at delivery time. Reject if more than 5 minutes old. | | `X-Stayblox-Event-Id` | The event id ULID, matching the envelope. | | `X-Stayblox-Topic` | The topic string (e.g. `booking.confirmed`). | | `X-Stayblox-App` | The app slug (e.g. `rate-sync`). Use this to key verification when one server handles multiple apps. | Verify signatures exactly as you would in production; the algorithm and key are unchanged. ### Webhook envelope shape ```json { "event_id": "01J9XYZ...", "topic": "booking.confirmed", "occurred_at": "2026-06-28T12:00:00Z", "api_version": "2026-01", "team": "8cs3o-qe", "resource": { "type": "booking", "id": 4821 } } ``` The `team` field carries the team slug. Per-resource object ids inside `resource` (booking id, contact id, and so on) remain as integers; they are the handles your app uses to call back to the GraphQL API. --- # App icon Your app icon is the first thing a host sees in the App Store, on the install screen, and in their installed-apps list. It renders at many sizes β€” from a large store hero down to a small list row β€” so it needs to stay legible when scaled down. > **Where the icon lives:** the icon is **not** part of `app.toml` and is **not** > uploaded by the CLI. For public apps it is managed in the Account-panel > Dashboard alongside the store listing. See > [Changing your app icon](#changing-your-app-icon) below. ## Specifications | Property | Requirement | | --- | --- | | Dimensions | **1000 Γ— 1000 px**, square | | Format | PNG (preferred) or JPG | | Color mode | RGB | | Background | Solid β€” **no transparency** | | Corners | **Square β€” do not round the corners** | | Artwork fill | Between **625 px and 750 px** (10⁄16–12⁄16 of the canvas) | | Clear margin | At least **50 px** (1⁄20) on every side, free of artwork | Design at the full 1000 Γ— 1000 px size. The platform applies rounded corners for you when it displays the icon, so submit a full square β€” if you round the corners yourself they get clipped twice and look wrong. ## Preparing your app icon **Center the artwork inside the safe area.** Keep your logo or symbol within a centered box of roughly **700 px** (11⁄16, the middle of the allowed range) and leave the outer **50 px** on each edge clear. Filling below 625 px looks lost; filling past 750 px feels cramped and risks being clipped by the corner rounding. **Use a solid background.** Icons appear on white and light-gray surfaces throughout the Dashboard and App Store. Never rely on transparency. If your mark is white or very light, place it on a colored background (or add a subtle edge) so the icon does not disappear against a light UI. **Keep it simple and recognizable.** A single logo or symbol reads far better at small sizes than a detailed illustration. Avoid excessive text β€” a wordmark that is legible at 1000 px is usually unreadable in a list row. **Only use artwork you own.** Do not use the Stayblox logo, or any brand, logo, or trademark you do not have the rights to represent. For an integration, use the partner service's official brand mark within their brand guidelines. **Check contrast both ways.** Preview the icon on a white card and on a light-gray card before submitting. ### Two common layouts - **Full-bleed brand icon** β€” a solid brand-colored (or gradient) background that fills the whole square, with the mark centered inside the safe area. This is the right choice when a service already has an icon with its own background. - **Mark on a neutral background** β€” a colored logo centered on a solid white (or light) background. Keep the mark inside the 700 px safe area. Either way the canvas is a full, opaque 1000 Γ— 1000 px square with no rounded corners. ### First-party icon generator First-party apps in the `stayblox-apps` repo can generate an on-brand icon (a white glyph on the Stayblox background) with the `_icon-generator` tool: ```bash cd _icon-generator npm install npm run icon -- ph-address-book --out ../my-app/icon.png ``` It emits a 1000 Γ— 1000 px PNG that already meets these specifications. Supported prefixes: `ph-*` (Phosphor) and `hero-*` (Heroicons). ## Changing your app icon `stayblox app init` scaffolds a placeholder `icon.png` in your app folder so you have a working file to iterate on locally, but pushing the app never uploads it β€” `app push` and `app.toml` carry config and manifest only. For a **public app**, upload and update the icon in the Account-panel Dashboard: 1. Open the **Account panel β†’ Apps** and select your app. 2. Go to the **Listing** (branding) section and find **App icon**. 3. Upload your 1000 Γ— 1000 px PNG and save. 4. Submit the listing for review. The new icon goes through the same content review as the rest of your store listing. Your currently approved icon keeps serving until the new one is approved, and this review runs independently of the config review β€” so an icon change never blocks a code fix, and vice versa. See [App lifecycle β†’ Listing and icon review](/apps/publishing#listing-and-icon-review). --- # Getting started This walks you from zero to a running app with a live GraphQL call on a team. ## 1. Install the CLI The `stayblox` CLI handles everything from scaffolding to forwarding live webhooks to your local server. ```bash npm install -g @stayblox/cli ``` Requires Node 20 or later. ## 2. Create your account PAT and log in App authoring uses an **account personal access token (PAT)**. Open the **Account panel**, go to **Developer access tokens**, and generate a token with the `develop-apps` ability. Copy it. It is shown once. ```bash stayblox login --token ``` This stores the token in `~/.config/stayblox/config.json`. The account PAT is your authoring credential. It is **not** the token your app server uses at runtime; that comes in Step 5. ## 3. Scaffold your app ```bash stayblox app init my-app cd my-app ``` This creates an `app.toml` with sensible defaults, an `icon.png` placeholder, and a `README.md`. Open `app.toml` and fill in the details: ```toml name = "My App" type = "remote" distribution = "private" scopes = ["read_bookings"] capabilities = [] webhooks = ["booking.confirmed"] webhook_url = "https://app.example.com/webhooks/stayblox" ``` See [Configuration (app.toml)](/apps/manifest) for every key. ## 4. Validate and push Validate before pushing: ```bash stayblox app validate ``` Then push the app to create the first version: ```bash stayblox app push ``` For a **private** app, the server generates a slug and the CLI writes it back to `app.toml`. For a **public** app, set `slug` in `app.toml` before the first push. See [App lifecycle](/apps/publishing) for the version and release model. ## 5. Install on a team Install the app on a team you own. This is the step that issues your **per-install runtime token** and webhook secret: ```bash stayblox app install --team ``` Output: ``` Installed my-app on . Token: stayblox_app_... Webhook secret: whsec_... Saved once. Copy now. ``` Copy both values and store them in your server's environment. The **runtime token** is what your app server sends on every GraphQL request. The **webhook secret** is what you use to verify signed calls from Stayblox. Neither is shown again. Use `stayblox app token --team ` to rotate the token if needed. ## 6. Make your first GraphQL call With the runtime token in your server's environment, verify authentication: ```bash curl -s https://api.stayblox.com/developer/api/2026-01/graphql \ -H "Authorization: Bearer $STAYBLOX_APP_TOKEN" \ -H "Content-Type: application/json" \ -H "Accept: application/json" \ -d '{"query":"{ apiVersion currentApp { name type } }"}' ``` A valid response: ```json { "data": { "apiVersion": "2026-01", "currentApp": { "name": "My App", "type": "remote" } } } ``` If `currentApp` is `null`, the token is missing or invalid. ## Credentials at a glance | Credential | How you get it | Where you use it | | --- | --- | --- | | **Account PAT** | Account panel β†’ Developer access tokens | CLI and Management API (authoring) | | **Runtime token** | `stayblox app install --team ` | `Authorization: Bearer` on every GraphQL request | | **Webhook secret** | Printed alongside the runtime token at install | Verifying HMAC signatures on Stayblox callbacks | ## Next - [GraphQL reference](/apps/graphql): browse every GraphQL type and field. - [Signing & security](/apps/signing): set up webhook verification. - [Payment apps](/apps/payments): build a payment integration. - [Stayblox CLI](/apps/cli): forward live webhooks to your dev server. --- # GraphQL reference The full Developer API schema below is **generated from the SDL** that backs the endpoint, so it always matches what the endpoint serves. All operations are sent as `POST` to the [dated GraphQL endpoint](/apps/getting-started#6-make-your-first-graphql-call) with your install's **runtime token** as a bearer credential: ``` Authorization: Bearer ``` The runtime token is issued once when you run `stayblox app install --team `. See [Getting started](/apps/getting-started#5-install-on-a-team) for the full install step. ## Core API Beyond the payment and channel mutations, the API exposes queries over the installing team's data (plus rate and booking writes), gated by scopes your app declares in `app.toml`: | Scope | Unlocks | | --- | --- | | `read_bookings` | `booking`, `bookings` | | `read_contacts` | `contact`: guest PII | | `read_conversations` | `conversation`, `message` | | `read_properties` | `property`, `properties`, `unitTypes`: inventory discovery | | `read_rates` | `unitTypeRates`, `bookingQuote`: per-day rates, restrictions, availability, and priced stay quotes | | `write_rates` | `ratesUpdate`: set nightly rates and stay restrictions | | `create_bookings` | `bookingCreate`: place an instant booking or file a booking request | | `read_tasks` | `task`, `tasks`, `propertyTaskCalendar`: operational work, checklists, custom fields | | `write_tasks` | `taskCreate`, `taskUpdate`, `taskAssign`, `taskTransitionStatus`, `taskComplete`, `taskCancel`, `taskAddChecklistItem`, `taskSetChecklistItemDone`, `taskSetCustomField` | | `provide_inbox_channel` | Provide a messaging channel: inject inbound guest messages via `inboundMessageCreate` and receive outbound delivery commands at your `message_send` endpoint. | | `provide_channel` | `channelIntegrations`, `channelIntegrationCreate`, `channelIntegrationDisconnect`, `channelListingLink`, `channelListingUnlink`, `reservationUpsert`: register OTA connections, receive ARI pushes at your `ari_push` endpoint, and submit reservations. See [Channel apps](/apps/channels). | An operation without its scope fails with a GraphQL error naming the missing scope. PII is gated separately: `Booking.contact` requires `read_contacts` **in addition to** `read_bookings`, so a bookings-only app never sees guest details. Mechanics worth knowing: - `bookings`, `properties`, and `unitTypes` are forward-paginated by id cursor; pass `first` and feed `pageInfo.endCursor` back as `after` until `hasNextPage` is `false`. - `unitTypeRates` serves forward-looking windows of at most **366 days** per call; page longer ranges by date. - Discover inventory with `properties` / `unitTypes` (each unit type carries a `unitsCount`), then feed a `unitTypeId` into `unitTypeRates` or `ratesUpdate`. - `unitTypes[].kind` is `standard` for an ordinary room type or `entire_property` for a listing that books the whole property; both kinds support the same queries and mutations. ### Writing rates `ratesUpdate` sets the nightly rate and stay restrictions (`minStay`, `maxStay`, `closedToArrival`, `closedToDeparture`, `stopSell`) for one unit type across an inclusive date range of at most **366 days**. It goes through the same pipeline as host edits, so connected channels sync and a `rates.updated` webhook digest is emitted. Field semantics: a field you **omit** keeps its current value; a field you send as **`null`** clears that override back to the unit type's base value. Invalid input is returned in `userErrors` (with the offending `field` path) rather than as a GraphQL error. Note that your own app also receives the resulting `rates.updated` digest; deliveries are not filtered by which actor caused the change. Guard against update loops by not reacting to digests for changes you just wrote. ### Creating bookings `bookingQuote` prices and validates a stay, returning itemised fees and taxes plus any blocking `violations`, without committing it; gated by `read_rates`. To transact, `bookingCreate` (scope `create_bookings`) reuses the host's own booking pipeline: on instant-book storefronts it places a held, unpaid booking and returns a `paymentUrl` where the guest pays; on review storefronts it files a booking request the host approves. `outcome` (`HELD` or `INQUIRY`) tells you which ran, and `instantBook` on the quote lets you label the action ahead of time. Pass a stable `idempotencyKey` so a retried call returns the original booking instead of a duplicate. The generated reference below carries every type and field. These queries pair with [webhooks](/apps/webhooks): the event tells you what changed, these queries return its current state. ## Try it Run queries against the live endpoint right here. Paste your install's runtime token, adjust the endpoint if needed, and explore the schema with autocomplete. # GraphQL API β€” `2026-01` The complete schema for the **2026-01** Developer API, generated from the SDL in the app repo (`graphql/developer/2026-01/`). All operations are served from `POST /developer/api/2026-01/graphql`. ## Queries ### `channelIntegrations` OTA integrations owned by this app, with listing links and sync status. ```graphql channelIntegrations(first: Int = 50, after: ID): ChannelIntegrationConnection! ``` | Argument | Type | Default | Description | | --- | --- | --- | --- | | `first` | `Int` | `50` | | | `after` | `ID` | | | ### `contact` A guest contact by id. Requires the read_contacts scope. ```graphql contact(id: ID!): Contact ``` | Argument | Type | Default | Description | | --- | --- | --- | --- | | `id` | `ID!` | | | ### `booking` A booking by id. Requires the read_bookings scope. ```graphql booking(id: ID!): Booking ``` | Argument | Type | Default | Description | | --- | --- | --- | --- | | `id` | `ID!` | | | ### `bookings` Bookings, oldest id first, forward-paginated by id cursor. Requires read_bookings. ```graphql bookings(status: String, checkInFrom: String, checkInTo: String, first: Int! = 50, after: ID): BookingConnection! ``` | Argument | Type | Default | Description | | --- | --- | --- | --- | | `status` | `String` | | | | `checkInFrom` | `String` | | | | `checkInTo` | `String` | | | | `first` | `Int!` | `50` | | | `after` | `ID` | | | ### `conversation` A conversation with its messages (latest 100, oldest first). Requires read_conversations. ```graphql conversation(id: ID!): Conversation ``` | Argument | Type | Default | Description | | --- | --- | --- | --- | | `id` | `ID!` | | | ### `message` A single message by id. Requires read_conversations. ```graphql message(id: ID!): Message ``` | Argument | Type | Default | Description | | --- | --- | --- | --- | | `id` | `ID!` | | | ### `unitTypeRates` Per-day rate, restrictions and available-unit count for a unit type. Window max 366 days. Requires read_rates. ```graphql unitTypeRates(unitTypeId: ID!, from: String!, to: String!): [UnitTypeRateDay!]! ``` | Argument | Type | Default | Description | | --- | --- | --- | --- | | `unitTypeId` | `ID!` | | | | `from` | `String!` | | | | `to` | `String!` | | | ### `property` A property by id with its unit types. Requires read_properties. ```graphql property(id: ID!): Property ``` | Argument | Type | Default | Description | | --- | --- | --- | --- | | `id` | `ID!` | | | ### `properties` Properties, oldest id first, forward-paginated by id cursor. Requires read_properties. ```graphql properties(first: Int! = 50, after: ID): PropertyConnection! ``` | Argument | Type | Default | Description | | --- | --- | --- | --- | | `first` | `Int!` | `50` | | | `after` | `ID` | | | ### `unitTypes` Unit types with unit counts, oldest id first, forward-paginated by id cursor. Requires read_properties. ```graphql unitTypes(propertyId: ID, first: Int! = 50, after: ID): UnitTypeConnection! ``` | Argument | Type | Default | Description | | --- | --- | --- | --- | | `propertyId` | `ID` | | | | `first` | `Int!` | `50` | | | `after` | `ID` | | | ### `bookingQuote` Price and validate a stay without committing it. Returns itemized totals (mandatory fees and added taxes included) plus any stay-restriction `violations` that would block a booking. `instantBook` reflects the host's booking mode, so a caller can label the action "Book now" vs "Request to book". Requires read_rates. ```graphql bookingQuote(input: BookingQuoteInput!): BookingQuote! ``` | Argument | Type | Default | Description | | --- | --- | --- | --- | | `input` | `BookingQuoteInput!` | | | ### `task` A task by id. Requires read_tasks. ```graphql task(id: ID!): Task ``` | Argument | Type | Default | Description | | --- | --- | --- | --- | | `id` | `ID!` | | | ### `tasks` Tasks, oldest id first, forward-paginated by id cursor. Filterable; pass filter.updatedSince to resync after downtime (returns everything changed at/after that instant, still id-ordered). Requires read_tasks. ```graphql tasks(filter: TaskFilterInput, first: Int! = 50, after: ID): TaskConnection! ``` | Argument | Type | Default | Description | | --- | --- | --- | --- | | `filter` | `TaskFilterInput` | | | | `first` | `Int!` | `50` | | | `after` | `ID` | | | ### `propertyTaskCalendar` Tasks for one property within a due-date window (inclusive, max 366 days), ordered by due time β€” for calendar rendering. Requires read_tasks. ```graphql propertyTaskCalendar(propertyId: ID!, from: String!, to: String!): [Task!]! ``` | Argument | Type | Default | Description | | --- | --- | --- | --- | | `propertyId` | `ID!` | | | | `from` | `String!` | | | | `to` | `String!` | | | ### `aiModels` Models available for aiGenerate: active, priced catalog models. Requires ai_inference. ```graphql aiModels: [AiModelInfo!]! ``` ### `apiVersion` The API version served by this endpoint. ```graphql apiVersion: String! ``` ### `currentApp` The app making the request, resolved from the bearer token. Null if unauthenticated. ```graphql currentApp: App ``` ## Mutations ### `paymentSessionResolve` Mark a payment session as successfully paid. ```graphql paymentSessionResolve(id: ID!, providerReference: String, amount: Float): PaymentSessionMutationResult! ``` | Argument | Type | Default | Description | | --- | --- | --- | --- | | `id` | `ID!` | | | | `providerReference` | `String` | | | | `amount` | `Float` | | | ### `paymentSessionReject` Mark a payment session as failed/declined. ```graphql paymentSessionReject(id: ID!, reason: String): PaymentSessionMutationResult! ``` | Argument | Type | Default | Description | | --- | --- | --- | --- | | `id` | `ID!` | | | | `reason` | `String` | | | ### `paymentSessionPending` Mark a payment session as still pending an asynchronous outcome. ```graphql paymentSessionPending(id: ID!, providerReference: String): PaymentSessionMutationResult! ``` | Argument | Type | Default | Description | | --- | --- | --- | --- | | `id` | `ID!` | | | | `providerReference` | `String` | | | ### `channelIntegrationCreate` Register (or re-activate) an OTA connection for a property this app manages. ```graphql channelIntegrationCreate(input: ChannelIntegrationCreateInput!): ChannelIntegrationResult! ``` | Argument | Type | Default | Description | | --- | --- | --- | --- | | `input` | `ChannelIntegrationCreateInput!` | | | ### `channelIntegrationDisconnect` Disconnect an OTA connection. Sync stops; history is preserved. ```graphql channelIntegrationDisconnect(id: ID!): ChannelIntegrationResult! ``` | Argument | Type | Default | Description | | --- | --- | --- | --- | | `id` | `ID!` | | | ### `channelListingLink` Link a unit type to an external room type / rate plan. Triggers an initial full ARI push. ```graphql channelListingLink(input: ChannelListingLinkInput!): ChannelListingResult! ``` | Argument | Type | Default | Description | | --- | --- | --- | --- | | `input` | `ChannelListingLinkInput!` | | | ### `channelListingUnlink` Unlink a unit type from its external listing. ```graphql channelListingUnlink(id: ID!): ChannelListingResult! ``` | Argument | Type | Default | Description | | --- | --- | --- | --- | | `id` | `ID!` | | | ### `reservationUpsert` Create or revise a reservation that originated on the external channel. Idempotent per externalId; stale revisions are ignored. ```graphql reservationUpsert(input: ReservationInput!): ReservationResult! ``` | Argument | Type | Default | Description | | --- | --- | --- | --- | | `input` | `ReservationInput!` | | | ### `ratesUpdate` Set the nightly rate and/or stay restrictions for a unit type across a date range (inclusive, max 366 days). Omitted fields keep their current value; an explicit null clears that override back to the unit type's base value. Changes sync to connected channels and emit a rates.updated webhook digest. Requires write_rates. ```graphql ratesUpdate(input: RatesUpdateInput!): RatesUpdateResult! ``` | Argument | Type | Default | Description | | --- | --- | --- | --- | | `input` | `RatesUpdateInput!` | | | ### `metafieldSet` Set an app-owned metafield on a booking, property or contact. Key must be declared in the app manifest; rides on the owner's read scope. ```graphql metafieldSet(ownerType: String!, ownerId: ID!, key: String!, value: String!): MetafieldMutationResult! ``` | Argument | Type | Default | Description | | --- | --- | --- | --- | | `ownerType` | `String!` | | | | `ownerId` | `ID!` | | | | `key` | `String!` | | | | `value` | `String!` | | | ### `metafieldDelete` Delete an app-owned metafield. ```graphql metafieldDelete(ownerType: String!, ownerId: ID!, key: String!): MetafieldDeleteResult! ``` | Argument | Type | Default | Description | | --- | --- | --- | --- | | `ownerType` | `String!` | | | | `ownerId` | `ID!` | | | | `key` | `String!` | | | ### `messageSend` Send a guest-facing message in a conversation. Requires write_conversations. ```graphql messageSend(conversationId: ID!, body: String): MessageSendResult! ``` | Argument | Type | Default | Description | | --- | --- | --- | --- | | `conversationId` | `ID!` | | | | `body` | `String` | | | ### `bookingFlagSet` Set (or replace) this app's flag on a booking, shown to the host as a badge. Requires write_bookings. ```graphql bookingFlagSet(bookingId: ID!, level: String!, message: String): BookingFlagResult! ``` | Argument | Type | Default | Description | | --- | --- | --- | --- | | `bookingId` | `ID!` | | | | `level` | `String!` | | | | `message` | `String` | | | ### `bookingChargeAdd` Add a charge line to a booking through the fees engine. Rejected once an invoice is issued. Requires write_charges. ```graphql bookingChargeAdd(bookingId: ID!, label: String!, amount: String!, currency: String!): BookingChargeResult! ``` | Argument | Type | Default | Description | | --- | --- | --- | --- | | `bookingId` | `ID!` | | | | `label` | `String!` | | | | `amount` | `String!` | | | | `currency` | `String!` | | | ### `bookingCreate` Create a booking for a unit type and date range. On instant-book hosts this holds a PENDING, unpaid booking and returns `paymentUrl` (the guest pays on the host's own storefront); on review hosts it files an inquiry the host approves manually. `outcome` says which path ran. Pass a stable `idempotencyKey` so retries return the original result instead of double-booking. Requires create_bookings. ```graphql bookingCreate(input: BookingCreateInput!): BookingCreatePayload! ``` | Argument | Type | Default | Description | | --- | --- | --- | --- | | `input` | `BookingCreateInput!` | | | ### `taskCreate` Create a task. Requires write_tasks. ```graphql taskCreate(input: TaskCreateInput!): TaskMutationResult! ``` | Argument | Type | Default | Description | | --- | --- | --- | --- | | `input` | `TaskCreateInput!` | | | ### `taskUpdate` Update a task's non-status fields. Requires write_tasks. ```graphql taskUpdate(input: TaskUpdateInput!): TaskMutationResult! ``` | Argument | Type | Default | Description | | --- | --- | --- | --- | | `input` | `TaskUpdateInput!` | | | ### `taskAssign` Assign a task to an actor and write back identifiers. The provider-app write-back path of the assignment handshake. Returns userErrors[].code ALREADY_ASSIGNED when the task is already assigned by anyone other than the calling app (host, fallback, or another app) β€” re-assigning your own assignment is allowed. Requires write_tasks. ```graphql taskAssign(input: TaskAssignInput!): TaskMutationResult! ``` | Argument | Type | Default | Description | | --- | --- | --- | --- | | `input` | `TaskAssignInput!` | | | ### `taskTransitionStatus` Transition a task to a canonical status. Invalid transitions return userErrors[].code INVALID_TRANSITION. Requires write_tasks. ```graphql taskTransitionStatus(id: ID!, status: TaskStatus!): TaskMutationResult! ``` | Argument | Type | Default | Description | | --- | --- | --- | --- | | `id` | `ID!` | | | | `status` | `TaskStatus!` | | | ### `taskComplete` Mark a task completed. Returns userErrors[].code INVALID_TRANSITION when COMPLETED isn't reachable from the task's current status. Requires write_tasks. ```graphql taskComplete(id: ID!): TaskMutationResult! ``` | Argument | Type | Default | Description | | --- | --- | --- | --- | | `id` | `ID!` | | | ### `taskCancel` Cancel a task. Returns userErrors[].code INVALID_TRANSITION when CANCELLED isn't reachable from the task's current status. Requires write_tasks. ```graphql taskCancel(id: ID!): TaskMutationResult! ``` | Argument | Type | Default | Description | | --- | --- | --- | --- | | `id` | `ID!` | | | ### `taskAddChecklistItem` Append a checklist item to a task. Requires write_tasks. ```graphql taskAddChecklistItem(taskId: ID!, label: String!): TaskMutationResult! ``` | Argument | Type | Default | Description | | --- | --- | --- | --- | | `taskId` | `ID!` | | | | `label` | `String!` | | | ### `taskSetChecklistItemDone` Mark a checklist item done or not. Requires write_tasks. ```graphql taskSetChecklistItemDone(itemId: ID!, done: Boolean!): TaskMutationResult! ``` | Argument | Type | Default | Description | | --- | --- | --- | --- | | `itemId` | `ID!` | | | | `done` | `Boolean!` | | | ### `taskSetCustomField` Set a namespaced, typed custom field on a task (e.g. turno.project_id). `value` is parsed per `type`. Requires write_tasks. ```graphql taskSetCustomField(taskId: ID!, namespace: String!, key: String!, type: String!, value: String!): TaskMutationResult! ``` | Argument | Type | Default | Description | | --- | --- | --- | --- | | `taskId` | `ID!` | | | | `namespace` | `String!` | | | | `key` | `String!` | | | | `type` | `String!` | | | | `value` | `String!` | | | ### `aiGenerate` Run a text completion billed to the store's AI credits. The app composes its own context into the prompt; the response reports the exact fractional credits charged. Refusals come back as coded userErrors. Requires ai_inference. ```graphql aiGenerate(input: AiGenerateInput!): AiGeneratePayload! ``` | Argument | Type | Default | Description | | --- | --- | --- | --- | | `input` | `AiGenerateInput!` | | | ### `inboundMessageCreate` Inject an inbound guest message received on a channel this app provides. ```graphql inboundMessageCreate(input: InboundMessageInput!): InboundMessageResult! ``` | Argument | Type | Default | Description | | --- | --- | --- | --- | | `input` | `InboundMessageInput!` | | | ### `messageStatusUpdate` Report a delivery/read status change for a message this app sent. ```graphql messageStatusUpdate(input: MessageStatusInput!): MessageStatusResult! ``` | Argument | Type | Default | Description | | --- | --- | --- | --- | | `input` | `MessageStatusInput!` | | | ## Object types ### PaymentSessionMutationResult | Field | Type | Description | | --- | --- | --- | | `paymentSession` | `PaymentSession` | The affected session, or null when the input was invalid. | | `userErrors` | `[UserError!]!` | Per-field problems with the request; empty on success. | ### PaymentSession | Field | Type | Description | | --- | --- | --- | | `id` | `ID!` | | | `status` | `String!` | | | `amount` | `String!` | | | `currency` | `String!` | | | `providerReference` | `String` | | ### UserError | Field | Type | Description | | --- | --- | --- | | `field` | `[String!]` | Path to the offending argument. | | `message` | `String!` | | | `code` | `String` | Machine-readable error code, when the mutation provides one. | ### ChannelIntegration | Field | Type | Description | | --- | --- | --- | | `id` | `ID!` | | | `propertyId` | `ID!` | | | `externalPropertyId` | `String!` | | | `status` | `String!` | active, disabled, error or pending. | | `lastSyncedAt` | `String` | | | `lastError` | `String` | | | `listings` | `[ChannelListing!]!` | | ### ChannelListing | Field | Type | Description | | --- | --- | --- | | `id` | `ID!` | | | `unitTypeId` | `ID!` | | | `externalRoomTypeId` | `String!` | | | `externalRatePlanId` | `String` | | ### ChannelIntegrationConnection | Field | Type | Description | | --- | --- | --- | | `nodes` | `[ChannelIntegration!]!` | | | `pageInfo` | `PageInfo!` | | ### ChannelIntegrationResult | Field | Type | Description | | --- | --- | --- | | `integration` | `ChannelIntegration` | | | `userErrors` | `[UserError!]!` | | ### ChannelListingResult | Field | Type | Description | | --- | --- | --- | | `listing` | `ChannelListing` | | | `userErrors` | `[UserError!]!` | | ### Reservation | Field | Type | Description | | --- | --- | --- | | `id` | `ID!` | | | `externalId` | `String!` | | | `status` | `String!` | synced, unmapped or cancelled. | | `bookingId` | `ID` | The platform booking created for this reservation, once mapped. | ### ReservationResult | Field | Type | Description | | --- | --- | --- | | `reservation` | `Reservation` | | | `userErrors` | `[UserError!]!` | | ### RatesUpdateResult | Field | Type | Description | | --- | --- | --- | | `ok` | `Boolean!` | | | `userErrors` | `[UserError!]!` | | ### MessageSendResult | Field | Type | Description | | --- | --- | --- | | `message` | `Message` | | | `userErrors` | `[UserError!]!` | | ### Metafield An app-owned metafield value on a record. | Field | Type | Description | | --- | --- | --- | | `key` | `String!` | | | `value` | `String` | | | `type` | `String!` | | | `visibility` | `String!` | | ### MetafieldMutationResult | Field | Type | Description | | --- | --- | --- | | `metafield` | `Metafield` | | | `userErrors` | `[UserError!]!` | | ### MetafieldDeleteResult | Field | Type | Description | | --- | --- | --- | | `deleted` | `Boolean!` | | | `userErrors` | `[UserError!]!` | | ### BookingFlag | Field | Type | Description | | --- | --- | --- | | `level` | `String!` | | | `message` | `String` | | ### BookingFlagResult | Field | Type | Description | | --- | --- | --- | | `flag` | `BookingFlag` | | | `userErrors` | `[UserError!]!` | | ### BookingChargeLine | Field | Type | Description | | --- | --- | --- | | `id` | `ID!` | | | `label` | `String` | | | `amount` | `String!` | | | `currency` | `String!` | | ### BookingChargeResult | Field | Type | Description | | --- | --- | --- | | `charge` | `BookingChargeLine` | | | `userErrors` | `[UserError!]!` | | ### Property A property, scoped to the installing team. | Field | Type | Description | | --- | --- | --- | | `id` | `ID!` | | | `name` | `String!` | | | `type` | `String!` | hotel or rental. | | `address` | `String` | | | `city` | `String` | | | `country` | `String` | | | `countryCode` | `String` | ISO 3166-1 alpha-2. | | `lat` | `Float` | | | `lng` | `Float` | | | `active` | `Boolean!` | | | `description` | `String` | Listing content for channel apps. | | `cancellationPolicy` | `String` | | | `checkInTime` | `String` | HH:MM, property local convention. | | `checkOutTime` | `String` | | | `imageUrl` | `String` | | | `facilities` | `[FacilityItem!]!` | | | `unitTypes` | `[UnitType!]!` | | | `metafields` | `[Metafield!]!` | This app's own metafields on this record. | ### PropertyConnection | Field | Type | Description | | --- | --- | --- | | `nodes` | `[Property!]!` | | | `pageInfo` | `PageInfo!` | | ### UnitType A bookable unit type; pass its id to unitTypeRates / ratesUpdate. | Field | Type | Description | | --- | --- | --- | | `id` | `ID!` | | | `propertyId` | `ID!` | | | `name` | `String` | | | `kind` | `String!` | Sellable kind: standard room type, or entire_property (books the whole property). | | `maxGuests` | `Int` | | | `baseRate` | `Float` | | | `unitsCount` | `Int!` | Number of physical units of this type. | | `active` | `Boolean!` | | | `description` | `String` | Listing content for channel apps. | | `occAdults` | `Int!` | | | `occChildren` | `Int!` | | | `occInfants` | `Int!` | | | `photoUrls` | `[String!]!` | Ordered public photo URLs. | | `facilities` | `[FacilityItem!]!` | | | `bedConfigurations` | `[BedConfigurationItem!]!` | | ### UnitTypeConnection | Field | Type | Description | | --- | --- | --- | | `nodes` | `[UnitType!]!` | | | `pageInfo` | `PageInfo!` | | ### Booking A booking, scoped to the installing team. Guest PII lives on the contact field (read_contacts). | Field | Type | Description | | --- | --- | --- | | `id` | `ID!` | | | `status` | `String!` | | | `checkIn` | `String` | | | `checkOut` | `String` | | | `totalAmount` | `String` | | | `currency` | `String` | | | `paymentStatus` | `String` | | | `createdAt` | `String!` | | | `units` | `[BookingUnitLine!]!` | | | `contactId` | `ID` | | | `contact` | `Contact` | Guest PII β€” requires the read_contacts scope in addition to read_bookings. | | `metafields` | `[Metafield!]!` | This app's own metafields on this record. | ### BookingUnitLine A distinct unit line on a booking (bookings store one row per unit per night). | Field | Type | Description | | --- | --- | --- | | `unitTypeId` | `ID!` | | | `unitId` | `ID` | | | `unitTypeName` | `String` | | ### BookingConnection | Field | Type | Description | | --- | --- | --- | | `nodes` | `[Booking!]!` | | | `pageInfo` | `PageInfo!` | | ### PageInfo | Field | Type | Description | | --- | --- | --- | | `hasNextPage` | `Boolean!` | | | `endCursor` | `ID` | The last node's id; pass as `after` for the next page. | ### Conversation | Field | Type | Description | | --- | --- | --- | | `id` | `ID!` | | | `channel` | `String!` | | | `status` | `String!` | | | `contactId` | `ID` | | | `createdAt` | `String!` | | | `messages` | `[Message!]!` | | ### Message | Field | Type | Description | | --- | --- | --- | | `id` | `ID!` | | | `conversationId` | `ID!` | | | `direction` | `String!` | | | `body` | `String` | | | `deliveryStatus` | `String` | Delivery lifecycle: queued, sent, delivered, read, failed, draft, discarded. 'draft' means the host's autonomy policy held this app's send for approval. | | `createdAt` | `String!` | | ### UnitTypeRateDay | Field | Type | Description | | --- | --- | --- | | `date` | `String!` | | | `rate` | `Float` | | | `available` | `Int!` | | | `minStay` | `Int` | | | `maxStay` | `Int` | | | `closedToArrival` | `Boolean` | | | `closedToDeparture` | `Boolean` | | | `stopSell` | `Boolean` | | ### Contact A guest contact, scoped to the installing team. | Field | Type | Description | | --- | --- | --- | | `id` | `ID!` | | | `fullName` | `String` | | | `email` | `String` | | | `phone` | `String` | | | `metafields` | `[Metafield!]!` | This app's own metafields on this record. | ### FacilityItem | Field | Type | Description | | --- | --- | --- | | `name` | `String!` | | | `scope` | `String!` | property, unit or both. | | `icon` | `String` | | ### BedConfigurationItem | Field | Type | Description | | --- | --- | --- | | `bedType` | `String!` | Canonical bed type key, e.g. double, single, sofa_bed. | | `quantity` | `Int!` | | | `sleeps` | `Int!` | Total sleeping capacity for this row. | ### BookingQuote Itemized price + validation for a stay. Amounts are decimal strings in the storefront currency. | Field | Type | Description | | --- | --- | --- | | `currency` | `String!` | | | `nights` | `Int!` | | | `units` | `Int!` | | | `subtotal` | `String!` | Accommodation subtotal before fees and taxes. | | `discount` | `String!` | | | `fees` | `[QuoteFee!]!` | Mandatory fees, one line each. | | `taxes` | `[QuoteTax!]!` | Taxes; an `included` tax is already inside the nightly rate, not added on top. | | `total` | `String!` | Grand total the guest pays (subtotal - discount + fees + added taxes). | | `perNight` | `[NightRate!]!` | Per-night nightly rate. | | `instantBook` | `Boolean!` | True when this host instant-books; false means bookingCreate files an inquiry. | | `bookingMode` | `String!` | The host's raw booking mode, e.g. 'instant' or 'request'. | | `violations` | `[StayViolation!]!` | Stay-restriction problems that would block a booking; empty when bookable. | ### QuoteFee | Field | Type | Description | | --- | --- | --- | | `label` | `String!` | | | `amount` | `String!` | | | `basis` | `String` | How the fee is charged, e.g. per_stay, per_night, per_person, per_person_per_night. | ### QuoteTax | Field | Type | Description | | --- | --- | --- | | `name` | `String!` | | | `amount` | `String!` | | | `rate` | `Float` | Percentage rate when applicable; null for fixed taxes. | | `included` | `Boolean!` | True when already included in the nightly rate (not added on top). | ### NightRate | Field | Type | Description | | --- | --- | --- | | `date` | `String!` | | | `rate` | `String!` | | ### StayViolation A stay-restriction violation. `code` mirrors the platform restriction codes. | Field | Type | Description | | --- | --- | --- | | `code` | `String!` | One of: min_stay, max_stay, closed_to_arrival, closed_to_departure, stop_sell, no_availability, invalid_range. | | `message` | `String!` | | | `date` | `String` | The date the violation applies to, if any. | ### BookingCreatePayload | Field | Type | Description | | --- | --- | --- | | `outcome` | `BookingOutcome` | Which path ran. Null when the request failed validation (see userErrors). | | `booking` | `BookingRef` | The held booking, when outcome is HELD. | | `inquiry` | `InquiryRef` | The inquiry, when outcome is INQUIRY. | | `paymentUrl` | `String` | Where the guest pays to confirm a held booking. Present only for HELD. | | `totals` | `BookingQuote` | The itemized totals the booking/inquiry was priced at. | | `userErrors` | `[UserError!]!` | | ### BookingRef A created booking, held and awaiting payment. | Field | Type | Description | | --- | --- | --- | | `id` | `ID!` | | | `reference` | `String!` | Human booking number, e.g. K7MP-Q9RT. | | `status` | `String!` | | | `paymentStatus` | `String!` | | | `checkIn` | `String!` | | | `checkOut` | `String!` | | | `total` | `String!` | | | `currency` | `String!` | | ### InquiryRef A filed booking request awaiting host review. | Field | Type | Description | | --- | --- | --- | | `id` | `ID!` | | | `status` | `String!` | | | `checkIn` | `String!` | | | `checkOut` | `String!` | | | `total` | `String!` | | | `currency` | `String!` | | ### Task A task instance, scoped to the installing team. | Field | Type | Description | | --- | --- | --- | | `id` | `ID!` | | | `type` | `String!` | Registry type slug (cleaning, maintenance, ... or an app-defined key). | | `canonicalStatus` | `TaskStatus!` | Canonical lifecycle status. | | `displayLabel` | `String` | Optional app-attached label rendered on top of canonical status. | | `substatus` | `String` | | | `title` | `String!` | | | `notes` | `String` | | | `priority` | `String!` | | | `propertyId` | `ID` | | | `reservationId` | `ID` | The reservation (booking) this task belongs to, if any. | | `assigneeType` | `String` | Actor class of the assignee: user, app, or external. | | `assigneeId` | `ID` | | | `assigneeName` | `String` | | | `dueAt` | `String` | Resolved absolute due time (ISO 8601 Zulu). | | `completedAt` | `String` | | | `cost` | `String` | Cost as a decimal string, posted at the cost effective date. | | `costCurrency` | `String` | | | `costEffectiveDate` | `String` | | | `createdAt` | `String!` | | | `updatedAt` | `String!` | | | `checklist` | `[TaskChecklistItem!]!` | | | `customFields` | `[TaskCustomField!]!` | Namespaced, typed custom fields set by apps on this task. | ### TaskChecklistItem | Field | Type | Description | | --- | --- | --- | | `id` | `ID!` | | | `label` | `String!` | | | `position` | `Int!` | | | `isDone` | `Boolean!` | | | `doneAt` | `String` | | ### TaskCustomField A namespaced, typed custom field on a task. `value` is stringified; parse per `type`. | Field | Type | Description | | --- | --- | --- | | `namespace` | `String!` | | | `key` | `String!` | | | `type` | `String!` | string, integer, float, boolean, date, datetime, or json. | | `value` | `String` | | ### TaskConnection | Field | Type | Description | | --- | --- | --- | | `nodes` | `[Task!]!` | | | `pageInfo` | `PageInfo!` | | ### TaskMutationResult | Field | Type | Description | | --- | --- | --- | | `task` | `Task` | | | `userErrors` | `[UserError!]!` | | ### AiModelInfo A catalog model apps may request in aiGenerate. | Field | Type | Description | | --- | --- | --- | | `key` | `String!` | | | `isDefault` | `Boolean!` | True for the model used when aiGenerate omits `model`. | ### AiGeneratePayload | Field | Type | Description | | --- | --- | --- | | `text` | `String` | The generated text. Null when userErrors is non-empty. | | `usage` | `AiUsage` | | | `userErrors` | `[UserError!]!` | | ### AiUsage | Field | Type | Description | | --- | --- | --- | | `inputTokens` | `Int!` | | | `outputTokens` | `Int!` | | | `creditsCharged` | `Float!` | Exact fractional credits metered for this call. | ### InboundMessageResult | Field | Type | Description | | --- | --- | --- | | `conversationId` | `ID` | | | `messageId` | `ID` | | | `userErrors` | `[UserError!]!` | | ### MessageStatusResult | Field | Type | Description | | --- | --- | --- | | `ok` | `Boolean!` | | | `userErrors` | `[UserError!]!` | | ### App An installed app instance, scoped to the team that installed it. | Field | Type | Description | | --- | --- | --- | | `id` | `ID!` | | | `name` | `String!` | | | `type` | `String!` | | | `teamSlug` | `String!` | The slug of the team that installed this app. | ## Input types ### ChannelIntegrationCreateInput | Field | Type | Description | | --- | --- | --- | | `propertyId` | `ID!` | | | `externalPropertyId` | `String!` | The property/account identifier on the external channel. | | `settings` | `String` | JSON-encoded provider-specific settings, e.g. {"syncFrequencyMinutes": 15}. | ### ChannelListingLinkInput | Field | Type | Description | | --- | --- | --- | | `integrationId` | `ID!` | | | `unitTypeId` | `ID!` | | | `externalRoomTypeId` | `String!` | | | `externalRatePlanId` | `String` | | ### ReservationInput | Field | Type | Description | | --- | --- | --- | | `integrationId` | `ID!` | | | `externalId` | `String!` | The reservation id on the external channel. | | `revisionId` | `String!` | Monotonic revision identifier from the channel; compared lexicographically, stale revisions no-op. | | `status` | `ReservationStatus!` | | | `checkIn` | `String!` | | | `checkOut` | `String!` | | | `currency` | `String!` | | | `paymentCollect` | `PaymentCollect!` | How the guest paid: collected by the channel (ota) or by the property. | | `externalPaymentId` | `String` | | | `guest` | `ReservationGuestInput!` | | | `totalAmount` | `Float!` | | | `rooms` | `[ReservationRoomInput!]!` | | ### ReservationGuestInput | Field | Type | Description | | --- | --- | --- | | `firstName` | `String!` | | | `lastName` | `String!` | | | `email` | `String` | | | `phone` | `String` | | ### ReservationRoomInput | Field | Type | Description | | --- | --- | --- | | `externalRoomTypeId` | `String!` | | | `externalRatePlanId` | `String` | | | `adults` | `Int!` | | | `children` | `Int!` | | | `nights` | `[ReservationNightInput!]!` | Per-night prices set by the channel β€” the platform never re-prices. | ### ReservationNightInput | Field | Type | Description | | --- | --- | --- | | `date` | `String!` | | | `price` | `Float!` | | ### RatesUpdateInput | Field | Type | Description | | --- | --- | --- | | `unitTypeId` | `ID!` | | | `from` | `String!` | ISO date (YYYY-MM-DD), inclusive. | | `to` | `String!` | ISO date (YYYY-MM-DD), inclusive. At most 366 days after `from`. | | `rate` | `Float` | Nightly rate override. Explicit null reverts to the base rate. | | `minStay` | `Int` | | | `maxStay` | `Int` | | | `closedToArrival` | `Boolean` | | | `closedToDeparture` | `Boolean` | | | `stopSell` | `Boolean` | | ### BookingQuoteInput A stay to price: one unit type, a date range, and the party. | Field | Type | Description | | --- | --- | --- | | `unitTypeId` | `ID!` | | | `checkIn` | `String!` | Check-in date, ISO (YYYY-MM-DD). | | `checkOut` | `String!` | Check-out date, ISO (YYYY-MM-DD). Must be after checkIn. | | `adults` | `Int!` | Adults in the party (>= 1). | | `children` | `Int!` | | | `units` | `Int!` | Number of units of this type to book (>= 1). | ### BookingGuestInput | Field | Type | Description | | --- | --- | --- | | `firstName` | `String!` | | | `lastName` | `String!` | | | `email` | `String!` | | | `phone` | `String` | | ### BookingCreateInput | Field | Type | Description | | --- | --- | --- | | `unitTypeId` | `ID!` | | | `checkIn` | `String!` | | | `checkOut` | `String!` | | | `adults` | `Int!` | | | `children` | `Int!` | | | `units` | `Int!` | | | `guest` | `BookingGuestInput!` | | | `message` | `String` | Optional guest message / special requests, stored on the booking or inquiry. | | `idempotencyKey` | `String` | Optional idempotency key. Retries with the same key return the original booking. | ### TaskFilterInput | Field | Type | Description | | --- | --- | --- | | `propertyId` | `ID` | | | `reservationId` | `ID` | Filter to a reservation (booking). | | `type` | `String` | Registry type slug, e.g. cleaning. | | `canonicalStatus` | `[TaskStatus!]` | Limit to these canonical statuses. | | `assigneeType` | `String` | Actor class of the assignee: user, app, or external. | | `assigneeId` | `ID` | | | `dueFrom` | `String` | ISO date/datetime; due on or after. | | `dueTo` | `String` | ISO date/datetime; due on or before. | | `updatedSince` | `String` | ISO datetime; tasks updated at/after this instant β€” use to resync after downtime. | ### TaskCreateInput | Field | Type | Description | | --- | --- | --- | | `type` | `String!` | Registry type slug, e.g. cleaning. | | `title` | `String!` | | | `propertyId` | `ID` | | | `reservationId` | `ID` | | | `dueAt` | `String` | ISO date/datetime. | | `priority` | `String` | | | `notes` | `String` | | | `assigneeType` | `String` | | | `assigneeId` | `ID` | | | `assigneeName` | `String` | | ### TaskUpdateInput | Field | Type | Description | | --- | --- | --- | | `id` | `ID!` | | | `title` | `String` | | | `type` | `String` | | | `priority` | `String` | | | `notes` | `String` | | | `dueAt` | `String` | | | `propertyId` | `ID` | | | `reservationId` | `ID` | | ### TaskAssignInput | Field | Type | Description | | --- | --- | --- | | `id` | `ID!` | | | `assigneeType` | `String!` | Actor class of the assignee: user, app, or external. | | `assigneeId` | `ID` | | | `assigneeName` | `String` | | ### AiGenerateInput | Field | Type | Description | | --- | --- | --- | | `system` | `String` | System instructions for the completion. | | `prompt` | `String` | The user prompt. Compose any context or history into this string. | | `model` | `String` | Catalog model key from aiModels. Omit for the platform default. | | `maxOutputTokens` | `Int` | Requested output-token ceiling. Values above the platform maximum are clamped. | ### InboundMessageInput | Field | Type | Description | | --- | --- | --- | | `channel` | `String!` | A channel key this app declares in its manifest. | | `externalThreadId` | `String!` | Provider thread id (e.g. PSID). Identifies the conversation. | | `senderIdentifier` | `String!` | Provider user id. Identifies the contact. | | `body` | `String` | | | `bodyFormat` | `String` | text (default) or another format the channel supports. | | `attachments` | `[AttachmentInput!]` | | | `externalMessageId` | `String` | Provider message id; used for idempotency. | | `sentAt` | `String` | ISO-8601 timestamp; defaults to now. | | `contactHints` | `ContactHintsInput` | | ### ContactHintsInput | Field | Type | Description | | --- | --- | --- | | `firstName` | `String` | | | `lastName` | `String` | | | `displayName` | `String` | | | `email` | `String` | | | `phone` | `String` | E.164 phone, used for contact matching. | | `avatarUrl` | `String` | | ### AttachmentInput | Field | Type | Description | | --- | --- | --- | | `url` | `String!` | | | `type` | `String` | | | `name` | `String` | | ### MessageStatusInput | Field | Type | Description | | --- | --- | --- | | `externalMessageId` | `String!` | | | `status` | `String!` | delivered \| read \| failed | ## Enums ### ReservationStatus | Value | Description | | --- | --- | | `NEW` | | | `MODIFIED` | | | `CANCELLED` | | ### PaymentCollect | Value | Description | | --- | --- | | `OTA` | | | `PROPERTY` | | ### BookingOutcome | Value | Description | | --- | --- | | `HELD` | A held, unpaid booking was created; pay at paymentUrl to confirm. | | `INQUIRY` | A booking request (inquiry) was filed for the host to review. | ### TaskStatus Platform-owned canonical task lifecycle. Apps may attach a displayLabel / substatus on top, but this canonical status is what all core logic keys off. | Value | Description | | --- | --- | | `OPEN` | | | `ASSIGNED` | | | `IN_PROGRESS` | | | `BLOCKED` | | | `COMPLETED` | | | `CANCELLED` | | | `VERIFIED` | | --- # Inbox channel-provider apps A channel-provider app lets you bring a new messaging channel into Stayblox, for example WhatsApp, Telegram, or a custom SMS provider. When a host installs your app and connects a property to it, Stayblox: - delivers outbound host replies to your endpoint (`message_send`): **platform to app** - accepts inbound guest messages you forward via GraphQL mutations: **app to platform** Like payment apps, channel-provider apps authenticate with a per-install bearer token and the same HMAC signing scheme. See [Signing & security](/apps/signing) for verification details. ## 1. Declare your app (manifest) Add the `provide_inbox_channel` scope to your manifest's `scopes` array and declare your channels under the top-level `channels` key. Also set `endpoints.message_send` to the HTTPS URL Stayblox will POST outbound messages to. ```jsonc { "name": "WhatsApp Connect", "scopes": ["provide_inbox_channel", "read_conversations"], "channels": [ { "key": "whatsapp", "name": "WhatsApp", "caps": { "richText": false, "quickReplies": false, "outboundWindowHours": 24, "templateRequiredOutsideWindow": true } } ], "endpoints": { "message_send": "https://app.example.com/stayblox/message-send" }, "settings_schema": [ { "key": "phone_number_id", "type": "string", "label": "Phone Number ID", "required": true }, { "key": "access_token", "type": "string", "label": "Access Token", "required": true } ] } ``` ### `channels` array Each entry declares one messaging channel your app provides. | Property | Required | Description | | --- | --- | --- | | `key` | Yes | A unique lowercase slug matching `^[a-z0-9_]{2,40}$`. Reserved values (`email`, `web_form`, `web`) are rejected. | | `name` | No | Display name shown to hosts, for example "Sample Chat". 2 to 60 characters. When omitted, the channel key is prettified ("sample_chat" renders as "Sample chat"). | | `caps` | Yes | Capabilities object (see below). | The `name` (or the prettified key when it is omitted) is what hosts see wherever your channel is listed in their inbox UI: the send-autonomy matrix, channel filters, and contact identity selects. ### `channels[].caps` | Property | Required | Type | Description | | --- | --- | --- | --- | | `outboundWindowHours` | Yes | integer | Number of hours after the last inbound message during which the host can send a free-form reply. Use `0` for no window (template-only). | | `richText` | No | boolean | Whether the channel supports bold, links, and other rich text. Defaults to `false`. | | `quickReplies` | No | boolean | Whether the channel can render quick-reply buttons. Defaults to `false`. | | `templateRequiredOutsideWindow` | No | boolean | Whether an approved message template is required when replying outside the outbound window. Defaults to `false`. | ### Validation rules - `channels` requires the `provide_inbox_channel` scope to be listed in `scopes`. - `channels` requires `endpoints.message_send` to be set. - Each channel `key` must match `^[a-z0-9_]{2,40}$`. Duplicate keys within one manifest are rejected. - Reserved keys (`email`, `web_form`, `web`) are rejected. - `name`, when present, must be a string of 2 to 60 characters. - `caps.outboundWindowHours` must be present. --- ## 2. Receive outbound messages (platform to app) When a host sends a reply on one of your channels, Stayblox POSTs a signed delivery command to your `endpoints.message_send` URL. Verify the signature before processing (same algorithm used for webhooks; see [Signing & security](/apps/signing)). ### 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)` | ### Request body ```jsonc { "message_id": "9b2c1f0e-...", "conversation_id": "7d3a0e1b-...", "channel": "whatsapp", "recipient": { "external_thread_id": "1234567890" }, "body": "Hello! Your check-in code is 4821.", "body_format": "text", "attachments": [], "settings": { "phone_number_id": "...", "access_token": "..." }, "api_base_url": "https://app.stayblox.com/developer/api/2026-01" } ``` | Field | Type | Description | | --- | --- | --- | | `message_id` | string (UUID) | Stayblox message id. Use for idempotency. | | `conversation_id` | string (UUID) | Stayblox conversation id. | | `channel` | string | The channel key that matched this install. | | `recipient.external_thread_id` | string | Provider thread id (e.g. a WhatsApp PSID or thread UID) identifying where to deliver the message. | | `body` | string | Message text. | | `body_format` | string | `text` or a richer format the channel supports. Degrade to plain text when unsupported. | | `attachments` | array | List of `{ url, type, name }` objects. May be empty. | | `settings` | object | The per-install settings values the host entered during installation. | | `api_base_url` | string | Root URL for the Developer GraphQL API. Use this to construct the endpoint when calling back. | ### Response Respond with HTTP `200` and a JSON body: ```jsonc { "status": "sent", "provider_message_id": "wamid.ABC123..." } ``` | Field | Required | Values | Description | | --- | --- | --- | --- | | `status` | Yes | `"sent"` or `"failed"` | Whether the message was delivered to the provider. | | `provider_message_id` | No | string | Provider's message id, used for delivery receipt correlation. | | `error` | No | string | Human-readable error description when `status` is `"failed"`. | Any non-200 response or network timeout is treated as a failure. --- ## 3. Forward inbound messages (app to platform) When a guest sends a message on your channel, forward it to Stayblox with the `inboundMessageCreate` mutation. Use your install's bearer token. ```graphql mutation InjectMessage($input: InboundMessageInput!) { inboundMessageCreate(input: $input) { conversationId messageId userErrors { field message } } } ``` ### `InboundMessageInput` | Field | Required | Description | | --- | --- | --- | | `channel` | Yes | The channel key declared in your manifest. | | `externalThreadId` | Yes | Provider thread id. Used to match or create a conversation. | | `senderIdentifier` | Yes | Provider user id. Used for contact matching. | | `body` | No | Message text. | | `bodyFormat` | No | `text` (default) or another format the channel supports. | | `attachments` | No | List of `{ url, type, name }` objects. | | `externalMessageId` | No | Provider message id. Used for idempotency: duplicate ids are ignored. | | `sentAt` | No | ISO-8601 timestamp. Defaults to the time of the API call. | | `contactHints` | No | Name/email/phone hints for contact matching (see below). | ### `ContactHintsInput` | Field | Description | | --- | --- | | `firstName` | Guest's first name. | | `lastName` | Guest's last name. | | `displayName` | Display name (used when first/last are absent). | | `email` | Email address for contact matching. | | `phone` | E.164 phone number for contact matching. | | `avatarUrl` | Profile photo URL. | ### Result ```jsonc { "data": { "inboundMessageCreate": { "conversationId": "7d3a0e1b-...", "messageId": "9b2c1f0e-...", "userErrors": [] } } } ``` `userErrors` is empty on success. On failure it describes the problem (unknown channel, missing required field, etc.). --- ## 4. Report delivery receipts (app to platform) After delivering a host reply, update its delivery status with `messageStatusUpdate`. Use the `externalMessageId` you returned in step 2's `provider_message_id` to correlate. ```graphql mutation StatusUpdate($input: MessageStatusInput!) { messageStatusUpdate(input: $input) { ok userErrors { field message } } } ``` ### `MessageStatusInput` | Field | Required | Description | | --- | --- | --- | | `externalMessageId` | Yes | The `provider_message_id` you returned in the `message_send` response. | | `status` | Yes | `"delivered"`, `"read"`, or `"failed"`. | --- ## Checklist - [ ] Manifest declares `provide_inbox_channel` in `scopes` and valid entries in `channels`. - [ ] `endpoints.message_send` is set to a publicly reachable `https://` URL. - [ ] `message_send` handler verifies the HMAC signature and rejects stale requests. - [ ] `message_send` responds `{ "status": "sent" | "failed" }` within the timeout. - [ ] Inbound guest messages are forwarded via `inboundMessageCreate`. - [ ] Delivery receipts are reported via `messageStatusUpdate`. - [ ] `userErrors` is checked on every mutation. --- # Injections Injection apps render small HTML snippets (analytics pixels, chat widgets, consent banners) directly into the storefront without writing any Twig theme code. The snippet is declared in your manifest and is rendered server-side on every storefront page load for each host that has your app installed. ## How it works ``` Host installs your app and fills in settings (e.g. "Tracking ID: UA-1234") β”‚ Guest visits any storefront page β”‚ Stayblox renders the page β”‚ β”œβ”€β”€ Reads all active app installs for this team β”œβ”€β”€ For each install with injections: β”‚ interpolates {{ setting_key }} placeholders from install settings β”‚ and drops the rendered HTML into the named slot β”‚ └── Page delivered to guest with all injection snippets inline ``` The injection is rendered per-install, so each host's snippet carries their own tracking ID, chat token, or other per-install setting values. ## Declaring injections in `app.toml` Add `[[injections]]` tables to your `app.toml`. Each entry specifies a `slot` (where on the page) and a `template` (what to insert): ```toml scopes = [] [[settings_schema]] key = "tracking_id" type = "string" label = "Google Analytics Tracking ID" required = true [[injections]] slot = "head" template = ''' ''' ``` You can declare multiple injections (e.g. one in `head`, one in `body_end`): ```toml [[injections]] slot = "head" template = '' [[injections]] slot = "body_end" template = '' ``` ## Slots | Slot | Position in the storefront | | --- | --- | | `head` | Inside ``, after platform styles and scripts. | | `body_end` | Immediately before ``. Preferred for JavaScript that doesn't need to block rendering. | Slot names must be known values; unknown slots are rejected at manifest validation. As new slots are added to the platform, the manifest validator is updated first. ## Template interpolation Templates may contain `{{ setting_key }}` placeholders. At render time, Stayblox replaces each placeholder with the value from the install's settings (what the host entered during installation). Only keys declared in `settings_schema` are available. ```html ``` If a setting key is missing or blank (the host hasn't filled it in), the placeholder resolves to an empty string. Design your snippet to degrade gracefully in that case. **Interpolation is intentionally minimal.** Only setting values from the install are available; booking data, property data, and any other runtime context are not. If you need dynamic runtime data in your script, fetch it from your own server using a value (e.g. an API key or token) that came from settings. ## Security and review Injection templates are reviewed as part of the app review process. The review checks that: - External resources (`src`, `href`) load from a declared `https://` origin. - No inline JavaScript (`' ``` ## Injections in general-purpose apps Injections are not exclusive to `type: injection` apps. Any app type can include an `injections` array. A smart-lock app might declare `type: remote`, handle webhooks, write metafields, *and* inject a check-in widget into the storefront, all from one app. ## Checklist - [ ] `slot` is a known value (`head`, `body_end`). - [ ] All external resources load from a fixed `https://` domain. - [ ] No inline `' ``` --- ## Keys ### `name` | Required | Type | | --- | --- | | Yes | string | The display name shown to hosts in the marketplace and panel. --- ### `type` | Required | Type | Default | | --- | --- | --- | | Yes | string | `remote` | The app type. One of `remote`, `payment`, `channel`, or `injection`. Private apps support `remote` only. --- ### `distribution` | Required | Type | Default | | --- | --- | --- | | Yes | string | `public` | `private` or `public`. Cannot be changed after the first push. See [App lifecycle](/apps/publishing). --- ### `slug` | Required | Type | | --- | --- | | Required for public | string | A globally unique, URL-safe identifier for the app. - **Public apps:** choose a slug and set it before the first push. - **Private apps:** omit `slug`; the server generates one on the first push and the CLI writes it back to `app.toml`. **Format:** `^[a-z0-9][a-z0-9-]{1,39}$` (lowercase letters, digits, and hyphens; must start with a letter or digit). --- ### `scopes` | Required | Type | | --- | --- | | No | array of strings | Access scopes the host consents to when installing. Stayblox checks the install's `granted_scopes` on every API call and webhook delivery. See [Scopes](/apps/scopes) for the full catalog. ```toml scopes = ["read_bookings", "read_contacts", "write_conversations"] ``` **Validation:** every entry must be a known scope value. A webhook topic also requires its scope to be listed here. --- ### `capabilities` | Required | Type | | --- | --- | | No | array of strings | Privileged behaviors the app requests, separate from data-access scopes. The host grants these at install time. | Value | What it enables | | --- | --- | | `act_as_assignment_provider` | Receive task assignment requests and write back the chosen assignee. | | `register_task_types` | Add custom task types with their own fields and checklists. | See [Assignment provider apps](/apps/assignment-providers) and [Tasks](/apps/tasks). --- ### `webhooks` | Required | Type | | --- | --- | | No | array of strings | Topics delivered to `webhook_url` for each install. See [Webhooks](/apps/webhooks) for the generated topic list. ```toml webhooks = ["booking.confirmed", "booking.cancelled"] ``` **Validation:** every topic must be a known value, and its required scope must appear in `scopes`. --- ### `webhook_url` | Required | Type | | --- | --- | | No | string | The HTTPS URL Stayblox delivers webhook events to. Copied onto each install at install time. Stayblox signs every delivery with a per-install `webhook_secret` issued at `app install`. See [Signing & security](/apps/signing). ```toml webhook_url = "https://app.example.com/webhooks/stayblox" ``` **Validation:** must be a valid `https://` URL. --- ### `[oauth]` | Required | Type | | --- | --- | | No | table | Registers redirect URIs for the OAuth 2.0 authorization-code install flow. See [OAuth install](/apps/oauth) for the complete flow. ```toml [oauth] redirect_uris = ["https://app.example.com/auth/stayblox/callback"] ``` | Property | Required | Description | | --- | --- | --- | | `redirect_uris` | Yes | Array of `https://` URIs. The `redirect_uri` in an authorize request must exactly match one; no wildcards, no partial matches. | **Validation:** every URI must be a valid `https://` URL. > **Private apps:** `[oauth]` is not supported for private apps. --- ### `[app_page]` | Required | Type | | --- | --- | | No | table | Declares an embedded UI page rendered inside the host panel as a sandboxed iframe. See [Embedded pages](/apps/app-pages) for the JWT session protocol. ```toml [app_page] url = "https://app.example.com/stayblox" ``` | Property | Required | Description | | --- | --- | --- | | `url` | Yes | The `https://` URL of your app page. The iframe loads `{url}?session=`. | **Validation:** `url` must be a valid `https://` URL. > **Private apps:** `[app_page]` is not supported for private apps. --- ### `[[settings_schema]]` | Required | Type | | --- | --- | | No | array of tables | Defines the per-install settings form shown to the host at install time and on the app settings page. Each entry is a field definition. | Property | Required | Type | Description | | --- | --- | --- | --- | | `key` | Yes | string | Identifier for the setting. | | `type` | Yes | string | `string`, `number`, `boolean`, or `select`. | | `label` | Yes | string | Human-readable label shown in the form. | | `required` | No | boolean | Defaults to `false`. | | `default` | No | any | Default value shown pre-filled. | | `options` | For `select` | array | Array of `{ value, label }` objects. | Setting values are available in `[[injections]]` templates as `{{ key }}` and in protocol payloads under `settings.key`. > **Private apps:** `[[settings_schema]]` is not supported for private apps. --- ### `[[injections]]` | Required | Type | | --- | --- | | No | array of tables | HTML snippets rendered into named slots in the storefront. See [Injections](/apps/injections) for slot names and template examples. ```toml [[injections]] slot = "head" template = '' ``` | Property | Required | Description | | --- | --- | --- | | `slot` | Yes | Named storefront slot (e.g. `head`, `body_end`). | | `template` | Yes | Raw HTML. May use `{{ setting_key }}` placeholders interpolated from the install's settings. | **Sanitization:** injection templates are reviewed as part of the app review. Scripts must load from a fixed `https://` origin; inline scripts are rejected. > **Private apps:** `[[injections]]` is not supported for private apps. --- ### `[[metafields]]` | Required | Type | | --- | --- | | No | array of tables | Declares app-owned data fields stored on bookings, properties, or contacts. See [Metafields](/apps/metafields) for how to write and read them at runtime. | Property | Required | Values | Description | | --- | --- | --- | --- | | `owner` | Yes | `booking` \| `property` \| `contact` | The model this field attaches to. | | `key` | Yes | `^[a-z0-9_]{1,64}$` | Unique identifier for this field (snake_case, max 64 chars). | | `type` | Yes | `string` \| `number` \| `boolean` \| `date` | Value data type. | | `visibility` | Yes | `private` \| `host` \| `guest` | Who can see the value. | **Visibility:** | Value | Who sees it | | --- | --- | | `private` | Your app only, via the API. Never shown in the host panel or guest emails. | | `host` | Shown in the host panel on booking, property, or contact detail pages. | | `guest` | Like `host`, plus exposed in guest email templates and in `app_data(booking)` in themes. | > **Private apps:** `[[metafields]]` is not supported for private apps. --- ## Distribution-aware validation The CLI sends your `distribution` value with the manifest. The server validates accordingly. | Check | Private | Public | | --- | --- | --- | | `type: remote` only | Required | Any type allowed | | `[oauth]`, `[app_page]`, `[[settings_schema]]`, `[[injections]]`, `[[metafields]]` | Not supported | Supported | | Slug | Server-generated (written back on first push) | Required; `^[a-z0-9][a-z0-9-]{1,39}$` | | Scope and topic validation | Same as public | Same as private | --- ### `channels` | Required | Type | | --- | --- | | No | array of objects | Declares the messaging channels this app provides. Requires the `provide_inbox_channel` scope in `scopes` and a valid `endpoints.message_send` URL. See [Inbox channel-provider apps](/apps/inbox-channel-provider) for the full protocol reference. ```jsonc "channels": [ { "key": "whatsapp", "name": "WhatsApp", "caps": { "richText": false, "quickReplies": false, "outboundWindowHours": 24, "templateRequiredOutsideWindow": true } } ] ``` | Property | Required | Description | | --- | --- | --- | | `key` | Yes | Unique lowercase slug, `^[a-z0-9_]{2,40}$`. Values `email`, `web_form`, and `web` are reserved. | | `name` | No | Display name shown to hosts in the inbox UI. 2 to 60 characters. When omitted, the key is prettified ("sample_chat" renders as "Sample chat"). | | `caps.outboundWindowHours` | Yes | Hours after the last inbound message during which free-form replies are allowed. | | `caps.richText` | No | Channel supports bold, links, etc. Defaults to `false`. | | `caps.quickReplies` | No | Channel supports quick-reply buttons. Defaults to `false`. | | `caps.templateRequiredOutsideWindow` | No | An approved template is required outside the outbound window. Defaults to `false`. | --- ### `endpoints` | Required | Type | | --- | --- | | No | object | HTTPS endpoint URLs Stayblox calls on your server for protocol-based interactions. | Property | Required when | Description | | --- | --- | --- | | `message_send` | `channels` is declared | Stayblox POSTs outbound host messages here, signed with HMAC. See [Inbox channel-provider apps](/apps/inbox-channel-provider#2-receive-outbound-messages-platform-to-app). | | `ari_push` | `provide_channel` is in `scopes` | Stayblox POSTs rate/availability/restriction pushes here, signed with HMAC. See [Channel apps](/apps/channels#3-receiving-ari-platform-to-app). | ```toml scopes = ["provide_channel", "read_properties", "read_rates"] [endpoints] ari_push = "https://app.example.com/stayblox/ari-push" ``` **Validation:** the `provide_channel` scope requires `endpoints.ari_push` to be a valid `https://` URL. ### `type = "channel"` A channel app implements the ARI push and reservation-ingestion protocol described in [Channel apps](/apps/channels). It needs `provide_channel` in `scopes` and `endpoints.ari_push` set; most channel apps also request `read_properties` and `read_rates` to discover inventory and back-fill rates, and subscribe to `property.updated` to re-sync listing content. A channel app may also provide guest messaging on the same install by additionally declaring `provide_inbox_channel`, `channels`, and `endpoints.message_send`. --- ## Validation rules summary | Rule | Detail | | --- | --- | | Scope strings | Must be known scope values. | | Webhook topics | Must be known topic values; required scope must be listed. | | `webhook_url` | Must be a valid `https://` URL. | | All URLs | Must be valid `https://` URLs (no `http://`). | | `oauth.redirect_uris` | Exact-match strings; no wildcards, no trailing slashes unless registered with one. | | Metafield owners | Must be `booking`, `property`, or `contact`. | | Metafield keys | Must match `^[a-z0-9_]{1,64}$`. | | Metafield types | Must be `string`, `number`, `boolean`, or `date`. | | Injection slots | Must be known storefront slot names. | | Injection templates | Sanitized at review; only trusted `https://` script origins allowed. | | `endpoints.ari_push` | Required and must be a valid `https://` URL when `provide_channel` is in `scopes`. | | Settings schema | Each entry must declare a string `key` and a string `type`. | Validation runs on every `app validate` and `app push`. Errors are shown inline; pushing with errors is blocked. --- See also: [Scopes reference](/apps/scopes) Β· [Webhook topics](/apps/webhooks) Β· [GraphQL reference](/apps/graphql) --- # Metafields Metafields are app-owned data fields that you attach to bookings, properties, or contacts. They let your app store structured data (access codes, screening verdicts, dynamic pricing notes, lock battery levels) directly against the platform record, rather than in your own database keyed by a Stayblox ID. Platform surfaces then expose that data to the host (panel), to guests (email templates, themes), or keep it private, depending on the `visibility` you declare. ## Declare metafields in `app.toml` Declare every field your app intends to write using `[[metafields]]` tables in your `app.toml`. The platform rejects writes to undeclared fields. ```toml [[metafields]] owner = "booking" key = "access_code" type = "string" visibility = "guest" [[metafields]] owner = "booking" key = "screening_status" type = "string" visibility = "host" [[metafields]] owner = "property" key = "lock_serial" type = "string" visibility = "private" ``` | Property | Values | Description | | --- | --- | --- | | `owner` | `booking` \| `property` \| `contact` | The model this field attaches to. | | `key` | `^[a-z0-9_]{1,64}$` | Identifier for this field within your app. Must be unique per owner type. | | `type` | `string` \| `number` \| `boolean` \| `date` | Advisory type for display purposes. All values are stored as text. | | `visibility` | `private` \| `host` \| `guest` | Controls where the value is surfaced (see below). | ## Write and delete via the GraphQL API Use `metafieldSet` and `metafieldDelete`. No extra scope is required beyond the owner's read scope (e.g. writing to a booking metafield needs `read_bookings`). Your app can only read and write its **own** metafields; other apps' fields are not visible. ### Write a metafield ```graphql mutation SetAccessCode($bookingId: ID!, $code: String!) { metafieldSet( ownerType: "booking" ownerId: $bookingId key: "access_code" value: $code ) { metafield { key value type visibility } userErrors { field message } } } ``` ```bash curl -s https://api.stayblox.com/developer/api/2026-01/graphql \ -H "Authorization: Bearer $STAYBLOX_APP_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "query": "mutation($id: ID!, $v: String!){ metafieldSet(ownerType:\"booking\", ownerId:$id, key:\"access_code\", value:$v){ metafield{ key value } userErrors{ message } } }", "variables": { "id": "1234", "v": "7723-A" } }' ``` `metafieldSet` is an **upsert**: if the key already exists for this install + owner, it updates the value in place. ### Delete a metafield ```graphql mutation DeleteLockSerial($propertyId: ID!) { metafieldDelete( ownerType: "property" ownerId: $propertyId key: "lock_serial" ) { deleted userErrors { field message } } } ``` ### Read metafields The `metafields` field is available on `Booking`, `Property`, and `Contact` types and returns only **your app's own** fields: ```graphql query BookingWithMetafields($id: ID!) { booking(id: $id) { id checkIn checkOut metafields { key value type visibility } } } ``` ## Visibility and where values appear ### `private` Values are accessible only through the API. They never appear in the host panel or in any guest-facing surface. Use `private` for internal operational data (lock serial numbers, internal IDs, scoring intermediaries) that the host and guest don't need to see. ### `host` Values appear in an **"App data"** section on the relevant detail page in the host panel (booking detail, property detail, contact detail), grouped under your app's name. The host can see the key and value; they cannot edit them. Useful for screening verdicts, risk scores, cleaning status, and any operational data the host should see at a glance: ``` Booking #1234 ──────────────────────────────────── Smart Lock Manager access_code 7723-A lock_battery_pct 84 Screening Pro screening_status approved risk_score 12 ``` ### `guest` All `host` display applies, **plus**: **1. Guest email templates.** The value is available as a variable wherever booking variables are available in guest email templates. The variable key is `metafields['{app-slug}.{key}']`: ``` metafields['smart-lock-manager.access_code'] ``` Use this to include an access code in check-in instructions: ``` Your access code for the property is: {{ metafields['smart-lock-manager.access_code'] }} ``` **2. Twig themes.** The `app_data(booking)` Twig function returns a map of all guest-visible metafields for a booking, keyed `"{app-slug}.{field}"`: ```twig {% set app_data = app_data(booking) %} {% if app_data['smart-lock-manager.access_code'] %}

Your access code: {{ app_data['smart-lock-manager.access_code'] }}

{% endif %} ``` Only fields with `"visibility": "guest"` are included; `host` and `private` fields are never returned by `app_data()`. See the [Templating reference](/themes/templating) for full `app_data` documentation. ## Isolation Metafields are scoped per install. If two different apps both declare a metafield with `owner: "booking"` and `key: "note"`: - Each app reads only its own `note` value via the API. - The host panel "App data" section shows each app's values under their respective app name. - Guest email variable and theme keys are namespaced by app slug (`{app-slug}.note`), so there's no collision. A team's metafield data is also isolated by team: the platform ensures an app can never read or write metafields for a team other than the one whose install token it's using. ## Common patterns ### Smart lock: deliver access code at check-in 1. Subscribe to `booking.checked_in` (needs `read_bookings` scope). 2. On delivery, generate a code with your lock provider. 3. Call `metafieldSet(ownerType: "booking", ownerId: ..., key: "access_code", value: code)`. 4. Declare `"visibility": "guest"` β†’ the code appears in the check-in email and in themes. ### Guest screening: flag a booking 1. Subscribe to `booking.confirmed` (needs `read_bookings`, `read_contacts` scopes). 2. Submit the guest data to your screening provider. 3. Store the result: `metafieldSet(ownerType: "booking", key: "screening_status", value: "approved")`. 4. Declare `"visibility": "host"` β†’ the host sees the verdict in the booking panel. 5. Optionally call `bookingFlagSet` to show a colored badge (needs `write_bookings`). ### Dynamic pricing: annotate the booking with the pricing rationale 1. Subscribe to `booking.confirmed`. 2. Store a pricing note: `metafieldSet(ownerType: "booking", key: "pricing_note", value: "High demand period +15%")`. 3. Declare `"visibility": "host"` β†’ the host sees the rationale in the booking panel. --- # OAuth install The OAuth install flow lets hosts connect their Stayblox account to your app from **your own website**: a "Connect your Stayblox account" button. After authorization, you receive a per-install API token identical to the one generated by a marketplace install. > **Who can use this:** OAuth install is available for free and > externally-billed apps only. If your app is paid and platform-billed > (Stayblox charges the host's card), it must be installed from the > marketplace; that's where the billing transaction lives. ## The flow at a glance ``` Host visits your site and clicks "Connect Stayblox" β”‚ Redirect to GET /oauth/authorize (with client_id, scope, redirect_uri, state) β”‚ Stayblox: log in if needed β†’ team picker β†’ consent screen β”‚ Redirect back to redirect_uri?code=...&state=... β”‚ Your server: POST /oauth/token (client_id, client_secret, code, redirect_uri) β”‚ Response: { access_token, token_type, scope, webhook_secret } β”‚ Store the access_token for this install β†’ call the Developer API ``` ## Step 1: Authorization request Send the host to: ``` GET https://admin.stayblox.com/oauth/authorize ``` Required parameters: | Parameter | Description | | --- | --- | | `response_type` | Must be `code`. | | `client_id` | Your app's client ID (from your account dashboard). | | `redirect_uri` | Must **exactly match** one of the `oauth.redirect_uris` declared in your `app.toml` (under `[oauth]`). Any mismatch returns a `400` error with no redirect. | | `state` | An opaque value you generate. Returned unchanged in the redirect. Use it to prevent CSRF attacks and to tie the callback to your session. | Optional parameters: | Parameter | Description | | --- | --- | | `scope` | Space-separated subset of the scopes declared in your `app.toml`. If omitted, all declared scopes are requested. Requesting a scope not in `app.toml` returns `400`. | Example link: ``` https://admin.stayblox.com/oauth/authorize ?response_type=code &client_id=sb_app_01JXXXXXXXXX &redirect_uri=https%3A%2F%2Fapp.example.com%2Fauth%2Fstayblox%2Fcallback &scope=read_bookings+write_conversations &state=xyzrandomstate123 ``` ## Step 2: Consent and team selection Stayblox: 1. Prompts the host to log in if they aren't already authenticated. 2. Shows a **team picker**: the host selects which team (property portfolio) to connect. 3. Shows the **consent screen**: a human-readable list of the requested scopes. The host can click **Deny**, which redirects back to your `redirect_uri` with `error=access_denied`. ## Step 3: Authorization callback On approval, Stayblox redirects to your `redirect_uri`: ``` https://app.example.com/auth/stayblox/callback ?code=AUTH_CODE_HERE &state=xyzrandomstate123 ``` **Always verify `state` matches what you sent in step 1** before proceeding. A mismatch indicates a CSRF attack; discard the request. The `code` is **single-use** and expires in **10 minutes**. ## Step 4: Token exchange Exchange the code for an access token: ``` POST https://admin.stayblox.com/oauth/token Content-Type: application/x-www-form-urlencoded ``` | Parameter | Description | | --- | --- | | `grant_type` | Must be `authorization_code`. | | `client_id` | Your app's client ID. | | `client_secret` | Your app's client secret (from your account dashboard). **Never expose this client-side.** | | `code` | The authorization code from step 3. | | `redirect_uri` | Must exactly match the URI used in step 1. | ```bash curl -s -X POST https://admin.stayblox.com/oauth/token \ -d grant_type=authorization_code \ -d client_id=sb_app_01JXXXXXXXXX \ -d client_secret=$STAYBLOX_CLIENT_SECRET \ -d code=AUTH_CODE_HERE \ -d redirect_uri=https://app.example.com/auth/stayblox/callback ``` Success response (`200`): ```json { "access_token": "1|xxxxxxxxxxxxxxxxxxxxxx", "token_type": "Bearer", "scope": "read_bookings write_conversations", "webhook_secret": "whsec_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" } ``` `access_token` is a Sanctum bearer token. Store it securely; treat it like a password. Use it as `Authorization: Bearer ` on all Developer API calls. The `scope` field reflects the scopes the host actually consented to (may be a subset if they declined some). `webhook_secret` is the per-install secret used to verify the HMAC signature on inbound webhook deliveries from Stayblox. Store it alongside the `access_token` and key it by install so one server can serve many hosts. See [Signing & security](/apps/signing) for the verification algorithm. **Never expose this value client-side or include it in a public repository.** Error responses: | Error code | Meaning | | --- | --- | | `invalid_grant` | Code is invalid, expired (> 10 min), already used, or the `client_id` / `redirect_uri` doesn't match what the code was issued for. | | `invalid_client` | `client_secret` doesn't match. | | `unsupported_grant_type` | `grant_type` is not `authorization_code`. | | `unauthorized_client` | Paid platform-billed app; must install from the marketplace. | ## Reconnects and re-consent If the same host reconnects (they hit your authorize URL again for a team they've already installed on), Stayblox **merges scopes** rather than creating a duplicate install. The new scopes are unioned with the existing grants, and a fresh token is returned. The existing token for that install remains valid until explicitly revoked. ## Complete example (Node.js) ```js import express from 'express' import crypto from 'node:crypto' const app = express() // 1. Generate the authorization URL and redirect the host. app.get('/auth/stayblox', (req, res) => { const state = crypto.randomBytes(16).toString('hex') req.session.oauthState = state // store in session to verify later const params = new URLSearchParams({ response_type: 'code', client_id: process.env.STAYBLOX_CLIENT_ID, redirect_uri: 'https://app.example.com/auth/stayblox/callback', scope: 'read_bookings write_conversations', state, }) res.redirect(`https://admin.stayblox.com/oauth/authorize?${params}`) }) // 2. Handle the callback. app.get('/auth/stayblox/callback', async (req, res) => { const { code, state, error } = req.query if (error === 'access_denied') { return res.redirect('/?error=cancelled') } // CSRF check. if (state !== req.session.oauthState) { return res.status(400).send('State mismatch.') } // 3. Exchange the code for a token. const response = await fetch('https://admin.stayblox.com/oauth/token', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ grant_type: 'authorization_code', client_id: process.env.STAYBLOX_CLIENT_ID, client_secret: process.env.STAYBLOX_CLIENT_SECRET, code, redirect_uri: 'https://app.example.com/auth/stayblox/callback', }), }) if (!response.ok) { const err = await response.json() return res.status(400).send(`OAuth error: ${err.error}`) } const { access_token, scope, webhook_secret } = await response.json() // Store the token and webhook secret associated with this install. // webhook_secret is used to verify inbound webhook signatures. Treat it like a password. await db.storeToken({ userId: req.session.userId, token: access_token, scope, webhookSecret: webhook_secret }) res.redirect('/dashboard?connected=true') }) ``` ## Security notes - **Never include `client_secret` in client-side code** or a public repository. The token exchange must happen server-side. - **Always verify `state`** in the callback to prevent CSRF. - **Use `https://` redirect URIs** in production. The manifest validator rejects non-HTTPS URIs. - **Rotate your client secret** from your account dashboard if it is ever exposed. Existing tokens remain valid after rotation; new OAuth exchanges use the new secret immediately. - Codes are **single-use**. Attempting to use a code twice returns `invalid_grant`. --- # Payment apps A payment app lets hosts charge buyers at checkout through your provider. It implements the Stayblox **payment-session protocol**: Stayblox opens a session against your server, the buyer pays on your hosted page, and you report the outcome back over GraphQL. This guide takes you from manifest to a resolved session. For a complete runnable server, see the [reference app](/apps/reference-app). ## 1. Declare your app (manifest) A payment app is registered as a marketplace **App** with `type: "payment"` and a manifest. Here is the reference Stripe app's manifest: ```json { "scopes": [], "webhooks": [], "api_permissions": [ "payment_sessions:write" ], "supported_currencies": [ "USD", "EUR", "GBP", "AUD", "CAD" ], "endpoints": { "payment_session": "https://stripe-app.example.com/sessions", "refund": "https://stripe-app.example.com/refunds" }, "capabilities": { "refunds": true }, "settings_schema": [ { "key": "stripe_account", "type": "text", "label": "Stripe account ID", "placeholder": "acct_XXXXXXXX", "required": true, "help": "Your connected Stripe account, used by the provider to route charges. No secret keys are stored here." }, { "key": "statement_descriptor", "type": "text", "label": "Statement descriptor", "required": false, "help": "Shown on the buyer's card statement." } ] } ``` ### `settings_schema` fields These are the fields the host fills in when configuring the install. Values are passed to your server in the session payload's `settings`. | Key | Type | Label | Required | Help | | --- | --- | --- | --- | --- | | `stripe_account` | `text` | Stripe account ID | yes | Your connected Stripe account, used by the provider to route charges. No secret keys are stored here. | | `statement_descriptor` | `text` | Statement descriptor | no | Shown on the buyer's card statement. | ### Supported currencies `USD`, `EUR`, `GBP`, `AUD`, `CAD` An empty list means the provider accepts any currency. ### Endpoints | Key | Purpose | | --- | --- | | `payment_session` | Stayblox POSTs here to open a session; returns `{ redirect_url }`. | | `refund` | Stayblox POSTs here to reverse a resolved session. | ## 2. Open a session (platform β†’ app) When a buyer chooses your app at checkout, Stayblox creates a pending session and POSTs it to your manifest's `payment_session` endpoint. The request is **HMAC-signed**: verify it before acting (see [Signing & security](/apps/signing)). Stayblox POSTs this signed JSON to your app's `payment_session` endpoint (keys from `RemotePaymentClient::sessionPayload()`): ```json { "session_id": "9b2c1f0e-4d6a-4f3b-8c21-7a0b5e9d1234", "amount": 100.0, "currency": "USD", "payable_type": "App\\Models\\Booking", "payable_id": 42, "return_url": "https://shop.example/pay/return/9b2c1f0e", "cancel_url": "https://shop.example/pay/cancel/9b2c1f0e", "api_base_url": "https://api.stayblox.com/developer/api/2026-01/graphql", "version": "…", "settings": { "stripe_account": "acct_123" } } ``` | Field | Description | | --- | --- | | `session_id` | The Stayblox payment-session id. Echo it back when you resolve/reject the session. | | `amount` | Amount to charge, in major currency units. | | `currency` | ISO 4217 currency code (uppercase). | | `payable_type` | The morph type of what is being paid for (e.g. a booking). | | `payable_id` | The id of the payable record. | | `return_url` | Send the buyer here after a successful payment. | | `cancel_url` | Send the buyer here if they abandon checkout. | | `api_base_url` | The GraphQL endpoint to call back to settle the session. | | `version` | | | `settings` | Non-secret host config from the install (the fields you declared in settings_schema). Secrets stay on your server. | Respond `200` with `{ "redirect_url": "https://…" }` β€” the hosted payment page Stayblox redirects the buyer to. ## 3. Report the outcome (app β†’ platform) After the buyer pays (or fails, or you're still waiting on an async result), call the Developer API with your bearer token to move the session to its final state. The session lifecycle on the Stayblox side: ``` PENDING β†’ PROCESSING β†’ ┬─ resolve ─▢ RESOLVED (paid) β”œβ”€ reject ─▢ REJECTED (declined) └─ pending ─▢ PENDING_EXTERNAL (awaiting async outcome) ``` `resolve`, `reject`, and `cancel`/`expire` are terminal. A session may only be acted on by the app that owns it (resolved from your token). ### Resolve a paid session ```graphql mutation Resolve($id: ID!, $ref: String) { paymentSessionResolve(id: $id, providerReference: $ref) { paymentSession { id status amount currency providerReference } userErrors { field message } } } ``` ```bash curl -s https://api.stayblox.com/developer/api/2026-01/graphql \ -H "Authorization: Bearer $STAYBLOX_APP_TOKEN" \ -H "Content-Type: application/json" -H "Accept: application/json" \ -d '{ "query": "mutation($id: ID!, $ref: String){ paymentSessionResolve(id:$id, providerReference:$ref){ paymentSession{ id status } userErrors{ message } } }", "variables": { "id": "9b2c1f0e-...", "ref": "pi_3Q…" } }' ``` Resolving is **idempotent**: replaying the same resolve does not record a second payment, so it's safe to retry on provider webhook redelivery. ### Reject a failed session ```graphql mutation Reject($id: ID!, $reason: String) { paymentSessionReject(id: $id, reason: $reason) { paymentSession { id status } userErrors { field message } } } ``` ### Mark a session pending (async outcomes) For payment methods that settle asynchronously, acknowledge receipt and resolve later when the provider confirms: ```graphql mutation Pending($id: ID!, $ref: String) { paymentSessionPending(id: $id, providerReference: $ref) { paymentSession { id status } userErrors { field message } } } ``` ## 4. Handle errors Every mutation returns a `userErrors` array. On success it's empty; on a bad request it describes the problem (e.g. an unknown session id) without throwing a GraphQL error. Always check it before treating a call as successful. ## Capabilities Declare what your provider can do under the `capabilities` key in your manifest. All flags default to `false`; omit any you don't support. | Flag | Meaning | | --- | --- | | `saved_payment_methods` | Your server can store a buyer's payment method and reuse it for future sessions without re-prompting. | | `off_session_charges` | Your server can initiate a charge without the buyer being present (requires `saved_payment_methods`). | | `refunds` | Your server handles refund requests POSTed to your `refund` endpoint. | | `manual_capture` | Your server uses an authorize-then-capture flow; the platform may ask you to capture or void separately. | | `offline` | Your server collects payment offline (e.g. cash on arrival). The platform keeps the booking pending until the host records receipt rather than waiting for a session resolve. | **Degrade gracefully.** Schedules never hard-depend on any flag; capabilities only change which collection strategy the platform uses. An offline-capable provider, for example, keeps the booking in a pending state until the owner confirms receipt; no session needs to be resolved programmatically. The reference Stripe app declares `refunds: true`. ## Refunds If your manifest declares `refunds: true` in `capabilities`, Stayblox calls your `refund` endpoint (platform β†’ app) when a host refunds a resolved session. Reverse the charge with your provider and return `200`. ## Payment schedules Bookings may carry owner-configured installment schedules. When they do, a payment session is linked to a **specific installment** rather than the full booking total, so `amount` in the session payload can be less than the booking's total value. This was always technically possible, but with schedules it is the norm rather than the exception. The wire protocol is unchanged: same signed session POST payload, same GraphQL mutations (`paymentSessionResolve`, `paymentSessionReject`, `paymentSessionPending`). Treat `amount` as the authoritative charge amount for that session and don't try to reconcile it against the booking total yourself. ## Checklist - [ ] Manifest registered with `type: payment`, `endpoints`, `settings_schema`. - [ ] `capabilities` block declares the flags your server actually supports. - [ ] `payment_session` endpoint verifies the signature and returns `{ redirect_url }`. - [ ] Provider callback resolves/rejects the session over GraphQL. - [ ] `userErrors` checked on every mutation. - [ ] Resolve handled idempotently. --- # PHP SDK The Stayblox PHP SDK (`stayblox/stayblox-php`) handles the Stayblox side of your app so you can focus on your integration: the OAuth install flow, the Developer GraphQL API client, request-signature verification, and the inbox messaging modules. ::: info Requires Laravel The SDK is a **Laravel 13** package (PHP 8.4+). It reuses Laravel's Eloquent, HTTP client, encryption, and middleware. If you build on another framework or language, talk to the same public surface directly: the [OAuth install flow](/apps/oauth), the [GraphQL Developer API](/apps/graphql), and the [signing scheme](/apps/signing). ::: ## Installation ```bash composer require stayblox/stayblox-php ``` It requires PHP 8.4+ and Laravel 13, and auto-registers through Laravel package discovery: it adds the install-storage migration and the `stayblox.signed` middleware alias. ## Modules - `Stayblox\Core`: OAuth install, install storage, the Developer API client, and signature verification. - `Stayblox\Inbox`: inbound and outbound messaging for [channel-provider apps](/apps/inbox-channel-provider). ## Core ### OAuth install `OAuthClient` speaks the [OAuth install flow](/apps/oauth). Build the consent URL, then exchange the returned code for a per-install token and webhook secret. ```php use Stayblox\Core\OAuth\OAuthClient; $oauth = new OAuthClient($clientId, $clientSecret, 'https://admin.stayblox.com'); // 1. Redirect the host to consent: return redirect()->away($oauth->authorizeUrl($redirectUri, ['provide_inbox_channel'], $state)); // 2. On your callback, exchange the code for a token: $token = $oauth->exchange($request->query('code'), $redirectUri); // $token->accessToken, $token->scopes, $token->webhookSecret ``` Persist the result with `InstallRepository`, keyed by the team slug. Learn the slug from `currentApp`: ```php use Stayblox\Core\Api\DeveloperApiClient; use Stayblox\Core\Installs\Install; use Stayblox\Core\Installs\InstallRepository; $api = new DeveloperApiClient('https://admin.stayblox.com/developer/api/2026-01/graphql'); $app = $api->currentApp(new Install(['access_token' => $token->accessToken])); (new InstallRepository)->store($app['teamSlug'], $token->accessToken, $token->webhookSecret, $token->scopes); ``` ### Developer API client `DeveloperApiClient::query()` runs any GraphQL query authenticated as the install: ```php $data = $api->query($install, 'query { currentApp { id name teamSlug } }'); ``` ### Verifying signed requests Stayblox signs the requests it sends to your app (for example the `message_send` command) with the install's webhook secret. Put those routes behind the `stayblox.signed` middleware. It verifies the HMAC on the raw body and binds the resolved install to the request: ```php Route::post('/webhooks/message-send', SendController::class)->middleware('stayblox.signed'); // in the controller: $install = $request->attributes->get('stayblox_install'); ``` See [Signing & security](/apps/signing) for the scheme. ## Inbox For [channel-provider apps](/apps/inbox-channel-provider), the `Inbox` module carries messages both ways. ### Inbound: guest message to Stayblox Forward a message you received from your provider to Stayblox: ```php use Stayblox\Inbox\InboxApiClient; use Stayblox\Inbox\Dto\InboundMessage; $client = new InboxApiClient('https://admin.stayblox.com/developer/api/2026-01/graphql'); $client->inboundMessageCreate($install, new InboundMessage( channel: 'messenger', externalThreadId: $threadId, senderIdentifier: $senderId, body: $text, externalMessageId: $providerMessageId, contactHints: ['firstName' => 'Dana', 'avatarUrl' => 'https://...'], )); ``` Report delivery and read receipts with `messageStatusUpdate($install, $providerMessageId, 'delivered')`. ### Outbound: Stayblox to your provider Stayblox POSTs a signed `message_send` command to your `endpoints.message_send` URL. Put that route behind `stayblox.signed`, then let `MessageSendReceiver` parse it and call your send handler: ```php use Stayblox\Inbox\MessageSendReceiver; use Stayblox\Inbox\Dto\OutboundMessage; use Stayblox\Inbox\Dto\SendResult; Route::post('/webhooks/message-send', function (Request $request) { $install = $request->attributes->get('stayblox_install'); $response = (new MessageSendReceiver)->handle( $request->json()->all(), function (OutboundMessage $message) use ($install): SendResult { // Call your provider's send API here. try { $providerId = $myProvider->send($message->externalThreadId, (string) $message->body); return SendResult::sent($providerId); } catch (\Throwable $e) { return SendResult::failed($e->getMessage()); } }, ); return response()->json($response); })->middleware('stayblox.signed'); ``` `OutboundMessage` is the normalized reply (recipient, body, attachments, settings). `SendResult::sent()` and `SendResult::failed()` map to the JSON Stayblox reads back. ### DTOs - `InboundMessage`, `InboundResult`: the inbound injection payload and its result. - `OutboundMessage`, `SendResult`: the normalized outbound reply and your send outcome. - `ChannelCaps`: the channel capabilities you declared in your manifest. Branch on capabilities, never on channel identity. ## Provider logic stays in your app The SDK gives you the normalized Stayblox interfaces. Your provider bindings (the Messenger, WhatsApp, or SMS API calls, their auth, and message rendering) live in your app. See the [reference app](/apps/reference-app) for a complete example. --- # App lifecycle This page covers the full lifecycle of a Stayblox app: choosing a distribution, pushing versions, releasing (and rolling back), and the review pipeline for public apps. ## Distribution: private or public Set `distribution` in `app.toml` before your first push. You cannot change distribution after an app is created. | Distribution | Who can install it | Review required | Store listing | | --- | --- | --- | --- | | `private` | Teams in your account only | No | No | | `public` | Any host, through the App Store | Yes | Yes (Dashboard) | Use `private` for internal automations and custom integrations where you control every installation. Use `public` when you want to distribute your app to other hosts. ## Push: immutable versions Every `stayblox app push` creates a new immutable **version** of your app. The config you pushed is frozen at that point; future pushes create new versions rather than overwriting the current one. ```bash stayblox app push # push + release (default) stayblox app push --no-release # push without releasing ``` View all versions: ```bash stayblox app versions ``` ## Release and rollback The **live version** is the config active installs run against. By default, `app push` releases the new version immediately. You can decouple the two steps: ```bash stayblox app push --no-release # snapshot a new version, keep current live stayblox app release # promote the latest pending version to live stayblox app release 3 # promote a specific version number (rollback) ``` Rolling back with `release 3` re-releases an older approved version. The version itself is immutable; re-releasing it just makes it live again. ## Review: private apps Private apps skip the review pipeline entirely. Every push takes effect immediately; `app push` creates and releases the version in one step. ## Review: public apps Public apps go through a **per-dimension** review. Config and listing are reviewed separately and can roll back independently. ### Config review A config change (a new push) is applied automatically in most cases. Two situations trigger an admin review before the version goes live: - **First publish**: the very first version of a public app is reviewed before going live. - **Scope escalation**: adding scopes that were not in the previously approved version. Automated checks run immediately on every push: | Check | Detail | | --- | --- | | Manifest schema | All fields pass structural validation. | | Known scopes | Every `scopes` entry is a known value. | | Known webhook topics | Every topic is known, and its required scope is listed. | | All URLs `https://` | No `http://` or insecure URLs. | | Injection templates | Pass the sanitizer; no inline scripts, only trusted `https://` origins. | Automated check failures block a version from going live but do not prevent a new push. ### Listing and icon review The store listing (description, gallery, pricing, and category) and the app icon are managed in the Account-panel Dashboard, not in `app.toml`. Every listing change goes through a content review before the updated store page goes live. Your currently approved listing keeps serving until the new one is approved. This review is independent of the config review, so you can ship a config fix without waiting for a listing review, and vice versa. ## Scope escalation for existing installs When a new public version escalates scopes: - The new config goes live after approval. - Existing installs keep running on their previous `granted_scopes`. - A "Requests new permissions" banner appears on the install settings page for each affected host. - API calls and webhook deliveries that require the new scope are denied until the host consents. Your app must handle scope-denied responses gracefully. ## Getting listed in the App Store A public app appears in the App Store once: 1. At least one config version has been approved. 2. The store listing in the Dashboard is complete and approved. 3. Your partner account is in good standing. Draft apps and apps pending their first review are not listed. ## Checklist - [ ] `distribution` set correctly in `app.toml` before the first push. - [ ] Config pushed and version released (`stayblox app push`). - [ ] App installed on a team; API calls, webhooks, and any optional surfaces (app page, OAuth) tested. - [ ] For public apps: store listing completed in the Dashboard (description, gallery, pricing, category). - [ ] For public apps: first-publish review approved. --- # Reference app: Stripe The fastest way to understand a payment app is to read one. The Stayblox app repo ships a complete, standalone Stripe provider at [`sample-apps/stripe-payments/`](https://github.com/stayblox/app/tree/main/sample-apps/stripe-payments). No framework, no Stayblox code, just the two endpoints and the GraphQL callback. Copy it as the starting point for any provider. ## What it does | Method & path | Caller | Purpose | | --- | --- | --- | | `POST /sessions` | Stayblox | Open a payment session; verify the signature, create a Stripe Checkout Session, return `{ redirect_url }`. | | `POST /stripe-webhook` | Stripe | On `checkout.session.completed`, call Stayblox's `paymentSessionResolve` to settle the session. | | `POST /refunds` | Stayblox | Reverse a resolved session. Enabled because the manifest declares `capabilities.refunds: true`. | It maps directly onto the [payment-session flow](/apps/#the-payment-session-flow): Stayblox opens the session β†’ buyer pays on Stripe β†’ Stripe's webhook triggers the GraphQL resolve. ## Configuration The example reads these environment variables: | Variable | Where it comes from | | --- | --- | | `STRIPE_SECRET_KEY` | Stripe dashboard (the provider's own secret; never sent to Stayblox). | | `STRIPE_WEBHOOK_SECRET` | Stripe webhook endpoint signing secret. | | `STAYBLOX_WEBHOOK_SECRET` | Printed once when you run `stayblox app install --team `. | | `STAYBLOX_API_URL` | `https://api.stayblox.com/developer/api/2026-01/graphql` (or pin a different version; see [Versioning](/apps/versioning)). | | `STAYBLOX_APP_TOKEN` | Printed once when you run `stayblox app install --team `. Rotate with `stayblox app token --team `. | ## Run it locally ```bash cd sample-apps/stripe-payments STRIPE_SECRET_KEY=sk_test_... \ STAYBLOX_WEBHOOK_SECRET=... \ STAYBLOX_API_URL=https://app.stayblox.test/developer/api/2026-01/graphql \ STAYBLOX_APP_TOKEN=... \ php -S 0.0.0.0:8088 server.php ``` Then point the app's `app.toml` `endpoints.payment_session` at `http://localhost:8088/sessions` and add a Stripe webhook to `http://localhost:8088/stripe-webhook`. ## Key things to copy - **Signature verification** on `/sessions` before doing anything; see [Signing & security](/apps/signing). - **Amount conversion**: Stayblox sends major units; Stripe wants the smallest unit (cents). - **Idempotent resolve**: carry your Stayblox `session_id` through Stripe's `client_reference_id` / `metadata` so the webhook can resolve the right session, and rely on resolve being idempotent across webhook redelivery. ::: tip Multi-host The reference handles a single host for clarity. In production, key host credentials by the install (`X-Stayblox-App` header / `settings.stripe_account`) so one server serves every host that installs your app. ::: --- # Scopes Scopes define what a host consents to share with your app at install time. Declare them in your manifest; the host sees a human-readable list at the consent screen before approving. ## The scope catalog | Scope | Label | What it grants | | --- | --- | --- | | `read_bookings` | Read bookings | Booking details including dates, status, guest counts, and totals. Required for most webhook topics. | | `read_contacts` | Read guest contacts (PII) | Guest names, email addresses, and phone numbers (personal data). Shown separately on the consent screen to flag PII access. | | `read_conversations` | Read conversations | Message threads between the host and their guests. | | `read_properties` | Read properties and unit types | Property names, addresses, and unit type inventory. | | `read_rates` | Read rates and availability | Nightly rates and availability calendars for the host's properties. | | `write_rates` | Update rates and restrictions | Set nightly rates and stay restrictions on unit types via `ratesUpdate`. | | `read_payments` | Read payments | Payments recorded against bookings: charges, refunds, amounts, and outcomes. | | `read_invoices` | Read invoices | Issued invoices and credit notes including numbers, totals, and status. | | `read_reviews` | Read guest reviews | Guest reviews including ratings, review text, and publication status. | | `write_conversations` | Send guest messages | Send messages to guests on the host's behalf via `messageSend`. | | `write_bookings` | Flag and annotate bookings | Add warning flags and notes to bookings via `bookingFlagSet`, shown to the host in the dashboard. | | `write_charges` | Add charges to bookings | Add fees or charges to a booking before its invoice is issued via `bookingChargeAdd`. | | `read_tasks` | Read tasks | Operational tasks (cleanings, maintenance, inspections, …): type, canonical status, checklist, assignee, cost, and namespaced custom fields. Required for the `task.*` webhook topics. | | `write_tasks` | Create and update tasks | Create, update, assign, transition, complete, and cancel tasks, manage checklist items, and write custom fields via the `task*` mutations. | | `provide_channel` | Connect OTA channels | Register OTA connections, receive rates and availability pushes, and submit reservations. Required for `endpoints.ari_push` and the `channel*`/`reservationUpsert` mutations. See [Channel apps](/apps/channels). | ## Capabilities A few privileged roles go beyond read/write data access and are granted as **capabilities**, separate from scopes. Scopes gate which API fields an app may touch; capabilities gate privileged behaviours. They are stored on the install (`granted_capabilities`) and enforced at the relevant gate. | Capability | What it grants | | --- | --- | | `act_as_assignment_provider` | Receive `task.assignment_requested` and write the chosen assignee back via `taskAssign`. Required to act as an external assignment provider (see [Assignment provider apps](/apps/assignment-providers)). | | `register_task_types` | Register custom task types (with their own custom-field schema and default checklist) for the installing team. | An app that only reads or annotates tasks needs `read_tasks` / `write_tasks` and no capability. An app that assigns work on the host's behalf additionally needs `act_as_assignment_provider`. ## Scopes and webhooks Most webhook topics require a read scope. A topic is only delivered if the install holds its required scope; subscribing without the scope delivers nothing silently. The scope requirement for each topic is shown in the [Webhooks](/apps/webhooks#topics) reference. ## How consent works ``` Partner publishes manifest with scopes ["read_bookings", "write_conversations"] β”‚ Host finds app in marketplace (or visits OAuth authorize URL) β”‚ Consent screen shows human-readable scope list Β· Read bookings Β· Send guest messages β”‚ Host approves β†’ install created with granted_scopes = ["read_bookings", "write_conversations"] β”‚ Every API call and webhook delivery checks granted_scopes, not the manifest ``` `granted_scopes` is stored on the install and is the sole source of truth for access checks. The manifest's `scopes` array defines the maximum set you may request; consent may be for a subset (when using the OAuth flow with a `scope` parameter). ## Requesting a subset via OAuth When using the [OAuth install flow](/apps/oauth), you can request fewer scopes than your manifest declares by passing a `scope` parameter: ``` GET /oauth/authorize ?client_id=your_client_id &scope=read_bookings+write_conversations ← space-separated, URL-encoded &redirect_uri=https://... &state=... &response_type=code ``` The `scope` parameter must be a subset of your manifest's declared scopes. Requesting a scope not in the manifest returns a `400` error. ## Scope escalation and re-consent When you publish a new app version that declares additional scopes, existing installs are **not** automatically upgraded: - The app keeps running on the install's current `granted_scopes`. - API calls and webhook deliveries that require a new scope are denied. - The host sees a **"Requests new permissions"** banner on the app's settings page and on their My Apps dashboard. - The host approves the new scopes β†’ `granted_scopes` is updated β†’ the app gains access to the new data. This means there is never a silent scope escalation. Your app must handle the period between version approval and host re-consent gracefully. The scope checks will return `userErrors` on any operation that needs a scope the host hasn't approved yet. ## Principle of least privilege Declare only the scopes your app genuinely needs. Hosts see the full list at install; unnecessary scopes reduce conversion and raise trust concerns. If your integration only needs to send messages on booking confirmation, you need `read_bookings` (for the webhook and booking data) and `write_conversations` (to send the message), not `read_contacts`, `read_payments`, etc. --- # Connecting self-hosted plugins There are two ways an app obtains a host's per-install API token, and which one you use depends on **where your app runs**. | Your app runs… | Use | Where OAuth happens | | --- | --- | --- | | On **your own server**, at a fixed URL you control | [Direct OAuth install](/apps/oauth) | You redirect the host to `/oauth/authorize` and exchange the code yourself. | | As a **self-hosted plugin** installed on each host's own server (WordPress, …), with no single callback URL to register | The **connect broker** (this page) | Stayblox runs the broker; it relays OAuth for every site. | The official WordPress plugin is the first of these self-hosted integrations; Framer, Webflow, and others follow the same path. ## Why a broker Direct OAuth needs a `redirect_uri` that **exactly matches** one registered in your app manifest. That works when your app is a single hosted service at a stable URL. A self-hosted plugin is the opposite: it runs on *each host's own domain* (`https://anyhotel.example/wp-admin/…`), so there is no single callback to register. The broker solves this with **one** fixed, registered callback, `https://connect.stayblox.com/callback`, that serves every site. Stayblox operates it; you deploy nothing. It is a **relay in front of the existing [OAuth server](/apps/oauth)**, not a replacement for it. ## One app per platform Each self-hosted platform is **one first-party app** (`wordpress`, `framer`, …) declaring only the scopes it needs. The broker resolves the right app from a `platform` slug on each request and uses that app's own credentials, so the WordPress plugin's scopes are independent of Framer's, and adding a platform is just registering another app whose `redirect_uri` is the broker callback. ## The flow ``` The host clicks "Connect" inside the plugin (on their own site) β”‚ 1. Plugin β†’ GET https://connect.stayblox.com/auth ?platform=wordpress&site_url=…&code_challenge=…&state=…&return=… β”‚ 2. Broker β†’ the real consent screen: https://admin.stayblox.com/oauth/authorize (the wordpress app's client_id + scopes; redirect_uri = the broker) β”‚ 3. Host approves β†’ OAuth redirects back to the broker /callback with a code β”‚ 4. Broker exchanges the code at /oauth/token (server-to-server, app secret) β†’ receives the install token + webhook secret β”‚ 5. Broker β†’ redirects the browser back to the site's `return` URL with a single-use handoff code (NOT the token) β”‚ 6. The site's server β†’ POST https://connect.stayblox.com/exchange { handoff_code, code_verifier } β†’ receives the token over TLS ``` Step 2 is the same `/oauth/authorize` an app on its own server would call directly; the broker just stands in the middle so the site doesn't need its own registered `redirect_uri`. ## Security - **The token never rides in a redirect URL.** The browser only ever carries a single-use `handoff_code`; the token is delivered to the site's *server* over the back-channel `/exchange` call (step 6). - **PKCE binds the handoff to the site that started the flow.** The plugin keeps a `code_verifier` and sends only its SHA-256 `code_challenge` to the broker; the handoff can only be redeemed by presenting the matching verifier, so an intercepted code is useless. - **The broker is near-stateless.** It keeps nothing permanently: only a short-TTL record of each in-flight flow and handoff. A host's token lives only on that host's own site. - **`state` is verified on both legs** (site↔broker and broker↔OAuth) against CSRF, and the post-connect `return` URL must share the host of `site_url`. --- # Signing & security Every signed exchange between Stayblox and your app (outbound session calls and event webhooks) uses the same HMAC scheme. Verify what Stayblox sends you, and sign what you send back the same way. **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=` **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` ## Verify an inbound request Reconstruct the signature from the timestamp header and the **raw** request body (do not re-serialize JSON), then compare in constant time. Reject stale requests to bound replays. ### PHP ```php function verifyStaybloxSignature(string $rawBody): void { $timestamp = $_SERVER['HTTP_X_STAYBLOX_TIMESTAMP'] ?? ''; $signature = $_SERVER['HTTP_X_STAYBLOX_SIGNATURE'] ?? ''; $secret = getenv('STAYBLOX_WEBHOOK_SECRET'); $expected = 'sha256=' . hash_hmac('sha256', $timestamp . '.' . $rawBody, $secret); if (! hash_equals($expected, $signature)) { http_response_code(401); exit; } // Reject requests older than 5 minutes. if (abs(time() - (int) $timestamp) > 300) { http_response_code(401); exit; } } ``` ### Node.js ```js import crypto from 'node:crypto' export function verifyStaybloxSignature(rawBody, headers, secret) { const timestamp = headers['x-stayblox-timestamp'] ?? '' const signature = headers['x-stayblox-signature'] ?? '' const expected = 'sha256=' + crypto.createHmac('sha256', secret).update(`${timestamp}.${rawBody}`).digest('hex') const ok = signature.length === expected.length && crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected)) if (!ok) throw new Error('Invalid signature') if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) throw new Error('Stale request') } ``` ## Notes - Always verify against the **raw** body bytes, before any JSON parsing. - Use a constant-time comparison (`hash_equals` / `crypto.timingSafeEqual`). - The webhook secret is per install; key your verification by the `X-Stayblox-App` header so one server can serve many hosts. - Your bearer token (for calling our GraphQL API) is separate from the webhook secret; never send the token to anyone but Stayblox. --- # Tasks Tasks are the platform's operational work (cleanings, maintenance, inspections, restocks, concierge requests) and a first-class, **extensible** domain your app can read, create, automate, and extend. A task references a property (and optionally a reservation), carries a type, a canonical status, a checklist, an assignee, a cost, and your own namespaced custom fields. This page covers reading and writing tasks. To act as an external assignment marketplace (Turno-style), see [Assignment provider apps](/apps/assignment-providers). ## Canonical status The platform owns a small, fixed status lifecycle. Apps may attach a `displayLabel` / `substatus` for richer UI, but **all core logic keys off the canonical status only**, never an app string. ``` OPEN β†’ ASSIGNED β†’ IN_PROGRESS β†’ BLOCKED β†’ COMPLETED β†’ CANCELLED β†’ VERIFIED ``` Transitions are validated: `taskTransitionStatus` returns a `userError` for an illegal move (e.g. `OPEN β†’ COMPLETED` directly) rather than forcing it. A property's guest-readiness derives from a cleaning-type task reaching `COMPLETED` / `VERIFIED`. ## Reading tasks `read_tasks` unlocks `task(id)`, `tasks(filter, first, after)`, and `propertyTaskCalendar(propertyId, from, to)`. Lists are forward-paginated by id cursor. ```graphql query Tasks($filter: TaskFilterInput) { tasks(filter: $filter, first: 50) { nodes { id type canonicalStatus title dueAt assigneeName } pageInfo { hasNextPage endCursor } } } ``` `TaskFilterInput` filters by `propertyId`, `reservationId`, `type`, `canonicalStatus` (a list), `assigneeType` / `assigneeId`, a due-date range, and `updatedSince`. ### Resync after downtime Pass `filter.updatedSince` (an ISO-8601 instant) to fetch every task changed at or after that time; this is the reconciliation query a backend uses to catch up after missing webhooks. Results stay id-ordered, so page through them the same way. ```graphql query Resync($since: String!) { tasks(filter: { updatedSince: $since }, first: 100) { nodes { id canonicalStatus updatedAt } pageInfo { hasNextPage endCursor } } } ``` ## Writing tasks `write_tasks` unlocks the `task*` mutations. Each returns a `{ task, userErrors }` result: check `userErrors` before treating the call as successful. | Mutation | Purpose | | --- | --- | | `taskCreate` | Create a task. | | `taskUpdate` | Update non-status fields. | | `taskAssign` | Set the assignee (provider write-back; needs `act_as_assignment_provider`). | | `taskTransitionStatus` | Move to a canonical status (validated). | | `taskComplete` / `taskCancel` | Shorthand transitions. | | `taskAddChecklistItem` / `taskSetChecklistItemDone` | Manage the checklist. | | `taskSetCustomField` | Set a namespaced, typed custom field. | ```graphql mutation Transition($id: ID!, $status: TaskStatus!) { taskTransitionStatus(id: $id, status: $status) { task { id canonicalStatus } userErrors { field message } } } ``` ```bash curl -s https://api.stayblox.com/developer/api/2026-01/graphql \ -H "Authorization: Bearer $STAYBLOX_APP_TOKEN" \ -H "Content-Type: application/json" -H "Accept: application/json" \ -d '{ "query": "mutation($id: ID!, $status: TaskStatus!){ taskTransitionStatus(id:$id, status:$status){ task{ id canonicalStatus } userErrors{ message } } }", "variables": { "id": "42", "status": "IN_PROGRESS" } }' ``` ## Custom fields Custom fields are how your app stores its own state on a task without the platform modelling your domain. They are **namespaced** (use your app's prefix, e.g. `turno`) and **typed** (`string`, `integer`, `float`, `boolean`, `date`, `datetime`, `json`) so they stay queryable rather than being an opaque blob. ```graphql mutation SetField($taskId: ID!) { taskSetCustomField(taskId: $taskId, namespace: "turno", key: "project_id", type: "string", value: "proj_42") { task { id } userErrors { message } } } ``` Read them back on the `Task.customFields` field; `value` is stringified, parse it per its `type`. ## Webhooks Subscribe to the `task.*` topics (all require `read_tasks`) to react to task activity, then fetch current state with `task(id)`: | Topic | Fires when | | --- | --- | | `task.created` | A task is created. | | `task.assigned` | A task gains an assignee. | | `task.status_changed` | Canonical status changes (carries `from`/`to`). | | `task.completed` | A task reaches `COMPLETED`. | | `task.overdue` | An active task passes its due date. | | `task.assignment_requested` | The platform asks a designated provider to assign a task (see [Assignment provider apps](/apps/assignment-providers)). | The exact required scope for each topic is listed in the [Webhooks](/apps/webhooks#topics) reference, and the full schema for every type and field is in the [GraphQL reference](/apps/graphql). --- # Versioning The Developer API uses **dated versions**, like Shopify. Each version is served from its own URL and backed by its own schema, so a schema change ships as a new dated version rather than a breaking change to an existing one. Supported API versions (from `app/Constants/DeveloperApi.php`): | Version | Status | | --- | --- | | `2026-01` | **Current** (served when unpinned) | Each version is served from `POST /developer/api/2026-01/graphql` (substitute the version). Pin a version in the URL; the unpinned default is `2026-01`. ## Pinning a version Always pin the version in the URL path so your integration keeps talking to a schema you've tested against: ``` POST https://api.stayblox.com/developer/api/2026-01/graphql ``` Requesting an unsupported version returns `404`. When a new version ships, test against it, then move your pinned URL forward. --- # 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](/apps/graphql). 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 `POST`s to the install's webhook URL, signed with the install's **webhook secret** (issued once when you run `stayblox app install --team ` and not independently rotatable; to get a new secret, reinstall using `stayblox app uninstall --team ` then `stayblox app install --team `). 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`](/apps/graphql) 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`](/apps/graphql) 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](/apps/signing). **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=` **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 - Verify signatures end to end β†’ [Signing & security](/apps/signing) - Fetch the state behind an event β†’ [GraphQL reference](/apps/graphql) --- # Build themes A theme controls how a public **storefront** looks and renders. Themes are written in **Liquid**. Designers publish public themes to the Stayblox marketplace with the [Stayblox CLI](/themes/cli); hosts who want a bespoke look can instead upload a `.zip` as a private custom theme for their own storefront. > Building an integration instead? That is a different surface. > [Build apps](/apps/) ## How a storefront renders a theme ``` You (author) β”‚ stayblox theme push (CLI: draft β†’ review β†’ approved) β”‚ …or upload a .zip in the admin (private custom theme) β–Ό Theme (marketplace catalog) ──versioned──▢ ThemeVersion ──contains──▢ files β”‚ host installs β–Ό Site theme (per site) ──stores──▢ settings + edited-file overrides β”‚ one is marked active β–Ό Storefront request ──▢ active theme's templates render via Liquid ``` - You author a theme; published versions live in the marketplace catalog as versioned, immutable file sets. - A host installs it: Stayblox seeds a per-site **settings** record and pins the install to that version. Files the host edits become site-level overrides; everything else keeps resolving to your published files. - On each storefront request, the active theme's templates are resolved and rendered by Liquid. Host edits and settings never affect the marketplace original. ## Two ways to publish | Path | For | How | | --- | --- | --- | | **Marketplace** | Designers selling public themes | [CLI](/themes/cli): `validate` β†’ `push` (private draft) β†’ submit β†’ review β†’ approved | | **Custom theme** | Hosts skinning their own storefront | Upload a `.zip` in the admin. No review, private to your site. | ## What you work with - **Templates** in Liquid, over a data model that's just the [storefront GraphQL schema](/themes/templating), plus a fixed set of filters and tags. - **Settings** you declare so hosts can customise the theme without code. - **Assets** (CSS and JS today) served from a stable URL. ## Where to go next - [Getting started](/themes/getting-started): scaffold, preview, and validate a minimal theme. - [Theme structure](/themes/structure): the package layout and manifest. - [Templating](/themes/templating): everything available in templates. - [Theme settings](/themes/settings): declare host-configurable options. - [Assets](/themes/assets): serve and reference CSS/JS/images. - [Stayblox CLI](/themes/cli): install, authenticate, and the theme commands. - [Publishing](/themes/packaging): marketplace review flow and custom zip upload. --- # Partner API Everything the [Stayblox CLI](/themes/cli) does goes through this REST API. You can call it directly (from CI, scripts, or your own tooling) with the same token the CLI uses. ## Authentication All endpoints sit under `https://api.stayblox.com/api` and require a Sanctum bearer token: ``` Authorization: Bearer Accept: application/json ``` `stayblox login` obtains and stores one; programmatically, `POST /auth/login` with your email and password returns `{ token, ... }`. Your user must belong to a **verified partner account**. If you belong to several, every endpoint accepts an `?account_id=` query parameter to pick the acting account; without it, themes are matched across all your partner accounts and pushes act for the account that owns the manifest slug. ## Rate limits | Endpoints | Limit | | --- | --- | | `/partner/themes*`, `/partner/teams` | 30 requests/minute | | `/partner/dev-themes*` | 240 requests/minute (file sync is chatty) | ## Error envelope Failed requests return a consistent JSON envelope: ```json { "message": "Theme validation failed.", "errors": { "package": ["The package field is required."] }, "report": { "passed": false, "errors": [], "warnings": [] } } ``` - `message`: always present; human-readable summary. - `errors`: only on request-validation failures: field β†’ list of messages. - `report`: only when a theme package was validated: the full [validation report](/themes/packaging#what-validation-checks). Each entry is `{ "rule": "graphql.validate", "message": "…", "file": "graphql/unit_types.graphql" }`. Status codes: `401` unauthenticated, `403` not your theme/team or no partner account, `404` unknown resource, `422` validation or state errors (e.g. deleting a released version), `429` throttled. ## Themes and versions ### `GET /partner/themes` Your themes across all your partner accounts: ```json { "themes": [ { "slug": "breeze", "name": "Breeze", "status": "active", "is_free": true, "price": "0.00", "desktop_preview_url": null, "mobile_preview_url": null } ] } ``` ### `POST /partner/themes/validate` Multipart body with a `package` zip (max 20 MB). Dry-run validation: writes nothing, returns the report with HTTP 200 whether it passed or not. ### `GET /partner/themes/{slug}/versions` All versions, newest first. Each version: ```json { "id": 12, "version_name": "1.2.0", "version_code": 4, "status": "rejected", "validation_report": { "passed": true, "errors": [], "warnings": [] }, "review_notes": "Fix the broken checkout flow.", "submitted_at": "2026-06-10T12:00:00.000000Z", "reviewed_at": "2026-06-11T09:30:00.000000Z", "created_at": "2026-06-10T11:58:21.000000Z", "graphql_operations": [ { "name": "UnitListing", "type": "query", "document_id": "3f1c…64 hex chars" } ] } ``` `status` is one of `draft`, `pending_review`, `approved`, `rejected`. `graphql_operations` lists every operation this version declared under `graphql/*.graphql`, with the `document_id` your storefront JS sends to the headless GraphQL endpoint β€” see [Storefront data (GraphQL)](/themes/graphql). ### `POST /partner/themes/{slug}/versions` Multipart `package` zip. Validates, then creates or re-pushes the **draft** version named by the manifest. Returns `201` with `{ version, report }`; a failing package returns `422` with `{ message, report }` and writes nothing. ### `GET /partner/themes/{slug}/versions/{id}` A single version with its full stored `validation_report` and `review_notes`. ### `DELETE /partner/themes/{slug}/versions/{id}` Deletes a **draft** version: its text files and its private-bucket assets. Released (approved/rejected) versions return `422`; they are immutable history. Deleting the last version of a never-released theme removes the theme itself. Returns `204`. ### `POST /partner/themes/{slug}/versions/{id}/submit` Moves a draft into the review queue (`pending_review`). Only drafts can be submitted; anything else returns `422`. You'll be notified by email and in your [partner dashboard](/themes/packaging#review) when it's approved or rejected. ## Dev themes ### `GET /partner/teams` The sites owned by your partner accounts, the candidates for [`stayblox theme dev`](/themes/cli#live-development). Teams are addressed by their `slug`: ```json { "teams": [ { "slug": "demo-hotel", "name": "Demo Hotel", "domain": "demo-hotel.example" } ] } ``` The payload also carries legacy numeric `id` and `account_id` fields for older CLIs; new integrations should use `slug`. ### `POST /partner/dev-themes` Multipart body: `team` (the team slug, from `GET /partner/teams`) + a `package` zip. A legacy numeric `team_id` is still accepted for older CLIs. Imports the package as a private **dev theme** on that site. Re-posting the same manifest slug reuses the same dev theme (stable id, stable preview URL) and replaces its files: ```json { "dev_theme": { "id": 2841, "team": "demo-hotel", "name": "Breeze", "theme_slug": "breeze", "version": "1.0.0" }, "preview_url": "https://demo-hotel.example/_theme-preview/2841/start?expires=…&signature=…", "files_synced": 24, "changed_at": 1760000000 } ``` On first create the dev theme's settings and pages are seeded from `config/settings_schema.json` / `config/pages.json`, exactly like a catalog install; re-syncs leave them alone so customizer state survives. `201` when created, `200` when reused. The signed `preview_url` is valid for two hours and only works in your browser session; visitors never see dev themes. While previewing a dev theme, the page polls for changes and reloads itself when a file is synced. ### `GET /partner/dev-themes/{id}` Current state plus a fresh `preview_url` and `changed_at` (the newest `updated_at` across the theme and its files, as a Unix timestamp). ### `PUT /partner/dev-themes/{id}/files` Sync a single file: - **Text file** (`liquid`, `css`, `js`, `json`, `svg`, `html`, `txt`, `xml`, `graphql`): JSON body `{ "name": "views/index.liquid", "content": "…" }`. - **Binary file** (images, fonts, video): multipart body with `name` and `file`. Send it as `POST` with a `_method=PUT` field: PHP only parses multipart bodies on POST requests. Names are theme-relative paths, using the same text/binary extensions as the CLI's [package rules](/themes/cli#what-gets-packaged). Anything outside the supported extensions, or containing `..`, is rejected with `422`. ### `DELETE /partner/dev-themes/{id}/files` JSON body `{ "name": "views/index.liquid" }` removes the file row (text) or the asset object (binary). ::: tip Dev themes are temporary A dev theme untouched for **30 days** is deleted automatically. Run `stayblox theme dev` again and it is recreated from your local directory. ::: --- # Assets CSS and JavaScript bundled in your theme are served at a URL matching their path in the package. There's no asset-tag function, just a plain path. The list of servable extensions below is **generated** from the platform's asset rules. Assets bundled in your theme are served at a matching `/assets/…` URL. There's no asset-tag function; templates reference the path directly. ## Servable extensions Only these text extensions are served, from the database, with ETag caching: `css`, `js`, `svg`, `txt`, `xml`, `json` Any other extension, including binary ones your package may still carry (`woff`, `woff2`, `ttf`, `eot`, `png`, `jpg`, `jpeg`, `gif`, `ico`, `webp`, `mp4`, `webm`), returns `404` at this route. See the note below. ## Referencing assets Put an asset under `assets/` in your `.zip` (e.g. `assets/css/app.css`); reference it at the same path, prefixed with `/assets`: ```liquid ``` ## Binary assets aren't served yet Images and fonts you bundle in the package are still imported and stored (packaging doesn't reject them), but nothing currently serves them back at a URL: the route above only ever reads text-file content. Until that lands, host binary assets you need (fonts, background images, icons your CSS references) externally and link to them directly rather than bundling them in the theme package. Anything a host uploads through an `image_picker` setting (logo, favicon, gallery images, …) is unaffected; those come back from the API as full, already-hosted URLs, not a theme-package path. ## Tips - Put assets in an `assets/` directory grouped by type (`assets/css/`, `assets/js/`) and reference them at the same path, prefixed with `/assets`, e.g. `assets/css/app.css` β†’ `/assets/css/app.css`. Top-level type directories (`css/`, `js/`, …) work too; both layouts produce the same paths. - The route only serves the text extensions above; anything else, including the binary extensions your package is still allowed to carry, returns `404` here for now. - For anything binary (a background image, a webfont, an icon your CSS references), link to an externally hosted URL rather than bundling it, per the note above. Images a host uploads through an `image_picker` setting come back from the API as their own hosted URL and aren't affected by this. --- # Building themes with AI Stayblox themes work well with AI coding agents such as Claude Code and Cursor. The platform gives your agent three things: machine-readable docs, an agent-ready scaffold, and a local MCP server with validation and preview tools. ## Machine-readable documentation - [/llms.txt](/llms.txt) is a compact index of this documentation. - [/llms-full.txt](/llms-full.txt) is the full corpus in one file. Point your agent at either URL, or rely on the bundled offline search described below. ## Start from the scaffold ```bash npm install -g @stayblox/cli stayblox theme init my-theme ``` The scaffold includes two files that make agents productive immediately: - `AGENTS.md` describes the theme structure and the validate-after-every-change loop. - `.mcp.json` registers the `stayblox mcp` server, so agent tools are available as soon as the agent opens the project. ## The dev MCP server Run `stayblox mcp` (agents with `.mcp.json` support start it automatically). Tools useful for theme work: | Tool | What it does | | --- | --- | | `search_docs` | Searches this documentation offline. | | `validate_theme` | Validates the theme against the platform rules and returns structured errors. | | `package_theme` | Builds the distributable zip without uploading. | | `push_dev` | Uploads the theme to your development store and returns a preview URL. | Validation and preview call the Stayblox API, so run `stayblox login` once before starting your agent. ## A working loop 1. Describe the change you want to the agent. 2. The agent edits the theme's Liquid files, then calls `validate_theme` and fixes every reported error. 3. The agent calls `push_dev` and gives you the preview URL to review. --- # Stayblox CLI `@stayblox/cli` is the developer CLI for building and publishing marketplace themes. It validates your theme against the platform's real rules, pushes versions as private drafts, and submits them for review, all from the theme directory. > Building a theme just for **your own** storefront? You don't need the CLI. > Upload a `.zip` in your admin instead. See > [Publishing β†’ custom themes](/themes/packaging#upload-a-custom-theme-zip). ## Install ```bash npm install -g @stayblox/cli ``` Requires Node 20+. ## Authenticate ```bash stayblox login ``` `login` prompts for your platform email and password and stores an API token in `~/.config/stayblox/config.json`. The theme commands require your user to belong to a **verified partner account**. If you belong to several partner accounts, pass `--account ` to any theme command. ## The `theme.json` manifest Every theme is identified by a `theme.json` manifest in the theme root, the same contract whether you publish with the CLI or upload a `.zip` to a site: ```json { "slug": "breeze", "name": "Breeze", "version": "1.0.0", "description": "A light, airy hotel theme." } ``` - `slug`: 2–40 chars of lowercase letters, digits and dashes, starting and ending with a letter or digit. Permanently yours after the first push; nobody else can publish to it. - `version`: strict semver (`major.minor.patch`). Bump it for every release. - `changelog`: optional; stored on the version when present. ## Commands Run these from the theme directory; the commands that read your theme accept `--path ` to point elsewhere: ```bash stayblox theme init my-theme # scaffold a minimal starter theme stayblox theme validate # server-side validation, nothing published stayblox theme push # publish a draft version (private until approved) stayblox theme push --submit # publish and submit for review in one go stayblox theme list # your themes on the platform stayblox theme versions # versions + review status for this theme stayblox theme version 1.2.0 # one version's status, notes, and full report stayblox theme delete 1.2.0 # delete a draft version (drafts only) stayblox theme dev # live development against one of your storefronts ``` `validate` and `push` upload your theme as a package and return a structured report of errors and warnings; see [Publishing β†’ validation](/themes/packaging#what-validation-checks) for what's checked. `push` refuses a failing package; warnings don't block. ## AI coding agents `stayblox mcp` starts the dev MCP server for AI coding agents; see [Building themes with AI](/themes/building-with-ai). ```bash stayblox mcp ``` ## Start a theme ```bash stayblox theme init my-theme ``` scaffolds the smallest valid theme: `theme.json` (you're prompted for slug and name), `layout/theme.liquid`, a starter `views/index.liquid`, the `config/*.json` files, `locales/en.json`, and an empty `assets/` directory. It passes `stayblox theme validate` as-is; from there, build it out following [Theme structure](/themes/structure). ## Live development ```bash stayblox theme dev # pick a storefront interactively stayblox theme dev --team # or pass the team slug (see `GET /partner/teams`) ``` `theme dev` syncs the theme directory to a private **dev theme** on one of your own storefronts and prints a signed preview URL. Open it and keep editing: every save uploads just the changed file, and the preview tab reloads itself within about a second. Deleting a file locally removes it from the dev theme too. Dev themes are invisible to visitors, keep the same dev theme across `theme dev` runs, and are pruned automatically after 30 days without changes. The underlying endpoints are documented in the [Partner API](/themes/api#dev-themes). ## Inspect and clean up versions ```bash stayblox theme version 1.2.0 # status, review notes, full validation report stayblox theme delete 1.2.0 # remove a draft (asks to confirm; --yes skips) ``` Only **draft** versions can be deleted; approved and rejected versions are immutable history. Deleting the last draft of a theme that never released removes the theme from your account entirely. ## What gets packaged {#what-gets-packaged} Only platform-supported file types are included: - **Text**: `liquid`, `css`, `js`, `json`, `svg`, `html`, `txt`, `xml`, `graphql` - **Binary**: `woff`, `woff2`, `ttf`, `eot`, `png`, `jpg`, `jpeg`, `gif`, `ico`, `webp`, `mp4`, `webm`. Imported and stored, but not currently served back at a URL; see [Assets](/themes/assets). `.git`, `node_modules`, `dist`, `vendor`, and dotfiles are always excluded. The packaged `.zip` must stay under 20 MB. ## Releases are immutable Draft versions can be re-pushed freely while you iterate. Once a version is **approved or rejected** it is frozen: bump `version` in `theme.json` to push again. The platform rejects a push that reuses a released version name. --- # Getting started Scaffold a minimal theme, preview it on a development site, and validate it with the CLI. ## 1. Create the files A theme is a folder of Liquid and config. A minimal theme: ``` my-theme/ theme.json layout/ theme.liquid views/ index.liquid config/ settings_schema.json menu.json locales/ en.json assets/ css/ app.css ``` `theme.json` is the manifest that identifies your theme on the platform. The same contract is used everywhere: [CLI publishing](/themes/cli) and private zip uploads both read the theme's slug, name, and version from it: ```json { "slug": "my-theme", "name": "My Theme", "version": "1.0.0", "description": "A minimal starter theme." } ``` `layout/theme.liquid` is the shell every page renders into, via `content_for_layout`. This is the one file that's actually required; importing a package without it fails: ```liquid {{ page_title }} {% if settings.base_color %} {% endif %}
{{ content_for_layout }}
``` `views/index.liquid` is the home page: ```liquid

{{ 'nav.home' | t }}

{% for unit_type in unit_types %} {% endfor %} ``` `config/settings_schema.json` declares the host-configurable settings (sections of fields; see [Theme settings](/themes/settings)): ```json [ { "name": "General", "fields": [ { "id": "base_color", "type": "color_picker", "label": "Brand color", "default": "#4f46e5" }, { "id": "show_footer", "type": "radio", "boolean": true, "label": "Show footer", "default": true } ] } ] ``` `config/menu.json` (navigation) rounds out the config; a `config/blocks.json` + `config/pages.json` pair seeds page-builder blocks on the home page and CMS pages, and `config/settings_data.json` is accepted too. All of them are optional; missing ones are validation warnings, not errors. See the [reference layout](/themes/structure#reference-layout). `locales/en.json` supplies the strings your views read through the `t` filter: ```json { "nav": { "home": "Home" }, "unit": { "details": "Details" } } ``` `assets/css/app.css`: ```css body { font-family: system-ui, sans-serif; } ``` ## 2. Preview on a development site Zip the **contents** so `layout/` sits at the archive root: ```bash cd my-theme zip -r ../my-theme.zip . ``` In a development site, go to **Themes β†’ Manage themes β†’ Upload theme** and upload the `.zip`. The theme's slug, name, and version come from `theme.json`. Activate it, then open the storefront. Use the in-browser **code editor** to tweak views and assets, and the **theme settings** page to see your `settings_schema.json` rendered as a form. ## 3. Validate with the CLI When you're aiming for the marketplace, install the [Stayblox CLI](/themes/cli) and validate from the theme directory: ```bash npm install -g @stayblox/cli stayblox login stayblox theme validate ``` The report tells you exactly what the marketplace requires (structure, valid config JSON, any `graphql/*.graphql` operations you shipped) before you ever publish. When it passes, `stayblox theme push` publishes a private draft and `stayblox theme push --submit` sends it for review; see [Publishing](/themes/packaging). ## Next - What's available in views β†’ [Templating](/themes/templating) - Make it configurable β†’ [Theme settings](/themes/settings) - Package layout & rules β†’ [Theme structure](/themes/structure) - Publish it β†’ [Publishing](/themes/packaging) Β· [Stayblox CLI](/themes/cli) --- # Storefront data (GraphQL) Themes fetch data β€” unit types, cart totals, site settings β€” through a small GraphQL schema, not raw Eloquent-shaped JSON or ad-hoc REST endpoints. There are two ways to run a query or mutation against it: - **Server-side, in Twig.** The platform runs your theme's operation before rendering a page and hands the result to your template as `data`. This is how today's GraphQL-backed pages work; no fetch call involved. - **Client-side, from theme JS.** A headless/interactive theme calls `POST /api/{version}/graphql.json` directly from the browser β€” add to cart without a full page reload, live-update a price as guests change dates, and so on. Both paths run the exact same schema, the exact same operations you declare, and the exact same validation rules. ## The schema ```graphql type Query { settings: Settings! unitTypes: [UnitType!]! } type Settings { siteName: String brandName: String currencyCode: String bookingMode: String } type UnitType { id: ID! name: String slug: String baseRate: Float maxGuests: Int property: PublicProperty } type PublicProperty { id: ID! name: String } type Mutation { cartAdd(input: CartAddInput!): CartAddResult! } input CartAddInput { unitTypeId: ID! checkIn: String! # YYYY-MM-DD checkOut: String # YYYY-MM-DD, nullable numberOfUnits: Int! adults: Int! children: Int! } type CartAddResult { totalUnits: Int! totalAmount: String! # plain decimal, e.g. "1200.00" β€” no currency symbol or thousands separator userErrors: [UserError!]! } type UserError { field: String message: String! } ``` `userErrors` carries **domain** failures β€” "this unit is already in your cart", "check-out must be after check-in" β€” as data, not GraphQL errors. A `cartAdd` response with a populated `userErrors` list is still a normal `200` with `data`; show those messages to the guest. Reserve error handling (below) for transport/validation failures β€” a query your theme should never send in production. This schema grows over time; only `unitTypes` (listings) and `cartAdd` (add-to-cart) are wired into platform pages today. More read/write surfaces land incrementally β€” check back here or watch the changelog. ## Declaring operations in your theme Write named GraphQL documents under a `graphql/` directory in your theme, one operation per concern: ``` my-theme/ graphql/ unit_listing.graphql cart_add.graphql ``` ```graphql # graphql/unit_listing.graphql query UnitListing { unitTypes { id name baseRate property { name } } } ``` Rules, enforced at publish (`stayblox theme push` / `POST /partner/themes/{slug}/versions`): - **Every operation must be named.** `query { unitTypes { id } }` is rejected; `query UnitListing { unitTypes { id } }` is required. - **Names must be unique across the whole theme** β€” not just per file. - **Fragments are supported** and are inlined automatically; you don't need to submit them as separate persisted documents. - **Depth and complexity are capped**, and **introspection is always rejected**. A query that's too deep or expensive fails validation with a `graphql.validate` entry in the push `report.errors`, the same report your `theme.json` manifest and template checks already surface β€” see [Partner API β†’ error envelope](/themes/api#error-envelope). - Publishing computes a **content-hash id** for every operation and returns it in the version payload as `graphql_operations`: ```json { "version": { "graphql_operations": [ { "name": "UnitListing", "type": "query", "document_id": "3f1c2a…64 hex chars" }, { "name": "CartAdd", "type": "mutation", "document_id": "9b0e77…64 hex chars" } ] } } ``` Fetch this from `GET /partner/themes/{slug}/versions/{id}` any time β€” see [Partner API](/themes/api#themes-and-versions). Your theme JS needs these `document_id` values for the headless client contract below; treat them as build output, not something you hand-compute. ### Required operations by feature | Feature | Required operation name | File | Consumed by | | --- | --- | --- | --- | | Accommodation listing page | `UnitListing` | `graphql/unit_listing.graphql` | Server-rendered `unit/types.twig`, via `data.unitTypes` | | Add to cart | `CartAdd` | `graphql/cart_add.graphql` | The cart form action, and the headless endpoint below | If your theme omits `UnitListing`, that page 500s (or degrades, depending on the team's error-handling setting) rather than falling back to legacy markup β€” declare it if you serve the accommodations page. `CartAdd` is different: the server-rendered add-to-cart **form** falls back to a platform-default operation when your theme doesn't declare one, but the **headless endpoint never does** β€” it only executes documents your theme itself published. If you're calling `cartAdd` from JS, you must ship your own `graphql/cart_add.graphql`. ## Calling it from theme JS For an interactive theme β€” add to cart without a reload, a live price preview as guests change dates β€” call the headless endpoint directly from the browser. ``` POST https://{your-storefront-domain}/api/{version}/graphql.json Content-Type: application/json X-XSRF-TOKEN: { "documentId": "9b0e77…64 hex chars", "operationName": "CartAdd", "variables": { "input": { "unitTypeId": "42", "checkIn": "2026-08-01", "checkOut": "2026-08-04", "numberOfUnits": 1, "adults": 2, "children": 0 } } } ``` ```js const res = await fetch(`/api/2026-01/graphql.json`, { method: 'POST', credentials: 'same-origin', // sends the session cookie β€” required headers: { 'Content-Type': 'application/json', 'X-XSRF-TOKEN': readCookie('XSRF-TOKEN'), // Laravel's standard CSRF cookie contract }, body: JSON.stringify({ documentId, variables: { input } }), }); const { data, errors } = await res.json(); ``` ### Request body | Field | Required | Notes | | --- | --- | --- | | `documentId` | One of `documentId` / `document` | The sha256 hash from `graphql_operations` (above). This is what production traffic sends. | | `document` | One of `documentId` / `document` | A raw GraphQL string. Only accepted while previewing β€” see below; `400 RAW_DOCUMENT_NOT_ALLOWED` on a live storefront. | | `operationName` | No | Must match the persisted operation's name if present; mismatches are `400`. | | `variables` | No | A plain object, e.g. `{ "input": { ... } }`. | | `locale` | No | One of the team's published language codes. Defaults to the team's default locale. | - **No batching.** One operation per request; a top-level JSON array is `400 BATCHING_NOT_SUPPORTED`. - **POST only.** Even reads β€” this keeps the contract simple and avoids caching a guest- and cart-specific response. - Request bodies over roughly 64 KB are rejected β€” keep `variables` small. - The route is rate-limited per IP per storefront; expect `429` with `Retry-After` under abusive traffic. ### Response Always `Content-Type: application/json`, always `Cache-Control: no-store, private` β€” nothing here is a candidate for a shared/browser cache. | Situation | Status | Body | | --- | --- | --- | | Executed (including a `cartAdd` with `userErrors`) | `200` | `{ "data": ... }`, plus `errors` if a resolver hit a system error | | Malformed body, both/neither of `documentId`/`document`, bad hash format, `operationName` mismatch, batching, oversized body | `400` | `{ "errors": [{ "message": ..., "extensions": { "code": "BAD_REQUEST" } }] }` (or `BATCHING_NOT_SUPPORTED` / `RAW_DOCUMENT_NOT_ALLOWED`) | | Unknown `documentId` for this team's current theme version | `404` | `extensions.code: "PERSISTED_DOCUMENT_NOT_FOUND"` β€” a stale deploy: your JS bundle and the currently published theme version disagree. Republish, or ship the matching `document_id`. | | A raw document (preview only) fails depth/complexity validation | `200` | `data` is absent; `extensions.code: "GRAPHQL_VALIDATION_FAILED"` | | Missing/stale CSRF token | `419` | Laravel's standard CSRF response | | Rate limited | `429` | `Retry-After` header | A `404 PERSISTED_DOCUMENT_NOT_FOUND` almost always means your published theme version and the `document_id` your JS bundle is hard-coding have drifted β€” re-fetch `graphql_operations` after your next push and rebuild. ### Session, identity, and CSRF The headless endpoint reuses the storefront's normal session cookie β€” the same cart and the same logged-in guest a server-rendered page would see, no separate token or identity scheme. That means: - Always send `credentials: 'same-origin'` (or your framework's equivalent) so the cookie goes along. - CSRF protection is **on**, same as any other state-changing storefront request. Read the non-`HttpOnly` `XSRF-TOKEN` cookie the storefront already sets and send it back as `X-XSRF-TOKEN` β€” most HTTP clients (axios, for example) do this automatically. A missing or stale token is a `419`. - Cross-origin requests never carry the session cookie, so this endpoint is effectively same-origin only for anything cart- or auth-related. Calling it from a different origin only ever reaches already-public data. ### Previewing raw documents In production, `document` (a raw query string instead of a `documentId`) always `400`s. While you're iterating, two preview contexts accept it instead: - **`stayblox theme dev`** β€” the signed preview URL it prints puts your session into preview mode for that dev theme; see [Stayblox CLI β†’ live development](/themes/cli#live-development). - **The team's customizer**, editing your theme live. In both cases you can fetch with an inline `document` and skip republishing just to test a query β€” the same validation rules run either way, so a query that fails there would fail in production too. ## Errors vs. `userErrors` β€” which to use Model **anything a guest can cause** (invalid dates, a duplicate cart entry, a sold-out unit) as `userErrors` on your mutation's result type, the way `CartAddResult` does β€” the request still succeeds as far as the transport is concerned, and your theme JS just renders the message. Reserve GraphQL-level `errors` (and the `extensions.code` table above) for things that mean your integration itself is broken: a bad request shape, an unpublished document, a query that never should have shipped. --- # Publishing There are two ways a theme gets onto a storefront, for two different audiences: | You are… | You want… | Use | | --- | --- | --- | | A designer selling to hosts | A **public theme** in the marketplace | The [Stayblox CLI](/themes/cli): validate, push, submit for review | | A host (or their developer) | A **custom theme** for your own storefront | The zip upload in your admin. No review, private to your site. | ## Publish to the marketplace (CLI) Marketplace themes are published with the [Stayblox CLI](/themes/cli) from a theme directory containing a `theme.json` manifest: ```bash stayblox theme validate # check the package, publish nothing stayblox theme push # publish a draft version stayblox theme push --submit # publish and submit for review in one go ``` `push` creates (or updates) a **draft version** of your theme. Drafts are completely private: the theme doesn't appear in the marketplace and its assets live in a private bucket until a version is approved. ### What validation checks Both `validate` and `push` run the same server-side validation and return a structured report. **Errors** block publishing; **warnings** don't, but fix them before submitting: - **Manifest**: `theme.json` present and valid. `slug` is 2–40 chars of lowercase letters, digits, and dashes, starting and ending with a letter or digit; `name` is max 100 chars; `version` is strict semver (`X.Y.Z`). - **Ownership**: the slug isn't owned by another account, and the version hasn't already been released. - **Structure**: `layout/theme.liquid` must exist. Each `config/*.json` file (`settings_schema.json`, `settings_data.json`, `menu.json`, `pages.json`) must be valid JSON when present; missing ones are warnings (defaults will be empty). In `config/pages.json`, an entry's `key` must be one of `blog`, `blog-post`, or `contact` (a reserved page), and can't be combined with `slug` or `is_home`; see [Templating β†’ reserved pages](/themes/templating#reserved-pages). - **GraphQL**: if your package includes any `graphql/*.graphql` files, each operation in them is validated against the live storefront schema. Every operation must be named and unique across your files, and query depth/complexity limits are enforced (a violation blocks publishing). Errors are reported with file and message. - **Quality**: a `locales/en.json` so storefront strings don't fall back to raw keys (a warning if missing). The package carries no screenshots. Store previews are captured automatically from your live demo URL; an optional `thumbnail.(webp|jpg|jpeg|png)` at the theme root or in `assets/` overrides the marketplace listing card image. See [Store previews](/themes/screenshots). ### Review Submitting (`push --submit`, or `stayblox theme push` followed by a later submit) moves the draft into the review queue. Each version moves through: ``` draft β†’ pending review β†’ approved (live in the marketplace) β†’ rejected (read the review notes, fix, bump, re-push) ``` Check where things stand with `stayblox theme versions` (or `stayblox theme version 1.2.0` for one version's full report); both show status, validation report, and any review notes. On approval the version's assets are promoted to the public bucket and the theme becomes visible (and installable) in the marketplace. When a review lands you're **notified** by email and in the bell of your partner dashboard, and a rejection includes the reviewer's notes. Approved and rejected versions are **immutable**. Bump `version` in `theme.json` and push again; drafts can be re-pushed freely while you iterate. Drafts you abandon can be deleted (`stayblox theme delete `); drafts untouched for 90 days are cleaned up automatically. ### Your partner dashboard Your account panel (where you manage your partner account) has a **Themes** section listing everything you've published: per-theme version history with status badges, validation reports, and review notes, plus actions to submit a draft for review or delete it. It's also where you manage the marketplace listing: name, description, demo URL, industries, and pricing. The slug and version always come from `theme.json`; the dashboard never changes code. ## Versioning Each release has: - a human **version name**, the semver string from `theme.json` (e.g. `1.2.0`), and - a monotonically increasing **version code**, assigned by the platform, used to decide what's newer. ## How updates reach hosts Each install is pinned to the version the host installed, with any files the host edited stored as per-site overrides. When a newer version is approved (higher version code), what happens depends on each install's **auto-update** setting: - **Auto-update on**: the install moves to the new version immediately when it's approved. Hosts toggle this per theme in their admin's theme library. - **Auto-update off** (the default): Stayblox flags an update for the install; the host applies it when they choose. Moving to a new version works the same either way: files the host has **not** overridden resolve to the new version automatically, while customised files keep their overrides until reset. See [Theme structure β†’ file resolution](/themes/structure#how-files-resolve-at-render-time). ## Marketplace listing Approved themes appear in the public marketplace, filterable by industry, price, and search, with previews and author info. The public listing is served from: ``` GET /public/themes # list (search, industry, price, sort, per_page) GET /public/themes/{slug} # single theme with previews + authors ``` Give your theme a clear name, description, and demo URL in your partner dashboard so it presents well in the marketplace. Desktop and mobile previews are captured automatically from the demo URL; see [Store previews](/themes/screenshots). ## Upload a custom theme (zip) Hosts who want a bespoke theme for their **own** storefront don't go through the marketplace at all. In your admin, go to **Themes β†’ Manage themes β†’ Upload theme** and upload a `.zip` of the theme directory: ```bash cd my-theme zip -r ../my-theme.zip . ``` - The same [`theme.json` contract](/themes/cli#the-theme-json-manifest) applies: the theme's slug, name, and version are read from the manifest, and the upload is rejected if it's missing or invalid. - Beyond the manifest, the only structural requirement is a `layout/` directory at the archive root; there is no review step. - The theme is private to your site: editable in the code editor, never listed in the marketplace. Theme designers use this same upload on a development site to preview work-in-progress before pushing it with the CLI. ## Checklist (marketplace) - [ ] `theme.json` with `slug`, `name`, semver `version`. - [ ] `layout/theme.liquid` present; page views render into it via `content_for_layout`. - [ ] `config/settings_schema.json` declared for anything hosts should customise. - [ ] CSS/JS assets in `assets/css/`, `assets/js/`, referenced at their `/assets/…` path. (Binary assets, images and fonts, aren't served from the theme package yet; see [Assets](/themes/assets).) - [ ] `locales/en.json` included; optional `thumbnail.(webp|jpg|jpeg|png)` at the root or in `assets/`. - [ ] Demo URL set in your partner dashboard so marketplace previews can be captured. - [ ] `stayblox theme validate` passes with no errors. - [ ] Version bumped in `theme.json` for each release. --- # Store previews Marketplace preview images are captured automatically: the platform renders your live **demo URL** in a headless browser and stores a desktop and a mobile screenshot. You don't ship screenshots in the package. Set the demo URL on your listing in the [partner dashboard](/themes/packaging#your-partner-dashboard). | Preview | Captured as | Where it shows | | --- | --- | --- | | Desktop | 1440 px-wide viewport at retina resolution, cropped to a tall portrait of the top of the page | Listing card (when no thumbnail is set) and the theme detail page | | Mobile | 390 px phone viewport at retina resolution | Theme detail page | Captures run for approved themes that have a demo URL and refresh about once a day, so the listing images track your live demo site. If a capture fails, the listing keeps the last successful one. ## Make the capture count - The capture starts at the top of the page, so the home-page hero is what sells the theme. The desktop crop is portrait (taller than one screen), so the first couple of screens of content end up in the card. - Whatever renders is what's stored: make the demo load fast and keep cookie banners, overlays, and placeholder content off it. - Keep the demo site up. A dead demo URL means stale previews. ## Optional thumbnail override To control the listing-card image yourself, put a `thumbnail.(webp|jpg|jpeg|png)` at the theme root or in `assets/`: ``` my-theme/ thumbnail.webp # or assets/thumbnail.webp ``` When present, it replaces the auto-captured desktop preview on the listing card. It ships with the package, so a new thumbnail takes effect when the version carrying it is approved. Without one, the card falls back to the desktop preview. --- # Theme settings Settings let hosts customise your theme (colors, labels, toggles) without touching code. You declare them in `config/settings_schema.json`; Stayblox renders the form, and your templates read the values from the `settings` global. The format and example below are **generated** from the platform's reference theme, so they stay in sync with what the platform accepts. Theme settings let hosts customise your theme without editing code. You declare them in `config/settings_schema.json`; Stayblox renders the form, and your templates read the values from the `settings` global. ## Format The file is an array of **sections**, each with a `name` and a list of `fields`. Each field has: | Property | Meaning | | --- | --- | | `id` | The setting key. Your templates read it as `settings.`. | | `type` | Input type (see below). | | `label` | Label shown in the settings form. | | `default` | Default value before the host changes anything. | | `fields` | For a `grid` field only: nested fields grouped visually (their `id`s are still top-level settings). | ### Field types in use `image_picker`, `color_picker`, `text`, `textarea`, `radio`, `select`, `code_editor` ## Example: `config/settings_schema.json` ```json [ { "name": "theme_settings.logo.label", "fields": [ { "type": "image_picker", "id": "logo_dark", "label": "theme_settings.logo_dark.label" }, { "type": "image_picker", "id": "logo_light", "label": "theme_settings.logo_light.label" }, { "type": "image_picker", "id": "favicon", "label": "theme_settings.favicon.label", "image_size": "128x128" } ] }, { "name": "theme_settings.colors.label", "fields": [ { "type": "color_picker", "id": "base_color", "label": "theme_settings.base_color.label", "default": "#2563eb" } ] }, { "name": "theme_settings.social_media.label", "fields": [ { "type": "text", "id": "social_link_facebook", "label": "theme_settings.social_media_links.facebook.label", "placeholder": "https://facebook.com/rentmave" }, { "type": "text", "id": "social_link_instagram", "label": "theme_settings.social_media_links.instagram.label", "placeholder": "https://instagram.com/rentmave" }, { "type": "text", "id": "social_link_x", "label": "theme_settings.social_media_links.x.label", "placeholder": "https://x.com/rentmave" }, { "type": "text", "id": "social_link_linkedin", "label": "theme_settings.social_media_links.linkedin.label", "placeholder": "https://www.linkedin.com/company/rentmave" }, { "type": "text", "id": "social_link_youtube", "label": "theme_settings.social_media_links.youtube.label", "placeholder": "https://www.youtube.com/rentmave" }, { "type": "text", "id": "social_link_vimeo", "label": "theme_settings.social_media_links.vimeo.label", "placeholder": "https://vimeo.com/vimeo" }, { "type": "text", "id": "social_link_tiktok", "label": "theme_settings.social_media_links.tiktok.label", "placeholder": "https://tiktok.com/@rentmave" }, { "type": "text", "id": "social_link_snapchat", "label": "theme_settings.social_media_links.snapchat.label", "placeholder": "https://www.snapchat.com/add/rentmave" }, { "type": "text", "id": "social_link_pinterest", "label": "theme_settings.social_media_links.pinterest.label", "placeholder": "https://pinterest.com/rentmave" }, { "type": "text", "id": "social_link_tumblr", "label": "theme_settings.social_media_links.tumblr.label", "placeholder": "https://rentmave.tumblr.com" }, { "type": "text", "id": "social_link_telegram", "label": "theme_settings.social_media_links.telegram.label", "placeholder": "https://t.me/rentmave" } ] }, { "name": "theme_settings.pages.label", "fields": [ { "type": "image_picker", "id": "pages_background_image", "label": "theme_settings.pages_background_image.label", "image_size": "1900x600" } ] }, { "name": "theme_settings.footer.label", "fields": [ { "type": "textarea", "id": "footer_about_text", "label": "theme_settings.footer_about_text.label" } ] }, { "name": "theme_settings.extra.label", "fields": [ { "type": "radio", "id": "enable_cookie_notice", "boolean": true, "label": "theme_settings.enable_cookie_notice.label" } ] }, { "name": "theme_settings.facility_icons.label", "fields": [ { "type": "select", "id": "icon_weight", "label": "theme_settings.icon_weight.label", "helper_text": "theme_settings.icon_weight.helper_text", "default": "regular", "options": { "thin": "theme_settings.icon_weight.options.thin", "light": "theme_settings.icon_weight.options.light", "regular": "theme_settings.icon_weight.options.regular", "bold": "theme_settings.icon_weight.options.bold", "fill": "theme_settings.icon_weight.options.fill", "duotone": "theme_settings.icon_weight.options.duotone" } } ] }, { "name": "theme_settings.custom_css.label", "fields": [ { "type": "code_editor", "id": "custom_css", "label": "theme_settings.custom_css.label", "helper_text": "theme_settings.custom_css.helper_text" } ] } ] ``` ## Reading settings in a template ```liquid {% if settings.show_footer %}
…
{% endif %} ``` On install, each field's `default` is seeded as the stored value, so `settings.` resolves from day one: a freshly installed theme never sees an undeclared key. Liquid has no `??` operator; reach for the standard `default` filter when you want an inline fallback anyway (e.g. before a host has changed a color picker away from a value that happens to be empty). ## Where values live - **You** declare fields in `config/settings_schema.json`. - **Hosts** edit values on the theme settings page; each field's `default` is seeded on install, and values are stored per install. - **Templates** read them from the `settings` global, e.g. {{ settings.base_color }}. There is no `theme_setting()` function; settings are plain keys on the `settings` global. `settings` is site-wide, available on every page; a section's own per-instance fields arrive as a different variable, `block_settings`, only inside that section's template. See [Templating β†’ settings, general_settings, and block_settings](/themes/templating#settings-general-settings-and-block-settings). A field's `label` (and a `helper_text`, if it has one) can be a translation key instead of literal text, resolved for the host from an optional `lang/.admin.json` catalog rather than your guest-facing `locales/.json`. See [Templating β†’ locales and routing](/themes/templating#locales-and-routing). --- # Theme structure A theme is a directory of layout, views, sections, snippets, config, and assets, identified by a `theme.json` manifest. The details below are **generated** from the platform's validation rules and the reference theme, so they match exactly what the platform accepts. ## The manifest: `theme.json` Every theme carries a `theme.json` in the theme root identifying it (`slug`, `name`, semver `version`, optional `description` and `changelog`); see [Stayblox CLI β†’ manifest](/themes/cli#the-theme-json-manifest). The contract is the same whether you publish with the CLI or upload a `.zip` to your own site: the platform always reads the theme's identity from the manifest. Files are stored by their path within the theme. Every package, whether a CLI publish or a private zip upload, carries the same `theme.json` manifest; the theme's slug, name, and version are always read from it. Beyond the manifest, what's required depends on the publishing path: - **Marketplace (CLI):** `layout/theme.liquid` must exist. Config files (`config/settings_schema.json`, `config/settings_data.json`, `config/menu.json`, `config/pages.json`) must be valid JSON when present; missing ones are warnings. - **Private zip upload:** only a `layout/` directory is required; there is no review step. ## Reference layout The `cove` theme shipped in the platform is a complete, minimal example: ``` cove/ assets/css/iziToast.min.css assets/css/iziToast_custom.css assets/css/theme.css assets/js/app.js assets/js/booking-status.js assets/js/booking-widget.js assets/js/card.js assets/js/cart-page.js assets/js/checkout-form.js assets/js/contact-form.js assets/js/iziToast.min.js assets/js/newsletter-widget.js assets/js/review-form.js assets/js/storefront-api.js config/blocks.json config/menu.json config/pages.json config/settings_schema.json lang/bg.admin.json lang/bg.json lang/en.admin.json lang/en.json layout/theme.liquid locales/bg.json locales/en.json sections.json sections/blog_posts.liquid sections/email_signup.liquid sections/faq.liquid sections/featured_units.liquid sections/gallery.liquid sections/hero.liquid sections/highlights.liquid sections/location.liquid sections/map.liquid sections/rich_text.liquid sections/stats.liquid sections/testimonials.liquid snippets/blog-post-card.liquid snippets/footer.liquid snippets/header.liquid snippets/hero-banner.liquid snippets/unit-card.liquid theme.json views/accommodation-detail.liquid views/accommodations.liquid views/blog-post.liquid views/blog.liquid views/booking-status.liquid views/cart.liquid views/checkout-confirmation.liquid views/checkout.liquid views/contact.liquid views/index.liquid views/maintenance.liquid views/page.liquid views/policy.liquid views/review.liquid ``` ## Top-level directories | Directory | Purpose | | --- | --- | | `assets/` | CSS, JS, images, and fonts (`assets/css/`, `assets/js/`, …), served at a matching `/assets/…` URL: `assets/css/app.css` becomes `/assets/css/app.css`. Store preview images (desktop/mobile) are rendered on demand. | | `config/` | `settings_schema.json` (host settings), `menu.json` (navigation), `blocks.json` (page-builder block types) / `pages.json` (seeds each page's initial blocks). | | `lang/` | Optional admin-facing translation catalogs (`.admin.json`) for your `settings_schema.json` / `blocks.json` field labels. Not shown to guests. | | `layout/` | The theme shell, `layout/theme.liquid`. **Required**; the importer rejects a theme without it. | | `locales/` | Storefront translation catalogs read by the `t` filter, one per locale code (e.g. `en.json`). | | `sections/` | Configurable page-builder blocks, rendered into `sections_html`. See [Templating β†’ sections and blocks](/themes/templating#sections-and-blocks). | | `snippets/` | Reusable partials pulled in with `{% include %}`. | | `views/` | Page-level Liquid views, one per page type (`index.liquid`, `page.liquid`, `accommodations.liquid`, …). | > A `layout/` directory is mandatory; importing a `.zip` without one fails. ## How files are stored Text files with these extensions are imported as **editable** theme files (editable later in the code editor): `liquid`, `css`, `js`, `json`, `svg`, `html`, `txt`, `xml`, `graphql` Files whose top-level directory is one of `css/`, `js/`, `fonts/`, `images/`, `webfonts/` are stored under an `assets/` prefix, so reference them at the matching `/assets/…` URL, e.g. `/assets/css/app.css`. Binary files with these extensions are copied to the theme's public asset folder rather than stored as source: `woff`, `woff2`, `ttf`, `eot`, `png`, `jpg`, `jpeg`, `gif`, `ico`, `webp`, `mp4`, `webm` ## Required views {#required-templates} In addition to the importer constraints above, the following views are **required** for the theme to function correctly on all platform pages. Publish validation doesn't check for them (they aren't part of any `config/*.json`), but the routes that serve them fail at runtime without one: | View | Location | Purpose | | --- | --- | --- | | `booking-status.liquid` | `views/` | Public payment-status page (post-checkout redirect). | | `review.liquid` | `views/` | Public post-stay review submission page (tokenized link from the review-request email). | See [Templating β†’ `booking-status.liquid`](/themes/templating#booking-status-liquid-required) and [Templating β†’ `review.liquid`](/themes/templating#review-liquid-required) for the variable contracts and the states to handle. ## How files resolve at render time Installing a theme doesn't copy its files. At render time a file is looked up by path among the site's own **overrides** first (a file the host edited in the code editor), falling back to the installed theme version for everything else. A host can customise just a few files without forking the whole theme, and files they never touch keep tracking the installed version. Custom themes uploaded as a `.zip` have no marketplace version to fall back to, so all of their files live as site-level files from the start. See [Publishing](/themes/packaging) for versioning and how updates flow to installed sites. --- # Templating Themes render with **Liquid** (the same language Shopify themes use, via the [keepsuit/liquid](https://github.com/keepsuit/liquid) PHP implementation). This page is the full contract: the data a view can read, the filters and tags available, and how settings and translations reach a view. ## The data model: the storefront GraphQL schema Everything a view can read (unit types, properties, the cart, site settings, blog posts…) is defined by the **storefront GraphQL schema**, versioned (currently `2026-01`) at `graphql/storefront/2026-01/` in the platform. The schema *is* the object catalog: there's no separate list to keep in sync. A theme never writes GraphQL by hand for the built-in pages; the platform resolves each page's data and hands it to your view as plain Liquid variables (objects and arrays, keys `snake_cased` from the schema's `camelCase` fields, so `unitType.baseRate` arrives as `unit_type.base_rate`). The full set of queries and mutations: | Operation | Returns | Used for | | --- | --- | --- | | `settings` | `Settings` (`siteName`, `brandName`, `currencyCode`, `bookingMode`, `pricingVisibility`, `siteContactAddress`, `siteContactEmail`, `siteContactPhone`) | Site-wide info. Injected as `general_settings` on every page. | | `themeSettings` | `ThemeSettings` (`settingsJson`) | Your theme's own settings, declared in `config/settings_schema.json`. Injected as `settings` on every page. | | `menu` | `[MenuItem]` (`label`, `url`, `openInNewTab`, `translationKey`) | Navigation, from `config/menu.json`. Injected as `menu`. | | `site` | `Site` (`policies`, `pages`: each `{ url, title }`) | Footer links to policy pages and CMS pages. Injected as `site`. | | `locales` | `[Locale]` (`code`, `name`, `nameLocal`, `image`, `isDefault`) | Published locales, for a language switcher. Injected as `locales` (each entry also gets a computed `url`). | | `countries` | `[Country]` (`isoCode`, `name`, `phone`, `currency`) | Country `