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.

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.

Kustomer

Your sync service

CRM

bulk, 100 per job

upsert by externalId

write back changed fields

Contacts
Accounts

CRM change
webhook

Backfill job

Upsert worker

Kustomer webhook
handler

REST API

Outbound webhook
kustomer.customer.update

Diagram source
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. Keep the CRM's ID in externalId; it's what makes every later step idempotent.

CRM fieldKustomer attributeNotes
Contact IDexternalIdThe join key. Never changes.
Full namename
Emailemails[]{ "type": "work", "email": "..." }
Phonephones[]E.164, such as +15555550123
AccountcompanyKustomer company ID (see step 3)
Lifecycle stagecustom.lifecycleStageStrGive it an enumeration in metadata for a dropdown
Annual revenuecustom.annualRevenueNum
Owner emailcustom.accountOwnerStr
Last syncedcustom.crmSyncedAtHelps you debug

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

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 of 100, with _clientRef and externalId set to the CRM ID:

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
}

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}

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
Request body
{
  "name": "Acme Corp",
  "externalId": "acct-0042",
  "domains": [{ "domain": "acme.example" }],
  "custom": { "tierStr": "enterprise" }
}
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:

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:

post/v1/outbound-webhooks
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:

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 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 will eventually stop it with 429s, 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

  1. Respect rate limits

    Run all sync traffic through one limiter, and leave headroom for other integrations. See Rate limits.

  2. 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.

  3. 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.