Skip to content

query

Terminal window
npx skills add https://github.com/crystallizeapi/ai --skill query

Crystallize Query Skill

Query product data, content, and commerce information from Crystallize using GraphQL APIs. This skill covers reading data for storefronts, admin interfaces, product pages, search, and cart operations.

Consultation Approach

Before writing queries, understand the context. Ask clarifying questions:

  1. What data do you need? Products, orders, customers, content, shapes?
  2. Is this for a storefront or admin interface? Discovery/Catalogue for storefronts, Shop API for carts and orders, Core for admin only — Core is rate limited and must not serve storefront traffic.
  3. Do you need search/filtering or exact path reads? Discovery for search and faceted navigation, Catalogue for deterministic reads by path.
  4. Do you have authentication configured? Core API requires access tokens. Discovery/Catalogue can be open but should be secured in production. A public Discovery endpoint serves every price variant it exposes to anyone who knows the field name (nokPrice, wholesalePrice, …), so confidential terms — negotiated B2B prices in particular — belong in a price list read from the Catalogue API server-side, not in a price variant. See [[pricing]].
  5. What volume of results? Pagination strategy matters — cursor-based is recommended for all APIs.

Choosing the Right API

  • Need search, filtering, or faceting? → Discovery API
  • Know the exact path? Need strong consistency? → Catalogue API
  • Admin interface? Orders, customers, shapes? → Core API
  • Cart/checkout operations? → Shop API
  • Need results ordered by relevance rules, personalization, or similarity? → Discovery API with rankBy / context / nearestTo — see [[vector-ranking]]

Core Is Not a Storefront API

Core is heavily rate limited and is not meant for storefront traffic — not even server-side with a short cache, and never once per page view. A storefront reads the catalogue from Discovery (and Catalogue for deterministic path reads), and does carts, orders, customers and subscription contracts on the Shop API. Core is for the back office: imports, seeding, scheduled jobs and admin tools, behind your own server.

This costs nothing in capability. The Shop API is edge-distributed and scales near the shopper, and it syncs with Core asynchronously, so an order placed through the Shop API is in Core a few seconds later for the back office to work on.

How It Works

Crystallize provides four main APIs for querying data:

  1. Core API - Full read/write API with advanced filtering. Best for admin interfaces, customer/order management.
  2. Discovery API - Semantic API for search, browse, filter, and faceting. Best for storefronts.
  3. Catalogue API - Path-based reads for deterministic data access.
  4. Shop API - Cart and checkout queries at the edge.

API Endpoints

API Endpoint Auth Required Use For
Core https://api.crystallize.com/@{tenant} Yes (access tokens) Admin, customers, orders
Discovery https://api.crystallize.com/{tenant}/discovery Optional (configurable) Storefront search/filter
Catalogue https://api.crystallize.com/{tenant}/catalogue Optional (configurable) Storefront path-based reads
Shop /cart https://shop-api.crystallize.com/{tenant}/cart Yes (JWT) Cart hydration, checkout flows
Shop /order https://shop-api.crystallize.com/{tenant}/order Yes (JWT) Order queries by ID/customer

Usage

Core API (Admin & Advanced Queries)

The Core API provides full read/write access with advanced filtering capabilities:

  • Read items by ID with full component data
  • List items with pagination and filtering
  • Query customers and orders with complex filters
  • Filter orders by customer, SKU, payment provider, metadata
  • Access shape and piece definitions
  • Manage flows and archives
# Example: Get item with components
query GetItem {
item(id: "item-id", language: "en") {
... on Product {
id
name
components {
componentId
content
}
defaultVariant {
sku
price
stock
}
}
}
}
# Example: List orders filtered by customer
query ListOrders {
orders(
tenantId: "tenant-id"
first: 20
filter: { customerIdentifier: "customer@example.com" }
sort: { field: createdAt, direction: desc }
) {
edges {
node {
id
total {
gross
currency
}
customer {
identifier
}
}
}
}
}

See Core API Queries Reference for complete documentation.

The Discovery API is the primary API for frontend development. It supports:

  • Full-text search
  • Filtering by any component or attribute
  • Faceted navigation
  • Sorting and cursor-based pagination

The Discovery API has three entry points: search for full-text queries across all shapes, browse for shape-typed access where each shape becomes its own query type, and autocomplete for type-ahead on name. A fourth query, topics, walks the topic map.

The Discovery schema is generated per tenant from its shapes and index settings — filter, facet and sort fields differ between tenants, and ranking arguments exist only on tenants served for ranking. Introspect rather than assume.

Note: The Discovery API uses lowercase type names in inline fragments (... on product, ... on category) because types are derived from your shape identifiers. You can still use it for interface (... on Product, ... on Folder).

