# Sync customers from a CRM

> Build a production two-way sync between a CRM and Kustomer, with an initial bulk backfill, incremental upserts by externalId, companies, webhooks back to the CRM, and loop prevention.

Source: https://developer.kustomer.com/docs/guides/sync-customers-from-crm

This guide builds a customer sync between a CRM (Salesforce, HubSpot, an in-house system) and Kustomer. By the end you'll have:

- a one-time **backfill** of every CRM contact and account;
- **incremental upserts** whenever a contact changes in the CRM;
- **changes flowing back** to the CRM when agents edit a customer in Kustomer;
- protection against **update loops** between the two systems.

```mermaid
flowchart LR
  subgraph CRM
    C[(Contacts<br/>Accounts)]
    CW[CRM change<br/>webhook]
  end
  subgraph Sync["Your sync service"]
    B[Backfill job]
    U[Upsert worker]
    H[Kustomer webhook<br/>handler]
  end
  subgraph K[Kustomer]
    API[REST API]
    OW[Outbound webhook<br/>kustomer.customer.update]
  end
  C --> B -- "bulk, 100 per job" --> API
  CW --> U -- "upsert by externalId" --> API
  API --> OW --> H -- "write back changed fields" --> C
```

**You'll need** an API key with `org.permission.customer.read`, `.create` and `.update`, the same for `company`, and `org.permission.outbound_webhook.create` (or the legacy `org.admin.outbound_webhooks`) if you create the webhook through the API.

## 1. Decide the mapping

