# Integrate a customer relationship management system Synchronize Customer and Order data between commercetools and an external CRM, migrate existing records, and handle consent, deletion, and erasure. Learn more about integrations in the self-paced [Integration patterns](/learning-integrate-with-commercetools/integration-patterns/overview.md) module. 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. This guide also excludes B2B organization modeling. Account hierarchies belong to [Model Business Units](/guides/implementation-guide/model-business-units.md), while account, credit, and payment-term data mastered in an ERP belongs to [Integrate ERP](/guides/erp-integration.md). For transactional email, see [Integrate email](/guides/connect-email-integration.md). Exporting commerce data to a warehouse is also separate from CRM synchronization. Before you start, you need a commercetools Project, a CRM with a documented API and test credentials, and an agreed owner for each data domain described in the next section. For the wider planning framework, see [Integration planning and patterns](/learning-integrate-with-commercetools/integration-patterns/integration-planning-and-patterns.md). ## 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 The system of record for customer data varies more across organizations than any other data type. Assign exactly one owner per domain and make the other side read-only for it. A field with two writers produces conflicts, update loops, and lost changes that are difficult to diagnose after the fact. For the wider decision, see [Plan integrations](/guides/implementation-guide/plan-integrations.md). | 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 | Even when the CRM masters the profile, a [Customer](/search.md?urn=ctp:api:type:Customer) must exist in commercetools. It carries customer permissions, ownership of Carts and Orders, and eligibility for personalized promotions. For the systems that own adjacent domains, see [Integrate an order management system](/guides/oms-integration.md) and [Integrate ERP](/guides/erp-integration.md). 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 `service` application. Polling maps to a `job` application. - **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](/api/projects/stores.md), and `view_customers` and `manage_customers` both 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](/api/projects/business-units.md) 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](/guides/analytics-export-integration.md). ## 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 Check the current Connector inventory instead of relying on a remembered list, because published Connectors and their versions change. Use an API Client with the `view_connectors` scope to call the [Search Connectors](/connect/connectors.md#search-connectors) endpoint and filter by integration type: ```http title="Search for CRM Connectors" GET https://connect.{region}.commercetools.com/connectors/search?integrationTypes=crm&integrationTypes=email&private=false ``` Query both `crm` and `email` because the [IntegrationType](/search.md?urn=ctp:connect:type: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](/connect/hosts-and-authorization.md). 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 certification and access, source availability, installation requirements, and Region support using the canonical [Connector representation](/search.md?urn=ctp:connect:type:Connector). Record the Connector key and version you evaluated. A listing in the [technology partner directory](https://marketplace.commercetools.com/connectors) is not automatically an installable Connector. For installable Public Connectors, use [Connect in the Merchant Center](/merchant-center/connect.md). Confirm the deployment model of each candidate before you plan a Connect deployment. The vendor documentation and repository are authoritative for how an integration installs and what it covers. 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. If no Connector matches your CRM, build one. The [Application templates overview](/connect/templates/templates-overview.md) documents the payment, product export, tax, and email templates, and none of them models customer synchronization. Scaffold the applications your direction requires with the [Connect CLI](/connect/cli.md) instead: ```bash title="Scaffold the applications a CRM integration needs" commercetools connect init my-crm-connector commercetools connect application add --type event ``` Add a `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 Every write in either direction is an upsert keyed on a stable link between the two records. Subscriptions deliver [at least once](/api/projects/subscriptions.md#delivery-guarantees), so a design that creates a record on every Message produces duplicate contacts and duplicate Customers. Choose the link first, because it decides how every later lookup works. ### Compare Customer link fields Three fields on a [Customer](/search.md?urn=ctp:api:type:Customer) can hold an external reference, and they differ in ways that change the implementation. | 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](/api/projects/customers.md#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. When the CRM identifier can be the Project-wide `key`, prefer it. [Get Customer by Key](/api/projects/customers.md#get-customer-by-key) and [Update Customer by Key](/api/projects/customers.md#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](/api/projects/custom-fields.md) 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 Correlate with a [Query Predicate](/api/predicates/query.md) on the linking field. Pass the CRM identifier as an input variable rather than concatenating it into the predicate string, so that an identifier containing a quote or a backslash cannot alter the predicate: ```http title="Find the Customer linked to a CRM record" GET /{projectKey}/customers?where=externalId = :crmRecordId&var.crmRecordId=crm-84213&limit=2 ``` Request a `limit` of two so that a duplicate is visible in the response rather than hidden behind the first result. [Customer Search](/api/projects/customer-search.md) indexes `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](/api/projects/customers.md#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](/api/projects/customer-search.md#reactivate), so an integration that searches only during a monthly batch can start returning a [SearchNotReadyError](/search.md?urn=ctp:api:type:SearchNotReadyError) without anything in the integration having changed. Call [Check if Customer Search index exists](/api/projects/customer-search.md#check-if-customer-search-index-exists) before a batch run rather than assuming the index is there. ## Design the sync flows A CRM integration is a set of one-way flows that fail independently. This guide uses the standard [Connect](/connect/overview.md) application types: an `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 The export reacts to a change in commercetools and pushes it to the CRM. Use an `event` application, which receives Messages through a Subscription and a Connect-provisioned message broker. ```mermaid title="Export a Customer change to the CRM" sequenceDiagram participant CT as commercetools participant Sub as Subscription participant Export as Connect event application participant CRM as CRM CT->>Sub: Emit Customer Message Sub->>Export: Deliver encoded envelope Export->>Export: Decode and filter by Message type Export->>CT: Get Customer by resource.id CT-->>Export: Current Customer and version Export->>CRM: Look up the record by the stored link CRM-->>Export: Existing record or none Export->>CRM: Create the record if absent CRM-->>Export: CRM record identifier Export->>CT: Write the link back with the current version Export-->>Sub: Acknowledge within 10 seconds ``` #### Subscribe to the smallest set of Messages that meets your requirements A Change Subscription on the `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](/search.md?urn=ctp:api:type:CustomerCreatedMessage) or [CustomerEmailChanged](/search.md?urn=ctp:api:type:CustomerEmailChangedMessage) when only some changes should reach the CRM. Select the types you need from [Customer Messages](/api/projects/messages/customer-messages.md), and add [OrderCreated](/search.md?urn=ctp:api:type:OrderCreatedMessage) from [Cart and Order Messages](/api/projects/messages/cart-order-messages.md) when Orders map to CRM records. Acknowledge anything else without acting on it. Exclude [CustomerEmailTokenCreated](/search.md?urn=ctp:api:type:CustomerEmailTokenCreatedMessage) and [CustomerPasswordTokenCreated](/search.md?urn=ctp:api:type:CustomerPasswordTokenCreatedMessage) from any Message Subscription you register. Both carry the token itself in `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 Retrieve the Customer by `resource.id`. Subscriptions deliver [at least once with no ordering guarantee](/api/projects/subscriptions.md#delivery-guarantees), 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 Look the CRM record up by the stored link, update it when it exists, and create it only when it does not. Connect [autoscales](/connect/deployment-behavior-and-environments.md#event) an application across instances, so an in-process set of processed identifiers is lost on restart and is not shared between them. The link is the only durable idempotency key available to the handler. When the handler creates a CRM record, write the returned identifier back to the Customer with [Set ExternalId](/search.md?urn=ctp:api:type:CustomerSetExternalIdAction), [Set Key](/search.md?urn=ctp:api:type:CustomerSetKeyAction), or [Set CustomField](/search.md?urn=ctp:api:type:CustomerSetCustomFieldAction), using the version from the retrieval in the previous step. A record created in the CRM with no link written back is the failure that produces a duplicate on the next Message. #### Do not re-export your own write-back Writing the link back is itself a Customer change. It emits [CustomerExternalIdSet](/search.md?urn=ctp:api:type:CustomerExternalIdSetMessage), and a Change Subscription on the `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 Respond with a status that the message queue accepts as an acknowledgment. Other responses are retried according to the retention and backoff rules in [Event application behavior](/connect/deployment-behavior-and-environments.md#event). The Subscription that feeds the queue has its own, shorter [delivery retry window](/api/projects/subscriptions.md#delivery-guarantees), which depends on the [SubscriptionHealthStatus](/search.md?urn=ctp:api:type:SubscriptionHealthStatus). Negative acknowledgment is therefore a short-term recovery path rather than a substitute for reconciliation. Set up an automatic alert on the health status, and size your recovery plan against the documented Subscription delivery window rather than the message queue retention period. Map each outcome to a status code on purpose. [Integrate an order management system](/guides/oms-integration.md#acknowledge-deliberately) gives the full outcome-to-status mapping, and it applies here unchanged: acknowledge a Message you synchronized, a type you do not handle, an envelope that can never be decoded, and a record that permanently cannot be synchronized, and withhold acknowledgment only for a transient CRM or network failure. Returning `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 Inbound updates arrive from the CRM. Use a `service` application as an authenticated webhook when the CRM can push changes, or a `job` application when you must poll for deltas. Do not implement inbound updates as an API Extension. An API Extension runs synchronously inside a commercetools API request, so CRM latency or an outage would delay or block the operations the shopper is waiting on. In webhook mode no API Extension is registered, the default two-second API Extension response limit does not apply, and the five-minute Connect [service timeout](/connect/deployment-behavior-and-environments.md#service) applies instead. ```mermaid title="Apply a CRM update to the Customer" sequenceDiagram participant CRM as CRM participant Inbound as Connect service application participant CT as commercetools CRM->>Inbound: Send update with the CRM record identifier Inbound->>Inbound: Authenticate caller and validate payload Inbound->>CT: Query Customers by the link predicate CT-->>Inbound: Customer and current version Inbound->>Inbound: Compare the CRM change against current state Inbound->>CT: Post update actions with that version CT-->>Inbound: Updated Customer, or a version conflict Inbound-->>CRM: Return a status the CRM can act on ``` #### 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. The handler below implements the second design: it updates only Customers that already exist. Find the Customer with the predicate from the previous section, require exactly one match, and use the version from that read in the update request. Handle a `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. ```typescript title="Apply a CRM profile update idempotently" 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 { 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}`); } ``` Return a success status when the update is applied or intentionally skipped, a client error for a payload that will never be valid, and a server error for a transient failure so the CRM retries. Handle `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 When the CRM cannot send webhooks, use a `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. Take a durable lock before reading the cursor so that two scheduled runs cannot process the same window. Give the lock a time to live longer than the maximum run time so a failed run does not block every later run. Stop with enough time to save progress before the documented [Job application timeout](/connect/deployment-behavior-and-environments.md#job). Treat an invalid record according to a recorded failure policy. Either stop the page without advancing the cursor, or record the item for correction and replay before continuing. Retry CRM `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 A Custom Field cannot be marked read-only. A [FieldDefinition](/search.md?urn=ctp:api:type:FieldDefinition) sets the data type, label, whether a value is required, and an input hint, and none of those restrict writes. Ownership is a governance decision that you enforce outside the data model. Enforce it in three places. Keep CRM-mastered values out of the update actions your storefront and backend send. Grant `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 A one-time backfill and an ongoing delta sync need different pagination, throughput, and error handling. Build them as separate applications and deploy the migration `job` independently. ```mermaid title="Migrate existing customer records" flowchart LR Start[Scheduled or on-demand run] --> Lock{Acquire lock} Lock -- Held by an active run --> Skip[Log and exit] Lock -- Acquired --> Cursor[Load checkpoint] Cursor --> Page[Read the next page from the source] Page --> Upsert[Upsert each record by the link] Upsert --> Save[Checkpoint the page] Save --> More{More pages and time remaining} More -- Yes --> Page More -- No --> Release[Release lock] ``` #### Plan for passwords before you plan the records Password hashes cannot be copied into commercetools, so migrating profiles does not migrate sign-in. [Import customers](/guides/implementation-guide/import-customers.md) sets out the two options, a gradual migration that rehashes each password on first sign-in and a forced password reset after the migration, and what each one requires. Choose between them before you scope the record mapping, because the gradual path adds a Custom Type to every Customer you migrate. #### Checkpoint, serialize, and respect rate limits Follow the documented [Job application behavior](/connect/deployment-behavior-and-environments.md#job) and [resource recommendations](/connect/best-practices.md#job-application). The documented runtime and resource limits make checkpointing mandatory rather than optional: a full backfill of a large customer base might not finish in one run, so it has to survive being stopped and resumed. Checkpoint your cursor after each page, stop with a margin before the timeout, and orchestrate heavy processing outside the container rather than performing it inside the job. 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. Email is the mandatory unique identifier of a Customer, and creating two Customers with the same email address at the same time can return a [LockedField](/search.md?urn=ctp:api:type:LockedFieldError) error. Partition the work so that a single email address is only ever processed by one worker, or serialize creation, rather than fanning out a migration and retrying the failures. Uniqueness depends on scope: a global Customer is unique across the Project, while [Store-specific Customers](/api/customers-overview.md#global-versus-store-specific-customers) can reuse an email address in another Store. In a multi-Store design the link field, not the email address, is the only safe correlation key. CRMs rate-limit aggressively. Give every outbound call a timeout, retry `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 Deleting a Customer produces the [CustomerDeleted](/search.md?urn=ctp:api:type:CustomerDeletedMessage) Message, and a Change Subscription on the `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. Deleting a Customer does not delete their Carts. [Carts are preserved when a Customer is deleted](/api/releases/2025-10-03-carts-are-now-preserved-when-a-customer-is-deleted), so include them when you scope what an erasure request has to reach. ### Erase personal data on request A standard `DELETE` request leaves personal data that is part of [Messages](/api/projects/messages.md) and in internal logs. Setting the `dataErasure` query parameter to `true` removes that data as well. ```http title="Delete a Customer and erase the related personal data" DELETE /{projectKey}/customers/{id}?version=3&dataErasure=true ``` Erasure spans more than the Customer. [GDPR compliance](/api/gdpr.md#data-erasure-of-personal-data) lists every resource that can hold personal data, the endpoints that accept the parameter, and the retrievals and deletions each one needs. Drive the CRM side from the erasure itself rather than from a separate workflow. A [ResourceDeleted](/search.md?urn=ctp:api:type:ResourceDeletedDeliveryPayload) delivery carries a `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 Declare the scopes in `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. Subscription access comes from [`manage_subscriptions`](/api/scopes.md#project-configuration), which covers both reading and writing. Declare only scopes that appear in the [scopes reference](/api/scopes.md), because a token request naming a scope the API Client does not hold returns a [400 `invalid_scope` error](/learning-developer-essentials/authentication-authorization/scopes.md) at authentication rather than a permission error at the failing call. ### Separate configuration from secrets The CRM API token or OAuth client secret belongs in `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](/connect/development.md). Mount each application's router at the `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 Register the Subscriptions and any Custom Type the integration relies on in the `postDeploy` script, and remove them in `preUndeploy`. Declare both in the `scripts` block of `connect.yaml`, as shown in [Automation scripts](/connect/automation-scripts.md). 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](/connect/deployment-logs.md). ## 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 | ## Related pages - [Area overview page with navigation](/guides.md) - [Previous page: Integrate shipping](/guides/shipping-integration.md) - [Next page: Integrate external search](/guides/search-integration.md) - [Search documentation and API specs](/search.md)