# Example: Browse products by shape with pagination
{
browse {
product(language: en, pagination: { limit: 25 }) {
summary {
totalHits
hasMoreHits
endCursor
}
hits {
name
path
... on Product {
defaultVariant {
sku
defaultPrice
firstImage {
url
}
}
}
}
}
}
}
# Example: Search with facets and filtering
{
search(
language: en
term: "green"
filters: { type_in: [product] }
facets: { shape: { limit: 5 } }
pagination: { limit: 20 }
) {
summary {
totalHits
hasMoreHits
endCursor
facets
}
hits {
name
path
... on product {
defaultVariant {
defaultPrice
}
}
}
}
}

Catalogue API (Path-Based Reads)

Use for deterministic reads when you know the exact path:

{
catalogue(language: "en", path: "/shop/plants") {
name
... on Product {
variants {
sku
name
price
}
}
}
}

Shop API Queries

Retrieve cart and order intent data:

query {
cart(id: "cart-id") {
id
items {
sku
name
quantity
price {
gross
net
}
}
total {
gross
net
}
}
}

Best Practices

  1. Choose the right API - Core for admin, Discovery for storefront search, Catalogue for path-based reads
  2. Use Discovery API for storefronts - It’s optimized for performance and supports search/filter
  3. Include deterministic sorting - Always add a secondary sort field (like itemId) for stable pagination
  4. Request only needed fields - GraphQL allows precise field selection to minimize payload
  5. Handle async updates - Discovery API may have sub-second delay for recently published content
  6. Protect APIs in production - Configure authentication for sensitive data
  7. Use Core API for complex filters - Only Core API supports filtering orders by customer, SKU, payment provider
  8. Detect the Discovery schema, don’t hardcode it - Filter/sort/facet fields and the ranking arguments are tenant-generated; introspect before building a query
  9. Keep Core out of the storefront - Storefront reads are Discovery/Catalogue, and carts, orders, customers and subscription contracts are the Shop API
  10. Scope customerIdentifier on the server - The Shop API token is per tenant, not per shopper: orders(customerIdentifier:) answers for any identifier it is given, so take it from the session and never from the client
  11. Read an order list through one store - Orders edited in Core appear more than once in the Shop API’s list; group by coreId and keep the newest copy (see the Shop API Order Queries Reference)

References

Reading is only half of it — [[mutation]] covers writes across the same APIs, and [[js-api-client]] wraps all of them for JS/TS. For ranking Discovery results by relevance rules, a shopper’s taste, or similarity to another item, use [[vector-ranking]].


Reference Details

Catalogue API Reference

The Catalogue API provides path-based access to items in your Crystallize tenant. It offers strong consistency for deterministic reads.

Base URL

https://api.crystallize.com/{tenant-identifier}/catalogue

Replace {tenant-identifier} with your tenant name.

Authentication

By default, the API is accessible without authentication. You can configure access in the Crystallize App:

  1. Go to Settings → API Access
  2. Choose your preferred access level
  3. Save changes

When to Use Catalogue API

Use the Catalogue API when you need:

  • Deterministic exports - Payload must reflect exact current state
  • Path-based reads - You know the exact path to the item
  • Strong consistency - Cannot tolerate async delays

For search, filtering, and faceting, use the Discovery API instead.

Query Structure

Basic Path Query

{
catalogue(language: "en", path: "/shop/plants") {
name
path
type
}
}

Product Query with Variants

{
catalogue(language: "en", path: "/shop/furniture/dining-chair") {
name
... on Product {
variants {
sku
name
price
stock
attributes {
attribute
value
}
}
}
}
}

Folder with Children

{
catalogue(language: "en", path: "/shop/plants") {
name
... on Folder {
children {
name
path
type
}
}
}
}

Document Query

{
catalogue(language: "en", path: "/blog/welcome-post") {
name
... on Document {
components {
id
name
content {
... on RichTextContent {
html
}
}
}
}
}
}

Reading a Colors component

A colors component returns ColorsContent, holding a list of entries. Each entry carries whichever notations were stored — they are the same colour expressed several ways, so select the ones your frontend actually renders rather than all of them.

{
catalogue(language: "en", path: "/shop/furniture/dining-chair") {
name
components {
id
content {
... on ColorsContent {
colors {
label
hex
rgb {
r
g
b
a
}
pantone
ral
}
}
}
}
}
}

ColorEntry exposes label, hex, rgb { r g b a }, hsl { h s l a }, cmyk { c m y k }, pantone and ral. Every field is nullable — an entry only carries the notations that were authored, so a storefront reading hex needs a fallback for entries stored as Pantone only.

Colour filtering does not belong here or in Discovery; filter on the Selection or topic that carries the colour name. See [[content-model]] for the modelling rule.

cURL Example

Terminal window
curl \
-X POST \
-H "Content-Type: application/json" \
--data '{ "query": "{ catalogue(language: \"en\", path: \"/shop/plants\") { name } }" }' \
https://api.crystallize.com/furniture/catalogue