Map each CRM field to a Kustomer standard attribute or a [custom attribute](https://developer.kustomer.com/docs/concepts/custom-attributes). Keep the CRM's ID in `externalId`; it's what makes every later step idempotent.

| CRM field       | Kustomer attribute         | Notes                                               |
| --------------- | -------------------------- | --------------------------------------------------- |
| Contact ID      | `externalId`               | The join key. Never changes.                        |
| Full name       | `name`                     |                                                     |
| Email           | `emails[]`                 | `{ "type": "work", "email": "..." }`                |
| Phone           | `phones[]`                 | E.164, such as `+15555550123`                       |
| Account         | `company`                  | Kustomer company ID (see step 3)                    |
| Lifecycle stage | `custom.lifecycleStageStr` | Give it an `enumeration` in metadata for a dropdown |
| Annual revenue  | `custom.annualRevenueNum`  |                                                     |
| Owner email     | `custom.accountOwnerStr`   |                                                     |
| Last synced     | `custom.crmSyncedAt`       | Helps you debug                                     |

Create the custom attribute metadata once, so agents see friendly labels:

```bash title="cURL"
curl -X PUT "https://acme.api.kustomerapp.com/v1/metadata/customer" \
  -H "Authorization: Bearer $KUSTOMER_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "properties": {
      "lifecycleStageStr": { "displayName": "Lifecycle stage", "enumeration": ["lead", "customer", "churned"] },
      "annualRevenueNum": { "displayName": "Annual revenue" },
      "accountOwnerStr": { "displayName": "Account owner" },
      "crmSyncedAt": { "displayName": "Last CRM sync" }
    }
  }'
```

## 2. Backfill with bulk jobs

For the first load, send contacts in [bulk jobs](https://developer.kustomer.com/docs/guides/bulk-operations) of 100, with `_clientRef` and `externalId` set to the CRM ID:

```ts title="Node.js"
for (const chunk of chunks(contacts, 100)) {
  const body = chunk.map((c) => ({
    _clientRef: c.id,
    externalId: c.id,
    name: c.fullName,
    emails: c.email ? [{ type: 'work', email: c.email }] : [],
    phones: c.phone ? [{ type: 'work', phone: c.phone }] : [],
    custom: { lifecycleStageStr: c.stage, crmSyncedAt: new Date().toISOString() },
  }))
  const job = await kfetch('/v1/customers/bulk', { method: 'POST', body: JSON.stringify(body) }).then((r) => r.json())
  await waitForBulk(job.data.id) // poll GET /v1/bulk/{id}; see Bulk operations
}
```

> **Customers who already exist:** If people have already emailed your support team, Kustomer has customers for them, matched on email. Before the backfill, look up each contact by `externalId` and then by email, and update matches rather than creating duplicates. Or run the backfill as creates and merge duplicates afterwards with the [customer merge](https://developer.kustomer.com/api/core-resources/create-customer-merge) endpoint.

## 3. Companies

Give each CRM account a company, with the account's ID in `externalId`. Look the company up by that ID first. The response is a list, and an empty `data` means the company doesn't exist yet:

`GET /v1/companies?externalId={externalId}` ([reference](https://developer.kustomer.com/api/core-resources/get-companies))

Don't add `filter` to the same request; sending both makes the API ignore both. If there's no match, create the company:

`POST /v1/companies` ([reference](https://developer.kustomer.com/api/core-resources/create-company))

```json title="Request body"
{
  "name": "Acme Corp",
  "externalId": "acct-0042",
  "domains": [{ "domain": "acme.example" }],
  "custom": { "tierStr": "enterprise" }
}
```

```ts title="Node.js"
async function companyIdFor(accountId: string): Promise<string> {
  const res = await kfetch(`/v1/companies?externalId=${encodeURIComponent(accountId)}`)
  const { data } = await res.json()
  if (data.length > 0) return data[0].id

  const account = await crm.getAccount(accountId)
  const created = await kfetch('/v1/companies', {
    method: 'POST',
    body: JSON.stringify({ name: account.name, externalId: accountId }),
  })
  return (await created.json()).data.id
}
```

Cache the mapping in your sync service so a busy account doesn't cost a lookup on every contact change. Then link customers by setting `company` to the Kustomer company ID. Remember that **omitting `company` on a customer update unlinks it**, so always send it when you update a customer that has an account.

## 4. Incremental upserts

When the CRM reports a change, upsert the one customer:

```ts title="Node.js"
async function upsertContact(c: CrmContact) {
  const attrs = {
    externalId: c.id,
    name: c.fullName,
    emails: c.email ? [{ type: 'work', email: c.email }] : [],
    company: c.accountId ? await companyIdFor(c.accountId) : undefined,
    custom: { lifecycleStageStr: c.stage, crmSyncedAt: new Date().toISOString() },
  }

  const found = await kfetch(`/v1/customers/externalId=${encodeURIComponent(c.id)}`)
  if (found.status === 404) {
    return kfetch('/v1/customers', { method: 'POST', body: JSON.stringify(attrs) })
  }
  const { data } = await found.json()
  if (!changed(data.attributes, attrs)) return // skip no-op writes
  return kfetch(`/v1/customers/${data.id}`, { method: 'PUT', body: JSON.stringify(attrs) })
}
```

The `changed()` check matters: it saves rate limit, and it's what keeps the next step from looping.

## 5. Send Kustomer changes back

Subscribe to customer updates with an [outbound webhook](https://developer.kustomer.com/docs/events/outbound-webhooks):

`POST /v1/outbound-webhooks` ([reference](https://developer.kustomer.com/api/apps-platform/create-outbound-webhook))

```json title="Request body"
{
  "name": "CRM sync",
  "url": "https://sync.acme.example/kustomer/webhooks",
  "events": ["kustomer.customer.update"]
}
```

Each delivery includes the customer and a `changes` object with the before and after value of every attribute that changed. Write back only the fields you own in Kustomer:

```ts title="Node.js"
const OWNED_IN_KUSTOMER = new Set(['phones', 'custom'])

app.post('/kustomer/webhooks', express.raw({ type: 'application/json' }), async (req, res) => {
  if (!verifySignature(req)) return res.sendStatus(401) // see Delivery and security
  const event = JSON.parse(req.body.toString())
  res.sendStatus(200) // acknowledge fast, then work

  const changed = Object.keys(event.changes?.attributes ?? {}).filter((k) => OWNED_IN_KUSTOMER.has(k))
  if (event.client === 'api' && (await wasOurWrite(event.data.id, event.changes))) return // see "Avoid loops"
  if (!changed.length) return
  await crm.updateContact(event.data.attributes.externalId, pick(event.data.attributes, changed))
})
```

[Delivery and security](https://developer.kustomer.com/docs/events/delivery-and-retries) shows how to verify `X-Kustomer-Webhook-Signature`.

## 6. Avoid loops

A two-way sync can ping-pong: the CRM update triggers a Kustomer update, which fires a webhook, which updates the CRM, and so on. Kustomer's [object limits](https://developer.kustomer.com/docs/concepts/rate-limits#object-limits) will eventually stop it with `429`s, but you should never get there.

- **Own each field in one system.** The CRM owns name, email and account; Kustomer owns phone and support-specific fields. Each side only writes the fields it owns.
- **Skip no-op writes.** Compare before you update, on both sides.
- **Ignore your own echoes.** Webhook events include a `client` field showing where the change came from, and `changes` lists exactly what changed. If the only changes are fields your sync just wrote, drop the event.

## 7. Run it in production

#### Respect rate limits

Run all sync traffic through one limiter, and leave headroom for other integrations. See [Rate limits](https://developer.kustomer.com/docs/concepts/rate-limits).

#### Reconcile nightly

Webhooks can be missed during outages. A nightly job that searches for customers with `customer_updated_at` in the last day and compares them with the CRM catches anything that slipped through. See [Search recipes](https://developer.kustomer.com/docs/guides/search-recipes).

#### Log by externalId

Log the CRM ID, Kustomer ID, action and status for each write. It makes "why didn't this contact sync?" a one-line query.
