Skip to content

mutation

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

Crystallize Mutation Skill

Create, update, and manage data in Crystallize using GraphQL mutations. This skill covers all write operations across the Core API (admin/content management) and Shop API (cart/checkout).

Consultation Approach

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

  1. What are you trying to create or update? Products, documents, folders, customers, orders?
  2. Do you have the tenant identifier and access tokens? Mutations require authentication.
  3. Where in the flow are you? Content/catalog management → Core API. Cart/checkout/orders → Shop API.
  4. Do you need to update individual fields or create items from scratch? updateComponent for fields, create for new items.
  5. Should changes be published immediately? Creating an item doesn’t publish it — that’s a separate step.
  6. Are you doing a one-off change or a bulk import? Single mutations vs mass operations.

Decision Tree

What do you need to do?
│
├─ Create/update catalogue items (products, documents, folders)
│ ├─ Create new item → Core API: createProduct / createDocument / createFolder
│ ├─ Update a field on an item → Core API: updateComponent
│ ├─ Add/update product variants → Core API: addProductVariant / updateProductVariant
│ ├─ Publish/unpublish → Core API: publishItem / unpublishItem
│ └─ Delete an item → Core API: deleteItem
│
├─ Manage customers
│ ├─ Create a person or a company → Core API: createCustomer (type: individual | organization)
│ └─ Update customer → Core API: updateCustomer(identifier:)
│
├─ Manage orders
│ ├─ Create order from cart → Shop API /order: createFromCart
│ ├─ Create order directly (POS, import) → Shop API /order: create
│ ├─ Add/update payments → Shop API /order: addPayments / setPayments
│ ├─ Track order through pipeline → Shop API /order: addToStage
│ └─ Update order metadata → Shop API /order: setMeta (not Core order.update — it duplicates)
│
├─ Cart & checkout (storefront)
│ ├─ Create/hydrate a cart → Shop API: hydrate
│ ├─ Modify cart items → Shop API: addItems / removeItems / setCartItem
│ ├─ Set customer & addresses → Shop API: setCustomer / setAddresses
│ └─ Convert cart to order → Shop API: cartAsOrderIntent
│
├─ Media (images, video)
│ ├─ Import from a supplier URL → Core API: copyRemoteAsset (images only)
│ ├─ Upload your own bytes, or any video → Core API: generatePresignedUploadRequest → registerImage
│ ├─ Alt text, topics, hotspots → Core API: updateImage (per language)
│ └─ Replace an image → upload a new one; registerImageRevision does not reach published items
│
└─ Bulk operations
└─ Use mass operation JSON via the content-model skill's output format

API Selection

Use Case API Why
Creating/editing items, shapes, customers Core API Full read/write, admin-level access
Cart management, checkout Shop API /cart Edge-distributed cart lifecycle
Order creation, payments, pipelines Shop API /order Full order CRUD after checkout
Bulk shape + item creation Core API via mass operations Ordered multi-step creation

Core is not a storefront API. It is heavily rate limited and not meant for storefront traffic: carts, orders, customers and subscription contracts belong on the Shop API, which is edge-distributed and syncs to Core asynchronously. Keep Core for the back office — imports, seeding, scheduled jobs and admin tools, behind your own server.

Write an order in one store. The sync runs one way, Shop → Core, and editing an order in Core adds another copy of it to the Shop store: three updateOrder calls left three Shop orders with the same coreId, so a storefront that sums an order list counts the money several times. If a storefront reads orders, create and change them on the Shop API (create, createFromCart, setMeta, addToStage). Details in Shop API Order Mutations.

API Endpoints & Authentication

API Endpoint Auth Required Use For
Core https://api.crystallize.com/@{tenant} Yes Items, shapes, customers
Shop /cart https://shop-api.crystallize.com/{tenant}/cart Yes (JWT) Cart management, checkout
Shop /order https://shop-api.crystallize.com/{tenant}/order Yes (JWT) Order CRUD, payments

Core API

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

Note the @ prefix before the tenant identifier.

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": "mutation { ... }"}'

Generate access tokens in the Crystallize App under Settings > Access Tokens. See the permissions skill for scoping tokens.

Shop API

POST https://shop-api.crystallize.com/{tenant-identifier}/cart
Authorization: Bearer YOUR_JWT_TOKEN

No @ prefix for the Shop API endpoint.

Common Workflow Patterns

Create a product end-to-end

  1. Create the product with shape and parent folder
  2. Set variants with SKU, pricing, stock, and images
  3. Update components (description, specs, media)
  4. Publish the item

See Core API Reference for each mutation.

Update content on an existing item

  1. Query the item to confirm its ID and current state (use the query skill)
  2. Call updateComponent for each field you need to change
  3. Publish if the item should go live immediately

Each updateComponent call targets a single component by componentId. You can update multiple components by sending multiple mutations.

Checkout flow (storefront)

  1. Hydrate a cart with product SKUs and quantities
  2. Add/remove items as the customer shops
  3. Set customer info and addresses
  4. Place the cart to lock it for payment
  5. Create the order from the placed cart

See Shop API Cart Mutations for steps 1-4, and Shop API Order Mutations for step 5.

Bulk import / mass operations

For creating many items at once, use the mass operations JSON format produced by the content-model skill. Mass operations follow a 4-phase ordering:

  1. Pieces (dependencies first)
  2. Shapes
  3. Topic maps
  4. Items

Error Handling

The Core API uses union return types. Always include error fragments in your mutations:

mutation {
product {
create(input: { ... }) {
... on Product {
id
name
}
... on BasicError {
errorName
message
}
}
}
}

Common error types: BasicError, UnauthorizedError, ItemNotFoundError, OrderDoesNotBelongToTenantError.

Using the JS API Client

For JavaScript/TypeScript projects, use @crystallize/js-api-client instead of raw HTTP calls. It provides typed helpers for all mutations. See the js-api-client skill for setup and usage.

import { createClient } from "@crystallize/js-api-client";
const api = createClient({
tenantIdentifier: "your-tenant",
accessTokenId: "...",
accessTokenSecret: "...",
});
// Core API mutations (api.crystallize.com/@tenant)
const result = await api.nextPimApi(mutationString, variables);
// Shop API mutations: one caller per endpoint, the Shop API token is fetched for you
await api.shopCartApi(cartMutation, variables); // /cart
await api.shopOrderApi(orderMutation, variables); // /order (js-api-client 7.5+)

For checkout, prefer the helpers: createCartManager (hydrate, place) and createShopOrderManager (createFromCart, addPayments, setPayments, addToStage).

Output Format

When generating mutations for the user, produce:

  1. GraphQL mutations with clear variable placeholders (e.g., "your-tenant-id", "item-id")
  2. Variable definitions when the mutation uses GraphQL variables
  3. Expected response shape so the user knows what to look for

If the user is working in a JS/TS project, prefer generating code using @crystallize/js-api-client helpers.

References

  • Core API Mutations - Item CRUD, variants, components, customers, publish/unpublish, delete, media uploads
  • Media & Images - Importing and uploading images and video, renditions, showcases, replacing and deleting
  • Shop API Cart Mutations - Cart hydration, item management, checkout flow, cart lifecycle
  • Shop API Order Mutations - Order creation (from cart or direct), payments, pipelines, metadata

