Error Handling
This document covers common failure scenarios your third-party app will encounter and the recommended strategy for each.
1. Platform Webhook Delivery Failures
Duplicate delivery: Platforms do not guarantee exactly-once delivery, the same event may arrive more than once. Use the platform's message ID as an idempotency key in a processed_events table.
Note: The same applies to webhooks the CRM sends you: duplicates are expected, especially if you enable retries for your app. Deduplicate CRM webhooks using the top-level envelope
id, which is stable across retries. See CRM Webhooks: Retries.
Idempotency Table
Note: This schema is provided as an example/guideline. You may adapt it to your specific requirements, database system, and naming conventions.
CREATE TABLE processed_platform_events (
platform_message_id TEXT PRIMARY KEY,
processed_at TIMESTAMPTZ DEFAULT NOW()
);// At the start of processing:
if (await db.existsProcessed(messageId)) {
return; // already handled
}
// At the end of successful processing:
await db.markProcessed(messageId);2. Platform API Errors (Outbound Push)
| Status | Meaning | Strategy |
|---|---|---|
200 OK | Success | Report SENT to CRM |
400 Bad Request | Bad payload (e.g. invalid user ID, message too long) | Log the error. Do not retry. Report FAILED to CRM. |
401 Unauthorized | Access token expired or revoked | Refresh token if platform supports it; otherwise alert agents. |
5xx Server Error | Platform transient error | Retry up to N times with exponential backoff. |
Exponential Backoff Pattern
async function pushWithRetry(fn: () => Promise<void>, maxAttempts: number): Promise<void> {
let delay = 1000;
for (let attempt = 0; attempt < maxAttempts; attempt++) {
try {
await fn();
return;
} catch (err) {
if (isNonRetryable(err)) { // 400, 401 (without refresh), 404
throw err;
}
if (attempt < maxAttempts - 1) {
const jitter = Math.floor(Math.random() * 500);
await new Promise((r) => setTimeout(r, delay + jitter));
delay *= 2;
}
}
}
throw new Error(`max retries (${maxAttempts}) exceeded`);
}Note: Rate limiting may be implemented in the future. If your platform has rate limits, use a job queue to serialize outbound pushes and stay within platform limits (e.g., BullMQ/Redis Streams for Node.js, SQS + Lambda for cloud, or goroutine pool + ticker for Go).
3. CRM API Errors (Inbound - Creating Contacts & Conversations)
| Status | Endpoint | Meaning | Strategy |
|---|---|---|---|
201 Created | POST /v1/contacts | Contact created | Save mapping and continue |
401 Unauthorized | Any endpoint | CRM access token expired or revoked | Mark installation as disconnected, notify admin |
404 Not Found | GET /v1/conversations/{id} | Conversation was deleted | Delete mapping from DB, create a new conversation |
409 Conflict | POST /v1/conversations | Conversation already exists for this contact+channel | Query GET /v1/conversations to find it, reuse it |
5xx | Any endpoint | CRM transient error | Retry with backoff |
Handling 409 on Conversation Create
try {
await crmClient.createConversation(token, req);
} catch (err) {
if (isHTTP409(err)) {
// Find the existing conversation, try OPEN first, then RESOLVED.
// The status param accepts only a single value, so two calls are needed to cover both states.
let convs = await crmClient.listConversations(token, {
contact_ids: contactId,
channel_ids: channelId,
status: "OPEN",
});
if (!convs.length) {
convs = await crmClient.listConversations(token, {
contact_ids: contactId,
channel_ids: channelId,
status: "RESOLVED",
});
}
if (!convs.length) {
throw new Error("got 409 but couldn't find existing conversation");
}
const existingId = convs[0].id;
await db.upsertConversationMapping(existingId, platformUserId, workspaceId, channelId);
await crmClient.sendMessage(token, existingId, {
content: text,
messageType: "MESSAGE",
sendAs: { contactId: contactId },
});
} else {
throw err;
}
}4. CRM Token Expiry (401 from CRM API)
There is no refresh token today. If a 401 is returned:
- Mark the workspace installation as expired in your DB.
- Stop processing messages for that workspace (to avoid repeated 401s).
- Notify the agent via email or in-app notification that they need to reconnect.
- Provide a reconnect flow in your admin dashboard.
async callCRMAPI(workspaceId: string, fn: (token: string) => Promise<void>): Promise<void> {
const installation = await this.db.getInstallation(workspaceId);
try {
await fn(installation.accessToken);
} catch (err) {
if (isHTTP401(err)) {
await this.db.markInstallationExpired(workspaceId);
await this.notifier.alertAdminTokenExpired(workspaceId);
throw new ErrTokenExpired();
}
throw err;
}
}5. CRM Webhook Event Handler Failures
| Scenario | Strategy |
|---|---|
| Your webhook event handler returns 5xx or times out | The event is marked FAILED and dropped, unless you enabled retries for your app, then it is retried with exponential backoff until they are exhausted (see Retries). Handle duplicates idempotently and implement a polling fallback for critical events. |
| Your webhook event handler returns 410 | The CRM treats the endpoint as permanently gone. The event is marked FAILED immediately, with no retries. Never return 410 for transient errors. |
| Platform push fails inside webhook event handler | Report FAILED to CRM via POST /v1/messages/{id}/status. Do not return 5xx to CRM. |
| DB lookup fails (conversation not in local DB) | Log the miss, skip forwarding. The conversation was likely created outside your third-party app. |
Always return 200 to the CRM, even if the platform push failed. The CRM is only responsible for delivering the webhook event - delivery to the end user is your responsibility. Use POST /v1/messages/{id}/status to report outcomes.
Note: Your handler will receive duplicates, routinely so once you enable retries. Deduplicate on the webhook envelope
id(stable across retries) as described in CRM Webhooks: Idempotency.
6. Conversation Race Condition
POST /v1/conversations returns 202 with no conversation_id in the body, the conversation is created asynchronously. The conversation_id arrives only via the conversation.created webhook. Once you receive the webhook, call GET /v1/conversations/{conversation_id} and verify the returned contact_id matches the one you requested before saving the mapping.
Because creation is asynchronous, a race condition can occur if a concurrent inbound message arrives while the CRM is still processing the conversation creation.
Recommended pattern
- Immediately after
POST /v1/conversations, write a pending conversation record to your DB. - On every subsequent inbound message for the same contact: check for a pending record before deciding to create a new conversation. If a pending record exists, enqueue the message, do not call
POST /v1/conversationsagain. - When
conversation.createdfires: callGET /v1/conversations/{conversation_id}, verify thecontact_id, save the mapping, delete the pending record, then flush the message queue in order.
As a fallback if the webhook does not arrive, poll GET /v1/conversations?contact_ids={contact_id}&channel_ids={channel_id}&status=OPEN to find the active conversation.
Suggested DB schema
Note: This schema is provided as an example/guideline. Adapt it to your database system and naming conventions.
-- Written immediately after POST /v1/conversations; deleted when conversation.created is handled
CREATE TABLE pending_conversation_creations (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
contact_id TEXT NOT NULL, -- CRM contact UUID sent in the create request
platform_user_id TEXT NOT NULL, -- platform sender ID, for completing the mapping
workspace_id TEXT NOT NULL,
channel_id TEXT NOT NULL, -- CRM channel UUID
platform_channel_id TEXT NOT NULL, -- platform account identifier (e.g. Telegram bot)
created_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE (contact_id, workspace_id, channel_id)
);
-- Messages that arrived while a conversation creation was still in flight
CREATE TABLE queued_messages (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
contact_id TEXT NOT NULL,
workspace_id TEXT NOT NULL,
channel_id TEXT NOT NULL,
content TEXT NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX idx_queued_messages_lookup ON queued_messages(contact_id, workspace_id, channel_id, created_at);The UNIQUE constraint on pending_conversation_creations prevents duplicate creation attempts for the same contact on the same channel within a workspace.