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:
PENDINGis 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:
| Transition | Meaning |
|---|---|
OPEN → RESOLVED | Agent closed the conversation |
RESOLVED → OPEN | Conversation reopened |
RESOLVED → FINALIZED | Permanently 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 Requestsresponses. - Do not burst-send many messages at once to the same user.
Incoming Messages
What happens when a user on the external platform sends a message, and how your webhook handler creates or continues a conversation in the CRM.
File Media Messages
Attachment handling in both directions: when a platform user sends a file to the CRM, and when an agent sends a file back to the platform user.