Skip to content
Guides

Sync Your CRM

If your team uses an external CRM alongside Kirim — HubSpot, Salesforce, Pipedrive, Notion, or any internal system — you’ll want contacts and conversations to flow between them. This guide shows the two patterns that work in production, when to pick each, and how to handle the awkward parts: conflict resolution, bulk operations, and keeping team assignments visible across both systems.

Map Kirim resources to whatever your CRM calls them:

KirimTypical CRM concept
contact (ctc_…)Person / Lead / Contact
conversation (cnv_…)Engagement / Ticket / Thread
message (msg_…)Note / Activity / Log entry
label (lbl_…)Tag / List / Custom field
Contact metadataCustom field bag

Use the Kirim id (e.g. ctc_01HXYZ…) as the external_id on the CRM side. That way the join key is stable, opaque, and unique within Kirim — never reused across orgs.

FlowWhen to useProsCons
Push (webhooks)Production, real-time sync, low contact volume mutations per second.Sub-second latency, no read quota burn, scales with mutations not contact count.Requires a public HTTPS endpoint + signature verification.
Pull (polling)Initial backfill, batch ETL pipelines, recovery from a webhook outage.No public endpoint needed; resumable via cursors.Latency = poll interval; burns read quota proportional to volume.

Most production setups do both — push for steady-state, pull for initial backfill and reconciliation.

  1. Subscribe to the events you care about.

    For full CRM sync, you typically want all four contact / conversation events:

    Terminal window
    curl -sS https://api.kirim.chat/v1/webhook_subscriptions \
    -H "Authorization: Bearer $KIRIM_KEY" \
    -H "Content-Type: application/json" \
    -d '{
    "url": "https://your-app.example.com/webhooks/kirim",
    "events": [
    "contact.created",
    "contact.updated",
    "conversation.assigned",
    "conversation.closed"
    ],
    "description": "CRM sync"
    }'

    Stash the initial_secret in your secrets manager — Kirim only returns it once. See the Webhooks Guide for the full setup flow.

  2. Idempotently upsert in your handler.

    Always upsert by Kirim’s external_id (the ctc_… prefix), never by phone number or email — those fields can change in either system, but the Kirim id is stable forever.

    app.post(
    '/webhooks/kirim',
    express.raw({ type: 'application/json' }),
    async (req, res) => {
    // signature verification + dedup (see /guides/webhooks/)
    const event = JSON.parse(req.body.toString('utf8'))
    switch (event.type) {
    case 'contact.created':
    case 'contact.updated': {
    const c = event.data.contact
    await crm.people.upsert({
    externalId: c.id, // ctc_01HXYZ…
    phone: c.phone_number,
    name: c.name,
    email: c.email,
    tags: c.labels?.map((l) => l.name) ?? [],
    customFields: c.metadata ?? {},
    })
    break
    }
    case 'conversation.assigned': {
    const { conversation, assignee } = event.data
    await crm.tickets.upsert({
    externalId: conversation.id,
    assigneeId: assignee.user_id,
    status: 'open',
    })
    break
    }
    case 'conversation.closed': {
    const { conversation } = event.data
    await crm.tickets.update(conversation.id, {
    status: 'closed',
    closedAt: new Date(),
    })
    break
    }
    }
    res.status(200).send('ok')
    },
    )
  3. Push CRM changes back to Kirim.

    When your CRM is the source of truth for a contact’s name, email, or custom metadata, propagate updates back to Kirim. Use the contact-scoped endpoint:

    // Inside your CRM webhook handler (or change-data-capture pipeline):
    async function pushContactToKirim(crmPerson: CrmPerson) {
    const phone = kirim.phoneNumbers(process.env.PHONE_ID!)
    await phone.contacts.update(crmPerson.kirimExternalId, {
    name: crmPerson.name,
    email: crmPerson.email,
    metadata: {
    crm_id: crmPerson.id,
    crm_segment: crmPerson.segment,
    last_synced_at: new Date().toISOString(),
    },
    })
    }

Polling is the right tool for two jobs: the very first sync (when there are no events yet), and reconciliation if your webhook handler was down. Use cursor pagination for both.

Walk all contacts under a phone number, paginating until cursor is exhausted:

Terminal window
# First page
curl -sS "https://api.kirim.chat/v1/$PHONE_ID/contacts?limit=100&created_after=2026-05-01T00:00:00Z" \
-H "Authorization: Bearer $KIRIM_KEY"
# Next page using the cursor from the previous response
curl -sS "https://api.kirim.chat/v1/$PHONE_ID/contacts?limit=100&cursor=ctc_01HXYZ…" \
-H "Authorization: Bearer $KIRIM_KEY"

To pull open conversations (e.g. for an inbox view, support routing, or escalation alerts):

Terminal window
curl -sS "https://api.kirim.chat/v1/$PHONE_ID/conversations?status=open&limit=50" \
-H "Authorization: Bearer $KIRIM_KEY"

See Pagination for cursor semantics — short version: cursors are opaque, only valid forward, and never expire.

If both Kirim and your CRM can mutate the same fields, you need a rule. The cleanest one that scales:

  • Kirim owns WhatsApp-related fields. Phone number, conversation status, message history, labels applied via the inbox, assignment state. Your CRM mirrors these read-only.
  • CRM owns business-side fields. Name, email, lifecycle stage, custom segments, deal value, owner. Kirim mirrors these by writing to contact metadata.

When a field could plausibly belong to either side, default to the CRM. WhatsApp is a channel; the CRM is the customer record.

A safe upsert pattern:

// Inside contact.updated handler from Kirim:
await crm.people.update(crmPersonId, {
// Only touch fields Kirim owns.
whatsappPhone: c.phone_number,
whatsappLabels: c.labels.map((l) => l.name),
// DO NOT clobber CRM-owned fields like name/email here.
})
// Inside CRM person.updated handler:
await phone.contacts.update(c.kirimExternalId, {
// Only touch fields the CRM owns.
name: c.name,
email: c.email,
metadata: {
crm_id: c.id,
crm_segment: c.segment,
},
})

This avoids the most common sync bug: a circular update where Kirim patches the CRM, the CRM patches Kirim back, and your handlers oscillate forever.

A common pattern: tag every contact in a CRM segment with a Kirim label, so the inbox can filter by it (e.g. “VIP”, “Trial”, “Past Due”).

// Pull the segment from your CRM, then bulk-tag in Kirim.
const vipContactIds = await crm.segments.fetchContactIds('vip-customers')
// vipContactIds is an array of Kirim ctc_… ids (your CRM stored them).
const phone = kirim.phoneNumbers(process.env.PHONE_ID!)
// Chunk to stay under the 1000-per-request cap.
function chunk<T>(arr: T[], size: number): T[][] {
const out: T[][] = []
for (let i = 0; i < arr.length; i += size) out.push(arr.slice(i, i + size))
return out
}
for (const ids of chunk(vipContactIds, 1000)) {
const result = await phone.contacts.bulkLabel({
contact_ids: ids,
label_id: 'lbl_vip',
operation: 'attach',
})
console.log(`Applied: ${result.applied}, skipped: ${result.skipped_cross_org}`)
}

Reverse the flow by subscribing to contact.updated and reading the new labels array to mirror the tag back into your CRM.

Subscribe to webhooks

Set up the push side of the sync — endpoint, secret, verification. Read the guide →