Developer Docs Logo
Webhooks

CRM Webhooks

The CRM sends events to your app by POSTing to the Webhook Event URL you registered in the CRM Dashboard. All webhook events from all installed workspaces arrive at this single URL.


Subscribing to Webhook Events

Webhook event subscriptions are configured per app in Developer Configuration:

  1. Navigate to 3rd Party Apps → Your Apps in the CRM Dashboard
  2. Select your app and click Developer Configuration
  3. Go to the Event Subscriptions tab
  4. Enter your Webhook Event URL in the Request URL field
  5. Select the events to subscribe to
  6. Optionally turn on Enable webhook retry (see Retries), off by default
  7. Save

All events from all installed workspaces are delivered to the same URL. Use workspace_id inside each event's data payload to identify which workspace the event belongs to.


Webhook Request Format

POST /webhooks/crm/events
Content-Type: application/json
X-Data-Signature: <hmac-sha256-hex>

{
  "id": "9a0e8400-e29b-41d4-a716-446655440020",
  "event": "message.created",
  "created_at": "2026-01-15T10:05:00Z",
  "data": { ...event-specific payload... }
}

Every event shares the same top-level envelope:

FieldDescription
idUnique identifier of this webhook event. Stable across retried deliveries of the same event, use it as your idempotency key (see Idempotency).
eventThe event type, e.g. message.created. Route on this field.
created_atServer-side timestamp of when the event was published (ISO 8601 UTC). Also stable across retries, use it to order events.
dataThe event-specific payload.
metadataOnly present on a retried delivery (see Retries). Absent on the first attempt, so its presence alone tells you this is a redelivery.

For complete payload schemas, see the OpenAPI Webhooks Specification.


Receiving Webhooks

Always return HTTP 200 within a few seconds. Queue the event and return immediately, never hold the connection open to process inline.

import { Request, Response } from "express";

function handleWebhook(req: Request, res: Response): void {
  const body = req.body as Buffer;

  // Verify before queuing, reject tampered requests immediately
  if (!verifyCRMSignature(body, req.headers["x-data-signature"] as string, signingSecret)) {
    res.status(401).json({ error: "unauthorized" });
    return;
  }

  queue.enqueue(body);
  res.sendStatus(200);
}

Delivery

  • One attempt by default. Each delivery attempt has a 30-second timeout (configurable per deployment, check with your CRM operator if you are on a self-hosted instance). If your endpoint returns a non-2xx response or times out, the event is marked FAILED and is not redelivered unless you have opted in to retries.
  • At-least-once, not exactly-once. Duplicate deliveries are still possible (retries, failover, redeploys). Your handler must deduplicate by the envelope id (see Idempotency).
  • No guaranteed ordering. A failed event can arrive minutes after newer events already succeeded. Use the envelope created_at (or event-specific timestamps like changed_at) if you need to order events.

Retries

Retries are opt-in per app and OFF by default. Turn on Enable webhook retry in Developer Configuration → Event Subscriptions for your app. The dashboard asks you to confirm, because the setting changes how often your endpoint is called.

Leave it off if your endpoint processes events synchronously or is not safe to call twice for the same event, a retry looks like a duplicate delivery to you.

When it is on: if a delivery returns a non-2xx response or times out, the CRM retries it with exponential backoff: the first retry after roughly 30 seconds, then doubling on each subsequent attempt up to a 15-minute cap, with random jitter, for up to 5 retries after the initial attempt. (Backoff, cap and retry limit are deployment-level settings; the values above are the defaults.)

  • Turning the toggle off stops retries already in flight. Pending retries for your app are dropped the next time the worker picks them up, so no delivery arrives after you opt out.
  • Exhausted retries = event dropped. After the final retry fails, the event is marked FAILED and is not redelivered. Implement a polling fallback for events you cannot afford to lose.
  • 410 Gone stops retries immediately. If your endpoint returns 410, the CRM treats the URL as permanently gone and marks the event FAILED without retrying. Do not return 410 for transient errors.

Retry metadata

A retried delivery carries a top-level metadata object. The first (non-retried) attempt does not include metadata at all, so its presence is how you tell a retry from an original delivery:

{
  "id": "9a0e8400-e29b-41d4-a716-446655440020",
  "event": "message.created",
  "created_at": "2026-01-15T10:05:00Z",
  "data": { "...": "..." },
  "metadata": {
    "retry_count": 2,
    "retry_limit": 5,
    "retry_reason": "server error"
  }
}
FieldDescription
retry_countWhich retry this is. The first retry is 1.
retry_limitMaximum retries for this event, once retry_count reaches it and delivery still fails, the event is dropped.
retry_reasonWhy the previous attempt failed (status code, timeout, …). May be an empty string.

Everything else is unchanged from the original request: same envelope id, same created_at, same data. The X-Data-Signature is recomputed over the retried body, so always verify the signature against the bytes you actually received, never cache the signature of the first delivery.


Idempotency

Duplicate deliveries happen during rolling deploys and failover, and become routine once you enable retries. Use the top-level envelope id as an idempotency key so duplicates are no-ops. It is generated when the event is first published and is identical on every retry of that event.

async function processEvent(envelopeID: string, payload: unknown): Promise<void> {
  const exists = await db.isProcessed(envelopeID);
  if (exists) {
    return;
  }

  // ... process ...

  // Mark processed only after a successful write, not before.
  // Marking first and then failing silently drops the event.
  await db.markProcessed(envelopeID);
}

Verifying CRM Webhooks

