Developer Docs Logo
Customer Chat

Incoming Messages (Platform → CRM)

This document explains what happens when a user on the external platform (e.g. Telegram) sends a message. Your third-party app webhook handler receives the event from the platform and creates or continues a conversation in the CRM.

The CRM API calls in this document follow the general conventions described in API Requests, authentication, response structure, error shape, and async handling.


Overview


The Decision Tree

Every incoming platform webhook goes through this logic:

1. Sends "Hello"        ↓ 2. Receives Message from Messaging Platform (usually via webhook)        ↓ 3. Verify Platform Signature        ↓ 4. Look Up Account Mapping by Platform Account ID        ↓ not found → return 200, ignore        ↓ found 5. Look Up CRM Contact        ↓ not found → 6. Create CRM Contact If Contact Not Found        ↓ found / created 7. Look Up CRM Conversation        ├─ found, OPEN / RESOLVED → 8. Post Message Into Conversation If OPEN or RESOLVED        ├─ no mapping, pending creation exists → Queue Message        └─ no mapping, no pending, or FINALIZED → 9. Create CRM Conversation If Not Found or FINALIZED        ↓ (all paths) 10. Return 200 OK

── async, triggered by separate CRM webhook ────────────────────── 11. Handle conversation.created Webhook


Detailed Implementation

Verify Platform Signature

Queue the event immediately and return 200, never process inline. Verify the platform signature before queuing:

import { Request, Response } from "express";

interface WebhookPayload {
  destination: string; // or bot_id, account_id, platform-specific
}

async function handlePlatformWebhook(req: Request, res: Response): Promise<void> {
  // Read the entire request body, assumes Express raw body middleware gives req.body as Buffer
  const body = req.body as Buffer;
  if (!body || body.length === 0) {
    res.status(400).send("bad request");
    return;
  }

  // Extract account identifier from payload, headers, or URL path
  // Platform-specific: may be called destination, bot_id, account_id, etc.
  let payload: WebhookPayload;
  try {
    payload = JSON.parse(body.toString());
  } catch {
    res.status(400).send("bad request");
    return;
  }
  const accountID = payload.destination; // or extract from header/URL depending on platform

  // Look up the secret for this specific account/bot
  const secret = await db.getAccountSecret(accountID);
  if (!secret) {
    res.status(404).send("unknown account");
    return;
  }

  // Verify the webhook signature with the account-specific secret
  if (!verifyPlatformSignature(body, req.headers["x-platform-signature"] as string, secret)) {
    res.status(401).send("unauthorized");
    return;
  }

  // Queue the event for async processing, never process inline
  await queue.enqueue(body);
  // Return 200 immediately to acknowledge receipt (platform may retry on timeout)
  res.sendStatus(200);
}

Note: Implement idempotency handling here. Most messaging platforms deliver webhooks at-least-once, not exactly-once.

Look Up Account Mapping by Platform Account ID

Every platform webhook carries an identifier for the platform account (bot, official account, etc.) that received the message. This may be called destination, bot_id, account_id, etc. depending on the platform.

const accountID = payload.destination; // or botId, accountId, platform-specific

const mapping = await db.getAccountMapping(accountID);
if (!mapping) {
  // Unknown account, this webhook is not for us, ignore silently
  return;
}

// Now we have mapping.workspaceId and mapping.channelId

Look Up CRM Contact

The platform identifies the sender with a platform-specific user ID (e.g. a Telegram user ID). The CRM has no knowledge of that ID, it works with its own contact_id. Check your local DB first for an existing platform_user_id → crm_contact_id mapping. If found, reuse it to avoid creating duplicate contacts on every message.

const contactID = await db.getContactID(platformUserID, workspaceID);
if (contactID) {
  return contactID; // found, proceed to Look Up CRM Conversation
}

Create CRM Contact If Contact Not Found

The first time someone messages, no CRM contact exists yet. Creating one is required because contact_id is mandatory when opening a conversation. It also gives agents a persistent record to view history, assign work, and enrich with profile data later.

const resp = await crmClient.createContact(crmToken, {
  name: displayName, // use platform's display name if available, or "Unknown User"
});

