mutation
npx skills add https://github.com/crystallizeapi/ai --skill mutationCrystallize 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:
- What are you trying to create or update? Products, documents, folders, customers, orders?
- Do you have the tenant identifier and access tokens? Mutations require authentication.
- Where in the flow are you? Content/catalog management → Core API. Cart/checkout/orders → Shop API.
- Do you need to update individual fields or create items from scratch?
updateComponentfor fields,createfor new items. - Should changes be published immediately? Creating an item doesn’t publish it — that’s a separate step.
- 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 formatAPI 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.
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}/cartAuthorization: Bearer YOUR_JWT_TOKENNo @ prefix for the Shop API endpoint.
Common Workflow Patterns
Create a product end-to-end
- Create the product with shape and parent folder
- Set variants with SKU, pricing, stock, and images
- Update components (description, specs, media)
- Publish the item
See Core API Reference for each mutation.
Update content on an existing item
- Query the item to confirm its ID and current state (use the query skill)
- Call
updateComponentfor each field you need to change - 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)
- Hydrate a cart with product SKUs and quantities
- Add/remove items as the customer shops
- Set customer info and addresses
- Place the cart to lock it for payment
- 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:
- Pieces (dependencies first)
- Shapes
- Topic maps
- 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 youawait api.shopCartApi(cartMutation, variables); // /cartawait 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:
- GraphQL mutations with clear variable placeholders (e.g.,
"your-tenant-id","item-id") - Variable definitions when the mutation uses GraphQL variables
- 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
Related skills
[[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 noproduct { … }oritem { … }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 atenantId. The PIM API does, which is the quickest way to tell a Core example from a PIM one.
Table of Contents
- Reading a result - The union pattern every mutation uses
- Item Mutations - Create, publish, unpublish, delete, move
- Component Updates - Update individual fields on items and variants
- Product Variants - SKUs, pricing, stock, images
- Customer Mutations - Create, update, delete, hierarchies
- Order Mutations - Update orders and their metadata
- Media & Images - Upload images for items and variants
- Flow Mutations - Manage item workflows
- Vector Ranking Mutations - Vocabularies, item taste, re-indexing
- Only in the legacy PIM API - What Core does not have
- Error Handling
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
updateOrderin Core adds another copy of the order to the Shop store: three updates on one order left three Shop orders with new ids, the samecoreIdand differentupdatedAt, and a storefront summing that list counted the money three times.updateOrderPipelineStageanddeleteOrderin Core, by contrast, never reach the Shop store at all. Use Shop/ordersetMetaandaddToStagefor 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 |
Related Links
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
registerImageafterwards.copyRemoteAssetalready registers the image, and the second call fails. (Tools Universe.) updateImageon a fresh key answersImageNotFoundErrorfor 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 leavespending/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:
- Takes SKUs and external items as input
- Fetches product data from Catalogue API
- Calculates prices, taxes, and totals
- 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
hydratewithout anid. 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 — createCustomer1. Hydrate Cart → POST /cart — Create cart with items, customer, context2. (Optional edits) → POST /cart — addSkuItem, setCustomer, setAddresses, etc.3. Place Cart → POST /cart — Lock cart for payment4. 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
createCustomerwithtype: individualortype: organization. The customer’sidentifier(typically email) can then be passed tohydrate. - 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
- Reuse cart IDs — Pass returned ID to subsequent requests
- Set customer and context in hydrate — Set them inline during hydration rather than separate calls
- Place before payment — Always place the cart before initiating payment to prevent modifications
- Use
/orderfor order creation —createFromCartis on the/orderendpoint, not/cart - Handle back navigation — If user goes back after placing, create a new cart via
hydratewithout anid - Use item groups — Group items logically (“Outdoor”, “Plants”) for organized order views
- Handle managed state — Know when items become unmanaged after price overrides
- External items for non-catalog items — Shipping, fees, discounts via
addExternalItem - Auto-cleanup — Carts expire after 3 months of inactivity
Related Links
- Shop API Order Mutations - Order creation, payments, pipelines (
/orderendpoint) - Core API Mutations - Customer creation (
createCustomer), order updates - Crystallize Shop API Documentation
- Checkout Flow Tutorial
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
coreIdand 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
- Use the correct endpoint — Cart operations on
/cart, order operations on/order - Include
orderscope in JWT — Token must haveorderscope for/orderendpoint - Place cart before creating order —
createFromCartrequires the cart to be in “placed” state - Always check for errors — Validate
result?.errorsin responses - Use
metafor custom data — Store fulfillment status, tracking numbers, external references - Use pipelines for workflow — Track orders through fulfillment stages
- Use
coreId— When you need to reference the order in Core API mutations - Write in one store — If a storefront reads the orders, create and change them here, not in Core
- 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
Related
- Shop API Cart Mutations - Cart hydration and management (
/cartendpoint) - Shop API Order Queries - Querying orders (
/orderendpoint)
Crystallize AI