Developer Docs Logo
Customer Chat

Outgoing Messages (CRM → Platform)

This document explains what happens when a CRM agent replies to a conversation. The CRM fires a webhook event to your third-party app, and your third-party app forwards the message to the user on the external platform.


Overview


The Decision Flow

1. Agent types and sends reply         ↓ 2. CRM persists message         ↓ 3. CRM POSTs message.created webhook         ↓ 4. Receive the Webhook Event         ↓ 5. Verify the Signature         ↓     Parse and Route by Webhook Event Type         ↓ message.created 6-8. Handle message.created         ↓ sender is CONTACT → skip         ↓ sender is not CONTACT → look up platform user + token, push to platform         ↓ attachment type → Forward Attachments 9. User receives message (platform-side) 10-11. Report Delivery Status


Receive the Webhook Event

The CRM POSTs to your Webhook Event URL. Return HTTP 200 immediately and queue the event for async processing. See CRM Webhook Handling for the full receiving, verification, and routing pattern.


Verify the Signature

Always verify X-Data-Signature before acting on the payload. See Webhook Handling for full code.

import { Request, Response } from "express";

async function handleCRMEvent(req: Request, res: Response): Promise<void> {
  const body = req.body as Buffer;

  const signature = req.headers["x-data-signature"] as string;
  if (!verifyCRMSignature(body, signature, signingSecret)) {
    res.status(401).send("unauthorized");
    return;
  }

  // Queue for async processing
  queue.enqueue(body);
  res.sendStatus(200);
}

Parse and Route by Webhook Event Type

See CRM Webhook Handling - Parsing and Routing Events for the full routing implementation.


Handle message.created

This is the most important webhook event for chat integrations. It fires every time a message is added to a conversation - by an agent, by a contact (via your third-party app), or by the system.

You must only forward messages where sender.type != "CONTACT". Forwarding CONTACT messages would cause an infinite loop (your third-party app sends a platform message → CRM receives it → fires message.created for CONTACT → your third-party app forwards it back → loop).

Payload example:

{
  "event": "message.created",
  "data": {
    "id": "ff0e8400-e29b-41d4-a716-446655440010",
    "channel_id": "cc0e8400-e29b-41d4-a716-446655440007",
    "conversation_id": "ee0e8400-e29b-41d4-a716-446655440009",
    "workspace_id": "660e8400-e29b-41d4-a716-446655440001",
    "content": "Thank you for contacting us. How can I assist you today?",
    "type": "MESSAGE",
    "sender": {
      "id": "770e8400-e29b-41d4-a716-446655440002",
      "type": "AGENT"
    },
    "contact_id": "bb0e8400-e29b-41d4-a716-446655440006"
  }
}

For ATTACHMENT type messages, content holds the attachment_id. See File and Media Messages: Outgoing for the payload example and full forwarding implementation.

interface Sender {
  type: string; // "AGENT", "CONTACT", "SYSTEM", "BOT"
  id?: string;
}

interface MessageCreatedData {
  id: string;
  conversation_id: string;
  channel_id: string;
  workspace_id: string;
  content: string;
  type: string; // "MESSAGE", "ATTACHMENT", "WHISPER", etc.
  sender: Sender;
  contact_id?: string;
}

async function handleMessageCreated(workspaceID: string, data: string): Promise<void> {
  const msg: MessageCreatedData = JSON.parse(data);

  // Only forward messages that are not from CONTACT
  if (msg.sender.type === "CONTACT") {
    return;
  }

  // Look up platform user and originating platform channel from conversation mapping
  const conv = await db.getConversationMapping(msg.conversation_id);
  if (!conv) {
    throw new Error("conversation not found in local DB");
  }

  // Look up platform access token for the originating platform channel
  const platformToken = await db.getPlatformToken(conv.platformChannelID);

  // Forward to platform
  let pushErr: Error | null = null;
  try {
    switch (msg.type) {
      case "MESSAGE":
        await platformClient.pushText(platformToken, conv.platformUserID, msg.content);
        break;
      case "ATTACHMENT":
        // content holds the attachment_id for ATTACHMENT type messages
        // see the "File and Media Messages" documentation
        await handleAttachmentForward(workspaceID, platformToken, conv.platformUserID, msg.content);
        break;
      default:
        await platformClient.pushText(platformToken, conv.platformUserID,
          "[The agent sent a rich message - please open the support app to view it]");
        break;
    }
  } catch (err) {
    pushErr = err as Error;
  }

  // Report delivery status back to CRM
  await reportDeliveryStatus(workspaceID, msg.id, pushErr);
}

