Replicate a product catalog from a PIM, an ERP, or both into commercetools, and keep Products, Prices, and Inventory current.
Product data is the foundation of a commercetools catalog. Whether your products originate in a Product Information Management (PIM) system, an enterprise resource planning (ERP) system, or a combination of both, a well-planned integration keeps your catalog accurate, searchable, and current without letting one slow feed block another.
This guide covers the decisions that shape a product data integration: which system owns each attribute, whether to configure a Connector or build the flows yourself, how to map an external product model onto the commercetools catalog, and how to load the catalog in bulk and keep it synchronized. It treats product content, Prices, and Inventory as separate synchronization concerns, because they change at different rates and fail independently.
Plan your product data integration
Assign a source of truth per attribute
Designate one system as the authoritative writer for each attribute, and make the commercetools copy read-only for it. A PIM typically owns enriched content such as names, descriptions, images, and specifications; an ERP typically owns SKUs, list Prices, and stock. Treat each row as a one-way contract, and reject or reconcile changes that originate on the read-only side.
| Data domain | Typical owner | Direction | Where the design lives |
|---|---|---|---|
| Core product data and classification | PIM, or the ERP when no PIM exists | External system to commercetools | This guide |
| Product content and enrichment | PIM | External system to commercetools | This guide |
| Categories and category assignment | PIM | External system to commercetools | This guide |
| Media and assets | PIM, or a DAM | External system to commercetools | This guide |
| List and base Prices | ERP | External system to commercetools | This guide |
| Contract and customer-specific Prices | ERP | External system to commercetools | Integrate ERP |
| Inventory quantities | The system that owns Orders | Usually ERP or OMS to commercetools | Integrate an order management system |
Confirm the Inventory and contract-Price rows against the linked guides before you rely on the defaults, because those flows are owned by the systems that own Orders and financial data.
Answer the questions that change the design
Record the following before choosing an approach, because each answer moves the architecture:
- How many sources feed the catalog? A single PIM with a clear source of truth is the simplest case. Multiple systems, such as a PIM for content, a DAM for media, and an ERP for Prices and stock, add sequencing and conflict rules.
- Is the flow one-way or bi-directional? One-way, from the external system to commercetools, is the recommended default. Bi-directional synchronization, where some attributes edited in commercetools flow back, is more expensive: you must define which attributes sync back and how conflicts resolve. Do not assume bi-directional.
- Is product data editable in the Merchant Center? If product managers edit attributes in commercetools, decide which attributes remain owned by the external system, and restrict who can edit them.
- What volume, and how often does each domain change? Core product data changes infrequently, Prices change on a schedule, and stock changes constantly. These are different integrations with different cadences, even when they leave the same system.
- Which locales, currencies, and Channels are in scope? This determines the localization, Price, and Inventory mapping, and whether one extract serves every Store.
- What must not be synchronized? Not every PIM attribute belongs in commercetools. Map only what search, display, pricing, and fulfillment need, and leave the rest in the source system.
Record special requirements
Capture constraints that could require custom extraction, transformation, or hosting, because each one can move the decision in the next section from configuring a Connector toward forking or building one:
- Reference or related entities, such as cross-sells and related products, that the PIM models as first-class links.
- Attribute values scoped by a PIM channel or context, where one attribute holds different values per scope.
- Store- or market-specific catalogs that need Product Selections or Product Tailoring.
- Measurement-unit conversion, quirks in variant and family modeling, or publish and staging rules.
- Data residency, retention, or audit constraints that restrict where the integration runs.
Multiple sources of product data
When several systems write to the same Product, define the ownership boundary explicitly so no source overwrites another:
- The source of truth for each attribute. Determine which attributes come from each system, for example descriptions from the PIM and stock from the ERP. To keep externally owned attributes from being changed by hand in the Merchant Center, group them in a restricted AttributeGroup and grant editing permission only to the responsible teams. This is a Merchant Center permission boundary, not an API-level lock: it does not restrict an API Client, so keep the Product write scopes of each integration narrow as well.
- The process for creating Products. Send the initial version of a Product to commercetools once, when it is first created. Restrict subsequent updates from each source to only the attributes that source owns, and never perform a blind full overwrite that replaces another system's fields.
- The number of integrations required. Choose between a single integration that consolidates all sources, or a primary process that creates the Product and secondary processes that add data afterward.
- The write-back path, for bi-directional flows only. Identify the attributes for which commercetools is the source of truth, and ensure only those attributes sync back to the source system. Filter your own writes so an inbound update does not loop back out.
Choose a connector
Start with the option that requires your team to build and maintain the least custom integration logic. A deployable Connector that already syncs your PIM is almost always the right choice; build a custom integration only when no Connector fits a recorded requirement.
Check for an existing product data Connector
view_connectors scope to call the Search Connectors endpoint and filter by integration type:GET https://connect.{region}.commercetools.com/connectors/search?integrationTypes=pim&integrationTypes=erp
pim and erp, because a Connector that replicates core product and Price data can be published under either type. For the full list of values, see IntegrationType. For the host to use in each Region, see Hosts and authorization. You can also browse and install Connectors in Connect in the Merchant Center.Evaluate each candidate against the attributes, directions, mappings, Regions, locales, and peak volume you recorded, rather than against its headline. Record the Connector key and version you evaluated.
Prove that a gap is not configuration
Fork or build
If a Connector matches your PIM and has a genuine gap that configuration cannot close, and its source is available, fork it. Add only the difference, such as a missing resource type or a bi-directional write-back, and deploy the result as an Organization Connector. You keep the working sync engine, keying, and dependency handling.
service application to receive pushed changes over an authenticated webhook, and a job application to run scheduled loads and reconciliation. Make scheduled runs restart-safe. Review the application behavior and limitations, and keep bulk file processing and long-running transformations outside Connect. For other workloads, review when to use an alternative to Connect. Scaffold each application with the Connect CLI:commercetools connect init my-pim-connector
commercetools connect application add
event application that subscribes to Product Messages and writes the commercetools-owned attributes back to the source. Messages are delivered at least once and without ordering guarantees, so make the consumer idempotent and tolerate out-of-order delivery. See Subscriptions for the delivery model.manage_products, manage_categories, and manage_product_types, rather than manage_project. A narrow scope bounds the damage when a mapping defect or a misrouted event writes to the wrong resource. See scopes.Authenticate the inbound webhook
service endpoint is the entry point an attacker reaches first, so two obligations come with it:- Register the endpoint with the source's event system so it delivers at all. Do this in an idempotent
postDeployautomation script or through the source system's own configuration, and use the same script to create the Product Types and AttributeGroups the mapping depends on. - Verify the signature on every request before you read the payload. Most PIM and ERP systems sign with an HMAC over the raw request body using a shared secret, which you store in
securedConfiguration. This is not the JSON Web Token (JWT) scheme commercetools uses for its own destinations, so read the source system's current documentation for the header name and algorithm rather than assuming. Reject unsigned, mis-signed, and replayed requests, and keep the handler idempotent so a redelivery is a no-op. For the status codes to return to the calling system, see Return a status the OMS can act on, which applies to any inbound webhook.
Choose your integration approach
The approach depends on the frequency of updates, the volume of data, and the capabilities of your source. Most catalogs need more than one approach: a bulk load for the initial catalog and periodic refreshes, and an incremental flow for time-critical changes.
Full versus incremental uploads
Full uploads send the entire catalog to commercetools. They take longer and leave the catalog partially updated during the run, so use them for the initial load and periodic complete refreshes.
updatedAt per product, rather than reprocessing everything.A robust integration does both: an incremental flow for freshness, and a scheduled full reconciliation that catches missed events and repairs drift.
Event-driven versus bulk, and the application shape
Match the delivery mechanism to the cadence, then pick the runtime shape it implies:
- Event-driven flows handle changes as they occur and give near real-time updates. The source pushes each change to an authenticated endpoint, which maps to a
servicewebhook application. Choose this when real-time accuracy matters. - Bulk flows run at scheduled intervals and are more efficient for large volumes where immediate updates are not essential. A schedule maps to a
jobapplication. Choose this for catalog refreshes and periodic synchronization.
service typically upserts through the HTTP API for an immediate result, and a scheduled job typically submits to the Import API for asynchronous bulk processing. Solution A and Solution B below implement each path.Data mapping
Map only commerce-relevant data
Not every PIM attribute belongs in commercetools. Transfer only what search, display, pricing, or fulfillment needs, and leave the rest in the PIM, where it remains the source of truth. Every attribute you sync is one more thing to keep consistent.
Do not map Product Types one-to-one with PIM families
Type search-critical attributes precisely, and consolidate the rest
Split PIM attributes into two buckets:
- Search, filter, and display-critical attributes, such as brand, color, size, and material, map to their own typed Product Type attributes. Type each one precisely:
enumorlenumfor controlled vocabularies so faceting works,numberwith a unit for measurements, andbooleanfor flags. Key each enum option on the PIM's stable option code, not its localized label, because labels change per translation and would silently break faceting. - Supplementary attributes that are displayed but never filtered consolidate into a single JSON
textattribute rather than exploding into dozens of rarely used fields. This keeps the Product Type lean and the catalog queryable.
Map locales explicitly
{ "en-US": "…", "de-DE": "…" }. Map each PIM locale to a commercetools locale explicitly, because the codes do not always match: a PIM en_US must become en-US. Sync only the locales you decided are in scope, and verify the mapped values after import.Resolve categories and references by key
key derived from a stable PIM identifier, never from a localized name. A Product references its Categories by key, and a child Category references its parent by key. The Import API can resolve key-based dependencies asynchronously. Design the extract around matching keys, and follow the reference resolution rules for timing and failure handling.Reference or related entities, such as cross-sells, are often the gap a public Connector does not cover, so confirm the Connector maps them or treat it as a reason to fork.
Give every resource a stable key
Product.key, Product Variant key, Product Variant sku when it is the cross-system SKU, Price key, Category key, and Product Type key. Derive each identifier from a stable PIM identifier, then make every write an upsert by key. Stable identifiers prevent duplicate resources and support restart-safe processing. For platform behavior, see resource identifiers and Import API upserts.Keep media, Prices, and Inventory on their own paths
Manage price and inventory separately
Price and Inventory change far more often and are more time-critical than descriptions and images. Implement them as separate, event-based integrations, even when they originate in the same system as product content, so a slow nightly catalog run never blocks a Price or stock update.
50 000 Standalone Prices, so size the account, Channel, validity, and tier combinations before choosing that model.key for Import API and HTTP API upserts, and keep sku aligned with the Product Variant SKU. The SKU does not need to exist as a Product Variant yet. Use Inventory Entry records for fulfillment and accurate checks, and Product Variant availability for non-critical storefront display. For the consistency model, see Product Variant availability.None deducts the same units a second time when the system that owns Orders has already decremented them. That decision, and the reconciliation it implies, is in Synchronize Inventory.Solution A: bulk import with the Import API
Five Import API behaviors shape monitoring, retry, and ownership boundaries:
- Queued processing. An Import Response confirms acceptance for asynchronous processing as ImportOperation resources, not completion.
- Asynchronous reference resolution. A resolved reference can still fail commerce API validation. Follow the reference resolution behavior when designing retry and monitoring.
- Updates remove omitted fields. When you import an update to an existing Product or Product Variant, the import removes the values of any fields you omit. Include every value you want to keep, and restrict each source to the fields it owns. See Choose the right Product import endpoint.
- Bounded batch sizing. One import request carries at most
20resources, so split every extract into batches of that size and send them in parallel. Size batches and containers according to the Import API best practices. - Publication is explicit. An import does not publish a Product for you, and importing an update to a published Product behaves differently from creating one. Decide whether the sync publishes, and follow Manage published state of Products.
resourceType unset so a single container can accept both Inventory and Product data. A container created without a retentionPolicy expires 72 hours after creation, so set a TimeToLiveRetentionPolicy when a recurring load reuses the same container key. Because references resolve by key within the operation lifetime, you can submit Inventory before the Products exist: each Inventory Entry links to its Product Variant by sku once the Product is created.Import inventory data from the ERP
Inventory data often originates in an ERP system. A common pattern is to export the ERP data as a CSV file, parse it, and submit it to the Import API.
manage_import_containers:{projectKey} to manage the Import Container and manage_products:{projectKey} to submit the Inventory and Product Draft import requests in this example. To monitor the operations, also grant view_import_containers:{projectKey} and view_products:{projectKey}. For details, see Import API authorization.Consider the following example CSV from the ERP:
sku,quantityOnStock,restockableInDays,expectedDelivery
product-sku-1,100,5,2026-05-01T10:00:00.000Z
product-sku-2,250,3,2026-04-15T10:00:00.000Z
product-sku-3,0,10,2026-06-01T10:00:00.000Z
BATCH_SIZE aligned with the maximum of 20 resources per import request:import { parse } from "csv-parse/sync";
import * as fs from "fs";
// Must not exceed the Import API limit on resources per request
const BATCH_SIZE = 20;
function batch<T>(items: T[], size: number): T[][] {
const batches: T[][] = [];
for (let i = 0; i < items.length; i += size) {
batches.push(items.slice(i, i + size));
}
return batches;
}
// Parse the ERP CSV export
const csvContent = fs.readFileSync("erp-inventory-export.csv", "utf-8");
const records = parse(csvContent, { columns: true, skip_empty_lines: true });
// Map CSV rows to Import API InventoryImport resources
const inventoryImports = records.map((row) => ({
key: `inventory-${row.sku}`,
sku: row.sku,
quantityOnStock: parseInt(row.quantityOnStock, 10),
restockableInDays: parseInt(row.restockableInDays, 10),
expectedDelivery: row.expectedDelivery,
}));
// Submit the inventory import operations, one batch per request
for (const resources of batch(inventoryImports, BATCH_SIZE)) {
await apiRoot
.inventories()
.importContainers()
.withImportContainerKeyValue({ importContainerKey: "pim-product-import" })
.post({
body: {
type: "inventory",
resources,
},
})
.execute();
}
Import product data from the PIM
Product data typically comes from a PIM system, also often as a CSV export. Products contain KeyReference fields for resources such as Product Types and Categories. The Import API resolves these references automatically.
Consider the following example CSV from the PIM:
key,name.en,slug.en,description.en,productType,categories,sku,priceKey,price
blue-widget,"Blue Widget","blue-widget","A high-quality blue widget.","standard-product","widgets;accessories","blue-widget-sku","blue-widget-price","EUR 1999"
red-gadget,"Red Gadget","red-gadget","A versatile red gadget.","standard-product","gadgets","red-gadget-sku","red-gadget-price","EUR 2499"
Parse the CSV and submit the product data as import operations:
import { parse } from "csv-parse/sync";
import * as fs from "fs";
// Must not exceed the Import API limit on resources per request
const BATCH_SIZE = 20;
function batch<T>(items: T[], size: number): T[][] {
const batches: T[][] = [];
for (let i = 0; i < items.length; i += size) {
batches.push(items.slice(i, i + size));
}
return batches;
}
// Parse the PIM CSV export
const csvContent = fs.readFileSync("pim-product-export.csv", "utf-8");
const records = parse(csvContent, { columns: true, skip_empty_lines: true });
// Map CSV rows to Import API ProductDraftImport resources
const productDraftImports = records.map((row) => {
const [currencyCode, centAmountStr] = row.price.split(" ");
return {
key: row.key,
name: { en: row["name.en"] },
slug: { en: row["slug.en"] },
description: { en: row["description.en"] },
productType: {
key: row.productType,
typeId: "product-type",
},
categories: row.categories.split(";").map((catKey) => ({
key: catKey.trim(),
typeId: "category",
})),
masterVariant: {
key: row.sku,
sku: row.sku,
prices: [
{
key: row.priceKey,
value: {
type: "centPrecision",
currencyCode: currencyCode,
centAmount: parseInt(centAmountStr, 10),
},
},
],
},
};
});
// Submit the product import operations, one batch per request
for (const resources of batch(productDraftImports, BATCH_SIZE)) {
await apiRoot
.productDrafts()
.importContainers()
.withImportContainerKeyValue({ importContainerKey: "pim-product-import" })
.post({
body: {
type: "product-draft",
resources,
},
})
.execute();
}
productType and categories fields use key references. The Import API resolves these dependencies asynchronously. If the referenced Product Type or Category does not exist yet, the operation stays unresolved for the 48-hour lifetime of the Import Operation and is retried up to 5 times once the dependency arrives. After that the operation expires.imported, rejected, validationFailed, canceled, and partiallyImported as terminal states. Treat processing, unresolved, and waitForMasterVariant as non-terminal states. Inspect an individual ImportOperation when the summary reports a resource that needs attention.Solution B: incremental sync with the HTTP API
service webhook, authenticate the caller, verify the payload signature, and make the handler idempotent so a redelivered event is a no-op.Upsert inventory entries
404), create it. If it exists, update it with the latest data.async function upsertInventoryEntry(
apiRoot,
inventoryKey: string,
sku: string,
quantityOnStock: number
) {
try {
// Attempt to fetch the existing inventory entry by key
const existing = await apiRoot
.inventories()
.withKey({ key: inventoryKey })
.get()
.execute();
// Resource exists: update it
const updated = await apiRoot
.inventories()
.withKey({ key: inventoryKey })
.post({
body: {
version: existing.body.version,
actions: [
{
action: "changeQuantity",
quantity: quantityOnStock,
},
],
},
})
.execute();
return updated.body;
} catch (error) {
if (error.statusCode === 404) {
// Resource doesn't exist: create it
const created = await apiRoot
.inventories()
.post({
body: {
key: inventoryKey,
sku: sku,
quantityOnStock: quantityOnStock,
},
})
.execute();
return created.body;
}
throw error;
}
}
Upsert products
The same pattern applies to Products. Fetch by key, then create or update depending on whether the Product already exists.
async function upsertProduct(
apiRoot,
productKey: string,
productData: {
name: { en: string };
slug: { en: string };
productType: { key: string; typeId: "product-type" };
description?: { en: string };
}
) {
try {
// Attempt to fetch the existing Product by key
const existing = await apiRoot
.products()
.withKey({ key: productKey })
.get()
.execute();
// Resource exists: update it
const updated = await apiRoot
.products()
.withKey({ key: productKey })
.post({
body: {
version: existing.body.version,
actions: [
{
action: "changeName",
name: productData.name,
},
{
action: "changeSlug",
slug: productData.slug,
},
],
},
})
.execute();
return updated.body;
} catch (error) {
if (error.statusCode === 404) {
// Resource doesn't exist: create it
const created = await apiRoot
.products()
.post({
body: {
key: productKey,
name: productData.name,
slug: productData.slug,
productType: productData.productType,
description: productData.description,
},
})
.execute();
return created.body;
}
throw error;
}
}
Build a resilient HTTP API client
409 ConcurrentModification responses, fetch the latest resource version and retry only if the update is still required. For implementation guidance, see client resiliency and error handling through timeouts and retries.Choose the right API
Verify the integration
Before the first production run, test the mapping in isolation. A mapping is a pure transformation from a source record to a commercetools draft, so you can test it with fixtures and no credentials. Cover the cases that silently corrupt a catalog: locale codes that differ between the systems, controlled vocabularies keyed on a stable code rather than a translated label, measurement units normalized to one measure, keys derived deterministically from a source identifier, references emitted by key, and out-of-scope attributes omitted rather than sent as empty values.
CT_ENV=test before the run proceeds, and refuse any Project key that is not on an allowlist. Use the same narrow scopes the integration will run with in production.Count before you write. Ask the source how many records the run covers, print the count, and refuse a run above a threshold unless the operator confirms it explicitly. An unbounded first sync can pull a full PIM export of hundreds of thousands of products, which is slow, expensive, and hard to undo in a shared Project. Bound the first run to a small representative sample, and reserve full-catalog runs for the Import API path.
With that sample, prove the following:
- The Products, Categories, Prices, and Inventory exist with the expected keys, localized values, and references.
- Re-running the same sample changes nothing and creates no duplicates, which confirms the upsert is idempotent.
- A record changed in the source reaches commercetools within the agreed window.
- A field another system owns remains unchanged after an update from a different source.
- A product discontinued in the source produces the agreed outcome, such as unpublish or deactivate, rather than remaining sellable.
- Reconciliation detects a deliberately skipped update and repairs it.
rejected, validationFailed, and partiallyImported operations. Use the Import Operation states, reference resolution behavior, and Product Variant availability to distinguish defects from expected asynchronous processing. Clean up the test data afterward.Troubleshoot observable symptoms
Match the symptom you observe to its likely cause and resolution:
| Symptom | Likely causes | Checks and resolution |
|---|---|---|
| An import created a second resource instead of updating the existing one | The existing resource has no key, or it has a different one. The Import API cannot set a key on an existing resource | Set keys through the HTTP API first, then re-run the import and remove the duplicates |
| Products imported successfully but the storefront returns nothing | The import created the Products without publishing them, so only the staged data exists | Decide where the sync publishes and follow Manage published state of Products |
| An import request was rejected before any operation was created | The request carried more than 20 resources | Split the extract into batches of that size and send the batches in parallel |
| A Price or stock load blanked descriptions or images | An import update omitted fields another system owns, and the omitted values were removed | Include every field to keep in the import, or restrict each source to the resources and fields it owns |
| Import operations stay unresolved and eventually expire | A referenced Product Type, Category, or Channel does not exist, and the reference never arrived within the operation lifetime | Create the prerequisite, then resubmit within the resolution window |
| An operation resolves its references but the resource is still missing | Reference resolution is not validation; the operation entered validationFailed because the data violated a constraint such as sku uniqueness | Read the operation errors field, correct the source record, and submit a new import |
| Facets or filters break for a translated catalog | An enum attribute was keyed on a localized label instead of a stable option code | Key each enum option on the PIM option code and re-map the affected attribute |
| A translation is missing after synchronization | A PIM locale code such as en_US was not mapped to the commercetools form en-US | Map each PIM locale explicitly, drop out-of-scope locales, and verify the imported LocalizedString values |
| Stock imported but the storefront still shows the old availability | The Product Variant availability field is eventually consistent and lags the InventoryEntry | Query the InventoryEntry for accurate stock; allow a few seconds for availability to settle |
| Stock is deducted twice for the same Order | The feed decrements Inventory and commercetools decrements it too, because the Cart InventoryMode is not None | Set the mode to None, or subtract the platform-side deduction in the mapping |
| An externally owned attribute keeps being changed in the Merchant Center | The attribute is not restricted, so a team edits it and the next sync overwrites it | Group externally owned attributes in an AttributeGroup and grant editing permission only to the responsible teams |
| Discontinued products remain sellable | No flow handles removal, because the Import API cannot delete | Detect resources present in commercetools and absent from the source during reconciliation, then unpublish or deactivate them |