Skip to content
Guides

Sync Your CRM

Most teams use Kirim alongside an existing CRM (HubSpot, Salesforce, Pipedrive, Notion). This guide shows the patterns we’ve seen work in production for bidirectional sync.

Map Kirim resources to CRM concepts:

KirimCRM
contactPerson / Lead / Contact
conversationEngagement / Ticket
messageNote / Activity / Log entry
labelTag / Custom Field
metadata on contactCustom field bag

When a new contact appears in Kirim, sync it to your CRM. Two mechanisms, pick one:

Subscribe to contact.created and contact.updated. On each event, look up or upsert by contact.phone_number in your CRM:

app.post('/kirim-webhook', async (req, res) => {
// ... signature verification, dedup ...
const event = JSON.parse(req.body.toString('utf8'))
switch (event.type) {
case 'contact.created':
await crm.upsertPerson({
phone: event.data.contact.phone_number,
name: event.data.contact.name,
source: 'kirim_' + event.data.acquisition_source,
kirimContactId: event.data.contact.id,
})
break
case 'contact.updated':
// Only patch the fields Kirim says changed.
const patch: Partial<CrmPerson> = {}
for (const field of event.data.changed_fields) {
if (field in event.data.contact) {
patch[field] = event.data.contact[field]
}
}
await crm.patchPersonByPhone(event.data.contact.phone_number, patch)
break
}
res.status(200).send('ok')
})

Pro: Real-time. Sub-second latency from contact creation to CRM update. Con: Requires reliable webhook endpoint + secret management.

Run a cron job that lists Kirim contacts modified since the last sync:

async function syncContactsSince(since: Date) {
let cursor: string | null = null
do {
const url = new URL('https://api-kckit.kirim.chat/v1/contacts')
url.searchParams.set('limit', '100')
if (cursor) url.searchParams.set('cursor', cursor)
const res = await fetch(url, {
headers: { Authorization: `Bearer ${process.env.KIRIM_KEY}` },
})
const body = await res.json()
// Filter client-side to rows updated since the watermark.
// (Server-side `updated_after` is on the Phase 4 roadmap.)
for (const c of body.data) {
if (new Date(c.updated_at) > since) {
await crm.upsertPerson({
phone: c.phone_number,
name: c.name,
email: c.email,
kirimContactId: c.id,
})
}
}
cursor = body.next_cursor
} while (cursor)
}

Pro: Resumable, no webhook surface area to maintain. Con: Latency = your cron interval. Burns read quota proportional to your contact count.

When you enrich a person in your CRM (add notes, change name, tag them), push the changes back to Kirim:

async function pushPersonToKirim(person: CrmPerson) {
// Find by phone — Kirim doesn't have a "find by external system id"
// endpoint, so phone is your join key.
const res = await fetch(
`https://api-kckit.kirim.chat/v1/contacts?phone=${encodeURIComponent(person.phone)}`,
{ headers: { Authorization: `Bearer ${process.env.KIRIM_KEY}` } },
)
const { data } = await res.json()
if (data.length === 0) {
// Create.
await fetch('https://api-kckit.kirim.chat/v1/contacts', {
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.KIRIM_KEY}`,
'Content-Type': 'application/json',
'Idempotency-Key': crypto.randomUUID(),
},
body: JSON.stringify({
phone_number: person.phone,
name: person.name,
email: person.email,
metadata: { crm_id: person.id, crm_segment: person.segment },
}),
})
} else {
// Update.
const kirimId = data[0].id
await fetch(`https://api-kckit.kirim.chat/v1/contacts/${kirimId}`, {
method: 'PATCH',
headers: {
'Authorization': `Bearer ${process.env.KIRIM_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
name: person.name,
email: person.email,
metadata: { crm_id: person.id, crm_segment: person.segment },
}),
})
}
}

If both sides can mutate, you need conflict resolution. The simplest rule that scales: last-writer-wins on a per-field basis, with the CRM as the source of truth for human-edited fields (name, email, custom tags) and Kirim as the source of truth for behavioural fields (last_message_at, acquisition_source).

Don’t try to sync metadata in both directions — pick one side to own each metadata key.

When a CRM segment changes (e.g. “all customers who haven’t bought in 30 days”), bulk-apply a Kirim label:

const staleCustomerIds = await crm.queryStaleCustomers() // returns Kirim contact ids
// Cap is 1000 per request — paginate.
const chunks = chunk(staleCustomerIds, 1000)
for (const ids of chunks) {
await fetch('https://api-kckit.kirim.chat/v1/contacts/bulk_label', {
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.KIRIM_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
contact_ids: ids,
label_id: 'lbl_…',
operation: 'attach',
}),
})
}

The response counts how many were applied, skipped because they’re cross-org, or skipped because of team mismatch:

{
"data": {
"applied": 847,
"skipped_cross_org": 12,
"skipped_team_mismatch": 0
}
}

When a Kirim conversation needs human attention (e.g. customer asked for a refund), use the assignee + label vocabulary to route it:

// 1. Tag the conversation so your inbox filter picks it up.
await fetch(`https://api-kckit.kirim.chat/v1/conversations/${cnvId}/labels`, {
method: 'POST',
headers: { 'Authorization': `Bearer ${process.env.KIRIM_KEY}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ label_id: 'lbl_refund_request' }),
})
// 2. Assign it to the right agent based on availability / round-robin.
const agent = await pickAvailableAgent('refunds')
await fetch(`https://api-kckit.kirim.chat/v1/conversations/${cnvId}`, {
method: 'PATCH',
headers: { 'Authorization': `Bearer ${process.env.KIRIM_KEY}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ assigned_to: agent.userId }),
})
// 3. Open Kirim Inbox for the agent in your CRM workflow.
notifyAgent(agent, `https://app.kirim.dev/inbox/${cnvId}`)

Subscribe to conversation.assigned to react to assignments made inside Kirim too — full bidirectional visibility.