Limitations

The Catalogue API does not support:

  • Full-text search
  • Filtering
  • Faceting
  • Sorting across multiple items

Use the Discovery API for these capabilities.

Prices for one customer

The Catalogue API is the only storefront API that resolves a price list for a customer. Discovery knows markets only, so this is where a B2B storefront reads an account’s agreed prices — server-side, cached per organisation:

query ContractPrice($skus: [String!]!, $customers: [String!]) {
productVariants(skus: $skus, language: "en") {
sku
priceVariant(identifier: "nok") {
price # the list price
priceFor(count: 10, customerIdentifiers: $customers) {
price # what this customer pays at this quantity
identifier # the price list that applied
modifier
modifierType
}
}
}
}

customerIdentifiers is a list, and priceFor also takes customerGroupIdentifiers and marketIdentifiers. A percentage list applies on top of the variant’s volume tiers, and a list aimed at an organisation resolves for its child customers too — see [[pricing]] for the measurements and the caveats (productVariants(skus:) caps at 150 SKUs, and priceFor is slow enough to need caching).

Core API Queries Reference

The Core API provides comprehensive read access to items, customers, orders, shapes, and tenant configuration. Use this for admin interfaces, reporting, and complex filtering needs.

Table of Contents

Base URL

https://api.crystallize.com/@{tenant-identifier}

Note the @ prefix before the tenant identifier.

Authentication

Authentication is required. Use access tokens generated in the Crystallize App:

Terminal window
curl -X POST 'https://api.crystallize.com/@your-tenant' \
-H 'Content-Type: application/json' \
-H 'X-Crystallize-Access-Token-Id: YOUR_TOKEN_ID' \
-H 'X-Crystallize-Access-Token-Secret: YOUR_TOKEN_SECRET' \
-d '{"query": "..."}'

Core API vs Other APIs

Use Core API for:

  • Admin interfaces and dashboards
  • Complex order filtering (by customer, SKU, payment provider)
  • Reading shape/piece definitions
  • Customer management
  • Bulk data access

Use Discovery/Catalogue API for:

  • Storefront product listings
  • Search and filtering
  • Public content access

Item Queries

Get Item by ID

query GetItem {
item(id: "item-id", language: "en") {
... on Product {
id
name
shapeIdentifier
tree {
path
parentId
}
components {
componentId
content
}
defaultVariant {
sku
name
price
stock
images {
url
altText
}
}
variants {
sku
name
price
stock
}
}
... on Document {
id
name
shapeIdentifier
components {
componentId
content
}
}
... on Folder {
id
name
shapeIdentifier
children {
id
name
type
}
}
... on ItemNotFoundError {
errorName
message
}
}
}

List Items with Pagination

first caps at 100. Asking for more is refused — Invalid pagination options: Cannot list more than 100 items at a time. — and it arrives as an INTERNAL_SERVER_ERROR with the path pointing at whatever field you selected, which makes it look like something else went wrong. Page with after instead. Verified on a live tenant: first: 100 returned 100 rows of 540 with hasNextPage: true, while first: 150 and first: 500 both errored.

query ListItems {
items(
language: "en"
first: 20
after: "cursor"
filter: {
shapeIdentifiers: ["product", "bundle"]
# path: { prefix: "/shop" }
# includeDescendants: true
}
sort: { field: updatedAt, direction: desc }
) {
edges {
cursor
node {
id
name
type
shapeIdentifier
createdAt
updatedAt
... on Product {
defaultVariant {
sku
price
stock
}
}
}
}
pageInfo {
hasNextPage
hasPreviousPage
startCursor
endCursor
}
totalCount
}
}

Get Item Component

Read a specific component from an item:

query GetItemComponent {
item(id: "item-id", language: "en") {
... on Item {
id
component(id: "description") {
componentId
content
}
}
}
}

Check Identifier Availability

query CheckIdentifier {
isIdentifierAvailable(tenantId: "tenant-id", identifier: "new-product-slug") {
... on IdentifierAvailability {
available
}
}
}

Customer Queries

Get Customer by Identifier

query GetCustomer {
customer(identifier: "customer@example.com") {
... on Customer {
id
identifier
tenantId
... on IndividualCustomer {
firstName
lastName
email
phone
}
... on OrganizationCustomer {
name
taxId
organizationNumber
}
addresses {
type
firstName
lastName
street
street2
city
state
postalCode
country
phone
email
}
meta {
key
value
}
}
... on CustomerNotFoundError {
errorName
message
}
}
}

List Customers

query ListCustomers {
customers(
tenantId: "tenant-id"
first: 20
filter: {
email: { contains: "@example.com" }
# customerType: individual
}
) {
edges {
node {
id
identifier
... on IndividualCustomer {
firstName
lastName
email
}
... on OrganizationCustomer {
name
}
}
}
pageInfo {
hasNextPage
endCursor
}
totalCount
}
}

