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.
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" --> CYou'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.
Create the custom attribute metadata once, so agents see friendly labels:
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:
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:
/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:
/v1/companies
{
"name": "Acme Corp",
"externalId": "acct-0042",
"domains": [{ "domain": "acme.example" }],
"custom": { "tierStr": "enterprise" }
}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:
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
{
"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:
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
clientfield showing where the change came from, andchangeslists 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.
Reconcile nightly
Webhooks can be missed during outages. A nightly job that searches for customers with
customer_updated_atin the last day and compares them with the CRM catches anything that slipped through. See 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.