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:
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.
generatePresignedUploadRequest mutation using the MASS_OPERATIONS upload type.createMassOperationBulkTask with the key of the uploaded file.autoStart: true, or later through startMassOperationBulkTask.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.
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 }
}
}
]
}
]
}{
"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 }
}
}
]
}
]
}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.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.
Every operation:
intent of the form domain/action (some intents have extra path segments, such as product/variant/stock/modify).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._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 |
|---|---|
| Manage pieces. Upsert matches on identifier. Update replaces the piece definition with the one you send. |
| Manage shapes. Upsert matches on identifier. |
| Manage items of type |
| Manage items of type |
| Manage items of type |
| Update a single component on an item. Requires itemId. |
| Update a single component on a product variant, by SKU. |
| Send a publish request for one item (by itemId) in one language. |
| 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. |
| Delete an item, by itemId or by resourceIdentifier (not both). |
| Increase, decrease or overwrite ( |
| Set a variant's price for one price variant. |
| Manage product variants. Requires version |
| Manage customers. Upsert matches on identifier. |
| Manage customer groups. Upsert matches on identifier. |
| 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. |
| 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. |
| Manage price lists. Upsert matches on identifier. |
| Add or update (variants) or remove (skus) the product variants selected in a price list, identified by its identifier. |
| Manage topics. Update, upsert and delete find the topic by topicId or resourceIdentifier. |
| Manage flows. Upsert matches on identifier. |
| Add items to a flow stage. |
| Manage item path aliases in one language, with a list of |
| Manage item path history in one language, with a list of |
| Manage item path shortcuts. Add and set take |
| 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" }
}
}
]
}{
"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.
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 }}"
}
}
]
}{
"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 |
|---|---|
| identifier |
| id.identifier, name |
| identifier |
| id.identifier, type, name |
| id (the item ID), language, versionLabel |
| id.id (the item ID), id.language, id.versionLabel, name |
| id (the item ID) |
| The item ID itself. Reference it as |
| language, success (the published item IDs), failure |
| removed |
| id.id (the product's item ID) |
| sku, name and the other variant fields (attributes, priceVariants, stockLocations, …) |
| sku |
| id (the image key), language |
| identifier |
| None. |
| identifier, name, type |
| id, createdAt |
| id and the rest of the order (customer, cart, total, …) |
| The contract ID itself. Reference it as |
| modified |
| None. |
| identifier, name, modifierType |
| identifier, name, upsertedSkus |
| identifier, name, removedSkus |
| A list: the created topic first, then its children, each with id and language. Reference the topic as |
| id.id, id.language, name, path |
| None. |
| identifier, name, stageIdentifiers |
| A list of IDs. |
| itemId.id, assignedPaths (per language: canonical, aliases, history, shortcuts) |
Other | 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.
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).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>"]
}
}
}
]
}{
"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>"]
}
}
}
]
}With the legacy behaviour, the returned structure differs depending on whether the item was created or updated:
Action performed | Returned structure |
|---|---|
Create |
|
Update |
|
Use Handlebars conditional logic to handle both structures:
{{#if rootProduct.id.id}}{{ rootProduct.id.id }}{{else}}{{ rootProduct.id }}{{/if}}{{#if rootProduct.id.id}}{{ rootProduct.id.id }}{{else}}{{ rootProduct.id }}{{/if}}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\" }}"
}{
"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"
}{
"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.
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 |
|---|---|
| itemId, language, version |
| resourceIdentifier, language |
| orderId |
| id |
| identifier |
| identifier |
| identifier |
| sku, language |
| resourceIdentifier, language |
| 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' }}{{ 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' }}{{ valueOf (fetchOrderById '626af86d6f9c909e39ecb8a6') 'reference' }}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
}
}
}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;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"# 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:
mutation CreateMassOperationBulkTask($key: String!, $autoStart: Boolean) {
createMassOperationBulkTask(input: { key: $key, autoStart: $autoStart }) {
... on BulkTaskMassOperation {
id
status
}
... on BasicError {
error
errorName
}
}
}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
}
}
}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 | 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>Content-Type: application/json
X-Crystallize-Access-Token-Id: <your-token-id>
X-Crystallize-Access-Token-Secret: <your-token-secret>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
}
}
}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 |
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.item/publish is logged as partial (206) when the publish request did not succeed for every item.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
}
}
}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 ..."
}
}
}{
"bulkTask": {
"id": "abc123",
"status": "error",
"info": {
"error": "Invalid Operation File",
"errorName": "Error",
"stack": "Error: Invalid Operation File\n at ..."
}
}
}Error | Cause | Solution |
|---|---|---|
| Missing union type fragments | Add |
| CORS restriction in browser | Use server-side upload or proxy the upload request |
Task stuck in |
| Call |
| Looking for top-level | Extract from |
Task status is | Not querying | Add |
Task error | The file is not valid JSON or does not match the schema | Run |
Task stops at an operation with | The intent is not executed for the file's version ( | Set |
A field contains the literal text | The template could not be rendered | Check the template syntax and the |
When uploading from a browser, the upload storage may block cross-origin requests. Options:
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/, ''),
},
},
},
});export default defineConfig({
server: {
proxy: {
'/api/upload': {
target: '<origin of the presigned upload url>',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api\/upload/, ''),
},
},
},
});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.
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.operationLogs for failures or partial successes; follow-up actions can be scripted based on the emitted output.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.