Every webhook the CRM sends includes an X-Data-Signature header: an HMAC-SHA256 hex digest of the raw request body, computed with your app's Signing Secret (CRM_CLIENT_SIGNED_KEY).

X-Data-Signature: 3a7bd3e2360a3d29eea436fcfb7e44c735d117c4df632dd8f6e2048f4...

Signature verification is the only correct access control for your webhook endpoint. Do not rely on IP allowlisting, CRM outbound IPs are not published and may change at any time due to infrastructure scaling or failover.

Reject requests where the header is absent. A missing X-Data-Signature is not a signal that verification can be skipped, it means the request is unsigned and must be rejected with 401 Unauthorized.

Compute the HMAC over the raw request bytes, before any JSON parsing. If you parse and re-serialize the body first, differences in whitespace or key ordering will produce a different hash and break verification.

Compare signatures with a constant-time function (crypto.timingSafeEqual in Node.js). A plain === short-circuits on the first mismatched byte, leaking timing information an attacker can use to reconstruct the secret over many requests.

import crypto from "crypto";

function verifyCRMSignature(body: Buffer, signatureHex: string, secret: string): boolean {
  const expected = crypto
    .createHmac("sha256", secret)
    .update(body)
    .digest("hex");
  // Use timingSafeEqual to avoid timing attacks
  try {
    return crypto.timingSafeEqual(
      Buffer.from(expected, "hex"),
      Buffer.from(signatureHex, "hex")
    );
  } catch {
    return false;
  }
}

Middleware (Express)

import { Request, Response, NextFunction } from "express";

function crmSignedMiddleware(signingSecret: string) {
  return (req: Request, res: Response, next: NextFunction): void => {
    const signatureHex = req.headers["x-data-signature"] as string | undefined;
    if (!signatureHex) {
      res.status(401).json({ error: "missing signature" });
      return;
    }

    // req.body is a Buffer when using express.raw({ type: "application/json" })
    const body = req.body as Buffer;

    if (!verifyCRMSignature(body, signatureHex, signingSecret)) {
      res.status(401).json({ error: "invalid signature" });
      return;
    }

    next();
  };
}

Message Delivery Status (message.sent / message.failed)

When you call POST /v1/conversations/{id}/messages, the API returns 202 Accepted and a request_id immediately. The send is queued, not yet delivered. Once the CRM finishes processing, it fires exactly one of these two events to your webhook URL:

EventWhen it fires
message.sentThe message was successfully delivered to the channel (e.g. Telegram).
message.failedDelivery failed: the channel rejected the message, the recipient was unreachable, or an internal error occurred.

Payload shape

The two events have different data structures:

// message.sent payload
interface MessageSentData {
  workspace_id: string; // workspace the message belongs to
  request_id: string;   // matches the request_id from the 202 response
  message_id: string;   // CRM message ID assigned to the sent message
}

// message.failed payload
interface MessageFailedData {
  workspace_id: string; // workspace the message belongs to
  request_id: string;   // matches the request_id from the 202 response
  error: {
    message: string;    // human-readable failure reason
    slug: string;       // machine-readable error code
  };
}

Correlating with the original request

Store the request_id from the 202 Accepted response, then match it against data.request_id in the webhook:

// After calling POST /v1/conversations/{id}/messages
const { request_id } = await sendMessage(conversationId, payload); // 202
await db.savePendingMessage({ request_id, conversationId, sentAt: Date.now() });

// In your webhook handler
async function handleMessageSent(data: MessageSentData): Promise<void> {
  const pending = await db.findPendingMessage(data.request_id);
  if (!pending) return; // not a message this app sent, or already handled

  await db.markDelivered(data.request_id, data.message_id);
}

async function handleMessageFailed(data: MessageFailedData): Promise<void> {
  const pending = await db.findPendingMessage(data.request_id);
  if (!pending) return;

  await db.markFailed(data.request_id, data.error.message);
  // surface the failure to the user or retry logic here
}

Delivery of status events is not guaranteed. A failed delivery is dropped unless retries are enabled, and even then it is dropped once retries are exhausted. If your app needs reliable delivery confirmation, implement a polling fallback using GET /v1/conversations/{id}/messages to check whether the message appears with a sent status.


Parsing and Routing Events

All CRM webhook events share the same top-level envelope. Route by the event field.

interface CRMEvent {
  id: string;         // unique event ID, stable across retries, use for dedup
  event: string;      // "message.created", "conversation.state-changed", etc.
  created_at: string; // server-side publish timestamp (ISO 8601 UTC)
  data: Record<string, unknown>;
}

// workspace_id is NOT in the top-level envelope, it's inside the data payload of each event.
// Extract it before routing.
interface BaseEventData {
  workspace_id: string;
}

async function dispatchEvent(rawBody: Buffer): Promise<void> {
  const event: CRMEvent = JSON.parse(rawBody.toString());
  const base = event.data as unknown as BaseEventData;

  switch (event.event) {
    case "message.created":
      await handleMessageCreated(base.workspace_id, event.data);
      break;
    case "conversation.state-changed":
      await handleConversationStateChanged(base.workspace_id, event.data);
      break;
    case "conversation.created":
      await handleConversationCreated(base.workspace_id, event.data);
      break;
    case "conversation.assigned":
      await handleConversationAssigned(base.workspace_id, event.data);
      break;
    case "message.sent":
      await handleMessageSent(event.data as MessageSentData);
      break;
    case "message.failed":
      await handleMessageFailed(event.data as MessageFailedData);
      break;
  }
}

On this page