Get Customer Groups

query GetCustomerGroup {
customerGroup(id: "group-id") {
... on CustomerGroup {
id
name
customerIdentifiers
}
}
}
query ListCustomerGroups {
customerGroups(tenantId: "tenant-id", first: 20) {
edges {
node {
id
name
customerIdentifiers
}
}
}
}

Order Queries

Get Order by ID

query GetOrder {
order(id: "order-id") {
... on Order {
id
createdAt
updatedAt
customer {
identifier
... on IndividualCustomer {
firstName
lastName
email
}
}
cart {
sku
name
quantity
price {
net
gross
currency
}
imageUrl
}
total {
net
gross
currency
}
payment {
provider
... on StripePayment {
paymentIntentId
}
}
meta {
key
value
}
}
... on OrderNotFoundError {
message
}
}
}

List Orders with Filtering

The Core API supports advanced order filtering by customer, SKU, payment provider, and metadata:

query ListOrders {
orders(
tenantId: "tenant-id"
first: 20
filter: {
customerIdentifier: "customer@example.com"
# sku: "PROD-123"
# paymentProvider: "stripe"
# meta: [{ key: "source", value: "mobile-app" }]
}
sort: { field: createdAt, direction: desc }
) {
edges {
cursor
node {
id
createdAt
total {
gross
currency
}
customer {
identifier
}
cart {
sku
name
quantity
price {
gross
}
}
}
}
pageInfo {
hasNextPage
endCursor
}
totalCount
}
}

Filter Options:

  • customerIdentifier - Filter by customer email/identifier
  • sku - Filter orders containing specific SKU
  • paymentProvider - Filter by payment method (stripe, klarna, etc.)
  • meta - Filter by metadata key/value pairs
  • createdAt - Date range filtering

Shape and Piece Queries

Get Shape Definition

query GetShape {
shape(identifier: "product") {
... on Shape {
identifier
name
type
components {
id
name
type
config
}
variantComponents {
id
name
type
config
}
}
}
}

List All Shapes

query ListShapes {
shapes(tenantId: "tenant-id") {
identifier
name
type
itemCount
}
}

Get Piece Definition

query GetPiece {
piece(identifier: "seo") {
... on Piece {
identifier
name
components {
id
name
type
config
}
}
}
}

List All Pieces

query ListPieces {
pieces(tenantId: "tenant-id", first: 100) {
edges {
node {
identifier
name
components {
id
name
type
}
}
}
}
}

Flow Queries

Get Flow

query GetFlow {
flow(id: "flow-id") {
... on Flow {
id
name
stages {
id
name
position
}
}
}
}

Get Flow Content

List items in a flow stage:

query GetFlowContent {
flowContent(flowId: "flow-id", stageId: "stage-id", language: "en", first: 20) {
edges {
node {
item {
id
name
type
}
assignedTo {
id
email
}
dueDate
}
}
}
}

Archive Queries

Get Archived Version

query GetArchive {
archive(id: "archive-id") {
... on ArchivedItemVersion {
id
number
name
archivedAt
archivedBy {
email
}
item {
id
name
}
}
}
}

List Archived Versions

query ListArchives {
archives(itemId: "item-id", language: "en", first: 10, sort: { field: number, direction: desc }) {
edges {
node {
id
number
name
archivedAt
archivedBy {
email
}
}
}
}
}

Bulk Task Queries

Get Bulk Task Status

query GetBulkTask {
bulkTask(id: "task-id") {
... on BulkTask {
id
type
status
createdAt
startedAt
stoppedAt
info
actor {
email
}
}
}
}

List Bulk Tasks

query ListBulkTasks {
bulkTasks(tenantId: "tenant-id", first: 20, filter: { status: running }) {
edges {
node {
id
type
status
createdAt
}
}
}
}

File and Image Queries

Get Image

query GetImage {
image(key: "image-key") {
... on Image {
key
url
altText
caption
variants {
url
width
height
}
}
}
}

List Images

query ListImages {
images(tenantId: "tenant-id", first: 20, filter: { path: { prefix: "/products/" } }) {
edges {
node {
key
url
altText
createdAt
}
}
}
}

Error Handling

The Core API uses union return types. Always handle potential errors:

query {
item(id: "item-id", language: "en") {
... on Product {
id
name
}
... on ItemNotFoundError {
errorName
message
}
... on UnauthorizedError {
errorName
message
}
}
}

Common error types:

  • UnauthorizedError - Invalid or missing credentials
  • ItemNotFoundError - Item doesn’t exist
  • CustomerNotFoundError - Customer doesn’t exist
  • OrderNotFoundError - Order doesn’t exist
  • BasicError - Generic error

TypeScript Types

Use generated types for type safety:

import { Query, GetItemQuery, GetCustomerQuery, ListOrdersQuery } from "@/generated/core";

