Crystallize logo

Mass operations

Mass operations let you execute large batches of Crystallize mutations by uploading a JSON file that lists every action you want to run. Each entry in the file is called an operation. Operations are consumed by the mass-operations runner as part of a BulkTask and executed with the same validation and side effects as if you had called the corresponding GraphQL mutation yourself.

Mass Operations let you describe a batch of changes to a tenant in a single JSON file. Crystallize runs the file asynchronously as a bulk task, executing the operations one after another and logging the result of each one. Typical uses:

  • Seeding or migrating a tenant (shapes, pieces, customers, orders, subscription contracts, etc.).
  • Replaying or fixing data outside the API rate limits.
  • Coordinating multi-step changes (e.g., create a piece, then a shape that references it, then items that use the shape).
  • Updating product content at scale (components, variants, stock levels, prices, publish requests).

We ship first-class support in the Crystallize CLI. The CLI validates your file, uploads it through a presigned request, creates and starts the bulk task, and waits for completion while streaming logs.

High-Level Flow

  1. Prepare the JSON file that describes every operation you need, and validate it locally.
  2. Upload the file through the generatePresignedUploadRequest mutation using the MASS_OPERATIONS upload type.
  3. Register the file by calling createMassOperationBulkTask with the key of the uploaded file.
  4. Start the bulk task immediately by passing autoStart: true, or later through startMassOperationBulkTask.
  5. Monitor the task with the bulkTask query and inspect detailed per-operation logs with the operationLogs query.

Each task runs asynchronously. Operations execute sequentially, in the order they appear in the file; the success or failure of each operation is recorded in the operation logs.

The operations run with the permissions of the user who created the task, using that user's current roles on the tenant when the task runs. If that user no longer exists or no longer has a role on the tenant, the task ends with the error status.

File Format

Mass operation files are JSON documents validated against the schema published in @crystallize/schema/mass-operation.

{
  "version": "1.0.0",
  "operations": [
    {
      "intent": "piece/upsert",
      "identifier": "rating-system",
      "name": "Rating System",
      "components": [
        {
          "id": "name",
          "name": "Name",
          "type": "singleLine",
          "config": {
            "singleLine": { "required": true }
          }
        }
      ]
    }
  ]
}

