Synchronize Customer and Order data between commercetools and an external CRM, migrate existing records, and handle consent, deletion, and erasure.
A customer relationship management system (CRM) stores customer profiles, segments, and interaction history so that marketing, sales, and support teams can act on them. This guide covers the flows that connect one to commercetools: an outbound export of Customer and Order changes, an inbound flow that applies CRM-mastered changes to Customers, and a one-time migration of existing records.
A CRM integration is asynchronous. Nothing in it runs on the checkout path, so a slow or unavailable CRM must never delay a registration, a Cart operation, or Order creation.
This guide does not cover authentication or identity providers. They decide how a shopper signs in rather than where their profile is mastered.
Define CRM integration requirements
The Connector choice and the sync design are both downstream of these decisions. Record them before you configure or build anything.
Assign a source of truth per data domain
| Data domain | Typical owner when CRM is present | Direction |
|---|---|---|
| Account credentials and sign-in | commercetools | Not synchronized |
| Customer profile and contact details | CRM, or commercetools when no CRM masters them | Varies |
| Addresses | commercetools when they drive shipping and tax | commercetools to CRM |
| Marketing consent and communication preferences | CRM | CRM to commercetools |
| Segments, lifecycle stage, and loyalty tier | CRM | CRM to commercetools |
| Customer Groups used for pricing | commercetools | Not synchronized, or derived from CRM segments |
| Order capture and contents | commercetools | commercetools to CRM |
| Order fulfillment lifecycle | OMS or ERP | OMS or ERP to commercetools |
| B2B accounts and hierarchies | ERP, or commercetools | Varies |
Keep credentials out of the synchronization entirely. Passwords are stored as hashes and cannot be copied between systems, which makes sign-in a migration problem rather than a sync problem.
A default suits most implementations: the CRM masters the profile, synchronization runs one way, migration runs as a separate job, and deletion propagates. Depart from it only for a stated reason. Bi-directional synchronization is the exception, not the starting point, and it requires field-level ownership on top of everything else in this guide.
Scope the sync flows
Answer and record the following questions because each one changes the design:
- Which direction, and which resources? Customers only, or Customers and Orders. A commercetools Customer usually maps to a CRM contact or person, and an Order to a CRM deal, opportunity, or sales record.
- Which changes trigger an outbound sync? Creation only, or every profile change, or specific changes such as an email address update. This decides whether you register a Change Subscription or a set of Message Subscriptions.
- How does the CRM deliver changes inbound? A webhook it calls, or an endpoint you poll. A webhook maps to a
serviceapplication. Polling maps to ajobapplication. - Migration, ongoing sync, or both? Most implementations need both built and deployed separately.
- What latency and volume must the integration support? Near-real-time delivery or an overnight batch, and the record counts that determine pagination and rate-limit handling.
- How do the two record models map? Localized names, the commercetools address array against a flat contact schema, Customer Groups, and Custom Fields rarely map one to one.
- What must happen on deletion? Whether an erasure request deletes or anonymizes the CRM record, and which other systems hold the same personal data.
- Which consent flags apply? Marketing consent, double opt-in, and channel preferences, and which system records them.
Record special requirements
The list above covers the common shape. Capture additional constraints as separate requirements. Each one can move the decision in the next section from configuring a Connector toward forking or building one:
- Multi-brand or multi-market contact separation. Customers can be assigned to Stores, and
view_customersandmanage_customersboth have Store-scoped variants. - Segments or loyalty tiers that must drive pricing, which makes them Customer Group input rather than profile data.
- B2B contacts that belong to a Business Unit as well as to a CRM account.
- Data residency and compliance constraints that restrict the deployment Region.
- An existing CRM contract or tenant that constrains the integration shape.
- A customer data platform already sitting between commercetools and the CRM. Feeding one from commercetools is an export pipeline rather than a CRM sync, and is covered by Integrate an analytics destination.
Choose an integration path
Work through these options in order and stop at the first one that meets your requirements. Each later option is more to build and more to maintain.
Check for an existing CRM Connector
view_connectors scope to call the Search Connectors endpoint and filter by integration type:GET https://connect.{region}.commercetools.com/connectors/search?integrationTypes=crm&integrationTypes=email&private=false
crm and email because the IntegrationType enum separates customer relationship management from email and marketing, and marketing automation and customer data platforms are commonly listed under the latter. Set private to false so that the response covers all available Connectors rather than only those already assigned to your Project. For the host to use in each Region, see Hosts and authorization.Clarify which system the business actually means before you evaluate candidates. Marketing automation, campaign, and personalization platforms are a different category from a system-of-record sales CRM, and the available Connectors differ between them.
Compare each candidate against your requirements rather than against its headline. Check the direction it supports, which resources it synchronizes, its field-mapping configuration, its consent and deletion handling, and support for your Region and volume.
Prove that a gap is not configuration
When a Connector matches your CRM but appears to be missing a behavior, confirm that configuration cannot close the gap before you consider forking it. Field mapping, Message selection, and consent handling are settings in most CRM Connectors rather than code.
Fork or build
If a Connector matches your CRM and has a genuine gap that configuration cannot close, and its source is available, fork it. Add only the difference and deploy the result as an Organization Connector.
commercetools connect init my-crm-connector
commercetools connect application add --type event
service or job application for the inbound flow, and a job application for migration. The scaffold supplies the application structure. Define the lifecycle scripts and Subscription registration, and implement the provider-specific envelope handling, CRM API calls, and record mapping.Whichever option you choose, the design in the following sections is the same. Only the question of who implements it changes.
Choose the record link before you design the sync
Compare Customer link fields
| Field | Uniqueness | Direct lookup | Use it when |
|---|---|---|---|
externalId | Not enforced by the API | None. Requires a query | The Customer already has a key, or the CRM identifier is not a valid key |
key | Enforced across the Project | Get Customer by Key | The CRM identifier is 2 to 256 characters and matches ^[A-Za-z0-9_-]+$, and no other system owns key |
customerNumber | Enforced across the Project | None. Requires a query | A human-readable account number is the shared identifier |
externalId is the documented field for references to external systems such as a CRM or ERP, and it is the safe default when another system already owns key. It gives up two things in exchange. The API does not reject a second Customer carrying the same externalId, and there is no endpoint that retrieves a Customer by it, so every correlation is a query whose result count you must check yourself.key, prefer it. Get Customer by Key and Update Customer by Key turn correlation into a single request against a uniqueness constraint the platform enforces, which removes a whole class of duplicate-record failures. Store the CRM identifier in a Custom Field when you need to carry more than one external reference.Whichever field you choose, reject more than one match instead of taking the first result. When the counterpart should already exist, also reject zero matches. The inbound flow defines the separate creation policy for a CRM record that does not yet have a Customer.
Look up the linked Customer
GET /{projectKey}/customers?where=externalId = :crmRecordId&var.crmRecordId=crm-84213&limit=2
limit of two so that a duplicate is visible in the response rather than hidden behind the first result.externalId as a keyword field and is the better fit for high-volume correlation, subject to two conditions. It is ID-first and returns Customer IDs only, so the handler still needs a Get Customer by ID call to read the version an update requires. It is also deactivated by default and deactivates again after 30 days without a search call, so an integration that searches only during a monthly batch can start returning a SearchNotReadyError without anything in the integration having changed. Call Check if Customer Search index exists before a batch run rather than assuming the index is there.Design the sync flows
event application for the export, a service or job application for the inbound flow, and a job application for migration.Export Customer changes to the CRM
event application, which receives Messages through a Subscription and a Connect-provisioned message broker.Subscribe to the smallest set of Messages that meets your requirements
customer resource delivers ResourceCreated, ResourceUpdated, and ResourceDeleted for every change, which is the simplest way to keep a full profile in sync. Message Subscriptions target specific changes such as CustomerCreated or CustomerEmailChanged when only some changes should reach the CRM. Select the types you need from Customer Messages, and add OrderCreated from Cart and Order Messages when Orders map to CRM records. Acknowledge anything else without acting on it.value when the token's validity is 60 minutes or less, which moves an account-recovery credential into a system that was never scoped to hold one. Decide per field what the CRM receives instead of forwarding a resource unchanged.Retrieve the Customer instead of reading the Message payload
resource.id. Subscriptions deliver at least once with no ordering guarantee, so a payload can describe an older state than the Customer currently has, and an out-of-order delivery would otherwise overwrite a newer profile with an older one. Retrieving the resource makes the export converge on the current state. When strict ordering matters, the same page documents how to order deliveries using resource.id with sequenceNumber for Messages, or with version and oldVersion otherwise.Decode the transport envelope before you read anything from it. Connect provisions either Google Cloud Pub/Sub or Amazon SNS as the destination, and each provider uses a different transport envelope. Decode it according to the provider's documented message format. The decoded body is either PlatformFormat or CloudEventsFormat depending on the Subscription configuration. Read the Message type and the resource identifier from whichever combination you receive, and validate the type before acting so that platform test notifications are acknowledged rather than processed.
Upsert on the link instead of creating
Do not re-export your own write-back
customer resource delivers the corresponding ResourceUpdated notification to the same handler that caused it. Every new Customer is then exported twice, and export volume climbs with no business cause.This happens in a one-way design, so guard against it even when nothing writes Customers inbound. Which guard is available depends on the Subscription you registered. A Change Subscription delivery carries no Message type, so the only guard is comparing the retrieved Customer against what the CRM already holds and exiting when the link is set and nothing else has changed. Type-based filtering, where the handler ignores the Message types its own writes produce, requires Message Subscriptions.
If an inbound flow also writes Customers, the same guard prevents the larger failure: an inbound write raises a change that the export pushes back to the CRM, which the CRM then reports as a change, without a natural stopping point.
Acknowledge deliberately
200 for every error is the opposite failure: a CRM outage is acknowledged, the Messages are discarded, and the profiles silently never arrive.Apply inbound CRM updates
service application as an authenticated webhook when the CRM can push changes, or a job application when you must poll for deltas.Authenticate every request and validate the payload
The endpoint is reachable from the internet and writes personal data to your Project. Verify a webhook signature, a shared secret, or a fully validated token including signature, issuer, audience, and expiry, on every processing route. Leave only a liveness route unauthenticated. Keep the secret in secured configuration.
Correlate by the stored link and read before writing
Decide what zero matches means before you implement the handler. If the CRM can originate Customers, create a Customer with the mandatory email address and the CRM identifier in the selected link field. If another flow creates every Customer first, treat zero matches as a permanent failure and record it for correction or replay. In both designs, more than one match is a data-integrity failure. Do not select the first result.
409 version conflict by re-reading the Customer and re-evaluating the decision rather than replaying the same actions against a new version.A redelivered webhook must be a no-op, and a notification that arrives late must not overwrite a newer value. Compare the CRM change against the current state and skip the write when the Customer already carries it.
The following handler applies a CRM profile update using that sequence: correlate, re-read, decide, write, and re-evaluate on conflict.
import type {
ByProjectKeyRequestBuilder,
Customer,
CustomerUpdateAction,
} from "@commercetools/platform-sdk";
class PermanentSyncError extends Error {}
async function applyCrmProfileUpdate(
apiRoot: ByProjectKeyRequestBuilder,
crmRecordId: string,
changes: { firstName?: string; lastName?: string }
): Promise<Customer> {
const MAX_ATTEMPTS = 3;
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
const matches = await apiRoot
.customers()
.get({
queryArgs: {
// An input variable keeps the CRM identifier out of the predicate string
where: "externalId = :crmRecordId",
"var.crmRecordId": crmRecordId,
limit: 2,
},
})
.execute();
// Treat correlation failures as permanent; redelivery cannot repair the data
if (matches.body.results.length === 0) {
throw new PermanentSyncError(
`No Customer exists for CRM record ${crmRecordId}`
);
}
if (matches.body.results.length > 1) {
throw new PermanentSyncError(
`Multiple Customers exist for CRM record ${crmRecordId}`
);
}
const customer = matches.body.results[0];
const actions: CustomerUpdateAction[] = [];
if (changes.firstName && changes.firstName !== customer.firstName) {
actions.push({ action: "setFirstName", firstName: changes.firstName });
}
if (changes.lastName && changes.lastName !== customer.lastName) {
actions.push({ action: "setLastName", lastName: changes.lastName });
}
// A redelivered webhook carrying no new value is a no-op
if (actions.length === 0) {
return customer;
}
try {
const updated = await apiRoot
.customers()
.withId({ ID: customer.id })
.post({ body: { version: customer.version, actions } })
.execute();
return updated.body;
} catch (error) {
const statusCode =
typeof error === "object" && error !== null && "statusCode" in error
? (error as { statusCode?: number }).statusCode
: undefined;
// Re-read and re-evaluate; never replay these actions against a new version
if (statusCode === 409) {
await new Promise((resolve) => setTimeout(resolve, 2 ** attempt * 100));
continue;
}
throw error;
}
}
throw new Error(`Version conflict persisted for CRM record ${crmRecordId}`);
}
PermanentSyncError as a non-retryable outcome: record it for correction or replay and return the client error defined by the CRM webhook contract.Poll the CRM for inbound changes
job application to request records changed after a durable cursor. Store the cursor outside the application process, such as in a Custom Object, because a later run can start on another application instance.Read pages in a stable order. If multiple records can have the same modification time, include the CRM record identifier in the cursor as a tie-breaker. Apply each record with the same creation policy, correlation, and version-conflict handling as the webhook flow. Save the next cursor only after every record in the page succeeds. If a transient failure interrupts the page, leave the previous cursor in place so the next run safely reprocesses it.
429 and 5xx responses with exponential backoff. Test that the cursor advances after a complete page, remains unchanged after an interrupted page, and that two overlapping runs cannot process the same window.Govern who can edit CRM-owned fields
manage_customers only to the API Clients that need it, and use the Store-scoped variant where it applies. Restrict Customer editing in the Merchant Center to the teams responsible for it. Merchant Center permissions do not constrain API Clients, so the first two places carry the enforcement.Migrate existing customer records
job independently.Plan for passwords before you plan the records
Checkpoint, serialize, and respect rate limits
Guard against overlapping scheduled runs with a durable lock whose time to live is longer than the maximum run time. A crashed run then eventually releases the lock.
429 and 5xx responses with exponential backoff, and use the CRM batch endpoints for bulk work. Cleanse and validate records on the way through, because a migration is the cheapest point at which to reject data that would fail later.Every unit of work must be idempotent, because a resumed run reprocesses the page it was working on. Share the mapping and the upsert with the ongoing sync rather than writing a second implementation that can drift from the first.
Handle consent, deletion, and erasure
Customer data is personal data. Synchronize the fields your requirements name and no more, keep CRM credentials in secured configuration, and log identifiers and processing decisions rather than payloads.
Carry consent through the mapping
Marketing consent and communication preferences must survive every mapping in both directions. A profile update that drops a "do not contact" flag re-enrolls someone who opted out. Map consent explicitly, assert it in the mapping tests, and treat a missing consent value as no consent rather than as a default.
Propagate deletion to the CRM
customer resource delivers a ResourceDeleted notification. Handle it in the export and delete or anonymize the counterpart CRM record. A deletion that stops at the commercetools boundary leaves personal data in a downstream system that no longer has a reason to hold it.Erase personal data on request
DELETE request leaves personal data that is part of Messages and in internal logs. Setting the dataErasure query parameter to true removes that data as well.DELETE /{projectKey}/customers/{id}?version=3&dataErasure=true
dataErasure field recording whether the DELETE request set the parameter to true, so one handler can distinguish an ordinary deletion from an erasure request and apply the stricter treatment to the CRM record. This document describes platform behavior and is not legal advice.Configure and deploy the Connector
Grant only the permissions the enabled flows use
connect.yaml and let Connect provision a least-privilege API Client rather than supplying credentials by hand. An outbound export needs view_customers, view_orders when it syncs Orders, and manage_subscriptions so that its postDeploy script can register the Subscriptions. An inbound flow needs manage_customers, and manage_types when it creates the Custom Type for CRM-mastered fields. A polling app also needs manage_key_value_documents when it stores its cursor or lock in Custom Objects. Grant each scope only to the application that uses it.manage_subscriptions, which covers both reading and writing. Declare only scopes that appear in the scopes reference, because a token request naming a scope the API Client does not hold returns a 400 invalid_scope error at authentication rather than a permission error at the failing call.Separate configuration from secrets
securedConfiguration. Base URLs, Region, sandbox toggles, page sizes, and which Messages to synchronize belong in standardConfiguration. Place connect.yaml in the root of the Connect application and use only documented envelope keys, as described in Connect development.endpoint base path declared for it in connect.yaml. A mismatch means platform traffic reaches an application that returns 404, which reads as a delivery failure rather than a routing mistake.Register the Subscriptions and Types idempotently
postDeploy script, and remove them in preUndeploy. Declare both in the scripts block of connect.yaml, as shown in Automation scripts. Create each resource when it is absent and update it in place rather than deleting and recreating it. Confirm the resources exist rather than inferring success from the Deployment status, using Deployment logs.Verify the round trip
Prove the flows against a test Project and a CRM sandbox before the first production run. The mapping is a pure transformation from a resource to a CRM record, so test it with fixtures and no credentials, and assert consent handling and the exclusion of token-bearing fields there.
Then prove the outcomes that only a deployed integration produces:
- A new Customer produces a counterpart CRM record, and the link is written back. A record that appears with no link indicates that the write-back step is missing, and the next change creates a duplicate.
- Updating one field produces exactly one write per direction, and no second record. Watch the write count settle rather than checking only that the value arrived.
- Redelivering the same Message changes nothing. Post the encoded envelope to the application a second time and confirm the CRM record is untouched.
- Creating a Customer produces one export, not two. A second export of the same Customer means the write-back is re-entering the handler.
- A CRM-originated change reaches commercetools and does not bounce back to the CRM.
- A CRM record with no linked Customer follows the selected creation policy: it either creates one linked Customer or produces a permanent replay record. A second delivery must not create another Customer.
- A polling run advances its cursor only after a complete page. Interrupt a page, run the job again, and confirm that it reprocesses the page without duplicating changes. Start two runs together and confirm that only the lock holder processes the window.
- Deleting a Customer deletes or anonymizes the CRM record, and no personal data remains in the logs of either system.
- The migration job resumes from its checkpoint after an interrupted run instead of reloading everything.
Some expected behavior looks like failure while testing. A CRM sandbox can cap records, expire data, or accept a write and return success without persisting it, so verify the contract against the sandbox and verify real persistence against a controlled full account. Clean up test records afterward so they do not reach CRM reporting or campaigns.
Troubleshoot observable symptoms
| Symptom | Likely cause | Resolution |
|---|---|---|
| Duplicate contacts in the CRM | The handler creates on every Message, or the link was never written back | Look the record up by the link before creating it, and write the identifier back in the same run |
| Duplicate Customers in commercetools | The inbound flow creates without first checking the link, or concurrent deliveries both observe zero matches | Apply the selected zero-match policy once, serialize creation by the CRM identifier, and reject multiple matches |
| Every new Customer is exported twice | The write-back emits a Customer change that the export Subscription redelivers | Exit early when the link is already set, or use Message Subscriptions and ignore the types your own writes produce |
| Each inbound write triggers an outbound write | A bi-directional design with no self-change filter | Move to one-way synchronization, or assert the filter with a test |
| The same Message is redelivered indefinitely | The response is outside the acknowledgment set | Return 102, 200, 201, 202, or 204 for handled and irrelevant Messages |
| Customers stop reaching the CRM with no errors | Every failure is acknowledged with 200, so transient failures are discarded | Return a non-acknowledgment status for transient failures only |
| Notifications stop arriving after a long CRM outage | Subscription delivery is retried for up to 48 hours on a TemporaryError, and for a shorter window on a ConfigurationError, after which notifications can be dropped | Alert on the Subscription health status, and reconcile the gap from commercetools rather than waiting for redelivery |
| The handler crashes on the incoming body | The transport envelope is not decoded, or the code assumes the wrong destination type | Decode the provider's transport envelope, then validate the Message type |
| A migration stops landing records | CRM rate limiting rather than a logic fault | Add exponential backoff on 429, use batch endpoints, and confirm the job resumed from its checkpoint |
A migration worker fails with LockedField | Two workers created Customers with the same email address at the same time | Partition or serialize by email address |
| The polling job skips CRM changes after a failure | The cursor advanced before every record in the page succeeded | Save the cursor only after the page completes, then replay the interrupted page |
| Two polling runs apply the same window concurrently | Scheduled runs overlapped without a durable lock | Acquire a lock with a time to live before reading the cursor |
Correlation fails with SearchNotReadyError | Customer Search is deactivated by default, and deactivates after 30 days without a search call | Reactivate it, and check the index before a batch run |
Platform traffic returns 404 | The router is not mounted at the endpoint declared in connect.yaml | Align the route with the declared base path |
| No Subscription exists after deployment | The postDeploy script is not declared in the scripts block | Declare it, redeploy, and confirm the Subscription exists |
| Storefront edits overwrite CRM-mastered fields | Custom Fields carry no read-only enforcement | Remove those fields from your write paths and narrow the API Client scopes |