Skip to content

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.
  • 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

FeatureRequired operation nameFileConsumed by
Accommodation listing pageUnitListinggraphql/unit_listing.graphqlServer-rendered unit/types.twig, via data.unitTypes
Add to cartCartAddgraphql/cart_add.graphqlThe 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

FieldRequiredNotes
documentIdOne of documentId / documentThe sha256 hash from graphql_operations (above). This is what production traffic sends.
documentOne of documentId / documentA raw GraphQL string. Only accepted while previewing — see below; 400 RAW_DOCUMENT_NOT_ALLOWED on a live storefront.
operationNameNoMust match the persisted operation's name if present; mismatches are 400.
variablesNoA plain object, e.g. { "input": { ... } }.
localeNoOne 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.

SituationStatusBody
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 body400{ "errors": [{ "message": ..., "extensions": { "code": "BAD_REQUEST" } }] } (or BATCHING_NOT_SUPPORTED / RAW_DOCUMENT_NOT_ALLOWED)
Unknown documentId for this team's current theme version404extensions.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 validation200data is absent; extensions.code: "GRAPHQL_VALIDATION_FAILED"
Missing/stale CSRF token419Laravel's standard CSRF response
Rate limited429Retry-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 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.

© Stayblox — Developer Platform