Types are generated from the Core API schema via GraphQL Code Generator.

Discovery API Reference

The Discovery API is the primary API for powering storefronts with product information and marketing content. It is a read-only API optimized for high performance.

Base URL

https://api.crystallize.com/{tenant-identifier}/discovery

Replace {tenant-identifier} with your tenant name — the bare identifier, no @ (that prefix belongs to the Core API).

Authentication

By default, the Discovery API is open. If you configure restricted access for your Catalogue API, you need to provide authentication:

  • Static token via header
  • Access tokens for programmatic access

Important: Always secure your API with authentication in production environments.

The schema is generated per tenant

This is the single most important thing to know before writing a query. The Discovery schema is derived from the tenant’s shapes and index settings, so it differs between tenants and changes when the tenant is re-indexed:

  • Every shape becomes a type and a browse field — product, category, brand, …
  • Filter, facet and sort inputs (TenantFilter, ProductFacet, TenantSort, …) are generated from indexed component fields — price_default, stock_oslo, specs_label, variants_topics, …
  • TenantLanguage is an enum of the tenant’s languages
  • Ranking inputs and their enums appear only on tenants served for ranking (see below)

Introspect, do not assume. An un-ignited tenant answers {"success": false, "message": "There is no ignited Tenant for <tenant>."} rather than serving a schema at all.

Note: The Discovery API uses lowercase type names in inline fragments (... on product, ... on category) because types are derived from your shape identifiers. Interface fragments keep their capital (... on Product, ... on Folder, ... on Document).

Queries

Query Use for
search Full-text search across all shapes; polymorphic hits
browse Shape-typed access — each shape becomes its own query with all its component fields
autocomplete Type-ahead on name — it restricts, it does not rank (see below)
topics Children of a topic in the topic map

Never send an explicit null for an optional argument. pagination: { after: null } is rejected with invalid_type: after Invalid input: expected string, received null, and the same goes for term and context. Leave the key out on the first page instead — with GraphQL variables that means building the argument object conditionally, not passing null.

A hit type keeps the shape identifier’s case. A shape called tool gives the Discovery type tool (lower case) while its inputs are ToolFilter, ToolFacet and ToolSort. __type(name: "Tool") answers null, so a script that introspects the schema should read the hit type from the shape query’s hits rather than capitalise the identifier itself.

search, autocomplete and every field under browse take the same argument set:

language, publicationState, path, pathResolutionMethod, term,
pagination, options, rankBy, context, nearestTo, filters, facets, sorting

Folder.children and Topic.items take that same set, which is what makes nested category listings filterable and rankable in one round trip.

{
search(language: en, filters: { type_in: [product] }, pagination: { limit: 20, after: "XXXX" }) {
summary {
totalHits
hasMoreHits
endCursor: endToken
facets
}
hits {
id
name
path
shape
score
}
}
}

Hits are polymorphic — use one inline fragment per shape, and read shape to tell them apart.

Filtering by Shape

{
search(language: en, filters: { shape: { equals: "sneaker" } }) {
hits {
name
path
}
}
}

Price Range Filter

{
search(language: en, filters: { price_sales: { range: { gte: 20, lte: 100 } } }) {
hits {
name
path
}
}
}
{
search(language: en, term: "plop") {
hits {
name
path
}
}
}

Filter operators

Filters compose with AND and OR, each taking a list of nested filters.

Input Operators
StringFilter exists, equals, not_equals, in, not_in, contains, not_contains, phrase, regex, not_regex
StringFilterWithAutocomplete the above plus autocomplete: { term, options }
NumberFilter exists, equals, not_equals, in, not_in, range: { gt, gte, lt, lte }
DateFilter exists, equals, not_equals, in, not_in, range: { gt, gte, lt, lte }
BooleanFilter exists, equals, not_equals

type_in: [ItemType] (product, document, folder) is the common way to narrow a search.

Search is exact by default. Opt into typo tolerance through options.fuzzy:

{
search(language: en, term: "gren", options: { fuzzy: { fuzziness: SINGLE, prefixLength: 1 } }) {
hits {
name
path
}
}
}
Option Default Meaning
fuzziness NONE Max single-character edits: NONE, SINGLE, DOUBLE
prefixLength 0 Leading characters that must match exactly before edits apply
maxExpensions 50 Max term variations generated
maxExpansions 50 The same option, spelled correctly — both exist on the input

Two spelling traps, both confirmed by introspection: FuzzySearchOptions carries maxExpensions and maxExpansions, and the enum type is Fuziness, not Fuzziness — a typed variable ($f: Fuzziness!) fails schema validation.

Raising fuzziness widens the candidate set and costs latency — prefer SINGLE before DOUBLE, and use prefixLength to keep short, common terms precise. DOUBLE is very loose on short tokens: on a 540-document tenant a multi-word query matched almost everything, which makes counts and facets meaningless. Keep a results page at SINGLE, and use DOUBLE only as a last-chance fallback when SINGLE found nothing.