Required top-level fields

  • version: The version of the format the runner applies to the file, in Major.Minor.Patch format. Use 1.0.0 for new files: it is required for the product/variant/* intents and makes the output of upsert intents consistent. Files with a lower version (such as 0.0.1) run with the legacy behaviour. Always write all three numbers: shorter forms such as 1.0 pass validation but make the task fail.
  • operations: Ordered array of operation objects. Operations are processed in order.

Validation

The runner validates the whole file before executing anything. If the file is not valid JSON or a single operation does not match the schema, no operation runs and the task ends with the error status. The task error does not list the individual issues, so validate files locally first (for example with mass-operation validate in the CLI) to see every issue with its location.

String values can contain templates (see the sections below). Templates are rendered just before each operation runs, after validation, and only inside string values. Fields that expect an ID accept either a 24-character ID or a template such as "{{ myFolder.id }}"; any other value fails validation.

Operation Structure

Every operation:

  • Must contain an intent of the form domain/action (some intents have extra path segments, such as product/variant/stock/modify).
  • Takes, next to the intent, the same fields as the matching Core API input. You can usually copy an input you already use in the API and paste it into the JSON file. Upsert intents take the full create input, not a partial update.
  • May include helper fields such as identifier, itemId, id or language, exactly as in GraphQL. Item operations require a language.
  • Can target an existing item or topic by its ID (itemId, id or topicId) or by a resourceIdentifier: a stable identifier of your choice that you set when you create the item or topic. Operations that target an existing entity need at least one of them.
  • Can reference entities created earlier in the file, either through shared identifiers (e.g., upsert a customer, then reference its identifier from an order) or through a _ref name (see Dependencies across Operations).

If an operation fails, the runner records the failure in the operation logs and continues with the next operation. There is no rollback: operations that already succeeded stay applied. The exception is an intent the runner cannot execute for the file's version (item/unpublish, or a product/variant/* intent in a file below version 1.0.0): the task stops at that operation with the error status, and the remaining operations do not run.

Intent

Description

piece/create, piece/update, piece/upsert

Manage pieces. Upsert matches on identifier. Update replaces the piece definition with the one you send.

shape/create, shape/update, shape/upsert

Manage shapes. Upsert matches on identifier.

folder/create, folder/update, folder/upsert

Manage items of type folder. Update finds the item by itemId or resourceIdentifier; upsert does the same and creates the item when neither is given or no item matches.

document/create, document/update, document/upsert

Manage items of type document. Update and upsert find the item the same way as for folders.

product/create, product/update, product/upsert

Manage items of type product. Update and upsert find the item the same way as for folders.

item/updateComponent/item

Update a single component on an item. Requires itemId.

item/updateComponent/sku

Update a single component on a product variant, by SKU.

item/publish

Send a publish request for one item (by itemId) in one language.

item/unpublish

Accepted by the schema but not executed yet: the task stops with an error when it reaches this operation. Use the unpublish mutation of the Core API instead.

item/delete

Delete an item, by itemId or by resourceIdentifier (not both).

product/variant/stock/modify

Increase, decrease or overwrite (operation) a variant's stock at a stock location.

product/variant/price/modify

Set a variant's price for one price variant.

product/variant/create, product/variant/update, product/variant/upsert, product/variant/delete

Manage product variants. Requires version 1.0.0. Create and upsert take the productId; upsert matches on productId and sku. Update and delete find the variant by sku.

customer/create, customer/update, customer/upsert

Manage customers. Upsert matches on identifier.

customer/group/create, customer/group/update, customer/group/upsert

Manage customer groups. Upsert matches on identifier.

order/register, order/update, order/upsert

Manage orders. Update requires the order id. Upsert updates the order with the given id, and registers a new order when no id is given or no order matches.

subscription-contract/create, subscription-contract/update, subscription-contract/upsert

Manage subscription contracts. Update requires the contract id. Upsert updates the contract with the given id, and creates a new contract when no id is given or no contract matches.

pricelist/create, pricelist/update, pricelist/upsert

Manage price lists. Upsert matches on identifier.

pricelist/selectedProductVariants/upsert, pricelist/selectedProductVariants/remove

Add or update (variants) or remove (skus) the product variants selected in a price list, identified by its identifier.

topic/create, topic/update, topic/upsert, topic/delete

Manage topics. Update, upsert and delete find the topic by topicId or resourceIdentifier.

flow/create, flow/update, flow/upsert

Manage flows. Upsert matches on identifier.

item/flow/stage/addItems

Add items to a flow stage.

item/paths/addAliases, item/paths/setAliases, item/paths/removeAliases

Manage item path aliases in one language, with a list of paths.

item/paths/addHistory, item/paths/setHistory, item/paths/removeHistory

Manage item path history in one language, with a list of paths.

item/paths/addShortcuts, item/paths/setShortcuts, item/paths/removeShortcuts

Manage item path shortcuts. Add and set take shortcuts (a list of parentId and optional position); remove takes parentIds.

image/register

Register an uploaded image by its key. When a resourceIdentifier is given and an image with it is already registered, the operation is skipped.

The set path intents (setAliases, setHistory, setShortcuts) compare your list with the item's current one. If some current entries are missing from your list, the operation only removes them; otherwise it adds yours. To both remove and add entries, repeat the operation. Shortcut intents always apply to the tenant's default language.

New intents are added over time. The @crystallize/schema/mass-operation package is the source of truth for what a file can contain.

Below is a shortened example that upserts a customer, registers a new order for that customer, and updates an item component:

{
  "version": "1.0.0",
  "operations": [
    {
      "intent": "customer/upsert",
      "identifier": "customer-for-order-123",
      "firstName": "John",
      "lastName": "Doe",
      "type": "individual"
    },
    {
      "intent": "order/register",
      "customer": {
        "identifier": "customer-for-order-123",
        "type": "individual"
      },
      "additionalInformation": "Please deliver between 9am-5pm",
      "cart": [
        {
          "sku": "SP-RED-001",
          "name": "Sample Product",
          "productId": "67e5d2d12d31ee752710a74b",
          "quantity": 2,
          "price": {
            "currency": "USD",
            "gross": 1000,
            "net": 800,
            "tax": { "name": "VAT", "percent": 20 }
          }
        }
      ]
    },
    {
      "intent": "item/updateComponent/item",
      "itemId": "632958a35dfc2c90cbbad20d",
      "language": "en",
      "component": {
        "componentId": "title",
        "singleLine": { "text": "Mass operation updated title" }
      }
    }
  ]
}

Registering an order is not idempotent: running this file twice registers two orders.

Dependencies across Operations

The simplest way to understand dependencies between operations is to imagine building a tree structure.

For example, you might want to create a folder inside another folder, and then import products into that newly created folder: all within the same list of operations.

To reference a previous operation in a subsequent one, give it a name with the _ref keyword, then use that name in a template in any later string value.

{
  "version": "1.0.0",
  "operations": [
    {
      "_ref": "rooms",
      "intent": "folder/create",
      "name": "My Rooms",
      "language": "en",
      "shapeIdentifier": "folder",
      "tree": {
        "parentId": "{{ defaults.rootItemId }}"
      }
    },
    {
      "intent": "folder/create",
      "name": "My Kitchen",
      "language": "en",
      "shapeIdentifier": "folder",
      "tree": {
        "parentId": "{{ rooms.id }}"
      }
    }
  ]
}

In this example, the folder "My Kitchen" will be created inside "My Rooms", which is created at the root of the catalogue.

You can also reference an operation by its zero-based position in the file, without a _ref: {{ 0.id }} refers to the output of the first operation.

The properties available on a reference depend on the intent. The table below applies to files with version 1.0.0.

Intent

Available properties

piece/create, piece/upsert

identifier

piece/update

id.identifier, name

shape/create, shape/upsert

identifier

shape/update

id.identifier, type, name

folder/create, document/create, product/create

id (the item ID), language, versionLabel

folder/update, document/update, product/update

id.id (the item ID), id.language, id.versionLabel, name

folder/upsert, document/upsert, product/upsert

id (the item ID)

item/updateComponent/item, item/updateComponent/sku, product/variant/stock/modify

The item ID itself. Reference it as {{ myRef }}.

item/publish

language, success (the published item IDs), failure

item/delete, topic/delete

removed

product/variant/price/modify

id.id (the product's item ID)

product/variant/create, product/variant/update, product/variant/upsert

sku, name and the other variant fields (attributes, priceVariants, stockLocations, …)

product/variant/delete

sku

image/register

id (the image key), language

customer/create, customer/update, customer/upsert

identifier

customer/group/create

None.

customer/group/update

identifier, name, type

order/register

id, createdAt

order/update

id and the rest of the order (customer, cart, total, …)

subscription-contract/create

The contract ID itself. Reference it as {{ myRef }}.

subscription-contract/update

modified

pricelist/create

None.

pricelist/update

identifier, name, modifierType

pricelist/selectedProductVariants/upsert

identifier, name, upsertedSkus

pricelist/selectedProductVariants/remove

identifier, name, removedSkus

topic/create

A list: the created topic first, then its children, each with id and language. Reference the topic as {{ myRef.[0].id }}.

topic/update

id.id, id.language, name, path

flow/create

None.

flow/update

identifier, name, stageIdentifiers

item/flow/stage/addItems

A list of IDs.

item/paths/*

itemId.id, assignedPaths (per language: canonical, aliases, history, shortcuts)

Other upsert intents

Same as the create or update intent, depending on the action performed.

An operation that failed or was skipped has an empty output, so references to it render as empty strings. If a template cannot be rendered at all (for example because of a syntax error), the string is kept as written, with its {{ … }}. If you are unsure which properties are available, look at the output of the operation log entry created for each operation.

Defaults and Global Variables

The operation runner also provides default context variables:

  • {{ defaults.taskId }}: the ID of the bulk task.
  • {{ defaults.tenantId }}
  • {{ defaults.tenantIdentifier }}
  • {{ defaults.rootItemId }}: the ID of the catalogue root.
  • {{ defaults.languages.[0] }}: the tenant's languages, the default language first.
  • {{ defaults.vatTypeIds.[0] }}: the IDs of the tenant's VAT types (up to 100, in no particular order).

Return structure from upsert operations

With version 1.0.0, the product/upsert, folder/upsert and document/upsert intents always return { id: "abc123" }, whether the item was created or updated, so you can reference the item ID as {{ myRef.id }}. Likewise, shape/upsert, piece/upsert and customer/upsert always return { identifier: "…" }.

Upserting an item matches an existing item by itemId, or else by resourceIdentifier, in the operation's language. Set one of them if you want to rerun a file without creating duplicates: externalReference is not used for matching.

Referencing an upserted product in a component update:

{
  "version": "1.0.0",
  "operations": [
    {
      "_ref": "rootProduct",
      "intent": "product/upsert",
      "resourceIdentifier": "product-sku-12345",
      "name": "My Product",
      "language": "en",
      "shapeIdentifier": "product",
      "vatTypeId": "{{ defaults.vatTypeIds.[0] }}",
      "tree": { "parentId": "{{ defaults.rootItemId }}" },
      "variants": [
        { "sku": "SKU-12345", "name": "My Product", "isDefault": true }
      ]
    },
    {
      "intent": "item/updateComponent/item",
      "itemId": "{{ rootProduct.id }}",
      "language": "en",
      "component": {
        "componentId": "description",
        "richText": {
          "html": ["<p>Updated description</p>"]
        }
      }
    }
  ]
}

Files with a version below 1.0.0

With the legacy behaviour, the returned structure differs depending on whether the item was created or updated:

Action performed

Returned structure

Create

{ id: "abc123", ... }

Update

{ id: { id: "abc123", ... }, ... }

Use Handlebars conditional logic to handle both structures:

{{#if rootProduct.id.id}}{{ rootProduct.id.id }}{{else}}{{ rootProduct.id }}{{/if}}

Upload an image and get the key

There is an upload helper that you can use to upload an image directly from a URL.

{
    "intent": "image/register",
    "key": "{{ upload \"https://my-image.com/image.jpg\" }}"
}

The helper returns the key the image will have in Crystallize. The runner then downloads the image from the URL and uploads it to Crystallize after the operation has run, before the next operation starts.

You can also use the upload helper directly wherever another operation expects an image key, and the image will be registered for you (like a mutation in the API).

Within one task, calling upload again with the same URL returns the same key, and the image is uploaded only once. Pass a resource identifier as a second argument to make image imports re-runnable: if an image with that resource identifier is already registered in the tenant, the helper returns its key and nothing is uploaded.

{
    "intent": "image/register",
    "key": "{{ upload \"https://my-image.com/image.jpg\" \"hero-image-1\" }}",
    "resourceIdentifier": "hero-image-1"
}

Each image upload gets its own entry in the operation logs, with the intent image/upload. A failed image upload does not make the operation that requested it fail.

Fetch content to reference it

Sometimes you want to reference an existing item, customer or other entity that is not created in the file. There are helpers for that:

Helper

Arguments

fetchItemById

itemId, language, version

fetchItemByResourceIdentifier

resourceIdentifier, language

fetchOrderById

orderId

fetchSubscriptionContractById

id

fetchCustomerByIdentifier

identifier

fetchCustomerGroupByIdentifier

identifier

fetchPriceListByIdentifier

identifier

fetchProductVariantBySku

sku, language

fetchTopicByResourceIdentifier

resourceIdentifier, language

fetchImageByResourceIdentifier

resourceIdentifier, language

They help you make a Mass Operation file more dynamic. Always pass every argument. For fetchItemById, the version is either published or draft; any other value reads the draft.

Read a property of the result with valueOf:

{{ valueOf (fetchItemById '626af86d6f9c909e39ecb8a6' 'en' 'published') 'name' }}

This would be replaced by the name of the published version of item 626af86d6f9c909e39ecb8a6 in English. The property can be a dotted path, such as 'components.description.content.plainText'; a missing property renders as an empty string.

The logic is the same for all helpers:

{{ valueOf (fetchOrderById '626af86d6f9c909e39ecb8a6') 'reference' }}

Uploading the File

1. Request a presigned upload:
Upload the JSON file to the returned url using the provided form fields. The key field identifies the stored file; you pass it to createMassOperationBulkTask.

mutation GeneratePresignedUpload($filename: String!, $contentType: String!) {
  generatePresignedUploadRequest(
    input: {
      type: MASS_OPERATIONS
      filename: $filename
      contentType: $contentType
    }
  ) {
    ... on PresignedUploadRequest {
      url
      fields {
        name
        value
      }
    }
    ... on BasicError {
      error
      errorName
    }
  }
}

2. Upload file:
The presigned URL requires a multipart form upload. Here's how to construct the request:

async function uploadToPresignedUrl(
  presignedUrl: string,
  fields: Array<{ name: string; value: string }>,
  fileContent: string
): Promise<void> {
  const formData = new FormData();
  
  // Add all presigned fields first (order matters!)
  for (const field of fields) {
    formData.append(field.name, field.value);
  }
  
  // Add the file last
  const blob = new Blob([fileContent], { type: 'application/json' });
  formData.append('file', blob);
  
  const response = await fetch(presignedUrl, {
    method: 'POST',
    body: formData,
  });
  
  if (!response.ok) {
    throw new Error(`Upload failed: ${response.status}`);
  }
}

// Extract the key from fields for use in createMassOperationBulkTask
const keyField = fields.find(f => f.name === 'key');
const storageKey = keyField?.value;
# Pass every field returned in `fields` as a form field, in order,
# then the file last.
curl -X POST "$PRESIGNED_URL" \
  -F "key=$KEY" \
  -F "<field name>=<field value>" \
  ... \
  -F "file=@operations.json"

3. Register the bulk task:

  • If autoStart is true, the API starts the task immediately.
  • If autoStart is omitted or false, the task stays pending: call startMassOperationBulkTask(id: ID!) when you are ready.
mutation CreateMassOperationBulkTask($key: String!, $autoStart: Boolean) {
  createMassOperationBulkTask(input: { key: $key, autoStart: $autoStart }) {
    ... on BulkTaskMassOperation {
      id
      status
    }
    ... on BasicError {
      error
      errorName
    }
  }
}

4. Start bulk task (optional):
If the autoStart parameter is not set to true when calling createMassOperationBulkTask, the task will not start automatically. In this case, you must explicitly start the task by invoking the startMassOperationBulkTask API, as shown below. Only a task with the pending status can be started, so each task runs once; to run a file again, create a new task.

mutation StartMassOperationBulkTask($id: ID!) {
  startMassOperationBulkTask(id: $id) {
    ... on BulkTaskMassOperation {
      id
      status
    }
    ... on BasicError {
      error
      errorName
    }
  }
}

API Reference

Endpoint

URL

Authentication

Core API

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

Headers below

Upload file

Returned in generatePresignedUploadRequest.url

Presigned (no auth needed)

Required headers for core API

Content-Type: application/json
X-Crystallize-Access-Token-Id: <your-token-id>
X-Crystallize-Access-Token-Secret: <your-token-secret>

Monitoring Progress

  • Bulk task status: Query bulkTask(id: ID!) or bulkTasks(filter: { type: massOperation }) to retrieve task status. The info field is a JSON value: select it without sub-fields.
query GetBulkTaskStatus($id: ID!) {
  bulkTask(id: $id) {
    ... on BulkTaskMassOperation {
      id
      status
      info
    }
    ... on BasicError {
      error
      errorName
    }
  }
}

The lifecycle is pending → started → complete or error.

Status

Description

pending

Task created but not started

started

Operations are being executed

complete

All operations were processed. Individual operations may still have failed: check the operation logs.

error

The task stopped (check the info field for details)

  • Execution logs: Use operationLogs(filter: { operationId: "<bulkTaskId>" }) to read detailed, paginated logs. Each log entry stores the input (the operation and the command executed), a message, the output, and a status (success, partial, failure) with a statusCode (200, 206, 500). You can also filter on status.
  • Partial results: item/publish is logged as partial (206) when the publish request did not succeed for every item.
  • Outputs: Successful operations include the command output (IDs, payloads, etc.), making it easy to chain follow-up actions.
query GetOperationLogs($taskId: ID!, $after: String) {
  operationLogs(filter: { operationId: $taskId }, first: 100, after: $after) {
    ... on OperationLogConnection {
      edges {
        node {
          id
          status
          statusCode
          message
          input
          output
          createdAt
        }
      }
      pageInfo {
        hasNextPage
        endCursor
      }
    }
    ... on BasicError {
      error
      errorName
    }
  }
}

If the file is not valid JSON or does not match the schema, the task is marked as error with the message Invalid Operation File, and no operation runs. A file with an empty operations array also ends in error.

Error response example:

{
  "bulkTask": {
    "id": "abc123",
    "status": "error",
    "info": {
      "error": "Invalid Operation File",
      "errorName": "Error",
      "stack": "Error: Invalid Operation File\n    at ..."
    }
  }
}

Common issues

Error

Cause

Solution

400 Bad Request on mutations

Missing union type fragments

Add ... on BulkTaskMassOperation and ... on BasicError fragments

Failed to fetch on file upload

CORS restriction in browser

Use server-side upload or proxy the upload request

Task stuck in pending

autoStart: false without calling start

Call startMassOperationBulkTask mutation

key is undefined

Looking for top-level key

Extract from fields array: fields.find(f => f.name === 'key').value

Task status is error with no message

Not querying info field

Add info to your bulkTask query

Task error Invalid Operation File

The file is not valid JSON or does not match the schema

Run mass-operation validate in the CLI to list every issue

Task stops at an operation with error

The intent is not executed for the file's version (item/unpublish, or product/variant/* below 1.0.0)

Set version to 1.0.0; unpublish through the Core API

A field contains the literal text {{ … }}

The template could not be rendered

Check the template syntax and the _ref name

Browser CORS considerations

When uploading from a browser, the upload storage may block cross-origin requests. Options:

  1. Use the CLI - Recommended for production migrations
  2. Server-side proxy - Route uploads through your backend
  3. Development proxy - Use Vite/webpack dev server proxy

Example Vite proxy configuration, where the target is the origin of the url returned by generatePresignedUploadRequest:

export default defineConfig({
  server: {
    proxy: {
      '/api/upload': {
        target: '<origin of the presigned upload url>',
        changeOrigin: true,
        rewrite: (path) => path.replace(/^\/api\/upload/, ''),
      },
    },
  },
});

Using the Crystallize CLI

The Crystallize CLI provides turnkey helpers around the workflow:

  • ~/crystallize mass-operation dump-content-model <tenant> <file>: Generate a starter mass operation file containing the current shapes and pieces.
  • ~/crystallize mass-operation validate <file>: Validate a file against the schema locally, without any network call, and list every issue.
  • ~/crystallize mass-operation add-operation <file>: Add skeletons of one or more operations (by intent) to a file.
  • ~/crystallize mass-operation run <tenant> <file>: Validate against the schema, request the presigned upload, push the file, create the bulk task (with autoStart), and wait for completion while tailing logs.
  • ~/crystallize mass-operation execute-mutations <tenant> <file>: Run client-side GraphQL mutations that complement mass operations (e.g., to script additional steps).

The commands support --no-interactive for CI usage and reuse stored credentials. The CLI performs JSON validation locally before uploading, so you catch schema issues early.

  • Validate early: Keep files under version control and lint them locally; the CLI validation uses the same schema as the runner.
  • Chunk large jobs: Split very large migrations into multiple tasks to simplify retries.
  • Reuse identifiers: Prefer identifier-based references (identifier, resourceIdentifier) so subsequent operations, and later runs, can find the entities created earlier.
  • Idempotency with upsert: Use upsert intents when you want to rerun the same file safely, and give them a key to match on (identifier, itemId, resourceIdentifier or id, depending on the intent). Without one, item, order and subscription contract upserts create a new entity on every run.
  • Monitor logs: Always review operationLogs for failures or partial successes; follow-up actions can be scripted based on the emitted output.
  • Security: Presigned URLs are short-lived. Upload immediately after requesting them and keep the file size within your tenant's upload limits.

Mass operations are powerful: they apply every change in the file with the permissions of the user who created the task. Treat the files as infrastructure artefacts, review them like code, and automate verification through the CLI wherever possible.