Skip to content

Sync contacts from your CRM

Two directions, and most integrations need both.

updated_since returns only what changed. This is what makes an incremental sync possible.

async function syncFromKMessage(lastRunAt) {
let cursor = null
let newHighWater = lastRunAt
do {
const url = new URL('https://k-message.kerneltics.com/v1/contacts')
url.searchParams.set('updated_since', lastRunAt)
url.searchParams.set('limit', '200')
if (cursor) url.searchParams.set('cursor', cursor)
const res = await fetch(url, { headers: { Authorization: `Bearer ${KEY}` } })
const page = await res.json()
for (const contact of page.data) {
await upsertInCrm(contact)
if (contact.updated_at > newHighWater) newHighWater = contact.updated_at
}
cursor = page.has_more ? page.next_cursor : null
} while (cursor)
return newHighWater
}

Two details worth copying:

Track the high-water mark from the data, not the clock. Using new Date().toISOString() as the next updated_since will skip any contact modified while the sync was running.

Overlap slightly. Start the next run a minute before the last high-water mark. Re-processing a handful of contacts costs nothing; missing one because two writes landed in the same second is a silent bug you find months later.

There is no bulk import endpoint, so write contacts one at a time and let the duplicate response tell you when one already exists:

async function pushToKMessage(customer) {
const res = await fetch('https://k-message.kerneltics.com/v1/contacts', {
method: 'POST',
headers: { Authorization: `Bearer ${KEY}`, 'Content-Type': 'application/json' },
body: JSON.stringify({
phone_number: customer.phone,
name: customer.name,
tags: customer.segments,
// Your own key, so later syncs are a lookup rather than a guess.
metadata: { crm_id: customer.id },
}),
})
if (res.status === 409) {
// Already there. Find it and update instead.
const existing = await findByPhone(customer.phone)
return updateContact(existing.id, customer)
}
return res.json()
}

A 409 is not an error to log and move past: it is the API telling you this customer already exists, with the id in the message. Nothing is merged automatically, because a sync creating the same customer twice has a bug worth seeing rather than hiding.

Poll only for the initial import. Afterwards, subscribe to contact.created and contact.updated and let changes arrive.

case 'contact.created':
case 'contact.updated':
await upsertInCrm({
phone: data.contact_phone,
name: data.contact_name,
tags: data.tags,
})
break

Subscribe to both. A sync that heard only about creations drifts out of date on the first edit, and nothing about that failure is visible until someone notices a stale name.

metadata is free-form JSON. Put your own primary key there:

{ "metadata": { "crm_id": "C-4471", "source": "shopify", "lifetime_value": 4200 } }

Matching on a phone number works until someone stores it differently, at which point a whole segment quietly stops matching. Matching on your own id does not have that failure mode.