Autocomplete

{
autocomplete(language: en, term: "espr", pagination: { limit: 8 }) {
hits {
name
path
}
}
}

autocomplete matches on name and otherwise takes the same arguments as search — including filters and ranking.

Autocomplete restricts; it does not rank

This is the trap. Measured on a 540-document tenant:

Query totalHits Scores
autocomplete(term: "milwaukee") 540 mixed
autocomplete(term: "zzzzqq") — matches nothing 540 all 0
search(filters: { name: { autocomplete: { term: "milwaukee" } } }) 146 all 0
the same filter plus term and sorting: { score: desc } 146 25.9 … 11.6

Two things follow:

  • The top-level autocomplete query answers with the whole index, padding the tail with score-0 hits, whatever the term. Drop hits with score: 0, or add the name filter, or you will show a type-ahead list of everything you sell.
  • name: { autocomplete: { term } } is a filter on StringFilterWithAutocomplete — only name has it — and on its own every hit comes back scored 0, in no useful order. Pass the same text as term as well and sort by score: desc: the term scores the matches, the filter restricts the set.

Autocomplete is prefix matching per token, so it does not fix a typo inside a word (“nikkon”, “hitli”), and it does not match inside a compound word (“kjernebor” does not come up for “diamantkjernebor”). Full-text search(term:, options: { fuzzy: … }) does both. The pattern that works: prefix autocomplete first, and full-text fuzzy as the fallback when it finds nothing.

Browse Queries

The browse API provides shape-typed access — each shape becomes its own query type with all component fields available directly. This is the recommended approach for storefronts.

Browse by Shape

{
browse {
product(language: en, pagination: { limit: 25 }) {
summary {
totalHits
hasMoreHits
endCursor
}
hits {
name
path
defaultVariant {
sku
defaultPrice
firstImage {
url
}
}
}
}
}
}

Browse by Path (Folder Listing)

{
browse {
category(language: en, path: "/shop/*") {
hits {
name
path
children(language: en) {
hits {
... on product {
name
path
defaultVariant {
sku
defaultPrice
}
}
}
}
}
}
}
}
  • Use path: "/shop/*" for direct children (wildcard)
  • Use path: "/shop/exact-item" for a specific item
  • pathResolutionMethod (canonical, alias, history, shortcut) controls how a path is resolved
  • Use aliases to combine multiple browse queries in one request

Combined Query with Aliases

{
folder: browse {
category(language: en, path: "/shop") {
hits {
name
path
}
}
}
products: browse {
product(language: en, path: "/shop/*") {
hits {
name
path
defaultVariant {
sku
defaultPrice
}
}
}
}
}

Sorting

sorting takes one or more generated fields plus score, each asc or desc:

{
browse {
product(language: en, sorting: { price_default: asc, itemId: asc }) {
hits {
name
path
}
}
}
}

Always add a deterministic secondary field (such as itemId) so pagination stays stable across pages. Note that sorting does not compose predictably with ranking — see below.

Pagination

Use paginationToken (returned as endToken in summary) for efficient, consistent pagination:

{
browse {
product(language: en, pagination: { limit: 25, after: "CURSOR_FROM_PREVIOUS_PAGE" }) {
summary {
totalHits
hasMoreHits
endCursor: endToken
}
hits {
name
path
}
}
}
}

Flow:

  1. First request: omit after (or set to null)
  2. Use summary.endToken as the after value for the next page
  3. Stop when summary.hasMoreHits is false

pagination accepts limit, after, before and skip.

Note: skip-based pagination is deprecated for ordinary queries. Use cursor-based pagination (after). skip becomes increasingly expensive on large result sets. The exception is ranked queries — see below.

Faceting

Get counts for filter values. StringFacet takes key and limit; NumberFacet and DateFacet also require boundaries.

{
search(language: en, term: "blue", facets: { shape: { limit: 5 } }) {
summary {
facets
}
hits {
name
path
}
}
}

summary.facets is a Hash, and accepts an optional key argument to pull a single facet out.

summary.priceRange(priceIdentifier: "default", quantity: 1) { min max } gives the price bounds of the current result set — useful for a range slider that matches the active filters.

Ranking, personalization and similarity

Ranking-enabled tenants additionally accept rankBy, context and nearestTo, and expose rankScore and rankExplain on hits. That surface — vocabularies, setItemTaste, igniteDiscoApi, the five rankBy signals, context.userTaste, nearestTo and the rerank window — is covered by the [[vector-ranking]] skill.

