Webhook events
Full payload shapes for contact.* and conversation.* events.
Read the reference →
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:
| Kirim | Typical 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 metadata | Custom 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.
| Flow | When to use | Pros | Cons |
|---|---|---|---|
| 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.
Subscribe to the events you care about.
For full CRM sync, you typically want all four contact / conversation events:
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" }'await kirim.webhookSubscriptions.create({ url: 'https://your-app.example.com/webhooks/kirim', events: [ 'contact.created', 'contact.updated', 'conversation.assigned', 'conversation.closed', ], description: 'CRM sync',})import os, httpx
httpx.post( "https://api.kirim.chat/v1/webhook_subscriptions", headers={"Authorization": f"Bearer {os.environ['KIRIM_KEY']}"}, json={ "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.
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') },)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:
# First pagecurl -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 responsecurl -sS "https://api.kirim.chat/v1/$PHONE_ID/contacts?limit=100&cursor=ctc_01HXYZ…" \ -H "Authorization: Bearer $KIRIM_KEY"import { Kirim } from '@kirimdev/sdk'
const kirim = new Kirim({ apiKey: process.env.KIRIM_KEY! })const phone = kirim.phoneNumbers(process.env.PHONE_ID!)
// Async iteration handles cursor paging automatically.for await (const ctc of phone.contacts.list({ limit: 100, created_after: '2026-05-01T00:00:00Z',})) { await crm.people.upsert({ externalId: ctc.id, phone: ctc.phone_number, name: ctc.name, email: ctc.email, })}import os, httpx
BASE = "https://api.kirim.chat/v1"HEADERS = {"Authorization": f"Bearer {os.environ['KIRIM_KEY']}"}PHONE_ID = os.environ["PHONE_ID"]
cursor = Nonewhile True: params = {"limit": 100, "created_after": "2026-05-01T00:00:00Z"} if cursor: params["cursor"] = cursor
r = httpx.get(f"{BASE}/{PHONE_ID}/contacts", headers=HEADERS, params=params) body = r.json()
for ctc in body["data"]: crm_upsert_person( external_id=ctc["id"], phone=ctc["phone_number"], name=ctc.get("name"), email=ctc.get("email"), )
if not body.get("has_more"): break cursor = body["next_cursor"]To pull open conversations (e.g. for an inbox view, support routing, or escalation alerts):
curl -sS "https://api.kirim.chat/v1/$PHONE_ID/conversations?status=open&limit=50" \ -H "Authorization: Bearer $KIRIM_KEY"for await (const cnv of phone.conversations.list({ status: 'open' })) { if (!cnv.assigned_to) { await crm.tickets.queueForRouting(cnv.id, cnv.contact.id) }}r = httpx.get( f"{BASE}/{PHONE_ID}/conversations", headers=HEADERS, params={"status": "open", "limit": 50},)for cnv in r.json()["data"]: if not cnv.get("assigned_to"): crm_queue_for_routing(cnv["id"], cnv["contact"]["id"])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:
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.
Webhook events
Full payload shapes for contact.* and conversation.* events.
Read the reference →
Subscribe to webhooks
Set up the push side of the sync — endpoint, secret, verification. Read the guide →
Idempotency
Safe retries for the CRM → Kirim direction. Read the concept →
Rate limits
Per-key budgets for high-throughput pulls and bulk operations. Read the concept →