Webhook

Webhook — это HTTP POST, который система отправляет на ваш URL при наступлении события. Эта страница описывает актуальный формат Payload v2. Payload v1 продолжает поддерживаться и доступен через переключатель версии.

Успешной доставкой считается любой ответ со статусом 200-299.

Формат payload v2

Версия задаётся полем payloadVersion: "2" подписки. Выбранная версия фиксируется вместе с телом в момент постановки события в очередь, поэтому изменение подписки не меняет уже созданные доставки. Переключатель в заголовке меняет только открытую страницу документации.

Если выбор v2 в кабинете пока недоступен, окружение находится на expand-этапе: workers уже умеют доставлять v2, но создание и перевод подписок на него откроются только после activation.

СобытиеТелоПодпись
Обычное{ id, type, apiVersion: "v2", occurredAt, data }APX-подпись
crm_changeStructured envelope с теми же верхнеуровневыми полями и CRM dataAPX-подпись

Для обычного события тело имеет вид:

JSON
{
  "id": "811793c8-d361-487b-b3f7-1d59b3fe3fc6",
  "type": "create_message",
  "apiVersion": "v2",
  "occurredAt": "2026-08-21T10:30:00.000Z",
  "data": {}
}

id и occurredAt одинаковы у всех v2-получателей одного обычного события и не меняются при повторной доставке. occurredAt — время постановки события, а не время конкретной HTTP-попытки.

Событие crm_change использует тот же structured envelope, но содержит контракт изменений CRM:

JSON
{
  "id": "811793c8-d361-487b-b3f7-1d59b3fe3fc6",
  "type": "crm.deal.updated",
  "apiVersion": "v2",
  "occurredAt": "2026-08-21T10:30:00.000Z",
  "data": {
    "resourceType": "deal",
    "resourceId": "9ba2e2f7-86b4-4dc1-b2d0-e6423c361d79",
    "externalId": "order-42",
    "action": "updated",
    "version": 7,
    "changedFields": ["amount", "stageUuid"]
  }
}

Для crm_change необходимо выбрать API-приложение со scope чтения соответствующего CRM-ресурса. externalId отсутствует, если выбранное приложение не создавало reference для ресурса.

Подпись (secret)

Для любого payload v2 secret обязателен. Каждый запрос содержит:

PLAINTEXT
X-APX-Event-Id: 811793c8-d361-487b-b3f7-1d59b3fe3fc6
X-APX-Event-Timestamp: 1787308200
X-APX-Signature: v1=<hex hmac-sha256>

Подписывается точная строка <timestamp>.<rawBody>. Префикс v1= обозначает версию алгоритма подписи, а не payload. Проверяйте подпись до JSON parsing, ограничивайте допустимый возраст timestamp и дедуплицируйте события по X-APX-Event-Id. Timestamp создаётся заново на каждой попытке, но raw body и event ID остаются прежними.

TS
import { createHmac, timingSafeEqual } from 'crypto';

function verifyApxSignature(bodyRaw: string, timestamp: string, signature: string, secret: string) {
  const expected = createHmac('sha256', secret).update(`${timestamp}.${bodyRaw}`).digest('hex');
  const received = signature.startsWith('v1=') ? signature.slice(3) : '';
  if (!/^[a-f0-9]{64}$/i.test(received)) return false;

  return timingSafeEqual(Buffer.from(received, 'hex'), Buffer.from(expected, 'hex'));
}

Базовая типизация v2

TS
type GenericWebhookEventType =
  | 'create_message'
  | 'edit_message'
  | 'delete_message'
  | 'create_chat'
  | 'close_chat'
  | 'open_chat'
  | 'transfer_chat'
  | 'create_deal'
  | 'move_deal'
  | 'close_deal'
  | 'delete_deal'
  | 'create_pipeline'
  | 'update_pipeline'
  | 'delete_pipeline'
  | 'create_crm_tag'
  | 'update_crm_tag'
  | 'delete_crm_tag'
  | 'create_client'
  | 'update_client';

type WebhookEnvelopeV2<T> = {
  id: string;
  type: GenericWebhookEventType;
  apiVersion: 'v2';
  occurredAt: string;
  data: T;
};

type CrmChangeWebhookEnvelopeV2 = {
  id: string;
  type: `crm.${string}.${CrmChangeAction}`;
  apiVersion: 'v2';
  occurredAt: string;
  data: {
    resourceType: string;
    resourceId: string | null;
    externalId?: string;
    action: CrmChangeAction;
    version: number | null;
    changedFields: string[];
  };
};

