Product & Catalog Imports
Bringing your product data into Crystallize shouldn't involve thousands of individual API calls. Whether you are migrating from a legacy system, syncing a CSV from a supplier, or pushing daily updates from an ERP, Mass Operations is the high-performance engine designed for the job.
By using a declarative JSON structure, you can define your entire catalog hierarchy, handle multi-variant products, and manage publishing states in a single batch. This approach significantly reduces network overhead and ensures your data is processed efficiently in the background.
We have collected a selection of typical product import operations to get you started.
The repository can be found on the Crystallize GitHub: https://github.com/CrystallizeAPI/examples/tree/main/mass-operations
Basic Product Import Example
In this example, we perform three distinct actions within one operation:
- Create a Folder: Establishing a "Demo" category.
- Create a Product: Adding a product with a SKU and pricing inside that folder.
- Publish: Immediately making both the folder and product live.
The Operations Payload
Note on References: We use _ref to create a temporary handle for an item. This allows subsequent operations in the same file (like the product creation or publishing) to reference the ID of an item that hasn't been created yet.
{
"version": "0.0.1",
"operations": [
{
"_ref": "rootCategory",
"intent": "folder/upsert",
"resourceIdentifier": "product-category",
"language": "en",
"shapeIdentifier": "folder",
"name": "Demo",
"tree": { "parentId": "{{ defaults.rootItemId }}" },
"components": []
},
{
"_ref": "p1",
"intent": "product/upsert",
"resourceIdentifier": "p-1",
"language": "en",
"shapeIdentifier": "product",
"name": "Product 1",
"tree": { "parentId": "{{ rootCategory.id }}" },
"components": [],
"vatTypeId": "{{ defaults.vatTypeIds.[0] }}",
"variants": [
{
"name": "Product 1 Variant",
"sku": "sku-001",
"isDefault": true,
"priceVariants": [{ "identifier": "default", "price": 9.99 }]
}
]
},
{
"intent": "item/publish",
"itemId": "{{ rootCategory.id }}",
"language": "en"
},
{
"intent": "item/publish",
"itemId": "{{ p1.id }}",
"language": "en"
}
]
}{
"version": "0.0.1",
"operations": [
{
"_ref": "rootCategory",
"intent": "folder/upsert",
"resourceIdentifier": "product-category",
"language": "en",
"shapeIdentifier": "folder",
"name": "Demo",
"tree": { "parentId": "{{ defaults.rootItemId }}" },
"components": []
},
{
"_ref": "p1",
"intent": "product/upsert",
"resourceIdentifier": "p-1",
"language": "en",
"shapeIdentifier": "product",
"name": "Product 1",
"tree": { "parentId": "{{ rootCategory.id }}" },
"components": [],
"vatTypeId": "{{ defaults.vatTypeIds.[0] }}",
"variants": [
{
"name": "Product 1 Variant",
"sku": "sku-001",
"isDefault": true,
"priceVariants": [{ "identifier": "default", "price": 9.99 }]
}
]
},
{
"intent": "item/publish",
"itemId": "{{ rootCategory.id }}",
"language": "en"
},
{
"intent": "item/publish",
"itemId": "{{ p1.id }}",
"language": "en"
}
]
}Key Highlights of this Example:
- _ref: Essential for chaining operations. rootCategory is used to tell the product where to live via {{ rootCategory.id }}.
- defaults: We use the built-in {{ defaults.rootItemId }} and {{ defaults.vatTypeIds.[0] }} to ensure the data lands in the right place with the correct tax settings without hardcoding IDs.
- Upsert Intent: Using upsert is a best practice for imports. If the resourceIdentifier already exists, Crystallize will update it; if not, it will create it.
- Implicit Publishing: By adding the item/publish intent at the end, you ensure the content is available on the Discovery/Catalogue API immediately after the import finishes.
Importing Products with Images
Managing media in bulk shouldn't be a manual task. With Mass Operations, you can register and import external images (via URL) and link them to your variants in a single job. The images are then automatically made available in responsive sizes and modern formats on the Crystallize CDN.
How Image Registration Works
- image/register: This intent tells Crystallize to fetch the image from the provided URL.
- {{ upload "..." }}: This helper function initiates the actual transfer.
- The Reference (_ref): We give the registration step a handle (e.g., img1).
- Linking: Inside the variant, we use {{ img1.id }} to tell the variant exactly which uploaded asset it should use.
The Operations Payload
{
"version": "0.0.1",
"operations": [
{
"_ref": "rootCategory",
"intent": "folder/upsert",
"resourceIdentifier": "product-category",
"language": "en",
"shapeIdentifier": "folder",
"name": "Demo",
"tree": { "parentId": "{{ defaults.rootItemId }}" },
"components": []
},
{
"_ref": "img1",
"intent": "image/register",
"key": "{{ upload \"https://picsum.photos/seed/product1/800/600\" }}"
},
{
"_ref": "p1",
"intent": "product/upsert",
"resourceIdentifier": "p-1",
"language": "en",
"shapeIdentifier": "product",
"name": "Product 1",
"tree": { "parentId": "{{ rootCategory.id }}" },
"components": [],
"vatTypeId": "{{ defaults.vatTypeIds.[0] }}",
"variants": [
{
"name": "Product 1 Variant",
"sku": "sku-001",
"isDefault": true,
"images": [{ "key": "{{ img1.id }}" }],
"priceVariants": [{ "identifier": "default", "price": 9.99 }]
}
]
},
{
"intent": "item/publish",
"itemId": "{{ rootCategory.id }}",
"language": "en"
},
{
"intent": "item/publish",
"itemId": "{{ p1.id }}",
"language": "en"
}
]
}{
"version": "0.0.1",
"operations": [
{
"_ref": "rootCategory",
"intent": "folder/upsert",
"resourceIdentifier": "product-category",
"language": "en",
"shapeIdentifier": "folder",
"name": "Demo",
"tree": { "parentId": "{{ defaults.rootItemId }}" },
"components": []
},
{
"_ref": "img1",
"intent": "image/register",
"key": "{{ upload \"https://picsum.photos/seed/product1/800/600\" }}"
},
{
"_ref": "p1",
"intent": "product/upsert",
"resourceIdentifier": "p-1",
"language": "en",
"shapeIdentifier": "product",
"name": "Product 1",
"tree": { "parentId": "{{ rootCategory.id }}" },
"components": [],
"vatTypeId": "{{ defaults.vatTypeIds.[0] }}",
"variants": [
{
"name": "Product 1 Variant",
"sku": "sku-001",
"isDefault": true,
"images": [{ "key": "{{ img1.id }}" }],
"priceVariants": [{ "identifier": "default", "price": 9.99 }]
}
]
},
{
"intent": "item/publish",
"itemId": "{{ rootCategory.id }}",
"language": "en"
},
{
"intent": "item/publish",
"itemId": "{{ p1.id }}",
"language": "en"
}
]
}Important Considerations for Assets
- Duplicate Prevention: If you use the same URL in the upload helper across multiple operations, Crystallize is smart enough to handle the deduplication internally, but it is still best practice to register an image once and reference its _ref multiple times if needed.
- Permissions: Ensure the source URL is publicly accessible. If your images are behind a firewall or require specific headers, the Mass Operation job will fail to fetch the asset.
- Alt Text & Captions: You can extend the images object in the variant to include localized metadata like altText and caption for better SEO.
Importing Product Categories and Catalog Trees
In Crystallize, categories are typically represented by Folders. To build a navigation tree or a category hierarchy, you use the tree property to define parent-child relationships.
Key Concept: The Reference Chain
When importing a full tree, you use _ref to create a "handle" for the parent folder. You then reference that handle in the child's parentId using Handlebars syntax: {{ your_reference.id }}.
The Multi-Step Workflow
This example demonstrates a complete catalog setup in one file:
- Create the Root Folder: The entry point for your demo.
- Create Sub-Folders: Nesting categories under the root.
- Register Images: Uploading assets from an external URL (picsum.photos).
- Create Products: Placing products inside specific sub-folders and linking the uploaded images.
- Bulk Publish: Ensuring everything is visible on the frontend.
Operations Payload
{
"version": "0.0.1",
"operations": [
{
"_ref": "rootCategory",
"intent": "folder/upsert",
"resourceIdentifier": "product-category",
"language": "en",
"shapeIdentifier": "folder",
"name": "Demo",
"tree": { "parentId": "{{ defaults.rootItemId }}" }
},
/* Sub-categories nested under the Root */
{
"_ref": "cat1",
"intent": "folder/upsert",
"resourceIdentifier": "category-1",
"language": "en",
"shapeIdentifier": "folder",
"name": "Category 1",
"tree": { "parentId": "{{ rootCategory.id }}" }
},
/* ... repeat for other categories ... */
/* Registering external images */
{
"_ref": "img1",
"intent": "image/register",
"key": "{{ upload \"https://picsum.photos/seed/product1/800/600\" }}"
},
/* Creating a product inside a sub-category */
{
"_ref": "p1",
"intent": "product/upsert",
"resourceIdentifier": "p-1",
"language": "en",
"shapeIdentifier": "product",
"name": "Product 1",
"tree": { "parentId": "{{ cat1.id }}" },
"vatTypeId": "{{ defaults.vatTypeIds.[0] }}",
"variants": [
{
"name": "Product 1 Variant",
"sku": "sku-001",
"isDefault": true,
"images": [{ "key": "{{ img1.id }}" }],
"priceVariants": [{ "identifier": "default", "price": 9.99 }]
}
]
},
/* Explicitly publishing the hierarchy */
{
"intent": "item/publish",
"itemId": "{{ rootCategory.id }}",
"language": "en"
}
/* ... continue publishing all items ... */
]
}{
"version": "0.0.1",
"operations": [
{
"_ref": "rootCategory",
"intent": "folder/upsert",
"resourceIdentifier": "product-category",
"language": "en",
"shapeIdentifier": "folder",
"name": "Demo",
"tree": { "parentId": "{{ defaults.rootItemId }}" }
},
/* Sub-categories nested under the Root */
{
"_ref": "cat1",
"intent": "folder/upsert",
"resourceIdentifier": "category-1",
"language": "en",
"shapeIdentifier": "folder",
"name": "Category 1",
"tree": { "parentId": "{{ rootCategory.id }}" }
},
/* ... repeat for other categories ... */
/* Registering external images */
{
"_ref": "img1",
"intent": "image/register",
"key": "{{ upload \"https://picsum.photos/seed/product1/800/600\" }}"
},
/* Creating a product inside a sub-category */
{
"_ref": "p1",
"intent": "product/upsert",
"resourceIdentifier": "p-1",
"language": "en",
"shapeIdentifier": "product",
"name": "Product 1",
"tree": { "parentId": "{{ cat1.id }}" },
"vatTypeId": "{{ defaults.vatTypeIds.[0] }}",
"variants": [
{
"name": "Product 1 Variant",
"sku": "sku-001",
"isDefault": true,
"images": [{ "key": "{{ img1.id }}" }],
"priceVariants": [{ "identifier": "default", "price": 9.99 }]
}
]
},
/* Explicitly publishing the hierarchy */
{
"intent": "item/publish",
"itemId": "{{ rootCategory.id }}",
"language": "en"
}
/* ... continue publishing all items ... */
]
}The complete operations file can be downloaded from GitHub.
Pro-Tips for Tree Imports
- Asset Registration: Notice the image/register intent. Crystallize will download the image from the URL and provide an internal id. Using {{ img1.id }} in the variant ensures the product uses the correctly processed asset.
- Breadth-First Creation: Always define your folders before the products that live inside them.
- Deep Nesting: You can go many levels deep (e.g., Root -> Category -> Sub-category -> Product) as long as each step references the one above it.
- Publishing: Publishing a parent folder does not automatically publish the children. You must include a publish intent for every item you want to go live.
Rich Product Import with Components
Product storytelling requires more than just a name and a price. In Crystallize, you use Shapes to define custom structures (like technical specs, marketing copy, or SEO metadata). When importing, you populate these structures via the components array.
Mapping to your Shape
To successfully import rich content, the componentId in your JSON must match the Identifier of the component defined in your Shape. In this example, we are populating a singleLine component with the ID title.
The Operations Payload
{
"version": "0.0.1",
"operations": [
{
"_ref": "rootCategory",
"intent": "folder/upsert",
"resourceIdentifier": "product-category",
"language": "en",
"shapeIdentifier": "folder",
"name": "Demo",
"tree": { "parentId": "{{ defaults.rootItemId }}" },
"components": []
},
{
"_ref": "p1",
"intent": "product/upsert",
"resourceIdentifier": "p-1",
"language": "en",
"shapeIdentifier": "product",
"name": "Product 1",
"tree": { "parentId": "{{ rootCategory.id }}" },
"components": [
{
"componentId": "title",
"singleLine": {
"text": "Longer title for Product 1"
}
}
],
"vatTypeId": "{{ defaults.vatTypeIds.[0] }}",
"variants": [
{
"name": "Product 1 Variant",
"sku": "sku-001",
"isDefault": true,
"priceVariants": [{ "identifier": "default", "price": 9.99 }]
}
]
},
{
"intent": "item/publish",
"itemId": "{{ rootCategory.id }}",
"language": "en"
},
{
"intent": "item/publish",
"itemId": "{{ p1.id }}",
"language": "en"
}
]
}{
"version": "0.0.1",
"operations": [
{
"_ref": "rootCategory",
"intent": "folder/upsert",
"resourceIdentifier": "product-category",
"language": "en",
"shapeIdentifier": "folder",
"name": "Demo",
"tree": { "parentId": "{{ defaults.rootItemId }}" },
"components": []
},
{
"_ref": "p1",
"intent": "product/upsert",
"resourceIdentifier": "p-1",
"language": "en",
"shapeIdentifier": "product",
"name": "Product 1",
"tree": { "parentId": "{{ rootCategory.id }}" },
"components": [
{
"componentId": "title",
"singleLine": {
"text": "Longer title for Product 1"
}
}
],
"vatTypeId": "{{ defaults.vatTypeIds.[0] }}",
"variants": [
{
"name": "Product 1 Variant",
"sku": "sku-001",
"isDefault": true,
"priceVariants": [{ "identifier": "default", "price": 9.99 }]
}
]
},
{
"intent": "item/publish",
"itemId": "{{ rootCategory.id }}",
"language": "en"
},
{
"intent": "item/publish",
"itemId": "{{ p1.id }}",
"language": "en"
}
]
}Bulk Stock Updates
When managing inventory at scale, you don't want to update products one by one. The Mass Operations API allows you to target specific variants by SKU and modify their stock across different SKUs and stock locations simultaneously.
The three operation types
Choosing the right operation is key to maintaining data integrity between your physical warehouse and your digital catalog:
Operation | Action | Best Use Case |
overwrite | Replaces the current stock value with the provided quantity. | Periodic full syncs from an ERP or daily inventory counts. |
increase | Adds the quantity to the existing stock. | Recording new stock arrivals or returns. |
decrease | Subtracts the quantity from the existing stock. | Processing sales that occurred on an external platform (offline or via POS). |
Example: Syncing a daily inventory count
In this scenario, we are performing an overwrite to ensure Crystallize exactly matches the numbers from our warehouse for the "Default" location. When working with multiple warehouse locations you can define this as additional Stock Locations in Crystallize and refer to the stockLocationIdentifier in the operation.
{
"version": "0.0.1",
"operations": [
{
"intent": "product/variant/stock/modify",
"operation": "overwrite",
"quantity": 100,
"sku": "sku-001",
"stockLocationIdentifier": "default"
},
{
"intent": "product/variant/stock/modify",
"operation": "overwrite",
"quantity": 50,
"sku": "sku-002",
"stockLocationIdentifier": "default"
},
{
"intent": "product/variant/stock/modify",
"operation": "overwrite",
"quantity": 10,
"sku": "sku-003",
"stockLocationIdentifier": "default"
}
]
}{
"version": "0.0.1",
"operations": [
{
"intent": "product/variant/stock/modify",
"operation": "overwrite",
"quantity": 100,
"sku": "sku-001",
"stockLocationIdentifier": "default"
},
{
"intent": "product/variant/stock/modify",
"operation": "overwrite",
"quantity": 50,
"sku": "sku-002",
"stockLocationIdentifier": "default"
},
{
"intent": "product/variant/stock/modify",
"operation": "overwrite",
"quantity": 10,
"sku": "sku-003",
"stockLocationIdentifier": "default"
}
]
}Implementation Checklist
To ensure your stock update runs smoothly, keep these details in mind:
- Identifier Consistency: The stockLocationIdentifier must match the identifier defined in your Crystallize Settings > Stock Locations.
- SKU Targeting: The sku is the unique global identifier for the variant. Ensure your source data uses the exact same string as Crystallize.
- Atomicity: If one operation in a batch fails (e.g., an invalid SKU), the rest of the valid operations in that batch will still be processed.