Forward Attachments

For ATTACHMENT type messages, download the file from CRM then forward it to the platform. See File and Media Messages - Outgoing for the full implementation.


Report Delivery Status

After pushing (or failing to push) to the platform, report the result back to the CRM so agents see accurate delivery states.

async function reportDeliveryStatus(
  workspaceID: string,
  messageID: string,
  pushErr: Error | null
): Promise<void> {
  const crmToken = await db.getCRMToken(workspaceID);

  if (pushErr) {
    logger.warn("platform push failed", { error: pushErr });
    await crmClient.updateMessageStatus(crmToken, messageID, "FAILED", pushErr.message);
  } else {
    await crmClient.updateMessageStatus(crmToken, messageID, "SENT");
  }
}

CRM endpoint:

POST /v1/messages/{message_id}/status
Authorization: Bearer {workspace_access_token}
Content-Type: application/json

{
  "status": "SENT"
}

For failed deliveries, include a reason:

POST /v1/messages/{message_id}/status
Authorization: Bearer {workspace_access_token}
Content-Type: application/json

{
  "status": "FAILED",
  "reason": "platform rejected the message: 403 Forbidden"
}

Valid statuses for this endpoint: SENT, DELIVERED, FAILED, READ.

Note: PENDING is a read-only status shown on messages before delivery confirmation. It is not a valid value for this update endpoint.


Handle conversation.state-changed

Subscribe to this if you want to react to conversation resolution, finalization, or reopening.

State transitions:

TransitionMeaning
OPEN → RESOLVEDAgent closed the conversation
RESOLVED → OPENConversation reopened
RESOLVED → FINALIZEDPermanently closed, no new messages allowed

Payload example:

{
  "event": "conversation.state-changed",
  "data": {
    "conversation_id": "ee0e8400-e29b-41d4-a716-446655440009",
    "state": "FINALIZED",
    "workspace_id": "660e8400-e29b-41d4-a716-446655440001",
    "changed_at": "2024-01-15T11:00:00Z"
  }
}
interface ConversationStateChangedData {
  conversation_id: string;
  state: string; // "OPEN", "RESOLVED", "FINALIZED"
  workspace_id: string;
  changed_at: string; // ISO 8601 date string
}

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

  // Update local status
  await db.updateConversationStatus(event.conversation_id, event.state);

  switch (event.state) {
    case "RESOLVED": {
      const conv = await db.getConversationMapping(event.conversation_id);
      const platformToken = await db.getPlatformToken(conv.platformChannelID);
      await platformClient.pushText(platformToken, conv.platformUserID,
        "Your conversation has been resolved. Feel free to message us again if you need further help.");
      break;
    }
    case "FINALIZED":
      // Clear the local mapping so the next message from this user opens a new conversation.
      // See Incoming Message Flow → FINALIZED handling.
      await db.deleteConversationMapping(event.conversation_id);
      break;
  }
}

Handle conversation.assigned (optional)

Subscribe to this if you want to track when a conversation is assigned to an agent, bot, or team, for example, to display the assigned agent's name in your platform UI.

Payload example:

{
  "event": "conversation.assigned",
  "data": {
    "conversation_id": "ee0e8400-e29b-41d4-a716-446655440009",
    "assignee_type": "AGENT",
    "assignee_id": "660e8400-e29b-41d4-a716-446655440001",
    "assigned_at": "2026-01-15T11:00:00Z",
    "workspace_id": "770e8400-e29b-41d4-a716-446655440002"
  }
}

assignee_type values: AGENT, BOT

interface ConversationAssignedData {
  conversation_id: string;
  assignee_id: string;
  assignee_type: string; // "AGENT", "BOT"
  workspace_id: string;
  assigned_at: string; // ISO 8601 date string
}

async function handleConversationAssigned(workspaceID: string, data: string): Promise<void> {
  const event: ConversationAssignedData = JSON.parse(data);
  await db.updateConversationAssignment(event.conversation_id, event.assignee_id, event.assignee_type);
}

Other Tips

Most messaging platforms enforce rate limits on push messages. To avoid hitting limits:

  • Use a job queue (BullMQ, Redis Streams, SQS) to send messages asynchronously.
  • Implement exponential backoff on 429 Too Many Requests responses.
  • Do not burst-send many messages at once to the same user.

On this page