type CrmChangeAction =
  | 'created'
  | 'updated'
  | 'deleted'
  | 'merged'
  | 'anonymized'
  | 'completed'
  | 'failed';

Повторные попытки

  • Повторяются только неуспешные доставки.
  • До 5 попыток всего; перед повторами используются задержки 1m, 2m, 4m, 8m.
  • Обработчик должен быть идемпотентным: сетевой сбой после успешной обработки может привести к повтору.

Повторные события одного объекта

create_message и delete_message приходят один раз на сообщение. Остальные события приходят на каждое изменение, даже если объект тот же: open_chat, close_chat и transfer_chat — при каждом открытии, закрытии и передаче диалога, edit_message — при каждой правке, события сделок, воронок, тегов и клиентов — при каждом изменении. Раньше повторное событие того же диалога, сделки или клиента могло не доставляться.

Не отбрасывайте такие события как дубли по data.id: у повторного open_chat тот же id диалога. Повтор доставки одного и того же события в v2 определяйте по id события и заголовку X-APX-Event-Id.

Список событий

  • Messages: create_message, edit_message, delete_message
  • Deals: create_deal, move_deal, close_deal, delete_deal
  • Pipelines: create_pipeline, update_pipeline, delete_pipeline
  • CRM Tags: create_crm_tag, update_crm_tag, delete_crm_tag
  • Clients: create_client, update_client
  • Chats: create_chat, close_chat, transfer_chat, open_chat
  • CRM API: crm_change

Типы data для обычных событий

create_message

TS
type CreateMessageData = {
  id: string;
  chatUuid: string;
  content: string;
  type: 'system' | 'default';
  senderType: 'client' | 'operator';
  senderUuid: string;
  isUpdated: boolean;
  isSpam: boolean;
  isEmailSent: boolean;
  answerMessageUuid: string | null;
  socialMessageUuid: string | null;
  aiOperatorMeta: any | null;
  createdAt: string;
  updatedAt: string;
};

edit_message

TS
type EditMessageData = CreateMessageData;

delete_message

TS
type DeleteMessageData = CreateMessageData & { deletedAt: string };

create_chat

TS
type CreateChatData = {
  id: string;
  clientUuid: string;
  operatorUuid: string;
  channelUuid: string;
  departmentUuid: string | null;
  transfersUuid: string[];
  status: 'active' | 'closed';
  subject?: string | null;
  providerPeerId?: string | null;
  avitoItem?: {
    id: string;
    title?: string;
    price?: string;
    url?: string;
    image?: string;
  } | null;
  createdAt: string;
  updatedAt: string;
};

providerPeerId — идентификатор собеседника в мессенджере, на который APX отправляет ответы в этом диалоге: например, chat_id в Telegram, peer_id во VK, JID в WhatsApp, chat:<id> или user:<id> у бота MAX, chat_id чата Avito. Формат зависит от канала, поэтому используйте значение как непрозрачную строку. Адресат закрепляется за диалогом при его создании и сохраняется при объединении клиентов. null — если канал не адресует собеседника таким идентификатором (сайт, почта) или диалог создан до появления поля. Поле добавлено обратно совместимо: оно приходит в create_chat, close_chat, open_chat и transfer_chat в payload v1 и v2 без смены версии.

avitoItem — объявление Avito, по которому идёт диалог. В Avito чат — это пара «покупатель + объявление», поэтому у покупателя, написавшего по двум объявлениям, будет два диалога, каждый со своим providerPeerId и avitoItem. price — строка цены от Avito вместе с валютой. null или поле отсутствует — канал не Avito, объявление не удалось получить или диалог создан до появления поля (до октября 2026 года у диалогов Avito providerPeerId — числовой id покупателя). Поле добавлено обратно совместимо, без смены версии payload.

close_chat

TS
type CloseChatData = CreateChatData & { closedAt: string };

open_chat

TS
type OpenChatData = CreateChatData & {
  openedAt: string;
  /** Номер обращения в диалоге: 1, 2, 3…; после «Вернуться в группу» — номер продолжающегося обращения */
  episode: number | null;
  /** Почему диалог снова открыт */
  reason: 'reopened_inbound' | 'reopened_manual' | 'reopened_api' | null;
};