Two things to know from here:

  1. The arguments are absent from the schema until the tenant is served for ranking. Referencing them on an ordinary tenant is a GraphQL validation error, not an unranked result. Detect the capability: { __type(name: "RankByInput") { name } }.
  2. Ranked queries page differently. When a rerank runs, cursor tokens fall back to offset pagination — use skip + limit, and remember that skip offsets into a bounded rerank window (options.rerankWindow, default 500, cap 2000).

Profiling

Every query can report how it was served:

{
search(language: en, term: "chair") {
summary {
profiling {
executionTime
queryEngine
collection
webNode
lastIndexCompletedAt
}
}
}
}

lastIndexCompletedAt is the reliable way to confirm a re-index actually landed.

Async Updates

The Discovery API is asynchronously updated from your published data and therefore eventually consistent:

  • Typical delay: under 1 second
  • Large imports may take longer to surface
  • A full re-index (igniteDiscoApi) takes minutes, not seconds, to propagate

For cases requiring exact current state, use the Catalogue API instead.

Shop API Order Queries Reference

The Shop API /order scope provides queries for retrieving orders. This is a separate endpoint from the /cart scope.

Base URL

https://shop-api.crystallize.com/{tenant-identifier}/order

Important: Order queries use the /order endpoint, NOT /cart.

Authentication

Requires JWT token with the order scope.

Authorization: Bearer YOUR_JWT_TOKEN

See the Shop API Queries Reference for token generation details.

Get a Single Order

Retrieve an order by its Shop API UUID.

query GetOrder($id: UUID!) {
order(id: $id) {
id
coreId
reference
type
paymentStatus
createdAt
updatedAt
additionalInformation
customer {
identifier
firstName
lastName
email
phone
companyName
type
addresses {
type
street
city
postalCode
country
}
}
items {
name
sku
productId
quantity
type
imageUrl
price {
gross
net
taxAmount
taxPercent
currency
}
subTotal {
gross
net
taxAmount
currency
}
}
total {
gross
net
taxAmount
taxPercent
currency
discounts {
amount
}
taxBreakdown {
taxRate
amount
}
}
payments {
provider
transactionId
amount
method
createdAt
}
pipelines {
identifier
stage
}
appliedPromotions {
identifier
name
}
meta
metaProperty(key: "fulfillment_status")
}
}

Variables:

{
"id": "order-uuid-here"
}

Get Orders by Customer

Retrieve orders for a specific customer with pagination.

query GetCustomerOrders($customerIdentifier: String!, $limit: Int, $skip: Int) {
orders(customerIdentifier: $customerIdentifier, limit: $limit, skip: $skip) {
id
coreId
type
paymentStatus
createdAt
total {
gross
net
currency
}
items {
name
sku
quantity
price {
gross
net
}
}
pipelines {
identifier
stage
}
}
}

Variables:

{
"customerIdentifier": "john@example.com",
"limit": 100,
"skip": 0
}

Scope this on the server. The JWT is per tenant, not per shopper: orders answers for whatever customerIdentifier it is given. Take the identifier from the session and never from the client. The same holds for subscriptionContracts(customerIdentifier:) on the /subscription-contract endpoint.

limit/skip paged inconsistently in the Tools Universe build: limit: 10 returned 5 orders and then 4 for a customer with 9. Ask for everything a customer has in one page (limit: 100) rather than walking pages.

For a company or a department there is no query by parent: read the people’s orders and filter on your own meta (the ordering person, the cost centre).

The Order Store and Core

Shop API orders and Core orders are the same orders in two stores, and they do not converge. Two build findings decide how a storefront should read them.

Orders edited in Core appear more than once here. Three updateOrder calls on one seeded order left three orders in orders(customerIdentifier:): new Shop ids, the same coreId, different updatedAt. A storefront that sums an order list then counts the same money several times over — the Lab Universe build saw a department budget of NOK 1,249,299 instead of 411,775. Two defences, use both:

  1. Group by coreId and keep the most recently updated copy whenever you read a list.
  2. Change orders through the Shop API (setMeta, addToStage) when a storefront reads them, so no copy is ever made.

The sync runs one way, Shop → Core. An order created with the Shop API gets its coreId in about ten seconds, and addToStage on the Shop API moves the Core stage too. Nothing comes back the other way: orders registered in Core with registerOrder showed up in the Shop store only sometimes, carrying the pipelines they had at creation time, and a later updateOrderPipelineStage or deleteOrder in Core never reached the Shop store at all (watched for minutes). So a storefront that lists orders with the Shop API must have them created there, and moved between stages there. Seed demo orders with the Shop API, not with registerOrder. (Tools Universe.)

Two smaller consequences:

  • pipelines comes back as identifiers only ({ identifier, stage }). A storefront that shows a stage name has to map the ids to names itself; the names live in the admin APIs, so resolve them at build or seed time rather than at runtime.
  • coreId can read null on an order created with Shop create even after it has reached Core. Don’t treat a null coreId as “not synced yet”.

Order Response Types

Order