[[query]] covers reads across the same APIs. For bulk writes that would otherwise hit rate limits, use [[mass-operations]]. Vector ranking has its own Core API mutations — upsertVocabulary, setItemTaste and igniteDiscoApi — documented in [[vector-ranking]].


Reference Details

Core API Mutations Reference

The Core API provides full read/write access to items, shapes, customers, orders, and configuration.

See SKILL.md for endpoint URLs and authentication headers.

Two things decide the shape of every call on this page:

  • Mutations are top level. createProduct, publishItem, updateComponent — there is no product { … } or item { … } wrapper. That wrapper belongs to the legacy PIM API, which is a different endpoint with a different schema.
  • The tenant is in the URL (https://api.crystallize.com/@<tenant>/core), so no input takes a tenantId. The PIM API does, which is the quickest way to tell a Core example from a PIM one.

Table of Contents


Reading a result

Every mutation returns a union: the thing you asked for, or one of several error types. The error members all implement BasicError, so one fragment catches every failure and errorName identifies it:

mutation PublishItem($id: ID!, $language: String!) {
publishItem(id: $id, language: $language) {
__typename
... on PublishInfo {
id
versionId
}
... on BasicError {
errorName
message
}
}
}

Three error members are on almost every union: UnauthorizedError (the token lacks the permission), UnknownError, and ExperimentalFeaturesNotAvailableError (the feature is not enabled for the tenant). Select __typename when you want to branch on the outcome in code.

The success member is often not the item. Reaching for ... on Item is the most common mistake here:

Mutation Success member
createProduct / updateProduct Product
createDocument / createFolder Document / Folder
publishItem / unpublishItem PublishInfo
deleteItem / deleteCustomer DeleteCount { removed }
updateComponent UpdatedComponent
removeComponent ItemComponentRemoved
addProductVariant / updateProductVariant ProductVariant
modifyProductVariantStock ProductStockLocation
modifyProductVariantPrice ProductPriceVariant
addItemsToFlowStage FlowContentList

The examples below keep the BasicError fragment where a call is easy to get wrong, and leave it out where it would only repeat itself. Add it everywhere in real code.


Item Mutations

language is an argument, not an input field, on every create and update.

Create Product

variants and vatTypeId are required — a product cannot exist without at least one SKU and a VAT type. Read the VAT types from the PIM API (see below) and keep the id in your config.

mutation CreateProduct($input: CreateProductInput!, $language: String!) {
createProduct(input: $input, language: $language) {
__typename
... on Product {
id
name
}
... on BasicError {
errorName
message
}
}
}
{
"language": "en",
"input": {
"shapeIdentifier": "sneaker",
"name": "Air Max 2024",
"vatTypeId": "<vat type id>",
"tree": { "parentId": "<folder id>" },
"variants": [{ "sku": "air-max-2024-42", "name": "Size 42", "isDefault": true, "price": 129.99 }]
}
}

tree is { parentId, position }. Leave it out and the item is created without a place in the tree; add one later with createItemTreeNode(input: { itemId, parentId, position }).

Send every required component on create. Creation validates the shape, so a required relation or a numeric with a unit list fails with ComponentContentValidationFailedError unless its content is in components. Create-then-fill does not work.

Create Document

mutation CreateDocument($input: CreateDocumentInput!, $language: String!) {
createDocument(input: $input, language: $language) {
... on Document {
id
name
}
... on BasicError {
errorName
message
}
}
}
{
"language": "en",
"input": {
"shapeIdentifier": "blog-post",
"name": "Welcome to Our Store",
"tree": { "parentId": "<folder id>" }
}
}

Create Folder

mutation CreateFolder($input: CreateFolderInput!, $language: String!) {
createFolder(input: $input, language: $language) {
... on Folder {
id
name
}
... on BasicError {
errorName
message
}
}
}
{
"language": "en",
"input": {
"shapeIdentifier": "category",
"name": "Summer Collection",
"tree": { "parentId": "<shop folder id>" }
}
}

Publish Item

Publishing makes the item visible on the storefront. Creating an item does NOT publish it.

mutation PublishItem {
publishItem(id: "<item id>", language: "en", includeDescendants: false) {
... on PublishInfo {
id
versionId
}
... on BasicError {
errorName
message
}
}
}

includeDescendants: true publishes the children too, which is what you want for a folder. disableComponentValidation: true publishes an item whose shape validation would otherwise block it — an empty numeric piece is the usual reason.

publishItems(ids: [ID!]!, language: String!) exists for batches, but it answers with a PublishItemsRequest whose return is not proof that the items are published yet. Prefer publishItem per item when the next step depends on the published version.

Unpublish Item

mutation UnpublishItem {
unpublishItem(id: "<item id>", language: "en", includeDescendants: false) {
... on PublishInfo {
id
}
... on BasicError {
errorName
message
}
}
}

Delete Item

mutation DeleteItem {
deleteItem(itemId: "<item id>") {
... on DeleteCount {
removed
}
... on BasicError {
errorName
message
}
}
}

The argument is itemId, and the answer is a count, not the item. Deleting a folder with children fails until the children are moved or deleted.

Move Item in Tree

mutation MoveItem {
moveItemTreeNode(itemId: "<item id>", language: "en", input: { parentId: "<new parent id>", position: 1 }) {
itemId
parentId
path
position
}
}

moveItemTreeNode returns an ItemTreeNode directly — it is one of the few mutations that is not a union. position is a PositiveInt, so it counts from 1.


What a partial write replaces

The item mutations are not patches. Sending a component list means “these are the components now”, and the calls that look narrow have the widest blast radius. Every row below cost a build a round of re-imports.

Call What it does to everything you did not send
updateProduct / updateDocument with components Replaces the whole component set for that language
updateProduct / updateDocument with only name Leaves components alone — the safe way to rename
updateProductVariant without components Empties that variant’s components in that language
updateComponent(itemId, language, component) Touches that one component only — use it for partial edits
product/upsert and friends in a mass operation Replace all components, so send every one, not only the changed one
setItemTaste Empties every variant’s components on the draft (see below)

Translating is where this bites. Writing two fields in a second language with updateProduct(language: "no", input: { components: [tagline, summary] }) fails with “Need to provide at least 1 related items for component brand” — and had it passed, it would have dropped everything not sent. Translate with updateComponent per component, and rename with update*(input: { name }). Shared (non-multilingual) components then keep showing in every language, untouched.

setItemTaste has a side effect on variants. After writing taste, the draft’s variant components were gone in every language while product components survived, and the publish that follows taste then published them empty. Order the pipeline: taste first, then (re)write variant components, then publish. (Tools Universe. The same build first blamed a shape update and re-ran it, which changed nothing.)

A variant’s content chunks may not publish at all. On the same tenant a variant chunk sat in the draft, but after publishItem the current version — and Discovery — had chunks: [], while a singleLine on the same variant and a numeric on another shape’s variants published fine. If a variant chunk disappears on publish, move that data to the product (one row per variant, with the SKU in it).

Creating an item validates its required components, so create is not “create then fill”: a document whose relation has minItems: 1, or a numeric with a unit list, fails createDocument with ComponentContentValidationFailedError unless those components are in the create input. Send everything on create.


Component Updates

updateComponent changes one component on one item, in one language. It is the call to reach for when you are editing rather than rebuilding: everything else that takes components replaces the whole set.

mutation UpdateComponent($itemId: ID!, $language: String!, $component: ComponentInput!) {
updateComponent(itemId: $itemId, language: $language, component: $component) {
__typename
... on UpdatedComponent {
updatedComponentPath
item {
id
}
}
... on BasicError {
errorName
message
}
}
}

Arguments worth knowing:

Argument Meaning
itemId The item to edit
sku Edit a variant’s component instead — pass the SKU rather than itemId
language Required; components are per language unless the shape says otherwise
disableContentValidation Write content the shape would otherwise reject

ComponentInput is { componentId, <one content key> }. The content key names the component type, and the examples below differ only in that key. removeComponent(itemId:, language:, componentId:) clears one.

Rich Text

html and json are lists — one entry per block.

{ "componentId": "description", "richText": { "html": ["<p>New product description</p>"] } }

Single Line

{ "componentId": "tagline", "singleLine": { "text": "Premium quality materials" } }

Numeric

number is required, so a numeric cannot carry a unit without a value.

{ "componentId": "weight", "numeric": { "number": 1.5, "unit": "kg" } }

Boolean (Switch)

{ "componentId": "featured", "boolean": { "value": true } }

Images Component

images is a list of ImageInput, and key is the only required field. The key comes from the media library — see Media & Images.

{ "componentId": "gallery", "images": [{ "key": "<image key>", "altText": "Product front view" }] }

Selection

{ "componentId": "color", "selection": { "keys": ["red"] } }

Colors

{
"componentId": "brand-color",
"colors": { "colors": [{ "label": "Midnight Blue", "hex": "#191970", "rgb": { "r": 25, "g": 25, "b": 112 } }] }
}

The content input is colors: { colors: [GraphqlInputColorEntry!] } — note the doubled key: the component input field is colors, and it wraps a list also called colors.

Each entry carries any combination of hex, rgb { r g b a }, hsl { h s l a }, cmyk { c m y k }, pantone, ral and label. They are notations of the same colour, not separate colours — send as many as the item actually has, and write the whole list every time, since the list replaces rather than merges. colors is also valid inside NestableComponentInput, so it works within chunks, choices and pieces.

See the [[content-model]] skill for when to use Colors rather than a Selection.

Item Relations

Relate by item, by SKU, or both.

{ "componentId": "related-products", "itemRelations": { "itemIds": ["<item id>"], "skus": ["<sku>"] } }

Content Chunk (repeatable)

chunks is a list of lists: one inner list per repetition, holding that repetition’s components.

{
"componentId": "specifications",
"contentChunk": {
"chunks": [
[
{ "componentId": "label", "singleLine": { "text": "Weight" } },
{ "componentId": "value", "singleLine": { "text": "1.5 kg" } }
],
[
{ "componentId": "label", "singleLine": { "text": "Dimensions" } },
{ "componentId": "value", "singleLine": { "text": "30x20x10 cm" } }
]
]
}
}

The other content keys on ComponentInput follow the same pattern: datetime, files, gridRelations, location, paragraphCollection, piece, propertiesTable, videos, and the structural componentChoice / componentMultipleChoice, which nest a NestableComponentInput.


Product Variants

Variants are purchasable SKUs on a product. There is no setVariants on Core: variants are added, updated and deleted one at a time, and stock and price have their own mutations.

Task Mutation
Add a variant addProductVariant(productId:, language:, input: CreateProductVariantInput!)
Change one variant updateProductVariant(sku:, language:, input: UpdateSingleProductVariantInput!)
Delete one deleteProductVariant(sku:) — refuses the default with CannotDeleteDefaultVariantError
Replace the whole set updateProduct(id:, language:, input: { variants: [...] })
Stock modifyProductVariantStock(sku:, stockLocationIdentifier:, operation:, quantity:)
Price modifyProductVariantPrice(sku:, priceVariantIdentifier:, price:, tiers:, tierType:)

Add a Variant

mutation AddVariant($productId: String!, $language: String!, $input: CreateProductVariantInput!) {
addProductVariant(productId: $productId, language: $language, input: $input) {
... on ProductVariant {
sku
name
}
... on BasicError {
errorName
message
}
}
}
{
"productId": "<product id>",
"language": "en",
"input": {
"sku": "sneaker-red-42",
"name": "Red - Size 42",
"isDefault": false,
"priceVariants": [{ "identifier": "default", "price": 129.99 }],
"attributes": [
{ "attribute": "color", "value": "Red" },
{ "attribute": "size", "value": "42" }
],
"images": [{ "key": "<image key>", "altText": "Red sneaker size 42" }]
}
}

price sets the default price variant; priceVariants: [{ identifier, price }] sets any of them, which is what you want on a multi-currency tenant. See [[pricing]].

Update Stock

mutation SetStock {
modifyProductVariantStock(
sku: "sneaker-red-42"
stockLocationIdentifier: "oslo"
operation: overwrite
quantity: 50
) {
... on ProductStockLocation {
identifier
stock
}
... on BasicError {
errorName
message
}
}
}

operation is increase, decrease or overwrite, so a delta needs no read first. The stock location must exist — create it in the PIM API.

Variant Attributes

Attributes define the variant matrix (e.g. colour + size) and appear as filterable properties in the storefront. Use consistent attribute names across products so the filters line up.


Customer Mutations

One mutation creates both kinds of customer: type is individual or organization. There is no createIndividual or createOrganization on Core.

Create a Customer

mutation CreateCustomer($input: CreateCustomerInput!) {
createCustomer(input: $input) {
__typename
... on Customer {
identifier
type
}
... on BasicError {
errorName
message
}
}
}
{
"input": {
"identifier": "jane@example.com",
"type": "individual",
"firstName": "Jane",
"lastName": "Smith",
"email": "jane@example.com",
"phone": "+1234567890",
"addresses": [
{
"type": "delivery",
"street": "123 Main St",
"city": "New York",
"postalCode": "10001",
"country": "US"
}
]
}
}

identifier is the only required field and it is the customer’s key everywhere else — carts, orders, subscription contracts and customer-targeted price lists all name it. An address type is the enum billing, delivery or other, not a string. A company uses type: organization with companyName and taxNumber.

Customer Hierarchies

parents links a customer to a company or a group:

{
"input": {
"identifier": "buyer@acme.example.com",
"type": "individual",
"parents": [{ "identifier": "acme", "type": "customer" }]
}
}

type on a parent is customer or customerGroup, and a customer can have more than one — too many answers TooManyCustomerParentsProvidedError. This is how a B2B contact belongs to its company, which in turn decides whose orders it sees and which contract prices apply — see [[pricing]].

Update and Delete

mutation UpdateCustomer {
updateCustomer(identifier: "jane@example.com", input: { lastName: "Doe" }) {
... on Customer {
identifier
}
... on BasicError {
errorName
message
}
}
}

Both updateCustomer and deleteCustomer take the identifier, not an id. deleteCustomer(identifier:, deleteSubscriptionContracts: true) removes the customer and its contracts in one call; orders are deleted separately. As with items, a list you send replaces the stored one — sending meta or addresses drops whatever you left out.


Order Mutations

If a storefront lists these orders, change them on the Shop API instead. Every updateOrder in Core adds another copy of the order to the Shop store: three updates on one order left three Shop orders with new ids, the same coreId and different updatedAt, and a storefront summing that list counted the money three times. updateOrderPipelineStage and deleteOrder in Core, by contrast, never reach the Shop store at all. Use Shop /order setMeta and addToStage for anything a storefront reads, and keep Core order writes for back-office work on orders nobody lists from the edge. See Shop API Order Mutations.

Update Order

mutation UpdateOrder($id: ID!, $input: UpdateOrderInput!) {
updateOrder(id: $id, input: $input) {
__typename
... on Order {
id
updatedAt
}
... on BasicError {
errorName
message
}
}
}
{ "id": "<order id>", "input": { "meta": [{ "key": "tracking_number", "value": "1Z999AA10123456784" }] } }

UpdateOrderInput also carries cart, customer, payment, paymentStatus, total, additionalInformation, relatedOrderIds and stockLocationIdentifier. For a single key, updateOrderMetadata(id:, key:, value:) is narrower and does not touch the rest; deleteOrderMetadata(id:, key:) removes one.

registerOrder(input: RegisterOrderInput!) writes an order that did not come from a cart — a renewal invoice, a POS sale, an import. Before using it, read what it does to an order a storefront lists, in shop-api-order-mutations.md.


Media & Images

Media is registered in the tenant’s library first, then referenced by key in components or on variants. Six mutations cover it:

Mutation Notes
copyRemoteAsset(sourceUrl:, type: image, targetFilename:, requestHeaders:) Crystallize fetches the URL. A bulk task: it returns targetKey before the file lands, and it registers the image for you
generatePresignedUploadRequest(filename:, contentType:, type: MEDIA) A target to POST your own bytes to — the only path for video
registerImage(imageKey:) Registers an uploaded key. Not needed after copyRemoteAsset
registerImageRevision(imageKey:, revisionKey:) Repoints the library entry — it does not reach published items
updateImage(key:, language:, input:) altText, caption, focalPoint, meta, topicIds, showcase — per language
deleteImage(key:, force:) Refuses while any item version references it; force: true overrides

An import that follows these six in the obvious order still breaks in four different ways — keys whose file never arrives, renditions that are not ready, revisions that never reach the storefront, and deletes that refuse. See Media & Images for the working pipeline, what to verify after an import, and how to replace an image later.


Flow Mutations

Flows model item workflows (e.g. Draft > Review > Published). Items are added to a stage and removed from it; there is no setFlowStage on Core.

mutation AddToStage($items: [ItemFlowStageAssociationInput!]!) {
addItemsToFlowStage(stageIdentifier: "review", items: $items) {
__typename
... on FlowContentList {
content {
id
}
}
... on BasicError {
errorName
message
}
}
}
{ "items": [{ "id": "<item id>", "language": "en", "version": "draft" }] }

An item is named by { id, language, version }, where version is current, draft or published. moveFromFlowIdentifier moves items out of another flow in the same call, and deleteItemsFromFlowStage(stageIdentifier:, items:) takes them out again. Stage and flow identifiers are the ones you gave createFlow / createFlowStage.


Vector Ranking Mutations

Discovery’s vector ranking is authored entirely on the Core API. Four calls, in this order:

Mutation Notes
upsertVocabulary(input: UpsertVocabularyInput!) Full replace, not a patch — omitted dimensions are dropped
setItemTaste(input: SetItemTasteInput!) One item, one language, one vocabulary. Writes the draft
publishItem(id: ID!, language: String!) The indexer reads the published version — skipping this fails silently. See the note on publishItems below
igniteDiscoApi(stacks: opensearch) Async; poll bulkTask(id:) until complete, then allow propagation. stacks: opensearch is required for vectors to be built
mutation UpsertVocabulary($input: UpsertVocabularyInput!) {
upsertVocabulary(input: $input) {
name
dimensions {
id
weight
}
lastUpdated
}
}
mutation SetItemTaste($input: SetItemTasteInput!) {
setItemTaste(input: $input) {
__typename
... on Product {
id
}
... on BasicError {
errorName
message
}
}
}
mutation Index {
igniteDiscoApi(stacks: opensearch) {
__typename
... on BulkTaskIgnition {
id
type
status
createdAt
}
... on BasicError {
errorName
message
}
}
}

All three results are unions whose error members implement BasicError, so a single fragment covers every failure and errorName identifies it. setItemTaste and igniteDiscoApi can both return ExperimentalFeaturesNotAvailableError, which means vectors are not enabled for the tenant.

Read back with vocabulary(name:) and item(id:, language:) { taste { vocabulary entries { key weight } } }.

Prefer publishItem per item over publishItems here. publishItem returns the published version (PublishInfo) or an error for that item; publishItems returns a PublishItemsRequest, and its return is not proof that the items are published yet — index right after it and the index may read the old versions.

Re-run igniteDiscoApi after every change to vocabularies or taste entries — an unindexed change has no effect and raises no error. Omitting stacks: opensearch likewise fails silently: the index rebuilds, but without vectors. Full guidance, including vocabulary design, positional weights and key validation, is in the [[vector-ranking]] skill.

Only in the legacy PIM API

Some tenant configuration has no Core equivalent at all — it is neither readable nor writable there. For these, use https://pim.crystallize.com/graphql, which is namespaced (subscriptionPlan { create(...) }) and takes the tenant id as an argument rather than @tenant in the URL:

Concept Why you need it
Subscription plans, periods, meters The template a subscription contract points at — see [[subscriptions]]
Order pipelines and their stages Creating them; Core can move an order between existing stages
Stock locations modifyProductVariantStock needs one to exist
VAT types createProduct requires a vatTypeId
Markets Targeting price lists at a market — see [[pricing]]
Tenant preferences (setPreferences) Registering a custom admin view through input: { frontends }

Price variants are not in this list: createPriceVariant, updatePriceVariant, deletePriceVariant and the priceVariant / priceVariants queries are all on Core.

Anything created in the PIM API is read back through its own generated ids — plan period ids in particular — so capture them when you create them rather than re-deriving them later.

Error Handling

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

mutation UpdateComponent($itemId: ID!, $language: String!, $component: ComponentInput!) {
updateComponent(itemId: $itemId, language: $language, component: $component) {
__typename
... on UpdatedComponent {
updatedComponentPath
}
... on ComponentContentValidationFailedError {
errors {
componentId
message
}
}
... on BasicError {
errorName
message
}
}
}

A specific member first, then BasicError as the catch-all, is the pattern to copy: errorName tells you which one you actually got.

Common error types:

Error Cause
UnauthorizedError Missing or insufficient access token permissions
UnknownError Unclassified failure
ExperimentalFeaturesNotAvailableError The feature is not enabled for this tenant
ItemNotFoundError Item ID doesn’t exist
ItemDoesNotBelongToTenantError Item ID belongs to a different tenant
ComponentContentValidationFailedError Content does not match the shape; carries per-component errors
ProductVariantNotFoundError No variant with that SKU
OrderNotFoundError Order ID doesn’t exist

Images, video and media (Core API)

You have a list of image URLs from a supplier, or files on disk, and they need to end up on products. There are two ways in, and the one you pick decides what can go wrong afterwards.

Signatures below were read from the live Core API schema on 2026-09-29. The behaviours — what arrives late, what fails silently — are attributed to the builds that hit them.

Rendering what you have imported is the other half, and it lives in [[responsive-images]].

You have Use
A URL Crystallize can fetch copyRemoteAsset — Crystallize pulls it, as a bulk task
Bytes you hold, or a URL it cannot fetch generatePresignedUploadRequest → form POST → registerImage
Video, whatever the source The upload path — copyRemoteAsset takes images only

Copying from a supplier URL

mutation Copy($sourceUrl: String!, $filename: String) {
copyRemoteAsset(sourceUrl: $sourceUrl, type: image, targetFilename: $filename) {
__typename
... on BulkTaskCopyRemoteAsset {
id
targetKey
status
}
... on BasicError {
errorName
message
}
}
}

type is RemoteAssetType, and today its only value is image. requestHeaders: [{ name, value }] is there for sources that will not serve a bare request.

It is a bulk task, so the key comes back before the file does. targetKey is handed to you immediately; the copy happens afterwards. Three consequences, all found the hard way:

  • Do not call registerImage afterwards. copyRemoteAsset already registers the image, and the second call fails. (Tools Universe.)
  • updateImage on a fresh key answers ImageNotFoundError for a while — WebP sources seemed slowest. Retry a “not found” after a delay, or wait for every copy task rather than the last few you happen to hold (bulkTask(id:) until it leaves pending/started). (Tools Universe.)
  • A key can be registered whose file never arrives, with no error anywhere. Two builds, two suppliers: 91 WebP sources (ZF, Brembo) got a key and a library entry while the file behind it 404s (Car Parts Universe), and every image from one fashion brand did the same because that CDN serves a normal browser but not Crystallize’s fetcher (Fashion Universe). The library shows broken tiles, items show broken images, and nothing reports it.

So after the copy tasks finish, HEAD-check the keys — all of them if the import is small, a sample if it is not. For the ones that 404: try requestHeaders first if you suspect the CDN is refusing the fetch, and otherwise download the file yourself, convert it if it needs converting (both builds went to JPEG), and take the upload path below.

WebP sources do work — some manufacturers publish nothing else — and PNG transparency is preserved. (Tools Universe.)

Uploading bytes yourself

Three steps: ask for a presigned target, POST the file to it, register what landed.

mutation Upload($filename: String!, $contentType: String!) {
generatePresignedUploadRequest(filename: $filename, contentType: $contentType, type: MEDIA) {
__typename
... on PresignedUploadRequest {
url
fields {
name
value
}
maxSize
lifetime
}
... on BasicError {
errorName
message
}
}
}

type is FileUploadType: MEDIA for images and video, STATIC for files, MASS_OPERATIONS for an operations file. Build a multipart form from fields in the order given, append the file last, and POST it to url — lifetime is how long that target stays valid and maxSize caps the upload. Then:

mutation Register($imageKey: String!) {
registerImage(imageKey: $imageKey) {
__typename
... on Image {
key
url
width
height
mimeType
}
... on BasicError {
errorName
message
}
}
}

Unlike the copy path, this one does need registerImage.

Video

The upload path is the only way in for video, since copyRemoteAsset is images only: download the file, generatePresignedUploadRequest(contentType: "video/mp4" | "video/quicktime", type: MEDIA), POST the form, then write { key, title } into a videos component or a variant’s videos. There is no registerVideo step — the video is registered on first use, and transcoded to HLS and DASH (playlists) within about a minute. MP4 and MOV upload as they are; an HLS-only source has to be remuxed first (ffmpeg -i x.m3u8 -c copy). A videos component’s max is enforced on create (Cannot provide more than 2 videos for component hero-video). (Fashion Universe.)

Renditions arrive later, and the storefront has to cope

Image.variants is the generated ladder that [[responsive-images]] builds a srcset from, and it is produced in a queue. After roughly 4,600 copies in one import, image(key) answered width: null, variants: null for the later ones for a long while, and Discovery indexed those items with variants: []. (Fashion Universe.)

Two things follow: a storefront must fall back to the original url when variants is empty, and the items need publishing and re-indexing again once the renditions exist, or Discovery keeps serving the empty list it indexed.

Replacing an image

registerImageRevision(imageKey:, revisionKey:) points the library entry at a new file — Core image(key) returns the new url and size, and the ladder rebuilds in about 25 seconds.

It does not reach items that are already published. Discovery kept serving the original file and its renditions for every item using the image, even after rewriting the image component and republishing. (Tools Universe, replacing 98 letterboxed manufacturer photos.)

So to replace an image in practice: upload the new file as a new image (generatePresignedUploadRequest → POST → registerImage) and write the new key onto the items. Treat registerImageRevision as a library-level correction, not a way to change what shoppers see.

Metadata, topics and showcases

mutation Annotate($key: String!, $language: String!, $input: UpdateImageInput!) {
updateImage(key: $key, language: $language, input: $input) {
__typename
... on Image {
key
}
... on BasicError {
errorName
message
}
}
}

UpdateImageInput carries altText, caption, focalPoint, meta, topicIds and showcase. Note the required language: an image’s topics and alt text are per language, so tagging an image in one language leaves the others untagged.

Showcases are hotspots on the image — the whole “shop the look” feature, and in no skill until now. Core takes showcase: [{ hotspot: { x, y }, itemIds, skus, meta }], several per image, on registerImage’s image input and on updateImage. Discovery exposes them on the image as showcases — plural, type Showcase, with hotspot as a Hash { x, y } plus items (the related documents), variants and meta. Verified against a live tenant; the naming difference between input (showcase) and output (showcases) is easy to trip over. (Fashion Universe.)

Deleting

mutation Delete($key: String!) {
deleteImage(key: $key, force: true) {
__typename
... on DeleteCount {
removed
}
... on AssetInUseError {
key
referrerCount
}
}
}

deleteImage refuses an image that any item version references, including old ones — AssetInUseError … referenced in 3 places came back for an image every current draft and published version had already moved away from. force: true removes it anyway. Check the current versions yourself before forcing. (Car Parts Universe.)

Failure modes

Symptom Cause Fix
Broken tiles in the library, broken images on items copyRemoteAsset gave a key but the copy never landed HEAD-check the keys; re-import those with requestHeaders, or upload
ImageNotFoundError from updateImage right after a copy The key exists before the file does Wait for every copy task, and retry “not found”
registerImage fails on a copied key copyRemoteAsset already registered it Don’t register a copied image
variants: [] in Discovery, width: null in Core Renditions are still queued Fall back to url; publish and re-index once they exist
A replaced image still shows the old file on the storefront registerImageRevision does not reach published items Upload a new image and write its key onto the items
AssetInUseError on an image nothing current uses Old item versions still reference it deleteImage(force: true) after checking current versions
copyRemoteAsset rejects a video URL RemoteAssetType is image only Use the presigned upload path

Shop API Cart Mutations Reference

The Shop API /cart scope provides edge-distributed mutations for cart management and checkout flows.

See SKILL.md for endpoint URLs and authentication headers. This file covers the /cart endpoint only. For order creation and management, use the /order endpoint.

Cart State Machine

Carts follow a state machine that controls what operations are allowed:

cart → placed → ordered
↘
abandoned
State Description Mutable?
cart Active cart, items and prices can be changed Yes
placed Locked for payment — no modifications allowed No
ordered Linked to an order via orderId No
abandoned Explicitly abandoned (e.g., user left checkout) No

Query the cart state with:

query {
cart(id: "cart-id") {
id
state # cart | placed | ordered | abandoned
isStale # true if prices may have changed since last hydration
isExpired # true if cart has expired
orderId # set when an order is created from the cart
}
}

Cart Hydration

The primary mutation for creating and updating carts. Hydration:

  1. Takes SKUs and external items as input
  2. Fetches product data from Catalogue API
  3. Calculates prices, taxes, and totals
  4. Returns a fully constructed cart

Basic Hydration

mutation {
hydrate(
input: { items: [{ sku: "robot-pink-standard", quantity: 1 }, { sku: "robot-red-standard", quantity: 3 }] }
) {
id
state
isStale
isExpired
items {
sku
name
quantity
price {
gross
net
}
}
total {
gross
net
}
}
}

Hydration with Customer and Context

The hydrate input accepts customer info inline — this is the recommended way to associate a customer during checkout. It also accepts item type and group for categorizing line items.

mutation {
hydrate(
input: {
customer: { identifier: "john@example.com", isGuest: false, firstName: "John", lastName: "Doe" }
context: {
language: "en"
price: {
pricesHaveTaxesIncludedInCrystallize: true
decimals: 4
currency: "EUR"
selectedVariantIdentifier: "sales"
compareAtVariantIdentifier: "default"
fallbackVariantIdentifiers: ["default"]
}
}
items: [
{ sku: "palissade-lounge-sofa-iron-red", quantity: 1, type: standard, group: "Outdoor" }
{ sku: "palissade-bar-stool-sky-grey", quantity: 1, type: standard, group: "Outdoor" }
{ sku: "monstera-deliciosa-medium", quantity: 2, type: standard, group: "Plants" }
]
}
) {
id
state
isStale
customer {
identifier
firstName
lastName
}
appliedPromotions {
identifier
name
mechanism {
type
value
}
}
items {
name
variant {
sku
price {
gross
net
taxAmount
taxPercent
}
compareAtPrice {
gross
net
}
}
price {
net
gross
taxAmount
discounts {
percent
amount
}
}
}
total {
net
gross
discounts {
percent
amount
}
}
}
}

CartInput Fields

Field Type Description
id UUID Existing cart ID (omit to create new cart)
items [CartSkuItemInput] SKU items to add to the cart
externalItems [CartItemInputType] External items (shipping, fees)
customer CustomerInput Customer info (inline with hydration)
context ContextInput Language and pricing context
type CartType cart or wishlist
name String Optional cart name (mainly for wishlists)
meta [KeyValueInput] Arbitrary key-value metadata

CartSkuItemInput Fields

Field Type Required Description
sku String! Yes Product variant SKU
quantity PositiveInt No Quantity (default: 1)
type CartItemType No standard, subscription, shipping, fee, promotion, refund, service, digital, bonus, tax
group String No Free-form group label (e.g., “Outdoor”, “Plants”)
taxRate Rate No Override tax rate for this item
meta [KeyValueInput] No Item-level metadata

Context Options

Field Description
language Locale for product names (default: “en”)
selectedVariantIdentifier Price variant to use as active price
compareAtVariantIdentifier Price variant for discount comparison
decimals Decimal precision (default: 0, recommended: 4)
pricesHaveTaxesIncludedInCrystallize Tax handling: true for B2C, false for B2B
taxRate Override tax rate
markets Array of markets for price lists
currency Display currency (must match price variant config)
customerGroup Customer group for pricing
fallbackVariantIdentifiers Fallback price variants if selected is missing
voucherCode Apply voucher code

Hydration with External Items

For items not in Crystallize (shipping, fees):

mutation {
hydrate(
input: {
items: [{ sku: "product-sku", quantity: 1 }]
externalItems: [
{
sku: "shipping-fedex"
quantity: 1
name: "FedEx Ground Shipping"
images: []
variant: {
price: { gross: 12.99, net: 10.39 }
product: { id: "shipping-product", path: "/shipping" }
}
}
]
}
) {
id
items {
sku
name
origin
}
}
}

Cart Item Management

Add SKU Item

Add a single SKU item to an existing cart. If the item already exists and is managed, only the quantity is updated.

mutation {
addSkuItem(id: "cart-id", input: { sku: "new-product-sku", quantity: 2, type: standard, group: "Furniture" }) {
id
items {
name
variant {
sku
}
quantity
}
}
}

Add External Item

Add a fully custom item not in Crystallize (e.g., shipping, service fees).

mutation {
addExternalItem(
id: "cart-id"
input: {
sku: "shipping-standard"
name: "Standard Shipping"
quantity: 1
price: { gross: 9.99, net: 7.99 }
type: shipping
}
) {
id
items {
name
variant {
sku
}
price {
gross
net
}
}
}
}

Remove Items

mutation {
removeCartItem(id: "cart-id", sku: "product-to-remove") {
id
items {
variant {
sku
}
quantity
}
}
}

Update Item Quantity

mutation {
setCartItem(id: "cart-id", sku: "product-sku", quantity: 5) {
id
items {
sku
quantity
}
}
}

Change Item Pricing

Override managed pricing (makes item unmanaged):

mutation {
changeCartItemPricing(id: "cart-id", sku: "product-sku", price: { gross: 49.99, net: 39.99 }) {
id
items {
sku
managed
price {
gross
net
}
}
}
}

Customer Information

Set Customer on Cart

mutation {
setCustomer(
id: "cart-id"
customer: { firstName: "John", lastName: "Doe", email: "john@example.com", phone: "+1234567890" }
) {
id
customer {
firstName
lastName
email
}
}
}

Set Addresses

mutation {
setAddresses(
id: "cart-id"
billing: { street: "123 Main St", city: "New York", postalCode: "10001", country: "US" }
delivery: { street: "456 Oak Ave", city: "Brooklyn", postalCode: "11201", country: "US" }
) {
id
}
}

Cart to Order Intent

Get cart formatted for order creation:

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

Cart Lifecycle

Place Cart

Placing a cart locks it and makes it immutable. This should happen when the user enters checkout (e.g., before collecting payment). After placing, items, quantities, and prices cannot be changed.

mutation {
place(id: "cart-id") {
id
state # now "placed"
items {
name
quantity
}
total {
gross
net
currency
}
}
}

Back navigation: If the user navigates back after the cart is placed, create a new cart by calling hydrate without an id. The placed cart cannot be modified.

Fulfill Cart

Not needed after createFromCart. createFromCart on the /order endpoint already moves the cart to ordered and sets orderId, and the order id is the cart id. Use fulfill to link a cart to an order created some other way:

mutation {
fulfill(id: "cart-id", orderId: "order-uuid") {
id
state # now "ordered"
orderId # the linked order ID
}
}

Abandon Cart

Explicitly mark a cart as abandoned (e.g., user left checkout, session timeout):

mutation {
abandon(id: "cart-id") {
id
state # now "abandoned"
}
}

Mark as Wishlist

Convert a cart to a non-expiring wishlist:

mutation {
markAsWishlist(id: "cart-id", name: "My Favorites") {
id
isWishlist
name
}
}

Set Cart Expiration

mutation {
setCartExpiration(id: "cart-id", expiresAt: "2024-12-31T23:59:59Z") {
id
expiresAt
}
}

Remove Cart

mutation {
remove(id: "cart-id") {
id
}
}

Complete Checkout Flow

The full checkout flow spans the Core API, /cart endpoint, and /order endpoint:

0. (Optional) Create Customer → Core API — createCustomer
1. Hydrate Cart → POST /cart — Create cart with items, customer, context
2. (Optional edits) → POST /cart — addSkuItem, setCustomer, setAddresses, etc.
3. Place Cart → POST /cart — Lock cart for payment
4. Create Order → POST /order — createFromCart (⚠ different endpoint!)

After step 4 the cart is ordered and linked to the order; the order id is the cart id. There is no separate fulfill step.

Customer Creation

Customers can be created before checkout via the Core API or inline during hydration:

  • Core API (persistent customer records): Use createCustomer with type: individual or type: organization. The customer’s identifier (typically email) can then be passed to hydrate.
  • Inline during hydrate (recommended for checkout): Pass customer: { identifier, firstName, lastName, isGuest } directly in the hydrate input. This associates the customer with the cart without requiring a separate API call.

For guest checkout, set isGuest: true in the hydrate customer input.

Minimal checkout example:

# Step 1: Create and hydrate cart (POST /cart)
mutation {
hydrate(
input: {
customer: { identifier: "john@example.com", isGuest: false }
context: {
language: "en"
price: {
pricesHaveTaxesIncludedInCrystallize: true
decimals: 4
currency: "EUR"
selectedVariantIdentifier: "default"
}
}
items: [
{ sku: "product-sku-1", quantity: 2, type: standard }
{ sku: "product-sku-2", quantity: 1, type: standard }
]
}
) {
id
state
total {
gross
net
currency
}
}
}
# Step 2: Place cart — locks it for payment (POST /cart)
mutation {
place(id: "cart-uuid-from-step-1") {
id
state # "placed"
}
}
# Step 3: Create order from placed cart (POST /order — DIFFERENT ENDPOINT!)
mutation {
createFromCart(id: "cart-uuid-from-step-1", input: { type: standard, paymentStatus: paid }) {
id
coreId
type
total {
gross
net
currency
}
}
}

Best Practices

  1. Reuse cart IDs — Pass returned ID to subsequent requests
  2. Set customer and context in hydrate — Set them inline during hydration rather than separate calls
  3. Place before payment — Always place the cart before initiating payment to prevent modifications
  4. Use /order for order creation — createFromCart is on the /order endpoint, not /cart
  5. Handle back navigation — If user goes back after placing, create a new cart via hydrate without an id
  6. Use item groups — Group items logically (“Outdoor”, “Plants”) for organized order views
  7. Handle managed state — Know when items become unmanaged after price overrides
  8. External items for non-catalog items — Shipping, fees, discounts via addExternalItem
  9. Auto-cleanup — Carts expire after 3 months of inactivity

Shop API Order Mutations Reference

The Shop API /order scope provides mutations for creating and managing orders. This is a separate endpoint from the /cart scope.

See SKILL.md for endpoint URLs and authentication headers. This file covers the /order endpoint. Order mutations use /order, NOT /cart — the createFromCart mutation and all order management mutations must be sent to this endpoint.

Create Order from Cart

Convert a placed cart into an order. The cart must be in “placed” state first (via place mutation on the /cart endpoint).

mutation CreateOrderFromCart($id: UUID!, $input: OrderFromCartInput) {
createFromCart(id: $id, input: $input) {
id
coreId
type
paymentStatus
total {
gross
net
taxAmount
currency
}
items {
name
sku
quantity
price {
gross
net
}
}
customer {
firstName
lastName
email
}
createdAt
}
}

Variables:

{
"id": "cart-uuid-here",
"input": {
"type": "standard",
"paymentStatus": "paid"
}
}

OrderFromCartInput

Field Type Description
type OrderType enum Order type (default: standard)
paymentStatus OrderPaymentStatus Payment status
payments [OrderPaymentInput] Payment records
pipelines [OrderPipelineInput] Pipeline stage assignments
stockLocationIdentifier String Stock location for inventory
relatedOrderIds [String] Related order IDs
additionalInformation String Free-text additional info

Create Order Directly

Create an order without a cart (e.g., for POS, imports, or manual order creation).

mutation CreateOrder($input: OrderInput!) {
create(input: $input) {
id
coreId
type
paymentStatus
total {
gross
net
taxAmount
currency
}
items {
name
sku
quantity
price {
gross
net
}
}
createdAt
}
}

Variables:

{
"input": {
"customer": {
"isGuest": false,
"firstName": "John",
"lastName": "Doe",
"email": "john@example.com",
"identifier": "john@example.com",
"addresses": [
{
"type": "billing",
"street": "123 Main St",
"city": "New York",
"postalCode": "10001",
"country": "US"
},
{
"type": "delivery",
"street": "456 Oak Ave",
"city": "Brooklyn",
"postalCode": "11201",
"country": "US"
}
]
},
"items": [
{
"name": "Robot Action Figure",
"sku": "robot-pink-standard",
"quantity": 2,
"productId": "product-id",
"imageUrl": "https://example.com/robot.jpg",
"price": {
"gross": 49.99,
"net": 39.99
}
}
],
"type": "standard",
"paymentStatus": "paid",
"payments": [
{
"provider": "stripe",
"transactionId": "pi_abc123",
"amount": 99.98,
"method": "card",
"createdAt": "2025-01-15T10:30:00Z"
}
],
"meta": [
{ "key": "source", "value": "web" },
{ "key": "campaign", "value": "summer-sale" }
]
}
}

OrderInput

Field Type Required Description
items [OrderItemInput] No Order line items
customer CustomerInput No Customer information
context OrderContextInput No Price/language context
type OrderType No Order type (default: standard)
paymentStatus OrderPaymentStatus No Payment status
payments [OrderPaymentInput] No Payment records
pipelines [OrderPipelineInput] No Pipeline stage assignments
stockLocationIdentifier String No Stock location for inventory deduction
relatedOrderIds [String] No Related order IDs (returns, replacements)
additionalInformation String No Free-text additional info
meta [KeyValueInput] No Arbitrary key-value metadata
createdAt DateTime No Override creation timestamp

OrderItemInput

Field Type Required Description
name String! Yes Item display name
sku String! Yes Item SKU
quantity PositiveInt! Yes Quantity ordered
price PriceInput! Yes Unit price (gross + net)
productId String No Crystallize product ID
imageUrl String No Item image URL
promotions [Float] No Discount amounts on item total
subscriptionContractId String No Subscription contract ID
subscription OrderItemSubscriptionInput No Subscription details
meta [KeyValueInput] No Item-level metadata

PriceInput

Field Type Required Description
gross Float! Yes Price including tax
net Float! Yes Price excluding tax
discounts [DiscountInput] No Applied discounts

CustomerInput

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

AddressInput

Field Type Required Description
type AddressType No delivery, billing, or other
firstName String No First name
middleName String No Middle name
lastName String No Last name
street String No Street address
street2 String No Additional street info
streetNumber String No Street number
postalCode String No Postal/ZIP code
city String No City
state String No State/province
country String No Country code
phone String No Phone number
email String No Email address
meta [KeyValueInput] No Address metadata

OrderPaymentInput

Field Type Required Description
provider String No Payment provider name
transactionId String No Transaction reference
amount Float No Payment amount
method String No Payment method
createdAt DateTime No Payment timestamp
meta [KeyValueInput] No Payment metadata

OrderPipelineInput

Field Type Required Description
identifier String! Yes Pipeline identifier
stage String No Stage within pipeline

Add Payments to Order

Add payment records to an existing order.

mutation AddPayments($id: UUID!, $payments: [OrderPaymentInput!]!) {
addPayments(id: $id, payments: $payments) {
id
paymentStatus
payments {
provider
transactionId
amount
method
createdAt
}
}
}

Variables:

{
"id": "order-uuid",
"payments": [
{
"provider": "stripe",
"transactionId": "pi_xyz789",
"amount": 99.98,
"method": "card",
"createdAt": "2025-01-15T10:30:00Z"
}
]
}

Replace All Payments

Replace all payment records on an order.

mutation SetPayments($id: UUID!, $payments: [OrderPaymentInput!]!) {
setPayments(id: $id, payments: $payments) {
id
paymentStatus
payments {
provider
transactionId
amount
}
}
}

Set Order Metadata

Set or merge metadata on an order.

mutation SetOrderMeta($id: UUID, $meta: [KeyValueInput], $merge: Boolean) {
setMeta(id: $id, meta: $meta, merge: $merge) {
id
meta
}
}

Variables:

{
"id": "order-uuid",
"meta": [
{ "key": "fulfillment_status", "value": "shipped" },
{ "key": "tracking_number", "value": "1Z999AA10123456784" }
],
"merge": true
}

When merge is true, new keys are added and existing keys are updated. When false (default), all existing metadata is replaced.

Set Order Customer

Update the customer information on an existing order.

mutation SetOrderCustomer($id: UUID!, $customer: CustomerInput!) {
setCustomer(id: $id, customer: $customer) {
id
customer {
firstName
lastName
email
identifier
}
}
}

Create Order from Subscription Contract

Create a new order from an existing subscription contract, using its current phase and period.

mutation CreateFromSubscription($subscriptionContractId: UUID!) {
createFromSubscriptionContract(subscriptionContractId: $subscriptionContractId) {
id
type
items {
name
sku
quantity
subscription {
name
start
end
}
}
}
}

Pipeline Management

Add Order to Pipeline Stage

Add an order to a pipeline stage while keeping it in any existing stages.

mutation AddToStage($id: UUID!, $pipeline: String!, $stage: String!) {
addToStage(id: $id, pipeline: $pipeline, stage: $stage) {
id
pipelines {
identifier
stage
}
}
}

Variables:

{
"id": "order-uuid",
"pipeline": "fulfillment",
"stage": "shipped"
}

Remove Order from Pipeline

mutation RemoveFromPipeline($id: UUID!, $pipeline: String!) {
removeFromPipeline(id: $id, pipeline: $pipeline) {
id
pipelines {
identifier
stage
}
}
}

Enums

OrderType

Value Description
standard Regular order
draft Draft order (not finalized)
creditNote Credit note / refund
replacement Replacement order
backorder Backorder (out-of-stock fulfillment)
preOrder Pre-order
quote Price quote
recurring Recurring/subscription order
split Split order (partial shipment)
test Test order

OrderPaymentStatus

Value Description
paid Fully paid
partiallyPaid Partially paid
partiallyRefunded Partially refunded
refunded Fully refunded
unpaid Not yet paid

CustomerType

Value Description
individual Individual person
organization Company/org

AddressType

Value Description
delivery Shipping address
billing Billing address
other Other address

CartItemType (on Order items)

Value Description
standard Regular product item
subscription Subscription item
shipping Shipping fee
fee Additional fee
promotion Promotional item
refund Refund line
service Service item
digital Digital product
bonus Bonus/gift item
tax Tax line item

Order Response Type

The Order type returned by all mutations:

Field Type Description
id UUID! Shop API order ID
coreId String Core API order ID
reference String Order reference
type OrderType Order type enum
additionalInformation String Additional info text
stockLocationIdentifier String Stock location
relatedOrderIds [String] Related order IDs
createdAt DateTime Creation timestamp
updatedAt DateTime Last update timestamp
context HashMap Order context (pricing, language)
customer Customer Customer details with addresses
items [Item] Order line items
total TotalPrice! Order totals (gross, net, tax, currency)
paymentStatus OrderPaymentStatus Payment status
payments [Payment] Payment records
pipelines [Pipeline] Pipeline stage assignments
appliedPromotions [PromotionSlim!] Applied promotions
meta HashMap All metadata as key-value map
metaProperty String Single metadata property by key

Complete Checkout Flow (Cart → Order)

The full checkout flow spans both /cart and /order endpoints:

1. Hydrate Cart → POST /cart (hydrate mutation)
2. Set Customer → POST /cart (setCustomer mutation)
3. Place Cart → POST /cart (place mutation)
4. Create Order → POST /order (createFromCart mutation)
# Step 1: Hydrate cart (on /cart endpoint)
mutation {
hydrate(input: { items: [{ sku: "product-sku", quantity: 1 }] }) {
id
}
}
# Step 2: Set customer info (on /cart endpoint)
mutation {
setCustomer(id: "cart-id", customer: { firstName: "John", lastName: "Doe", email: "john@example.com" }) {
id
}
}
# Step 3: Place the cart (on /cart endpoint)
mutation {
place(id: "cart-id") {
id
}
}
# Step 4: Create order from cart (on /order endpoint — DIFFERENT ENDPOINT!)
mutation {
createFromCart(id: "cart-id", input: { type: standard, paymentStatus: paid }) {
id
coreId
total {
gross
net
currency
}
}
}

createFromCart also moves the cart to ordered and sets its orderId. The order id is the cart id, so there is nothing left to link: do not follow it with fulfill.

The Order Store Syncs One Way

Shop API orders and Core orders are two stores, and only one direction is reliable.

Shop → Core works. An order created here gets its coreId in about ten seconds, and addToStage moves the Core stage with it. (coreId can still read null in the answer of a Shop create even after the order has reached Core, so don’t use it as a sync flag.)

Core → Shop does not. Orders registered in Core with registerOrder appeared in the Shop store only sometimes, carrying the pipelines they had at creation, and a later updateOrderPipelineStage or deleteOrder in Core never reached the Shop store at all (watched for minutes). Seed demo orders with the Shop API, not with registerOrder, if a storefront is going to list them.

Worse: editing an order in Core adds another copy of it to the Shop store. 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 that list counts the same money several times over; the Lab Universe build read a department budget of NOK 1,249,299 instead of 411,775. So:

  • Change orders through the Shop API (setMeta, addToStage, setPayments) whenever a storefront reads them.
  • If something must be edited in Core anyway, have the reader group by coreId and keep the most recently updated copy.

There is no delete on the Shop API. Cancelled or mistaken orders stay in the list. Archive them with setMeta (archived: true) and filter them out when reading; deleting the Core order does not remove the Shop one.

(Both findings come from the Tools Universe and Lab Universe builds.)

Best Practices

  1. Use the correct endpoint — Cart operations on /cart, order operations on /order
  2. Include order scope in JWT — Token must have order scope for /order endpoint
  3. Place cart before creating order — createFromCart requires the cart to be in “placed” state
  4. Always check for errors — Validate result?.errors in responses
  5. Use meta for custom data — Store fulfillment status, tracking numbers, external references
  6. Use pipelines for workflow — Track orders through fulfillment stages
  7. Use coreId — When you need to reference the order in Core API mutations
  8. Write in one store — If a storefront reads the orders, create and change them here, not in Core
  9. Resolve pipeline names up front — Orders carry pipeline and stage ids only; map them to names at build or seed time from the admin APIs


Crystallize AI