open_chat приходит, когда закрытый диалог снова становится активным. reason показывает причину:

  • reopened_inbound — клиент написал в закрытый диалог;
  • reopened_manual — диалог возобновил сотрудник: в APX или приложением Messaging API «Рабочее место сотрудника» перед отправкой в закрытые почту, Avito или сайт, — или сотрудник вернулся в группу («Вернуться в группу»): обращение группы продолжается, episode прежний;
  • reopened_api — приложение-автоматизация Messaging API отправило сообщение в закрытый диалог, и он вернулся в работу по правилам канала.

Если номер или причину определить не удалось, поле равно null.

Сообщение сотрудника в закрытый диалог 1:1 мессенджера — из «Закрытых» в APX или приложением «Рабочее место сотрудника» — диалог не открывает: он остаётся закрытым, и open_chat не приходит. Когда клиент ответит, придёт open_chat с reason: "reopened_inbound", а диалог вернётся к ответственному, если тот доступен, иначе — по правилам канала. Подробнее — в разделе Messaging API.

В мессенджерах вернувшийся клиент продолжает тот же диалог: вместо create_chat с новым id придёт open_chat с прежним id, следующим номером episode и reason: "reopened_inbound". Если интеграция создавала запись на каждый create_chat, обрабатывайте и такой open_chat: это новое обращение в том же диалоге. Почта, Avito и групповые чаты по-прежнему создают новый диалог и присылают create_chat. Сводка изменений — в разделе Изменения 29.09.2026 (v1).

transfer_chat

TS
type TransferChatData = CreateChatData & {
  previousOperatorUuid: string;
  newOperatorUuid: string;
  transferredAt: string;
};

create_deal

TS
type CreateDealData = {
  id: string;
  title: string;
  description: string | null;
  amount: number;
  currency: string;
  status: 'active' | 'won' | 'lost' | 'paused';
  priority: 'low' | 'medium' | 'high' | 'urgent';
  source: string;
  expectedCloseDate: string | null;
  actualCloseDate: string | null;
  lossReason: string | null;
  customFields: object | null;
  clientUuid: string | null;
  ownerUuid: string;
  pipelineUuid: string;
  stageUuid: string;
  lostReasonCategoryUuid: string | null;
  chatUuid: string | null;
  rootChanelUuid: string;
  createdAt: string;
  updatedAt: string;
};

delete_deal

TS
type DeleteDealData = CreateDealData & { deletedAt: string };

move_deal

TS
type MoveDealData = CreateDealData & {
  previousStageUuid: string;
  newStageUuid: string;
  movedAt: string;
};

close_deal

TS
type CloseDealData = CreateDealData & { closedAt: string };

create_pipeline

TS
type CreatePipelineData = {
  id: string;
  name: string;
  description: string | null;
  color: string;
  isActive: boolean;
  isDefault: boolean;
  sortOrder: number;
  rootChanelUuid: string;
  createdAt: string;
  updatedAt: string;
};

update_pipeline

TS
type UpdatePipelineData = CreatePipelineData;

delete_pipeline

TS
type DeletePipelineData = CreatePipelineData & { deletedAt: string };

create_crm_tag

TS
type CreateCrmTagData = {
  id: string;
  name: string;
  color: string;
  description: string | null;
  type: 'client' | 'deal' | 'universal';
  isActive: boolean;
  usageCount: number;
  rootChanelUuid: string;
  createdAt: string;
  updatedAt: string;
};

update_crm_tag

TS
type UpdateCrmTagData = CreateCrmTagData;

delete_crm_tag

TS
type DeleteCrmTagData = CreateCrmTagData & { deletedAt: string };

create_client

TS
type CreateClientData = {
  id: string;
  type: 'site' | 'telegram' | 'mail' | 'whatsapp' | 'vk' | 'max';
  rootChanelUuid: string;
  socialId: number | null;
  nameSocial: string | null;
  lastName: string | null;
  name: string | null;
  email: string[];
  avatarUrl: string | null;
  phone: string[];
  ip: string[];
  city: string[];
  isOnline: boolean;
  isContactRequestPending: boolean;
  otherFields: object | null;
  lastVisit: string;
  createdAt: string;
  updatedAt: string;
};

update_client

TS
type UpdateClientData = CreateClientData;

Поля в data могут расширяться обратно-совместимо. Не завязывайтесь на строго фиксированный набор полей без необходимости.