Field Type Description
id UUID! Shop API order ID
coreId String Core API order ID (for PIM operations)
reference String Order reference number
type OrderType standard, draft, creditNote, etc.
additionalInformation String Free-text additional info
stockLocationIdentifier String Stock location identifier
relatedOrderIds [String] IDs of related orders
createdAt DateTime Creation timestamp
updatedAt DateTime Last update timestamp
context HashMap Order context (pricing, language)
customer Customer Customer details
items [Item] Order line items
total TotalPrice! Order totals
paymentStatus OrderPaymentStatus paid, unpaid, refunded, etc.
payments [Payment] Payment records
pipelines [Pipeline] Pipeline/workflow stage assignments
appliedPromotions [PromotionSlim!] Applied promotion details
meta HashMap All metadata as key-value map
metaProperty(key) String Single metadata value by key

Item (Order Line Item)

Field Type Description
name String! Item display name
sku String Product SKU
productId String Crystallize product ID
quantity PositiveInt Quantity ordered
group String Item grouping
type CartItemType standard, shipping, fee, etc.
imageUrl String Item image URL
price ItemPrice! Unit price
subTotal ItemPrice! Line total (price × quantity)
subscriptionContractId String Subscription contract reference
subscription Subscription Subscription details
meta HashMap Item-level metadata

ItemPrice / TotalPrice

Field Type Description
gross Float! Price including tax
net Float! Price excluding tax
taxAmount Float! Tax amount
taxPercent Float! Tax percentage
currency String! Currency code
discounts [Discount!] Applied discounts

TotalPrice also includes:

Field Type Description
taxBreakdown [TaxBreakdownEntry!] Tax by rate

Customer

Field Type Description
isGuest Boolean! Whether customer is guest
identifier String Unique customer ID
firstName String First name
lastName String Last name
middleName String Middle name
email String Email address
phone String Phone number
birthDate DateTime Date of birth
companyName String Company name
taxNumber String Tax/VAT number
type CustomerType individual or organization
externalReference String External reference
externalReferences HashMap Multiple external refs
addresses [Address] Customer addresses
meta HashMap Customer metadata

Payment

Field Type Description
provider String Payment provider (e.g., “stripe”)
transactionId String Transaction reference
amount Float Payment amount
method String Payment method (e.g., “card”)
createdAt String Payment timestamp
meta HashMap Payment metadata

Pipeline

Field Type Description
identifier String Pipeline identifier
stage String Current stage

Shop API Queries Reference

The Shop API handles cart and checkout operations at the edge for low-latency commerce experiences.

Base URL

https://shop-api.crystallize.com/{tenant-identifier}/cart

The endpoint also serves as a GraphQL playground for documentation.

Authentication

The Shop API requires a JWT token. Obtain tokens from:

POST https://shop-api.crystallize.com/{tenant-identifier}/auth/token

Getting a Token

Terminal window
curl -X POST 'https://shop-api.crystallize.com/YOUR_TENANT/auth/token' \
-H 'Accept: application/json' \
-H 'x-crystallize-access-token-id: YOUR_ACCESS_TOKEN_ID' \
-H 'x-crystallize-access-token-secret: YOUR_ACCESS_TOKEN_SECRET' \
-H 'Content-Type: application/json' \
-d '{"scopes":["cart","cart:admin","order"],"expiresIn":18000}'

Token Scopes

Scope Description
cart Manage a single cart
cart:admin Manage multiple carts
order Create and manage orders (/order endpoint)
usage Access usage/metrics API
lock Acquire/release locks

Using the Token

Include in request headers:

Authorization: Bearer YOUR_JWT_TOKEN

Cart Queries

Retrieve Cart

query {
cart(id: "cart-id-here") {
id
state
isStale
isExpired
createdAt
updatedAt
items {
sku
name
quantity
managed
origin
images {
url
}
price {
gross
net
taxAmount
}
}
total {
gross
net
taxAmount
}
}
}

Cart as Order Intent

Retrieve cart formatted for order creation:

query {
cartAsOrderIntent(id: "cart-id-here") {
customer {
firstName
lastName
email
}
cart {
sku
name
quantity
price {
gross
net
}
}
total {
gross
net
}
}
}

This format can be pushed directly to the Order API without transformation.

Cart Concepts

Hydration

The process where Shop API fetches and computes data on your behalf to construct or update the cart.

Cart Items

Two types of items can be in a cart:

  1. SKU Items - Items with a SKU that exist in Crystallize
  2. External Items - Items that don’t exist in Crystallize (e.g., shipping)

Managed vs Unmanaged Items

  • Managed (true): Shop API fetches info from Crystallize
  • Managed (false): You provide all item information

SKU items start as managed but become unmanaged if you override properties like pricing.

Expiration

  • You can set cart expiration time
  • Carts without updates for 3+ months are automatically deleted


Crystallize AI