await db.saveContactMapping(platformUserID, workspaceID, resp.id);
return resp.id;

CRM API call:

POST /v1/contacts
Authorization: Bearer {workspace_access_token}
Content-Type: application/json

{
  "name": "Telegram User"
}

Response (201 Created):

{
  "id": "ctc_abc123"
}

You can enrich the contact later by calling the platform's profile API and then PATCH /v1/contacts/{id}.

Look Up CRM Conversation

Use your local DB to look up the stored conversation_id by (platform_user_id, channel_id).

If no mapping exists, also check for a pending conversation creation, a POST /v1/conversations may have already been called for this contact and is still waiting for the conversation.created webhook. If a pending record exists, queue the message and return, do not call POST /v1/conversations again. The queued message will be forwarded once the webhook fires and the mapping is complete.

If there is no mapping and no pending record, proceed to Create CRM Conversation If Not Found or FINALIZED.

If a mapping exists, fetch the conversation from the CRM API to verify its current status, not all statuses accept new messages.

GET /v1/conversations/{conversation_id}
Authorization: Bearer {workspace_access_token}

Response (200 OK):

{
  "data": {
    "id": "conv_def456",
    "status": "OPEN",
    "contact_id": "ctc_abc123",
    "channel_id": "ch_xyz789"
  }
}

If data.status is OPEN or RESOLVED, proceed to Post Message Into Conversation If OPEN or RESOLVED.

If data.status is FINALIZED, a finalized conversation is permanently closed and cannot receive new messages.

Finalization can happen in several ways:

  • Manual - an agent explicitly finalizes the conversation from the dashboard.
  • Auto-finalize - the CRM automatically finalizes conversations that have been inactive for a configured period.

Your integration should handle finalization regardless of the cause. Create a new conversation for the incoming message, then replace your local mapping so future messages from this contact route to the new conversation:

if (conversation.data.status === "FINALIZED") {
  const { request_id: conversationID } = await crmClient.createConversation(token, {
    channelId: channelID,
    contactId: contactID,
    message: text,
  });

  await db.deleteConversationMapping(oldConvID);
  // request_id is the conversation_id; save a pending record so messages queued
  // before conversation.created fires can be flushed once creation is confirmed.
  await db.savePendingConversationCreation(contactID, platformUserID, workspaceID, channelID, accountID, conversationID);
}

Post Message Into Conversation If OPEN or RESOLVED

POST /v1/conversations/{conversation_id}/messages
Authorization: Bearer {workspace_access_token}
Content-Type: application/json

{
  "content": "Hello",
  "message_type": "MESSAGE",
  "send_as": {
    "contact_id": "ctc_abc123"
  }
}

Response (202 Accepted):

{
  "request_id": "ca9b5a07-1d77-46f3-9fa6-6c827db6ac3d"
}

Create CRM Conversation If Not Found or FINALIZED

Your DB has no stored conversation_id for this contact on this channel. This means either they are messaging for the first time, or their previous conversation was finalized and its mapping was cleared.

Create a new conversation. You can include the first message in the same create call to avoid a second round-trip:

POST /v1/conversations
Authorization: Bearer {workspace_access_token}
Content-Type: application/json

{
  "channel_id": "ch_xyz789",
  "contact_id": "ctc_abc123",
  "message": "Hello"
}

Response (202 Accepted):

{
  "request_id": "550e8400-e29b-41d4-a716-446655440003"
}

The CRM creates the conversation and first message asynchronously. The request_id returned in the 202 response is the conversation ID. You can reference this ID in follow-up API calls immediately. However, because creation is async, the conversation may not be fully committed yet. You still need the conversation.created webhook event to:

  1. Confirm the conversation was actually created.
  2. Retrieve the contact_id (not included in the 202 response) to validate and complete the mapping.

Note: Due to a current platform limitation, the conversation.created webhook payload does not include the request_id from the 202. Correlating the webhook to the pending record is done by matching contact_id.

Immediately after the 202, save a pending conversation record to your DB, including the conversation ID from request_id:

FieldValuePurpose
conversation_idThe request_id from the 202 responseThe conversation ID: available immediately, used when flushing queued messages
contact_idCRM contact UUID you sent in the requestUsed by the webhook handler to match this pending record
platform_user_idPlatform-specific sender IDNeeded to complete the conversation_id → platform_user_id mapping
workspace_idCRM workspace UUIDScopes the lookup
channel_idCRM channel UUIDNeeded to complete the mapping
platform_channel_idPlatform account identifier (e.g. Telegram bot)Needed to look up the correct platform_access_token when forwarding outbound messages

Any subsequent message from this contact that arrives before the conversation.created webhook fires must be queued (keyed by contact_id + channel_id + workspace_id) rather than triggering another POST /v1/conversations. The queued messages are forwarded in order once the mapping is complete.

Handling 409 Conflict

The CRM enforces a one active conversation per contact per channel rule:

  • A contact can only have one non-finalized conversation open on a given channel at a time.
  • A new conversation can only be created once the previous one has been finalized (permanently closed).

Because of this constraint, POST /v1/conversations returns 409 if the contact already has an ongoing conversation on that channel. This typically means your DB mapping is stale, the conversation already exists but your local record doesn't know about it. Query the existing conversation and route the message there instead:

try {
  await crmClient.createConversation(token, req);
} catch (err: unknown) {
  if (isConflict(err)) {
    // status accepts only a single value, so try OPEN first then RESOLVED
    let conversations = await crmClient.listConversations(token, {
      contact_ids: contactID,
      channel_ids: channelID,
      status: "OPEN",
    });
    if (!conversations || conversations.length === 0) {
      conversations = await crmClient.listConversations(token, {
        contact_ids: contactID,
        channel_ids: channelID,
        status: "RESOLVED",
      });
    }
    if (!conversations || conversations.length === 0) {
      throw err;
    }
    const existingConvID = conversations[0].id;
    await db.saveConversationMapping(existingConvID, platformUserID, workspaceID, channelID, accountID);
    return addMessage(token, existingConvID, contactID, text);
  }
  throw err;
}

Handle conversation.created Webhook

The CRM emits this webhook event after a conversation is successfully created. While the conversation_id is already available from the request_id field in the 202 response, this webhook is still essential because it confirms the conversation is fully committed and the payload allows you to verify the contact_id (not included in the 202 response).

When this event fires, your handler must:

  1. Call GET /v1/conversations/{conversation_id} to fetch the full conversation details (the webhook payload does not include contact_id).
  2. Look up the pending creation record by contact_id to confirm this webhook corresponds to your request.
  3. Save the conversation_id → platform_user_id + channel_id + platform_channel_id mapping.
  4. Delete the pending creation record.
  5. Flush any queued messages for this contact, forward them to the CRM in order using POST /v1/conversations/{conversation_id}/messages, then clear the queue.

Payload example:

{
  "event": "conversation.created",
  "data": {
    "conversation_id": "ee0e8400-e29b-41d4-a716-446655440009",
    "workspace_id": "660e8400-e29b-41d4-a716-446655440001"
  }
}

Note: The payload includes conversation_id and workspace_id only, contact_id and channel_id are not present. Call GET /v1/conversations/{conversation_id} to retrieve the full conversation details and verify the returned contact_id matches the one from your pending creation record before saving the mapping.

interface ConversationCreatedData {
  conversation_id: string;
  workspace_id: string;
}

async function handleConversationCreated(workspaceID: string, data: string): Promise<void> {
  const event: ConversationCreatedData = JSON.parse(data);

  // Fetch full conversation details to obtain contact_id (not present in the webhook payload)
  const installation = await db.getInstallation(workspaceID);
  const conv = await crmClient.getConversation(installation.accessToken, event.conversation_id);

  // Match against the pending creation record by contact_id
  const pending = await db.getPendingConversationCreationByContactID(workspaceID, conv.data.contactId);

  if (!pending) {
    // No matching pending request, not our conversation; ignore
    return;
  }

  // Save the conversation mapping using data from the pending record
  await db.saveConversationMapping(
    event.conversation_id,
    pending.platformUserID,
    workspaceID,
    pending.channelID,
    pending.platformChannelID,
  );

  // Delete the pending record, mapping is now complete
  await db.deletePendingConversationCreation(pending.id);

  // Flush any messages that arrived while the conversation creation was in flight
  const queued = await db.getQueuedMessages(conv.data.contactId, workspaceID, pending.channelID);
  for (const msg of queued) {
    await crmClient.sendMessage(installation.accessToken, event.conversation_id, {
      content: msg.content,
      messageType: "MESSAGE",
      sendAs: { contactId: conv.data.contactId },
    });
  }
  await db.deleteQueuedMessages(conv.data.contactId, workspaceID, pending.channelID);
}

