Developer Docs Logo
Customer Chat

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)

StatusMeaningStrategy
200 OKSuccessReport SENT to CRM
400 Bad RequestBad payload (e.g. invalid user ID, message too long)Log the error. Do not retry. Report FAILED to CRM.
401 UnauthorizedAccess token expired or revokedRefresh token if platform supports it; otherwise alert agents.
5xx Server ErrorPlatform transient errorRetry 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)

StatusEndpointMeaningStrategy
201 CreatedPOST /v1/contactsContact createdSave mapping and continue
401 UnauthorizedAny endpointCRM access token expired or revokedMark installation as disconnected, notify admin
404 Not FoundGET /v1/conversations/{id}Conversation was deletedDelete mapping from DB, create a new conversation
409 ConflictPOST /v1/conversationsConversation already exists for this contact+channelQuery GET /v1/conversations to find it, reuse it
5xxAny endpointCRM transient errorRetry 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:

  1. Mark the workspace installation as expired in your DB.
  2. Stop processing messages for that workspace (to avoid repeated 401s).
  3. Notify the agent via email or in-app notification that they need to reconnect.
  4. 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

ScenarioStrategy
Your webhook event handler returns 5xx or times outThe 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 410The 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 handlerReport 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.

  1. Immediately after POST /v1/conversations, write a pending conversation record to your DB.
  2. 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/conversations again.
  3. When conversation.created fires: call GET /v1/conversations/{conversation_id}, verify the contact_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.

On this page