Appearance
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.jsondirectly 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.graphqlgraphql
# 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.validateentry in the pushreport.errors, the same report yourtheme.jsonmanifest and template checks already surface — see Partner 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. 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: <value of the XSRF-TOKEN cookie>
{
"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
variablessmall. - The route is rate-limited per IP per storefront; expect
429withRetry-Afterunder 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-
HttpOnlyXSRF-TOKENcookie the storefront already sets and send it back asX-XSRF-TOKEN— most HTTP clients (axios, for example) do this automatically. A missing or stale token is a419. - 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 400s. 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.- 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.