Handling File / Media Messages

See File and Media Messages for the full upload flow, attachment limits, and allowed file extensions.


Complete Handler Example (TypeScript)

interface PlatformEvent {
  messageId: string;
  accountId: string;
  userId: string;
  displayName: string;
  channelId: string;
  text: string;
}

async function processIncomingMessage(event: PlatformEvent): Promise<void> {
  // 1. Idempotency
  const processed = await db.isProcessed(event.messageId);
  if (processed) {
    return;
  }

  // 2. Account mapping
  const mapping = await db.getAccountMapping(event.accountId);
  if (!mapping) {
    return; // unknown account, ignore
  }

  // 3. CRM access token
  const installation = await db.getInstallation(mapping.workspaceId);
  const token = installation.accessToken;

  // 4. Contact
  const contactID = await getOrCreateContact(event.userId, mapping.workspaceId, token, event.displayName);

  // 5. Conversation
  const convID = await db.getConversationID(event.userId, mapping.channelId);
  if (!convID) {
    // No mapping, check whether a creation is already in flight for this contact
    const pending = await db.getPendingConversationCreationByContactID(mapping.workspaceId, contactID);
    if (pending) {
      // conversation.created not yet received, queue this message for later delivery
      await db.enqueueMessage(contactID, mapping.workspaceId, mapping.channelId, event.text);
      return;
    }

    // No pending record, create a new conversation (first message included)
    // request_id in the 202 response IS the conversation_id
    const { request_id: newConvID } = await crmClient.createConversation(token, {
      channelId: mapping.channelId,
      contactId: contactID,
      message: event.text,
    });
    // Save pending record with the conversation_id; mapping is confirmed by handleConversationCreated
    await db.savePendingConversationCreation(contactID, event.userId, mapping.workspaceId, mapping.channelId, event.accountId, newConvID);
    return;
  }

  // Check status
  const conv = await crmClient.getConversation(token, convID);

  if (conv.data.status === "FINALIZED") {
    await db.deleteConversationMapping(convID);
    // Check whether a new creation is already in flight (e.g. from a concurrent message)
    const pending = await db.getPendingConversationCreationByContactID(mapping.workspaceId, contactID);
    if (pending) {
      await db.enqueueMessage(contactID, mapping.workspaceId, mapping.channelId, event.text);
      return;
    }
    const { request_id: newConvID } = await crmClient.createConversation(token, {
      channelId: mapping.channelId,
      contactId: contactID,
      message: event.text,
    });
    await db.savePendingConversationCreation(contactID, event.userId, mapping.workspaceId, mapping.channelId, event.accountId, newConvID);
    return;
  }

  // 6. Add message to existing conversation
  await crmClient.sendMessage(token, convID, {
    content: event.text,
    messageType: "MESSAGE",
    sendAs: { contactId: contactID },
  });

  // 7. Mark as processed
  await db.markProcessed(event.messageId);
}

Platform-Specific Notes

ConcernNote
Account identifierYour webhook handler is registered per Telegram bot token, so the receiving bot is implied by which webhook URL was called. Look up the correct workspace + channel from that registration.
Non-message eventsPlatforms also send follow/unfollow, read receipts, etc. Filter to only message type events for CRM routing.
Sticker / reaction messagesMap these to a text representation (e.g. "[Sticker]") if the platform doesn't give you extractable text.
File / media messagesImages, video, audio, and file attachments require a separate upload flow. See File and Media Messages.

On this page